diff --git a/scodoc.py b/scodoc.py index 148916875..2e2f06d88 100755 --- a/scodoc.py +++ b/scodoc.py @@ -724,7 +724,13 @@ def generate_ens_calendars(): # generate-ens-calendars @app.cli.command() +@click.option( + "-e", + "--endpoint", + default="api", + help="Endpoint à partir duquel générer la carte des routes", +) @with_appcontext -def gen_api_map(): - """Show the API map""" - tools.gen_api_map(app) +def gen_api_map(endpoint): + """Génère la carte des routes de l'API.""" + tools.gen_api_map(app, endpoint_start=endpoint) diff --git a/tools/create_api_map.py b/tools/create_api_map.py index f0383e66a..953f384a6 100644 --- a/tools/create_api_map.py +++ b/tools/create_api_map.py @@ -1,15 +1,48 @@ +""" +Script permettant de générer une carte SVG de l'API de ScoDoc + +Écrit par Matthias HARTMANN +""" + import xml.etree.ElementTree as ET import re class COLORS: - BLUE = "rgb(114,159,207)" - GREEN = "rgb(165,214,165)" - PINK = "rgb(230,156,190)" - GREY = "rgb(224,224,224)" + """ + Couleurs utilisées pour les éléments de la carte + """ + + BLUE = "rgb(114,159,207)" # Couleur de base / élément simple + GREEN = "rgb(165,214,165)" # Couleur route GET / valeur query + PINK = "rgb(230,156,190)" # Couleur route POST + GREY = "rgb(224,224,224)" # Couleur séparateur class Token: + """ + Classe permettant de représenter un élément de l'API + Exemple : + + /ScoDoc/api/test + + Token(ScoDoc)-> Token(api) -> Token(test) + + Chaque token peut avoir des enfants (Token) et des paramètres de query + Chaque token dispose + d'un nom (texte écrit dans le rectangle), + d'une méthode (GET ou POST) par défaut GET, + d'une func_name (nom de la fonction associée à ce token) + [OPTIONNEL] d'une query (dictionnaire {param: }) + + Un token est une leaf si il n'a pas d'enfants. + Une LEAF possède un `?` renvoyant vers la doc de la route + + Il est possible de forcer un token à être une pseudo LEAF en mettant force_leaf=True + Une PSEUDO LEAF possède aussi un `?` renvoyant vers la doc de la route + tout en ayant des enfants. + """ + def __init__(self, name, method="GET", query=None, leaf=False): self.children: list["Token"] = [] self.name: str = name @@ -19,24 +52,42 @@ class Token: self.func_name = "" def add_child(self, child): + """ + Ajoute un enfant à ce token + """ self.children.append(child) def find_child(self, name): + """ + Renvoie l'enfant portant le nom `name` ou None si aucun enfant ne correspond + """ for child in self.children: if child.name == name: return child return None def __repr__(self, level=0): + """ + représentation textuelle simplifiée de l'arbre + (ne prend pas en compte les query, les méthodes, les func_name, ...) + """ ret = "\t" * level + f"({self.name})\n" for child in self.children: ret += child.__repr__(level + 1) return ret def is_leaf(self): + """ + Renvoie True si le token est une leaf, False sinon + (i.e. s'il n'a pas d'enfants) + (force_leaf n'est pas pris en compte ici) + """ return len(self.children) == 0 def get_height(self, y_step): + """ + Renvoie la hauteur de l'élément en prenant en compte la hauteur de ses enfants + """ # Calculer la hauteur totale des enfants children_height = sum(child.get_height(y_step) for child in self.children) @@ -51,14 +102,19 @@ class Token: def to_svg_group( self, - x_offset=0, - y_offset=0, - x_step=150, - y_step=50, - parent_coords=None, - parent_children_nb=0, + x_offset: int = 0, + y_offset: int = 0, + x_step: int = 150, + y_step: int = 50, + parent_coords: tuple[tuple[int, int], tuple[int, int]] = None, + parent_children_nb: int = 0, ): - group = ET.Element("g") + """ + Transforme un token en un groupe SVG + (récursif, appelle la fonction sur ses enfants) + """ + + group = ET.Element("g") # groupe principal color = COLORS.BLUE if self.is_leaf(): if self.method == "GET": @@ -66,22 +122,27 @@ class Token: elif self.method == "POST": color = COLORS.PINK + # Création du rectangle avec le nom du token et placement sur la carte element = _create_svg_element(self.name, color) element.set("transform", f"translate({x_offset}, {y_offset})") + group.append(element) + + # On récupère les coordonnées de début et de fin de l'élément pour les flèches current_start_coords, current_end_coords = _get_anchor_coords( element, x_offset, y_offset ) + # Préparation du lien vers la doc de la route href = "#" + self.func_name.replace("_", "-") if self.query: href += "-query" question_mark_group = _create_question_mark_group(current_end_coords, href) - group.append(element) - # Add an arrow from the parent element to the current element + # Ajout de la flèche partant du parent jusqu'à l'élément courant if parent_coords and parent_children_nb > 1: arrow = _create_arrow(parent_coords, current_start_coords) group.append(arrow) + # Ajout du `/` si le token n'est pas une leaf (ne prend pas en compte force_leaf) if not self.is_leaf(): slash_group = _create_svg_element("/", COLORS.GREY) slash_group.set( @@ -89,6 +150,7 @@ class Token: f"translate({x_offset + _get_element_width(element)}, {y_offset})", ) group.append(slash_group) + # Ajout du `?` si le token est une leaf et possède une query if self.is_leaf() and self.query: slash_group = _create_svg_element("?", COLORS.GREY) slash_group.set( @@ -96,17 +158,23 @@ class Token: f"translate({x_offset + _get_element_width(element)}, {y_offset})", ) group.append(slash_group) + + # Actualisation des coordonnées de fin current_end_coords = _get_anchor_coords(group, 0, 0)[1] + # Gestion des éléments de la query + # Pour chaque élément on va créer : + # (param) (=) (valeur) (&) query_y_offset = y_offset query_sub_element = ET.Element("g") for key, value in self.query.items(): + # Création d'un sous-groupe pour chaque élément de la query sub_group = ET.Element("g") - # = + # On décale l'élément de la query vers la droite par rapport à l'élément parent translate_x = x_offset + x_step - # + # création élément (param) param_el = _create_svg_element(key, COLORS.BLUE) param_el.set( "transform", @@ -114,15 +182,16 @@ class Token: ) sub_group.append(param_el) - # add Arrow from "query" to element + # Ajout d'une flèche partant de l'élément "query" vers le paramètre courant coords = ( current_end_coords, _get_anchor_coords(param_el, translate_x, query_y_offset)[0], ) sub_group.append(_create_arrow(*coords)) - # = + # création élément (=) equal_el = _create_svg_element("=", COLORS.GREY) + # On met à jour le décalage en fonction de l'élément précédent translate_x = x_offset + x_step + _get_element_width(param_el) equal_el.set( "transform", @@ -130,8 +199,9 @@ class Token: ) sub_group.append(equal_el) - # + # création élément (value) value_el = _create_svg_element(value, COLORS.GREEN) + # On met à jour le décalage en fonction des éléments précédents translate_x = ( x_offset + x_step @@ -142,9 +212,13 @@ class Token: f"translate({translate_x}, {query_y_offset})", ) sub_group.append(value_el) + # Si il y a qu'un seul élément dans la query, on ne met pas de `&` if len(self.query) == 1: continue + + # création élément (&) ampersand_group = _create_svg_element("&", "rgb(224,224,224)") + # On met à jour le décalage en fonction des éléments précédents translate_x = ( x_offset + x_step @@ -156,17 +230,29 @@ class Token: ) sub_group.append(ampersand_group) + # On décale le prochain élément de la query vers le bas query_y_offset += y_step * 1.33 - + # On ajoute le sous-groupe (param = value &) au groupe de la query query_sub_element.append(sub_group) + + # On ajoute le groupe de la query à l'élément principal group.append(query_sub_element) + # Gestion des enfants du Token + + # On met à jour les décalages en fonction des éléments précédents y_offset = query_y_offset current_y_offset = y_offset + + # Pour chaque enfant, on crée un groupe SVG de façon récursive for child in self.children: + # On décale l'enfant vers la droite par rapport à l'élément parent + # Si il n'y a qu'un enfant, alors on colle l'enfant à l'élément parent rel_x_offset = x_offset + _get_group_width(group) if len(self.children) > 1: rel_x_offset += x_step + + # On crée le groupe SVG de l'enfant child_group = child.to_svg_group( rel_x_offset, current_y_offset, @@ -175,10 +261,12 @@ class Token: parent_coords=current_end_coords, parent_children_nb=len(self.children), ) + # On ajoute le groupe de l'enfant au groupe principal group.append(child_group) + # On met à jour le décalage Y en fonction de la hauteur de l'enfant current_y_offset += child.get_height(y_step) - # add `?` circle a:href to element + # Ajout du `?` si le token est une pseudo leaf ou une leaf if self.force_leaf or self.is_leaf(): group.append(question_mark_group) @@ -186,23 +274,32 @@ class Token: def _create_svg_element(text, color="rgb(230,156,190)"): - # Dimensions and styling + """ + Fonction générale pour créer un élément SVG simple + (rectangle avec du texte à l'intérieur) + + text : texte à afficher dans l'élément + color : couleur de l'élément + """ + + # Paramètres de style de l'élément padding = 5 font_size = 16 rect_height = 30 rect_x = 10 rect_y = 20 - # Estimate the text width + # Estimation de la largeur du texte text_width = ( len(text) * font_size * 0.6 - ) # Estimate based on average character width + ) # On suppose que la largeur d'un caractère est 0.6 * font_size + # Largeur du rectangle = Largeur du texte + padding à gauche et à droite rect_width = text_width + padding * 2 - # Create the SVG group element + # Création du groupe SVG group = ET.Element("g") - # Create the rectangle + # Création du rectangle ET.SubElement( group, "rect", @@ -215,7 +312,7 @@ def _create_svg_element(text, color="rgb(230,156,190)"): }, ) - # Create the text element + # Création du texte text_element = ET.SubElement( group, "text", @@ -223,47 +320,66 @@ def _create_svg_element(text, color="rgb(230,156,190)"): "x": str(rect_x + padding), "y": str( rect_y + rect_height / 2 + font_size / 2.5 - ), # Adjust to vertically center the text + ), # Ajustement pour centrer verticalement "font-family": "Courier New, monospace", "font-size": str(font_size), "fill": "black", "style": "white-space: pre;", }, ) + + # Ajout du texte à l'élément text_element.text = text return group def _get_anchor_coords(element, x_offset, y_offset): + """ + Récupération des coordonnées des points d'ancrage d'un élément SVG + (début et fin de l'élément pour les flèches) + (le milieu vertical de l'élément est utilisé pour les flèches) + """ bbox = _get_bbox(element, x_offset, y_offset) startX = bbox["x_min"] endX = bbox["x_max"] + # Milieu vertical de l'élément y = bbox["y_min"] + (bbox["y_max"] - bbox["y_min"]) / 2 return (startX, y), (endX, y) def _create_arrow(start_coords, end_coords): + """ + Création d'une flèche entre deux points + """ + # On récupère les coordonnées de début et de fin de la flèche start_x, start_y = start_coords end_x, end_y = end_coords + # On calcule le milieu horizontal de la flèche mid_x = (start_x + end_x) / 2 + # On crée le chemin de la flèche path_data = ( f"M {start_x},{start_y} L {mid_x},{start_y} L {mid_x},{end_y} L {end_x},{end_y}" ) - + # On crée l'élément path de la flèche path = ET.Element( "path", { "d": path_data, "style": "stroke:black;stroke-width:2;fill:none", - "marker-end": "url(#arrowhead)", + "marker-end": "url(#arrowhead)", # Ajout de la flèche à la fin du path }, ) return path def _get_element_width(element): + """ + Retourne la largueur d'un élément simple + L'élément simple correspond à un rectangle avec du texte à l'intérieur + on récupère la largueur du rectangle + """ rect = element.find("rect") if rect is not None: return float(rect.get("width", 0)) @@ -271,18 +387,27 @@ def _get_element_width(element): def _get_group_width(group): + """ + Récupère la largeur d'un groupe d'éléments + on fait la somme des largeurs de chaque élément du groupe + """ return sum(_get_element_width(child) for child in group) def _create_question_mark_group(coords, href): + """ + Création d'un groupe SVG contenant un cercle et un lien vers la doc de la route + le `?` renvoie vers la doc de la route + """ + # Récupération du point d'ancrage de l'élément x, y = coords - radius = 10 # Radius of the circle + radius = 10 # Rayon du cercle y -= radius * 2 - font_size = 17 # Font size of the question mark + font_size = 17 # Taille de la police group = ET.Element("g") - # Create the circle + # Création du cercle ET.SubElement( group, "circle", @@ -296,17 +421,17 @@ def _create_question_mark_group(coords, href): }, ) - # Create the link element + # Création du lien (a) vers la doc de la route link = ET.Element("a", {"href": href}) - # Create the text element + # Création du texte `?` text_element = ET.SubElement( link, "text", { "x": str(x + 1), - "y": str(y + font_size / 3), # Adjust to vertically center the text - "text-anchor": "middle", # Center the text horizontally + "y": str(y + font_size / 3), # Ajustement pour centrer verticalement + "text-anchor": "middle", # Centrage horizontal "font-family": "Arial", "font-size": str(font_size), "fill": "black", @@ -319,23 +444,49 @@ def _create_question_mark_group(coords, href): return group -def gen_api_map(app): +def gen_api_map(app, endpoint_start="api"): + """ + Fonction permettant de générer une carte SVG de l'API de ScoDoc + Elle récupère les routes de l'API et les transforme en un arbre de Token + puis génère un fichier SVG à partir de cet arbre + """ + # Création du token racine api_map = Token("") + + # Parcours de toutes les routes de l'application for rule in app.url_map.iter_rules(): # On ne garde que les routes de l'API / APIWEB - if not rule.endpoint.lower().startswith("api"): + if not rule.endpoint.lower().startswith(endpoint_start.lower()): continue + # Transformation de la route en segments + # ex : /ScoDoc/api/test -> ["ScoDoc", "api", "test"] segments = rule.rule.strip("/").split("/") + + # On positionne le token courant sur le token racine current_token = api_map + # Pour chaque segment de la route for i, segment in enumerate(segments): - # Check if the segment already exists in the current level + # On cherche si le segment est déjà un enfant du token courant child = current_token.find_child(segment) + + # Si ce n'est pas le cas on crée un nouveau token et on l'ajoute comme enfant if child is None: func = app.view_functions[rule.endpoint] - # If it's the last segment + # Si c'est le dernier segment, on marque le token comme une leaf + # On utilise force_leaf car il est possible que le token ne soit que + # momentanément une leaf + # ex : + # - /ScoDoc/api/test/ -> ["ScoDoc", "api", "test"] + # - /ScoDoc/api/test/1 -> ["ScoDoc", "api", "test", "1"] + # dans le premier cas test est une leaf, dans le deuxième cas test n'est pas une leaf + # force_leaf permet de forcer le token à être une leaf même s'il a des enfants + # permettant d'afficher le `?` renvoyant vers la doc de la route + # car la route peut être utilisée sans forcément la continuer. + if i == len(segments) - 1: + # Un Token sera query si parse_query_doc retourne un dictionnaire non vide child = Token( segment, leaf=True, @@ -345,16 +496,18 @@ def gen_api_map(app): child = Token( segment, ) + + # On ajoute le token comme enfant du token courant + # en donnant la méthode et le nom de la fonction associée child.func_name = func.__name__ method: str = "POST" if "POST" in rule.methods else "GET" child.method = method current_token.add_child(child) + + # On met à jour le token courant pour le prochain segment current_token = child - # Mark the last segment as a leaf node if it's not already marked - if not current_token.is_leaf(): - current_token.force_leaf = True - + # On génère le SVG à partir de l'arbre de Token generate_svg(api_map.to_svg_group(), "/tmp/api_map.svg") print( "La carte a été générée avec succès. " @@ -363,7 +516,12 @@ def gen_api_map(app): def _get_bbox(element, x_offset=0, y_offset=0): - # Helper function to calculate the bounding box of an SVG element + """ + Récupérer les coordonnées de la boîte englobante d'un élément SVG + Utilisé pour calculer les coordonnées d'un élément SVG et pour avoir la taille + total du SVG + """ + # Initialisation des coordonnées de la boîte englobante bbox = { "x_min": float("inf"), "y_min": float("inf"), @@ -371,17 +529,26 @@ def _get_bbox(element, x_offset=0, y_offset=0): "y_max": float("-inf"), } + # Parcours récursif des enfants de l'élément for child in element: + # On récupère la transformation (position) de l'enfant + # On met les OffSet par défaut à leur valeur donnée en paramètre transform = child.get("transform") child_x_offset = x_offset child_y_offset = y_offset + # Si la transformation est définie, on récupère les coordonnées de translation + # et on les ajoute aux offsets if transform: translate = transform.replace("translate(", "").replace(")", "").split(",") if len(translate) == 2: child_x_offset += float(translate[0]) child_y_offset += float(translate[1]) + # On regarde ensuite la boite englobante de l'enfant + # On met à jour les coordonnées de la boîte englobante en fonction de l'enfant + # x_min, y_min, x_max, y_max. + if child.tag == "rect": x = child_x_offset + float(child.get("x", 0)) y = child_y_offset + float(child.get("y", 0)) @@ -403,10 +570,16 @@ def _get_bbox(element, x_offset=0, y_offset=0): def generate_svg(element, fname): + """ + Génère un fichier SVG à partir d'un élément SVG + """ + # On récupère les dimensions de l'élément racine bbox = _get_bbox(element) - width = bbox["x_max"] - bbox["x_min"] + 80 # Add some padding - height = bbox["y_max"] - bbox["y_min"] + 80 # Add some padding + # On définit la taille du SVG en fonction des dimensions de l'élément racine + width = bbox["x_max"] - bbox["x_min"] + 80 + height = bbox["y_max"] - bbox["y_min"] + 80 + # Création de l'élément racine du SVG svg = ET.Element( "svg", { @@ -417,7 +590,8 @@ def generate_svg(element, fname): }, ) - # Define the marker for the arrowhead + # Création du motif de la flèche pour les liens + # (définition d'un marqueur pour les flèches) defs = ET.SubElement(svg, "defs") marker = ET.SubElement( defs, @@ -433,8 +607,10 @@ def generate_svg(element, fname): ) ET.SubElement(marker, "polygon", {"points": "0 0, 10 3.5, 0 7"}) + # Ajout de l'élément principal à l'élément racine svg.append(element) + # Écriture du fichier SVG tree = ET.ElementTree(svg) tree.write(fname, encoding="utf-8", xml_declaration=True) @@ -454,30 +630,39 @@ def parse_query_doc(doc): Dès qu'une ligne ne respecte pas ce format (voir regex dans la fonction), on arrête de parser Attention, la ligne ----- doit être collée contre QUERY et contre le premier paramètre """ - + # Récupérer les lignes de la doc lines = [line.strip() for line in doc.split("\n")] + # On cherche la ligne "QUERY" et on vérifie que la ligne suivante est "-----" + # Si ce n'est pas le cas, on renvoie un dictionnaire vide try: query_index = lines.index("QUERY") if lines[query_index + 1] != "-----": return {} except ValueError: return {} - + # On récupère les lignes de la doc qui correspondent à la query (enfin on espère) query_lines = lines[query_index + 2 :] query = {} regex = re.compile(r"^(\w+):(<.+>)$") for line in query_lines: + # On verifie que la ligne respecte le format attendu + # Si ce n'est pas le cas, on arrête de parser parts = regex.match(line) if not parts: break + # On récupère le paramètre et son type:nom param, type_nom_param = parts.groups() + # On ajoute le paramètre au dictionnaire query[param] = type_nom_param return query if __name__ == "__main__": + # Exemple d'utilisation de la classe Token + # Exemple simple de création d'un arbre de Token + root = Token("api") child1 = Token("assiduites", leaf=True) child1.func_name = "assiduites_get"