Olá pessoal!
Já temos um artigo sobre o desenvolvimento da digitação no Ostrovok.ru . Explica por que estamos mudando de pyContracts para typeguard, por que estamos mudando para typeguard e o que acabamos fazendo. E hoje vou falar mais sobre como essa transição ocorre.

Uma declaração de função com pyContracts geralmente se parece com isso:
from contracts import new_contract import datetime @new_contract def User (x): from models import User return isinstance(x, User) @new_contract def dt_datetime (x): return isinstance(x, datetime.datetime) @contract def func(user_list, amount, dt=None): """ :type user_list: list(User) :type amount: int|float :type dt: dt_datetime|None :rtype: bool """ …
Este é um exemplo abstrato, porque não encontrei em nosso projeto uma definição de função curta e significativa em termos do número de casos para verificação de tipo. Normalmente, as definições para pyContracts são armazenadas em arquivos que não contêm nenhuma outra lógica. Observe que aqui User é uma classe de usuário específica e não é importada diretamente.
E este é o resultado desejado com o typeguard:
from typechecked import typechecked from typing import List, Optional, Union from models import User import datetime @typechecked def func (user_list: List[User], amount: Union[int, float], dt: Optional[datetime.datetime]=None) -> bool: ...
Em geral, existem tantas funções e métodos com verificação de tipo no projeto que, se você os empilhar em uma pilha, poderá chegar à lua. Portanto, traduzi-los manualmente de pyContracts para typeguard simplesmente não é possível (tentei!). Então eu decidi escrever um script.
O script é dividido em duas partes: uma armazena em cache as importações de novos contratos e a segunda trata da refatoração de código.
Quero observar que nem um nem outro script afirma ser universal. Não pretendemos escrever uma ferramenta para resolver todos os casos necessários. Portanto, muitas vezes omiti o processamento automático de alguns casos especiais; se eles raramente são encontrados no projeto, é mais rápido corrigi-lo manualmente. Por exemplo, o script para gerar contratos e importações de mapeamento coletou 90% dos valores, os 10% restantes são mapeamento de artesanato feito à mão.
A lógica do script para gerar mapeamento:
Etapa 1. Percorra todos os arquivos do projeto, leia-os. Para cada arquivo:
- se a substring "@new_contract" não estiver presente, pule este arquivo,
- se houver, divida o arquivo pela linha "@new_contract". Para cada item:
- analisar para definição e importação,
- se for bem-sucedido, escreva no arquivo de sucesso,
- caso contrário, escreva no arquivo de erro.
Etapa 2. Processar erros manualmente
Agora que temos os nomes de todos os tipos que pyContracts usa (eles foram definidos com o decorador new_contract) e temos todas as importações necessárias, podemos escrever código para refatoração. Enquanto eu traduzia do pyContracts para o typeguard manualmente, percebi o que precisava no script:
- Este é um comando que usa um nome de módulo como argumento (vários podem ser usados), no qual a sintaxe das anotações de função deve ser substituída.
- Percorra todos os arquivos do módulo, leia-os. Para cada arquivo:
- se não houver substring "@contract", pule este arquivo;
- se assim for, transforme o código em ast (árvore de sintaxe abstrata);
- encontre todas as funções que estão sob o decorador do contrato de cada uma:
- obtenha dockstring, analise e exclua,
- crie um dicionário no formato {arg_name: arg_type}, use-o para substituir a anotação da função,
- lembre-se de novas importações,
- escreva a árvore modificada em um arquivo através de astunparse;
- adicione novas importações na parte superior do arquivo;
- substitua as linhas "@contract" por "@typechecked" porque é mais fácil do que através do ast.
Resolva a pergunta "esse nome já foi importado neste arquivo?" Eu não pretendia desde o início: com esse problema, lidaremos com uma execução adicional da biblioteca isort.
Mas, depois de executar a primeira versão do script, surgiram questões que ainda precisavam ser resolvidas. Verificou-se que 1) ast não é onipotente, 2) astunparse é mais onipotente do que gostaríamos. Isso foi manifestado da seguinte maneira:
- no momento da transição para a árvore de sintaxe, todos os comentários em uma única linha desaparecem do código;
- linhas vazias também desaparecem;
- ast não distingue entre funções e métodos da classe, tivemos que adicionar lógica;
- por outro lado, ao passar de uma árvore para um código, comentários de várias linhas entre aspas triplas são gravados em comentários de aspas simples e ocupam uma linha, e as quebras de linha novas são substituídas por \ n;
- colchetes desnecessários aparecem, por exemplo, se A e B e C ou D se tornam se ((A e B e C) ou D).
O código passado por ast e astunparse permanece funcionando, mas sua legibilidade é reduzida.
A desvantagem mais séria do exposto acima é o desaparecimento dos comentários de linha única (em outros casos, não perdemos nada, mas apenas ganhos - colchetes, por exemplo). A biblioteca horast baseada em ast, astunparse e tokenize promete descobrir isso. Promete e faz.
Agora as linhas vazias. Havia duas soluções possíveis:
- O tokenize sabe como determinar a "parte da fala" de um python, e o horast aproveita-o quando obtém tokens de tipo de comentário. Mas tokenize também tem tokens como NewLine e NL. Portanto, você precisa ver como o horast restaura os comentários e copia, substituindo o tipo de token.
- sugeriu Anya, experiência no desenvolvimento de 2 meses - Como o horast pode restaurar comentários, primeiro substituímos todas as linhas vazias por um comentário específico, depois pularemos o horast e substituiremos o comentário por uma linha vazia.
- surgiu com Eugene, experiência no desenvolvimento de 8 anos
Vou dizer um pouco mais baixo sobre as aspas triplas nos comentários, e foi muito fácil tolerar colchetes extras, principalmente porque alguns deles são removidos pela formatação automática.
No horast, usamos duas funções: analisar e não analisar, mas ambas não são ideais - a análise contém erros internos estranhos; em casos raros, não é possível analisar o código-fonte e a análise não pode escrever algo que tenha o tipo type (tipo que Acontece que se você digitar (any_other_type)).
Decidi não lidar com a análise, porque a lógica do trabalho é bastante confusa e as exceções são raras - o princípio da não universalidade funciona aqui.
Mas o unparse funciona de maneira muito clara e elegante. A função unparse cria uma instância da classe Unparser, que no init processa a árvore e a grava em um arquivo. O Horast.Unparser é sucessivamente herdado de muitos outros Analisadores, onde a classe mais básica é astunparse.Unparser. Todas as classes descendentes simplesmente estendem a funcionalidade da classe base, mas a lógica do trabalho permanece a mesma, portanto, considere astunparse.Unparser. Possui cinco métodos importantes:
- escrever - apenas escreve algo em um arquivo.
- fill - usa gravação com base no número de indentações (o número de indentações é armazenado como um campo de classe).
- enter - aumenta o recuo.
- sair - reduz o recuo.
- expedição - determina o tipo do nó da árvore (digamos T), chama o método correspondente pelo nome do tipo de nó, mas com sublinhado (ou seja, _T). Este é um método meta.
Todos os outros métodos são métodos no formato _T, por exemplo, _Module ou _Str. Em cada método, ele pode: 1) despachar recursivamente para os nós da subárvore, ou 2) usar write para escrever o conteúdo do nó ou adicionar caracteres e palavras-chave para que o resultado seja uma expressão válida em python.
Por exemplo, encontramos um nó do tipo arg, no qual o ast armazena o nome do argumento e o nó da anotação. Em seguida, o despacho chamará o método _arg, que primeiro escreverá o nome do argumento, depois gravará os dois pontos e executará o despacho para o nó da anotação, onde a subárvore da anotação será analisada e o despacho e a gravação ainda serão chamados para essa subárvore.
Voltemos ao nosso problema da impossibilidade de processar o tipo de tipo. Agora que você entende como o desemparelhamento funciona, é fácil criar seu tipo. Vamos criar algum tipo:
class NewType(object): def __init__ (self, t): self.s = ts
Ele armazena uma string em si mesma, e não apenas assim: precisamos tipificar argumentos de função e obtemos os tipos de argumentos na forma de strings a partir do encaixe. Portanto, vamos substituir as anotações de argumento não pelos tipos que exigimos, mas por um objeto NewType que armazena apenas o nome do tipo desejado.
Para fazer isso, expanda horast.Unparser - escreva seu UnparserWithType, herdando de horast.Unparser e adicione processamento do nosso novo tipo.
class UnparserWithType(horast.Unparser): def _NewType (self, t): self.write(ts)
Isso combina com o espírito da biblioteca. Os nomes das variáveis são feitos no estilo ast, e é por isso que consistem em uma letra, e não porque eu não consigo pensar em nomes. Eu acho que t é a abreviação de árvore es é string. A propósito, NewType não é uma string. Se quiséssemos que fosse interpretado como um tipo de string, teríamos que escrever aspas antes e depois da chamada de gravação.
E agora a magia patch de macaco: substitua horast.Unparser pelo nosso UnparserWithType.
Como funciona agora: temos uma árvore de sintaxe, tem alguma função, funções têm argumentos, argumentos têm anotações de tipo, uma agulha está oculta na anotação de tipo e a morte de Koshcheev está oculta nela. Anteriormente, não havia nós de anotação, nós os criamos e qualquer nó desse tipo é uma instância do NewType. Chamamos a função unparse para nossa árvore e para cada nó que chama de despacho, que classifica esse nó e chama sua função correspondente. Assim que a função de despacho recebe o nó do argumento, ele escreve o nome do argumento e procura ver se há uma anotação (costumava ser None, mas colocamos NewType lá); se houver, ele escreve dois pontos e chama o despacho para a anotação, que chama nosso _NewType, que apenas escreve a string que armazena - esse é o nome do tipo. Como resultado, obtemos o argumento escrito: type.
Na verdade, isso não é totalmente legal. Do ponto de vista do compilador, escrevemos as anotações dos argumentos com algumas palavras que não são definidas em nenhum lugar; portanto, quando o desemparelamento termina seu trabalho, obtemos o código errado: precisamos de importações. Simplesmente formei uma linha com o formato correto e o adicionei ao início do arquivo e depois anexei o resultado para não ser analisado, embora eu pudesse adicionar importações como nós à árvore de sintaxe, pois o ast suporta os nós Import e ImportFrom.
Resolver o problema das aspas triplas não é mais difícil do que adicionar um novo tipo. Vamos criar a classe StrType e o método _StrType. O método não é diferente do método _NewType usado para anotar tipos, mas a definição da classe mudou: armazenaremos não apenas a sequência em si, mas também o nível da guia no qual ela deve ser gravada. O número de indentação é definido da seguinte forma: se essa linha for encontrada em uma função, então uma, se em um método, depois duas, e não haverá casos em que a função seja definida no corpo de outra função e seja decorada ao mesmo tempo em nosso projeto.
class StrType(object): def __init__ (self, s, indent): self.s = s self.indent = indent def __repr__ (self): return '"""\n' + self.s + '\n' + ' ' * 4 * self.indent + '"""\n'
Em repr , definimos como deve ser nossa linha. Eu acho que isso está longe de ser a única solução, mas funciona. Pode-se experimentar astunparse.fill e astunparse.Unparser.indent, então seria mais universal, mas essa ideia me veio à mente já no momento em que escrevi este artigo.
Isso resolveu as dificuldades terminam. Depois de executar meu script, o problema das importações cíclicas às vezes surge, mas isso é uma questão de arquitetura. Não encontrei uma solução de terceiros pronta e lidar com esses casos na estrutura do meu script parece ser uma complicação séria da tarefa. Provavelmente, com a ajuda da ast, é possível detectar e resolver importações cíclicas, mas essa ideia precisa ser considerada separadamente. Em geral, o número insignificante de tais incidentes em nosso projeto me permitiu não processá-los automaticamente.
Outra dificuldade que encontrei foi a falta de processamento de expressão no ast from astro import, pois um leitor cuidadoso já sabe que o patch para macacos é a cura para todas as doenças. Que esse seja o dever de casa dele, mas eu decidi fazer isso: basta adicionar essas importações ao arquivo de mapeamento, porque essa construção geralmente é usada para contornar o conflito de nomes, e temos poucos deles.
Apesar das imperfeições encontradas, o script faz o que se destinava a fazer. Qual é o resultado:
- O tempo para o qual o projeto foi lançado foi reduzido de 10 para 3 segundos;
- O número de arquivos diminuiu devido à remoção das definições de new_contract. Os arquivos em si foram reduzidos: eu não medi, mas em média o git totalizou n linhas adicionadas e 2n linhas excluídas;
- IDEs inteligentes começaram a dar dicas diferentes, porque agora não são comentários, mas importações honestas;
- A legibilidade melhorou;
- Em algum lugar parênteses apareceram.
Obrigada
Links úteis:
- Ast
- Horast
- Todos os tipos de nós ast e o que é armazenado neles
- Mostra lindamente a árvore de sintaxe
- Isort