Génie logiciel
Dans le cadre de différents projets, on est amené à récupérer différents codes provenant de différents concepteurs déposés sur github. Dans une certaine mesure, ces codes dispose d’une documentation limitée et peu détaillée mais qui permet rapidement de prendre en mains ces codes pour une exécution et obtenir le résultat souhaité. Maintenant dans un contexte qui le permet où l’on souhaite faire évoluer le code pour rajouter ou modifier des instructions afin de l’adapter ou de le mettre à jour, il est nécessaire de se “plonger” de façon plus intense dans la compréhension des éléments et d’éviter de le dégrader.
Dans de dernier cas, plusieurs solutions ont été explorées comme Sphinx, reverse ainsi que Visual Paradigm mais rien de concluant et d’adapter pour nos objectifs. En effet, afin d’optimiser les développements et la réutilisation, il nous semble opportun de constituer une bibliothèque de composants logiciels. Cette bibliothèque que l’on se propose de constituer permettra d’identifier à partir de requêtes de haut niveau les blocs instructions intéressants et correspondants à son besoin. Pour ce faire, il est important à partir de fichiers sources d’un projet d’être capable de reconstituer différents schémas d’aide et d’analyse.
La solution que l’on souhaite mettre en oeuvre nommée ParsPy est de développer un parser pour effectuer ces analyses et ainsi constituer notre base de connaissance. Cette solution va s’appuyer sur deux éléments de fondations une base de données MongoDB et une base de données de type graphe Neo4J .
Objectifs de ce parser :
En input : un ou plusieurs fichiers python
Constituer une documentation de l’ensemble de ces fichiers en deux approches complémentaires :
- Première approche : recenser dans tous les fichiers d’un projet les informations de dépendances, les structures internes de données de type classe et les fonctions définies. Cette ensemble d’informations alimentera la base MongoDB et permettra d’interroger ce contenu avec des critères poussés. Dans cette partie on pourra pousser la vérification et le contrôle de la bonne manipulation des variables. Récupérer la documentation à travers les commentaires.
- Deuxième approche : pour l’ensemble du projet on souhaite reconstituer l’arbre des dépendances et l’arbre d’appel des fonctions selon un point d’entrée. Cette arbre doit permettre à l’utilisateur de visualiser et de naviguer sur l’enchaînement pour l’aider dans sa compréhension du fonctionnement de l’application.
Première approche : le parsing des sources python
Dans l’analyse du contenu des sources on va procéder par bloc :
- les dépendances
- les classes
- les variables
- les fonctions
Pour chaque bloc on va décrire le formalisme utilisé, l’information que l’on veut conserver et compléter, l’analyse que l’on peut en faire et les représentations qui en découlent.
Les dépendances : mots clés (import as from)
Dans cette première partie plusieurs formalismes sont utilisés pour importer des librairies dans un contexte d’exécution python. Les mots clés utilisés dans ce contexte sont :
- import
- import …. as
- from
La description des librairies à importer peut être sur une ou plusieurs lignes et avec séparateur , pour en indiquer plusieurs.
Exemples :
import time
from collections import defaultdict
from typing import TYPE_CHECKING
from tqdm import tqdm
from aizynthfinder.analysis import (
RouteCollection,
RouteSelectionArguments,
TreeAnalysis,
)
from aizynthfinder.chem import FixedRetroReaction, Molecule, TreeMolecule
from aizynthfinder.context.config import Configuration
Plusieurs aspects à traiter dans cette première partie, les dépendances vers des librairies “packagées” d’une version donnée que l’on pourra récupérer avec sa version à partir de fichier requirements.txt ou autres. Des librairies du projet lui même avec en préfixes le nom aizynthfinder et l’indication de sa localisation. De plus dans la partie import on pourra distinguer les fonctions des classes à importer et utiliser dans le code. Dans cette partie, on peut également rencontrer des instructions de type conditionnelle de type TYPE_CHECKING avec des instructions d’importation associées. On a pris le pli de ne pas en tenir compte dans notre contexte.
L’arborescence avec le point en délimiteur pose des problèmes dans le contexte MongoDB. En effet, le caractère . est interprété lors de l’insertion dans la base à partir de l’instruction mycol.insert_one(D_struct). Alors que MongoDB autorise ce caractère pour ses clés. Du coup, pour régler ce problème, on remplace le . par un –
▼Détail de l’interprétation
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
import yaml
from aizynthfinder.context.policy import ExpansionPolicy, FilterPolicy
from aizynthfinder.context.scoring import ScorerCollection
from aizynthfinder.context.stock import Stock
from aizynthfinder.utils.logging import logger
if TYPE_CHECKING:
from aizynthfinder.utils.type_utils import Any, Dict, List, Optional, StrDict, Union
"dependances" : {
"import" : ["os","re","yaml"],
"import_as" : [],
"from" : [
{"__future__" : "annotations"},
{"dataclasses" : "dataclass, field"},
{"typing" : "TYPE_CHECKING"},
{"aizynthfinder-context-policy" : "ExpansionPolicy, FilterPolicy"},
{"aizynthfinder-context-scoring" : "ScorerCollection"},
{"aizynthfinder-context-stock" : "Stock"},
{"aizynthfinder-utils-logging" : "logger"},
{"aizynthfinder-utils-type_utils" : "Any, Dict, List, Optional, StrDict,Union"}
],
"conditionnel" : []
},
Les classes : mot clé (class)
Dans le cadre de l’utilisation de classes on peut utiliser deux façons de les décrire avec la directive @dataclass (avec comme préalable l’importation de dataclass,et de field) ou utiliser la manière classique on préfixant le nom des variables de self et les méthodes de classes __init__ ….
Le parser doit pouvoir discriminer les deux formes et reconstituer l’ensemble des attributs d’une classe. On va prendre décrire le traitement à faire dans ces deux cas:
▼ Exemple de mise en oeuvre : sans directive
class AiZynthFinder:
def __init__(self, configfile: Optional[str] = None, configdict: Optional[StrDict] = None ) -> None:
self._logger = logger()
if configfile:
self.config = Configuration.from_file(configfile)
elif configdict:
self.config = Configuration.from_dict(configdict)
else:
self.config = Configuration()
self.expansion_policy = self.config.expansion_policy
self.filter_policy = self.config.filter_policy
self.stock = self.config.stock
self.scorers = self.config.scorers
self.tree: Optional[Union[MctsSearchTree, AndOrSearchTreeBase]] = None
self._target_mol: Optional[Molecule] = None
self.search_stats: StrDict = dict()
self.routes = RouteCollection([])
self.analysis: Optional[TreeAnalysis] = None
self._num_objectives = len(
self.config.search.algorithm_config.get("search_rewards", [])
)
"classes" : {
"AiZynthFinder" : {
"attributs" : {
"_logger" : {"type" : ""},
"config" : {"type" : ""},
"expansion_policy" : {"type" : ""},
"filter_policy" : {"type" : ""},
"stock" : {"type" : ""},
"scorers" : {"type" : ""},
"routes" : {"type" : ""},
"_num_objectives" : {"type" : ""},
"target_mol" : {"type" : ""},
"tree" : {"type" : ""},
"_target_mol" : {"type" : ""},
"analysis" : {"type" : ""},
"search_stats" : {"type" : ""}
},
▼ Exemple de mise en oeuvre : avec directive
@dataclass
class _PostprocessingConfiguration:
min_routes: int = 5
max_routes: int = 25
all_routes: bool = False
route_distance_model: Optional[str] = None
route_scorers: List[str] = field(default_factory=lambda: [])
scorer_weights: Optional[List[float]] = field(default_factory=lambda: None)
"classes" : {
"_PostprocessingConfiguration" : {
"attributs" : {
"min_routes" : {"type" : " int"},
"max_routes" : {"type" : " int"},
"all_routes" : {"type" : " bool"},
"route_distance_model" : {"type" : " Optional[str]"},
"route_scorers" : {"type" : " List[str]"},
"scorer_weights" : {"type" : " Optional[List[float]]"}
},
"def" : {}
},
Les variables :
Recensement de l’ensemble des variables utilisées par bloc d’instructions.
Les fonctions : mot clé (def)
La description de la fonction peut se trouver dans le contexte d’une classe ou dans le contexte globale de votre source. cela signifie qu’il faut conserver le contexte au préalable et identifier lors de changement de tabulation si on sort du contexte d’une déclaration de classe. Dans un premier temps on va référencer la signature de la fonction et l’inventorier au bon endroit (dans le bon contexte).
▼ Exemple de la structure complète
{
"_id" : ObjectId("6787c2017e9b73bd7d232b5c"),
"description" : "",
"dependances" : {
"import" : ["os","re","yaml"],
"import_as" : [],
"from" : [
{"__future__" : "annotations"},
{"dataclasses" : "dataclass, field"},
{"typing" : "TYPE_CHECKING"},
{"aizynthfinder-context-policy" : "ExpansionPolicy, FilterPolicy"},
{"aizynthfinder-context-scoring" : "ScorerCollection"},
{"aizynthfinder-context-stock" : "Stock"},
{"aizynthfinder-utils-logging" : "logger"},
{"aizynthfinder-utils-type_utils" : "Any, Dict, List, Optional, StrDict,Union"}
],
"conditionnel" : []
},
"def" : {
"_handle_bond_pair_tuples" : {"args" : ["bonds: List[List[int]]"],"return" : "List[List[int]]"}
},
"comments" : "",
"classes" : {
"_PostprocessingConfiguration" : {
"attributs" : {
"min_routes" : {"type" : " int"},
"max_routes" : {"type" : " int"},
"all_routes" : {"type" : " bool"},
"route_distance_model" : {"type" : " Optional[str]"},
"route_scorers" : {"type" : " List[str]"},
"scorer_weights" : {"type" : " Optional[List[float]]"}
},
"def" : {}
},
"_SearchConfiguration" : {
"attributs" : {
"algorithm" : {"type" : " str"},
"algorithm_config" : {"type" : " Dict[str, Any]"},
"default_factor" : {"type" : ""},
"max_transforms" : {"type" : " int"},
"iteration_limit" : {"type" : " int"},
"time_limit" : {"type" : " int"},
"return_first" : {"type" : " bool"},
"exclude_target_from_stock" : {"type" : " bool"},
"break_bonds" : {"type" : " List[List[int]]"},
"freeze_bonds" : {"type" : " List[List[int]]"},
"break_bonds_operator" : {"type" : " str"}
},
"def" : {}
},
"Configuration" : {
"attributs" : {},
"def" : {
"_post_init" : {"args" : ["self"],"return" : "None"},
"_eq" : {"args" : ["self","other: Any"],"return" : "bool"},
"from_dict" : {"args" : ["cls","source: StrDict"],"return" : "Configuration","comments" : "Loads a configuration from a dictionary structure. The parameters not set in the dictionary are taken from the default values. The policies and stocks specified are directly loaded. :param source: the dictionary source :return: a Configuration object with settings from the source"
},
"from_file" : {"args" : ["cls","filename: str"],"return" : "Configuration","comments" : "Loads a configuration from a yaml file. The parameters not set in the yaml file are taken from the default values. The policies and stocks specified in the yaml file are directly loaded. The parameters in the yaml file may also contain environment variables as values. :param filename: the path to a yaml file :return: a Configuration object with settings from the yaml file :raises: ValueError: if parameter's value expects an environment variable that does not exist in the current environment"
},
"_update_from_config" : {"args":["self","config: StrDict"],"return" : "None"}
},
"comments" : "Encapsulating the settings of the tree search, including the policy, the stock, the loaded scorers and various parameters."
}
}
}
Deuxième approche : élaboration des arbres de dépendance et d’appel
Dans cette partie, on va s’attacher au formalisme et aux différentes représentations souhaitées pour une aide à la compréhension. Pour ce faire, on va utiliser des instructions de Neo4J pour générer les nœuds (Node) et les relations (Link) dans cette base. L’usage de MERGE va être privilégier car elle vérifie avant la création de l’élément si il existe déjà alors que l’instruction CREATE renvoie une erreur si l’on tente de créer déjà un élément existant.
Exploration approfondie des fonctions :
Quelles sont les informations à remonter d’un bloc d’instructions contenant des fonctions et sous quelle forme compréhensible faut-il les amener ?
Si on prend un exemple pour guider le parsing :
▼ Détail d’une interprétation
class ReactionTreeFromDict(ReactionTreeLoader):
"""Creates a reaction tree object from a dictionary"""
def _load(self, tree_dict: StrDict) -> None: # type: ignore
if tree_dict.get("route_metadata"):
self.tree.created_at_iteration = tree_dict["route_metadata"].get(
"created_at_iteration"
)
self._parse_tree_dict(tree_dict)
def _parse_tree_dict(self, tree_dict: StrDict, ncalls: int = 0) -> UniqueMolecule:
product_node = UniqueMolecule(smiles=tree_dict["smiles"])
self._add_node(
product_node,
depth=2 * ncalls,
transform=ncalls,
hide=tree_dict.get("hide", False),
in_stock=tree_dict["in_stock"],
)
rxn_tree_dict = tree_dict.get("children", [])
if not rxn_tree_dict:
return product_node
rxn_tree_dict = rxn_tree_dict[0]
reaction_node = FixedRetroReaction(
product_node,
smiles=rxn_tree_dict["smiles"],
metadata=rxn_tree_dict.get("metadata", {}),
)
self._add_node(
reaction_node, depth=2 * ncalls + 1, hide=rxn_tree_dict.get("hide", False)
)
self.tree.graph.add_edge(product_node, reaction_node)
reactant_nodes = []
for reactant_tree in rxn_tree_dict.get("children", []):
reactant_node = self._parse_tree_dict(reactant_tree, ncalls + 1)
self.tree.graph.add_edge(reaction_node, reactant_node)
reactant_nodes.append(reactant_node)
reaction_node.reactants = (tuple(reactant_nodes),)
return product_node
Interprétation
CLASS ReactionTreeFromDict
PARENT: ReactionTreeLoader
DOC : "Creates a reaction .... dictionnary"
DEF: _load()
COND:
AFF: self.tree.created_at_iteration
CALL: self._parse_tree_dict()
DEF: _parse_tree_dict()
AFF: product_node <- CALL: UniqueMolecule()
CALL: self.add_node()
AFF: rxn_tree_dict <- CALL: tree_dict.get()
COND:
RET: product_node
AFF: rxn_tree_dict
AFF: reaction_node <- CALL FixedRetroReaction()
CALL: self.add_node()
CALL: self.tree.graph.add_egde()
AFF: reactant_nodes
BOUCL_PAR: rxn_tree_dict
AFF: reacatant_nodes <- CALL: self._parse_tree_dict()
CALL: self.tree.graph.add_edge()
CALL: reactant_nodes.append()
RET: product_node