Tapez tout

Bonjour à tous!


Nous avons déjà un article sur le développement de la frappe dans Ostrovok.ru . Il explique pourquoi nous passons de pyContracts à typeguard, pourquoi nous passons à typeguard et ce que nous nous retrouvons. Et aujourd'hui, je vais vous en dire plus sur la façon dont cette transition se produit.



Une déclaration de fonction avec pyContracts ressemble généralement à ceci:


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 """ 

Ceci est un exemple abstrait, car je n'ai pas trouvé dans notre projet une définition d'une fonction qui soit courte et significative en termes de nombre de cas pour la vérification de type. En règle générale, les définitions de pyContracts sont stockées dans des fichiers qui ne contiennent aucune autre logique. Notez qu'ici, User est une classe d'utilisateurs spécifique et qu'elle n'est pas importée directement.


Et voici le résultat souhaité avec 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: ... 

En général, il y a tellement de fonctions et de méthodes avec vérification de type dans le projet que si vous les empilez dans une pile, vous pouvez atteindre la lune. Donc, les traduire manuellement de pyContracts en typeguard n'est tout simplement pas possible (j'ai essayé!). J'ai donc décidé d'écrire un script.


Le script est divisé en deux parties: l'une met en cache les importations de nouveaux contrats et la seconde traite du refactoring de code.


Je veux noter que ni l'un ni l'autre script ne prétend être universel. Nous n'avions pas pour objectif d'écrire un outil pour résoudre tous les cas requis. Par conséquent, j'ai souvent omis le traitement automatique de certains cas spéciaux, s'ils sont rarement trouvés dans le projet, il est plus rapide de le réparer à la main. Par exemple, le script pour générer la cartographie des contrats et des importations a collecté 90% des valeurs, les 10% restants étant des cartographies artisanales.


La logique du script pour générer le mappage:


Étape 1. Parcourez tous les fichiers du projet, lisez-les. Pour chaque fichier:


  • si la sous-chaîne "@new_contract" n'est pas présente, sautez ce fichier,
  • le cas échéant, divisez le fichier par la ligne "@new_contract". Pour chaque article:
    - analyse pour la définition et l'importation,
    - en cas de succès, écrivez dans le fichier de réussite,
    - sinon, écrivez dans le fichier d'erreur.

Étape 2. Traiter manuellement les erreurs


Maintenant que nous avons les noms de tous les types que pyContracts utilise (ils ont été définis avec le décorateur new_contract), et que nous avons toutes les importations nécessaires, nous pouvons écrire du code pour le refactoring. Pendant que je traduisais manuellement de pyContracts en typeguard, j'ai réalisé ce dont j'avais besoin à partir du script:


  1. Il s'agit d'une commande qui prend un nom de module comme argument (plusieurs peuvent être utilisés), dans laquelle la syntaxe des annotations de fonction doit être remplacée.
  2. Parcourez tous les fichiers du module, lisez-les. Pour chaque fichier:
    • s'il n'y a pas de sous-chaîne «@contract», ignorez ce fichier;
    • si c'est le cas, transformez le code en ast (arbre de syntaxe abstraite);
    • trouver toutes les fonctions qui sont sous le décorateur de contrat pour chacun:
      • obtenir un dockstring, analyser, puis supprimer,
      • créer un dictionnaire de la forme {arg_name: arg_type}, l'utiliser pour remplacer l'annotation de fonction,
      • rappelez-vous de nouvelles importations,
    • écrire l'arbre modifié dans un fichier via astunparse;
    • ajoutez de nouvelles importations en haut du fichier;
    • remplacez les lignes "@contract" par "@typechecked" car c'est plus facile que via ast.

Résoudre la question "ce nom est-il déjà importé dans ce fichier?" Je n'avais pas l'intention de le faire dès le début: avec ce problème, nous allons faire face à une exécution supplémentaire de la bibliothèque isort.


Mais après avoir exécuté la première version du script, des questions se sont posées qui devaient encore être résolues. Il s'est avéré que 1) ast n'est pas omnipotent, 2) astunparse est plus omnipotent que nous le souhaiterions. Cela s'est manifesté comme suit:


  • au moment de la transition vers l'arbre de syntaxe, tous les commentaires sur une seule ligne disparaissent du code;
  • les lignes vides disparaissent également;
  • ast ne fait pas de distinction entre les fonctions et les méthodes de la classe, nous avons dû ajouter de la logique;
  • inversement, lors du passage d'une arborescence à un code, les commentaires sur plusieurs lignes entre guillemets triples sont écrits dans des commentaires entre guillemets simples et occupent une ligne, et les nouveaux sauts de ligne sont remplacés par \ n;
  • des parenthèses inutiles apparaissent, par exemple si A et B et C ou D devient if ((A et B et C) ou D).

Le code passé par ast et astunparse continue de fonctionner, mais sa lisibilité est réduite.


L'inconvénient le plus grave de ce qui précède est la disparition des commentaires sur une seule ligne (dans d'autres cas, nous ne perdons rien, mais seulement des gains - entre parenthèses, par exemple). L'horrible bibliothèque basée sur ast, astunparse et tokenize promet de le comprendre. Promet et fait.


Maintenant, les lignes vides. Il y avait deux solutions possibles:


  1. tokenize sait comment déterminer la «partie parole» d'un python, et horast en profite lorsqu'il obtient des jetons de type commentaire. Mais tokenize a également des jetons comme NewLine et NL. Donc, vous devez voir comment horast restaure les commentaires et les copier, en remplaçant le type de jeton.
    - Anya suggérée, expérience dans le développement de 2 mois
  2. Étant donné que horast peut restaurer les commentaires, nous remplaçons d'abord toutes les lignes vides par un commentaire spécifique, puis ignorons horast et remplaçons notre commentaire par une ligne vide.
    - est venu avec Eugene, expérience dans le développement de 8 ans

Je dirai un peu plus bas sur les guillemets triples dans les commentaires, et il était assez facile de mettre en place des crochets supplémentaires, d'autant plus que certains d'entre eux sont supprimés par mise en forme automatique.


Dans horast, nous utilisons deux fonctions: analyser et annuler l'analyse, mais les deux ne sont pas idéales - l'analyse contient des erreurs internes étranges, dans de rares cas, elle ne peut pas analyser le code source, et l'analyse ne peut pas écrire quelque chose qui a un type type (un tel type qui Il s'avère que vous tapez (any_other_type)).


J'ai décidé de ne pas traiter l'analyse, car la logique du travail est assez confuse et les exceptions sont rares - le principe de non-universalité fonctionne ici.


Mais l'imprévisibilité fonctionne très clairement et assez élégamment. La fonction unparse crée une instance de la classe Unparser qui, dans init, traite l'arborescence puis l'écrit dans un fichier. Horast.Unparser est successivement hérité de nombreux autres Unparsers, où la classe la plus basique est astunparse.Unparser. Toutes les classes descendantes étendent simplement les fonctionnalités de la classe de base, mais la logique du travail reste la même, alors pensez à astunparse.Unparser. Il a cinq méthodes importantes:


  1. écrire - écrit simplement quelque chose dans un fichier.
  2. fill - utilise l'écriture en fonction du nombre de retraits (le nombre de retraits est stocké en tant que champ de classe).
  3. enter - augmente le retrait.
  4. congé - réduit le retrait.
  5. dispatch - détermine le type du nœud de l'arbre (disons T), appelle la méthode correspondante par le nom du type de nœud, mais avec un trait de soulignement (c'est-à-dire _T). Il s'agit d'une méta méthode.

Toutes les autres méthodes sont des méthodes de la forme _T, par exemple, _Module ou _Str. Dans chacune de ces méthodes, il peut: 1) répartir de manière récursive pour les nœuds de sous-arbre, ou 2) utiliser l'écriture pour écrire le contenu du nœud ou ajouter des caractères et des mots clés afin que le résultat soit une expression valide en python.


Par exemple, nous sommes tombés sur un nœud de type arg, dans lequel ast stocke le nom de l'argument et le nœud d'annotation. Ensuite, dispatch appellera la méthode _arg, qui notera d'abord le nom de l'argument, puis rédigera les deux points et exécutera dispatch pour le noeud d'annotation, où le sous-arbre d'annotation sera analysé, et dispatch et write seront toujours appelés pour ce sous-arbre.


Revenons à notre problème d'impossibilité de traitement de type type. Maintenant que vous comprenez comment fonctionne l’analyse, il est facile de créer votre type. Créons un type:


 class NewType(object): def __init__ (self, t): self.s = ts 

Il stocke une chaîne en elle-même, et pas seulement comme ça: nous devons typifier les arguments de fonction, et nous obtenons les types d'arguments sous forme de chaînes à partir de l'ancrage. Par conséquent, remplaçons les annotations d'arguments non par les types dont nous avons besoin, mais par un objet NewType qui ne stocke que le nom du type souhaité à l'intérieur.


Pour ce faire, développez horast.Unparser - écrivez votre UnparserWithType, héritant de horast.Unparser, et ajoutez le traitement de notre nouveau type.


 class UnparserWithType(horast.Unparser): def _NewType (self, t): self.write(ts) 

Cela se combine avec l'esprit de la bibliothèque. Les noms des variables sont faits dans le style de ast, et c'est pourquoi ils se composent d'une seule lettre, et non parce que je ne peux pas penser aux noms. Je pense que t est court pour tree et s pour string. Soit dit en passant, NewType n'est pas une chaîne. Si nous voulions qu'il soit interprété comme un type de chaîne, nous devions alors écrire des guillemets avant et après l'appel en écriture.


Et maintenant la magie patch singe: remplacez horast.Unparser par notre UnparserWithType.


Comment ça marche maintenant: nous avons un arbre de syntaxe, il a une fonction, les fonctions ont des arguments, les arguments ont des annotations de type, une aiguille est cachée dans l'annotation de type, et la mort de Koshcheev y est cachée. Auparavant, il n'y avait aucun nœud d'annotation, nous les avons créés et un tel nœud est une instance de NewType. Nous appelons la fonction non analysée pour notre arbre, et pour chaque nœud, elle appelle dispatch, qui classe ce nœud et appelle sa fonction correspondante. Dès que la fonction de répartition reçoit le nœud de l'argument, elle écrit le nom de l'argument, puis cherche s'il y a une annotation (auparavant, mais nous y mettons NewType), si c'est le cas, elle écrit deux points et appelle la répartition pour l'annotation, qui appelle notre _NewType, qui écrit simplement la chaîne qu'il stocke - c'est le nom du type. En conséquence, nous obtenons l'argument écrit: type.


En fait, ce n'est pas entièrement légal. Du point de vue du compilateur, nous avons noté les annotations des arguments avec des mots qui ne sont définis nulle part, donc quand unparse termine son travail, nous obtenons le mauvais code: nous avons besoin d'importations. Je forme simplement une ligne du format correct et je l'ajoute au début du fichier, puis j'ajoute le résultat à l'analyse, bien que je puisse ajouter des importations en tant que nœuds à l'arbre de syntaxe, car ast prend en charge les nœuds Import et ImportFrom.


Résoudre le problème du guillemet triple n'est pas plus difficile que d'ajouter un nouveau type. Nous allons créer la classe StrType et la méthode _StrType. La méthode n'est pas différente de la méthode _NewType utilisée pour annoter les types, mais la définition de la classe a changé: nous allons stocker non seulement la chaîne elle-même, mais aussi le niveau de tabulation auquel elle doit être écrite. Le nombre d'indentation est défini comme suit: si cette ligne est rencontrée dans une fonction, alors une, si dans une méthode, puis deux, et il n'y a pas de cas où la fonction est définie dans le corps d'une autre fonction et décorée, dans notre projet.


 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' 

En repr, nous définissons à quoi devrait ressembler notre gamme. Je pense que c'est loin d'être la seule solution, mais ça marche. On pourrait expérimenter avec astunparse.fill et astunparse.Unparser.indent, alors ce serait plus universel, mais cette idée m'est venue à l'esprit au moment de la rédaction de cet article.


Cette difficulté résolue prend fin. Après avoir exécuté mon script, le problème des importations cycliques se pose parfois, mais c'est une question d'architecture. Je n'ai pas trouvé de solution tierce prête à l'emploi, et gérer de tels cas dans le cadre de mon script semble être une complication sérieuse de la tâche. Probablement, avec l'aide de ast, il est possible de détecter et de résoudre les importations cycliques, mais cette idée doit être examinée séparément. En général, le nombre négligeable de tels incidents dans notre projet m'a complètement permis de ne pas les traiter automatiquement.


Une autre difficulté que j'ai rencontrée était le manque de traitement d'expression dans ast à partir de l'importation astro car un lecteur attentif sait déjà que le patch de singe est le remède pour toutes les maladies. Que ce soit ses devoirs pour lui, mais j'ai décidé de le faire: ajoutez simplement ces importations au fichier de mappage, car cette construction est généralement utilisée pour contourner le conflit de noms, et nous en avons peu.


Malgré les imperfections trouvées, le script fait ce qu'il était censé faire. Quel est le résultat:


  1. Le temps de lancement du projet est passé de 10 à 3 secondes;
  2. Le nombre de fichiers a diminué en raison de la suppression des définitions de new_contract. Les fichiers eux-mêmes ont été réduits: je n'ai pas mesuré, mais en moyenne le git totalisait n lignes ajoutées et 2n supprimées;
  3. Les IDE intelligents ont commencé à faire des indices différents, car maintenant ce ne sont plus des commentaires, mais des importations honnêtes;
  4. La lisibilité s'est améliorée;
  5. Quelque part, des parenthèses sont apparues.

Je vous remercie!


Liens utiles:


  1. Ast
  2. Horast
  3. Tous les types de nœuds ast et ce qui y est stocké
  4. Affiche magnifiquement l'arbre de syntaxe
  5. Isort

Source: https://habr.com/ru/post/fr480930/


All Articles