forked from ScoDoc/ScoDoc
949 lines
31 KiB
Python
949 lines
31 KiB
Python
"""
|
|
Script permettant de générer une carte SVG de l'API de ScoDoc
|
|
|
|
Écrit par Matthias HARTMANN
|
|
"""
|
|
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
import re
|
|
|
|
from app.auth.models import Permission
|
|
from flask import render_template
|
|
|
|
|
|
class COLORS:
|
|
"""
|
|
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: <type:nom_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
|
|
self.method: str = method
|
|
self.query: dict[str, str] = query or {}
|
|
self.force_leaf: bool = leaf
|
|
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)
|
|
|
|
# Calculer la hauteur des éléments de la query
|
|
query_height = len(self.query) * (y_step * 1.33)
|
|
|
|
# La hauteur totale est la somme de la hauteur des enfants et des éléments de la query
|
|
height = children_height + query_height
|
|
if height == 0:
|
|
height = y_step
|
|
return height
|
|
|
|
def to_svg_group(
|
|
self,
|
|
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,
|
|
):
|
|
"""
|
|
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":
|
|
color = COLORS.GREEN
|
|
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 and not href.endswith("-query"):
|
|
href += "-query"
|
|
question_mark_group = _create_question_mark_group(current_end_coords, href)
|
|
|
|
# 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(
|
|
"transform",
|
|
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(
|
|
"transform",
|
|
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 + _get_group_width(group) + x_step // 2
|
|
|
|
# création élément (param)
|
|
param_el = _create_svg_element(key, COLORS.BLUE)
|
|
param_el.set(
|
|
"transform",
|
|
f"translate({translate_x}, {query_y_offset})",
|
|
)
|
|
sub_group.append(param_el)
|
|
|
|
# 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 += _get_element_width(param_el)
|
|
equal_el.set(
|
|
"transform",
|
|
f"translate({translate_x}, {query_y_offset})",
|
|
)
|
|
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 += _get_element_width(equal_el)
|
|
value_el.set(
|
|
"transform",
|
|
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:
|
|
query_sub_element.append(sub_group)
|
|
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 += _get_element_width(value_el)
|
|
ampersand_group.set(
|
|
"transform",
|
|
f"translate({translate_x}, {query_y_offset})",
|
|
)
|
|
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,
|
|
x_step,
|
|
y_step,
|
|
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)
|
|
|
|
# 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)
|
|
|
|
return group
|
|
|
|
|
|
def _create_svg_element(text, color="rgb(230,156,190)"):
|
|
"""
|
|
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
|
|
|
|
# Estimation de la largeur du texte
|
|
text_width = (
|
|
len(text) * font_size * 0.6
|
|
) # 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
|
|
|
|
# Création du groupe SVG
|
|
group = ET.Element("g")
|
|
|
|
# Création du rectangle
|
|
ET.SubElement(
|
|
group,
|
|
"rect",
|
|
{
|
|
"x": str(rect_x),
|
|
"y": str(rect_y),
|
|
"width": str(rect_width),
|
|
"height": str(rect_height),
|
|
"style": f"fill:{color};stroke:black;stroke-width:2;fill-opacity:1;stroke-opacity:1",
|
|
},
|
|
)
|
|
|
|
# Création du texte
|
|
text_element = ET.SubElement(
|
|
group,
|
|
"text",
|
|
{
|
|
"x": str(rect_x + padding),
|
|
"y": str(
|
|
rect_y + rect_height / 2 + font_size / 2.5
|
|
), # 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)", # 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))
|
|
return 0
|
|
|
|
|
|
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 # Rayon du cercle
|
|
y -= radius * 2
|
|
font_size = 17 # Taille de la police
|
|
|
|
group = ET.Element("g")
|
|
|
|
# Création du cercle
|
|
ET.SubElement(
|
|
group,
|
|
"circle",
|
|
{
|
|
"cx": str(x),
|
|
"cy": str(y),
|
|
"r": str(radius),
|
|
"fill": COLORS.GREY,
|
|
"stroke": "black",
|
|
"stroke-width": "2",
|
|
},
|
|
)
|
|
|
|
# Création du lien (a) vers la doc de la route
|
|
link = ET.Element("a", {"href": href})
|
|
|
|
# Création du texte `?`
|
|
text_element = ET.SubElement(
|
|
link,
|
|
"text",
|
|
{
|
|
"x": str(x + 1),
|
|
"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",
|
|
},
|
|
)
|
|
text_element.text = "?"
|
|
|
|
group.append(link)
|
|
|
|
return group
|
|
|
|
|
|
def analyze_api_routes(app, endpoint_start: str) -> tuple:
|
|
"""Parcours de toutes les routes de l'application
|
|
analyse docstrings
|
|
"""
|
|
# Création du token racine
|
|
api_map = Token("")
|
|
|
|
doctable_lines: dict[str, dict] = {}
|
|
|
|
for rule in app.url_map.iter_rules():
|
|
# On ne garde que les routes de l'API / APIWEB
|
|
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
|
|
|
|
# Récupération de la fonction associée à la route
|
|
func = app.view_functions[rule.endpoint]
|
|
doc_dict = _parse_doc_string(func.__doc__ or "")
|
|
func_name = doc_dict.get("DOC_ANCHOR", [None])[0] or func.__name__
|
|
# Pour chaque segment de la route
|
|
for i, segment in enumerate(segments):
|
|
# 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:
|
|
# 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,
|
|
query=parse_query_doc(func.__doc__ or ""),
|
|
)
|
|
else:
|
|
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)
|
|
|
|
# Gestion de doctable
|
|
doctable = parse_doctable_doc(func.__doc__ or "")
|
|
href = func_name.replace("_", "-")
|
|
if child.query and not href.endswith("-query"):
|
|
href += "-query"
|
|
|
|
# category
|
|
|
|
category: str = func.__module__.replace("app.api.", "")
|
|
mod_doc: str = sys.modules[func.__module__].__doc__ or ""
|
|
mod_doc_dict: dict = _parse_doc_string(mod_doc)
|
|
if mod_doc_dict.get("CATEGORY"):
|
|
category = mod_doc_dict["CATEGORY"][0].strip()
|
|
|
|
permissions: str
|
|
try:
|
|
permissions: str = ", ".join(
|
|
sorted(Permission.permissions_names(func.scodoc_permission))
|
|
)
|
|
except AttributeError:
|
|
permissions = "Aucune permission requise"
|
|
|
|
if func_name not in doctable_lines:
|
|
doctable_lines[func_name] = {
|
|
"doctable": doctable,
|
|
"method": method,
|
|
"nom": func_name,
|
|
"href": href,
|
|
"permission": permissions,
|
|
"description": doc_dict.get("", ""),
|
|
"params": doc_dict.get("PARAMS", ""),
|
|
"category": doc_dict.get("CATEGORY", [False])[0] or category,
|
|
}
|
|
|
|
# On met à jour le token courant pour le prochain segment
|
|
current_token = child
|
|
if func_name in doctable_lines: # endpoint déjà ajouté, ajoute au besoin route
|
|
doctable_lines[func_name]["routes"] = doctable_lines[func_name].get(
|
|
"routes", []
|
|
) + [rule.rule]
|
|
return api_map, doctable_lines
|
|
|
|
|
|
# point d'entrée de la commande `flask gen-api-map`
|
|
def gen_api_map(app, endpoint_start="api.") -> str:
|
|
"""
|
|
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
|
|
"""
|
|
|
|
api_map, doctable_lines = analyze_api_routes(app, endpoint_start)
|
|
|
|
# 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. "
|
|
+ "Vous pouvez la consulter à l'adresse suivante : /tmp/api_map.svg"
|
|
)
|
|
|
|
# On génère le tableau à partir de doctable_lines
|
|
table = _gen_table(sorted(doctable_lines.values(), key=lambda x: x["nom"]))
|
|
_write_gen_table(table)
|
|
return table
|
|
|
|
|
|
def _get_bbox(element, x_offset=0, y_offset=0):
|
|
"""
|
|
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"),
|
|
"x_max": float("-inf"),
|
|
"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))
|
|
width = float(child.get("width", 0))
|
|
height = float(child.get("height", 0))
|
|
bbox["x_min"] = min(bbox["x_min"], x)
|
|
bbox["y_min"] = min(bbox["y_min"], y)
|
|
bbox["x_max"] = max(bbox["x_max"], x + width)
|
|
bbox["y_max"] = max(bbox["y_max"], y + height)
|
|
|
|
if len(child):
|
|
child_bbox = _get_bbox(child, child_x_offset, child_y_offset)
|
|
bbox["x_min"] = min(bbox["x_min"], child_bbox["x_min"])
|
|
bbox["y_min"] = min(bbox["y_min"], child_bbox["y_min"])
|
|
bbox["x_max"] = max(bbox["x_max"], child_bbox["x_max"])
|
|
bbox["y_max"] = max(bbox["y_max"], child_bbox["y_max"])
|
|
|
|
return bbox
|
|
|
|
|
|
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)
|
|
# 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",
|
|
{
|
|
"width": str(width),
|
|
"height": str(height),
|
|
"xmlns": "http://www.w3.org/2000/svg",
|
|
"viewBox": f"{bbox['x_min'] - 10} {bbox['y_min'] - 10} {width} {height}",
|
|
},
|
|
)
|
|
|
|
# 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,
|
|
"marker",
|
|
{
|
|
"id": "arrowhead",
|
|
"markerWidth": "10",
|
|
"markerHeight": "7",
|
|
"refX": "10",
|
|
"refY": "3.5",
|
|
"orient": "auto",
|
|
},
|
|
)
|
|
ET.SubElement(marker, "polygon", {"points": "0 0, 10 3.5, 0 7"})
|
|
|
|
# Ajoute un décalage vertical pour avoir un peu de padding en haut
|
|
element.set("transform", "translate(0, 10)")
|
|
|
|
# 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)
|
|
|
|
|
|
def _parse_doc_string(doc_string: str) -> dict[str, list[str]]:
|
|
"""Parse doc string and extract a dict:
|
|
{
|
|
"" : description_lines,
|
|
"keyword" : lines
|
|
}
|
|
|
|
In the docstring, each keyword is associated to a section like
|
|
|
|
KEYWORD
|
|
-------
|
|
...
|
|
(blank line)
|
|
|
|
All non blank lines not associated to a keyword go to description.
|
|
"""
|
|
doc_dict = {}
|
|
matches = re.finditer(
|
|
r"^\s*(?P<kw>[A-Z_\-]+)$\n^\s*-+\n(?P<txt>(^(?!\s*$).+$\n?)+)",
|
|
doc_string,
|
|
re.MULTILINE,
|
|
)
|
|
description = ""
|
|
i = 0
|
|
for match in matches:
|
|
start, end = match.span()
|
|
description += doc_string[i:start]
|
|
doc_dict[match.group("kw")] = [
|
|
x.strip() for x in match.group("txt").split("\n") if x.strip()
|
|
]
|
|
i = end
|
|
|
|
description += doc_string[i:]
|
|
doc_dict[""] = description.split("\n")
|
|
return doc_dict
|
|
|
|
|
|
def _get_doc_lines(keyword, doc_string: str) -> list[str]:
|
|
"""
|
|
Renvoie les lignes de la doc qui suivent le mot clé keyword
|
|
Attention : s'arrête à la première ligne vide
|
|
|
|
La doc doit contenir des lignes de la forme:
|
|
|
|
KEYWORD
|
|
-------
|
|
...
|
|
|
|
"""
|
|
# Récupérer les lignes de la doc
|
|
lines = [line.strip() for line in doc_string.split("\n")]
|
|
# On cherche la ligne "KEYWORD" et on vérifie que la ligne suivante est "-----"
|
|
# Si ce n'est pas le cas, on renvoie un dictionnaire vide
|
|
try:
|
|
kw_index = lines.index(keyword)
|
|
kw_line = "-" * len(keyword)
|
|
if lines[kw_index + 1] != kw_line:
|
|
return []
|
|
except ValueError:
|
|
return []
|
|
# On récupère les lignes de la doc qui correspondent au keyword (enfin on espère)
|
|
kw_lines = lines[kw_index + 2 :]
|
|
|
|
# On s'arrête à la première ligne vide
|
|
first_empty_line: int
|
|
try:
|
|
first_empty_line: int = kw_lines.index("")
|
|
except ValueError:
|
|
first_empty_line = len(kw_lines)
|
|
kw_lines = kw_lines[:first_empty_line]
|
|
return kw_lines
|
|
|
|
|
|
def parse_doc_name(doc_string: str) -> str:
|
|
"""
|
|
renvoie le nom de la route à partir de la docstring
|
|
|
|
La doc doit contenir des lignes de la forme:
|
|
|
|
DOC_ANCHOR
|
|
----------
|
|
nom_de_la_route
|
|
|
|
Il ne peut y avoir qu'une seule ligne suivant -----
|
|
|
|
"""
|
|
name_lines: list[str] = _get_doc_lines("DOC_ANCHOR", doc_string)
|
|
return name_lines[0] if name_lines else None
|
|
|
|
|
|
def parse_query_doc(doc_string: str) -> dict[str, str]:
|
|
"""
|
|
renvoie un dictionnaire {param: <type:nom_param>} (ex: {assiduite_id : <int:assiduite_id>})
|
|
|
|
La doc doit contenir des lignes de la forme:
|
|
|
|
QUERY
|
|
-----
|
|
param:<string:nom_param>
|
|
param1:<int:num>
|
|
param2:<array[string]:array_nom>
|
|
|
|
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
|
|
"""
|
|
query_lines: list[str] = _get_doc_lines("QUERY", doc_string)
|
|
|
|
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
|
|
|
|
|
|
def parse_doctable_doc(doc_string: str) -> dict[str, str]:
|
|
"""
|
|
Retourne un dictionnaire représentant les informations du tableau d'api
|
|
à partir de la doc (DOC-TABLE)
|
|
|
|
éléments optionnels:
|
|
- `permissions` permissions nécessaires pour accéder à la route (ScoView, AbsChange, ...)
|
|
- `href` nom (sans #) de l'ancre dans la page ScoDoc9API
|
|
|
|
DOC-TABLE
|
|
---------
|
|
permissions: ScoView
|
|
href: une-fonction
|
|
"""
|
|
|
|
doc_lines: list[str] = _get_doc_lines("DOC-TABLE", doc_string)
|
|
table = {}
|
|
|
|
# on parcourt les lignes de la doc
|
|
for line in doc_lines:
|
|
# On sépare le paramètre et sa valeur
|
|
param, value = line.split(":")
|
|
# On met à jour le dictionnaire
|
|
table[param.strip()] = value.strip()
|
|
|
|
return table
|
|
|
|
|
|
def _gen_table_line(
|
|
nom="", href="", method="", permission="", doctable: dict = None, **kwargs
|
|
):
|
|
"""
|
|
Génère une ligne de tableau markdown
|
|
|
|
| nom de la route| methode HTTP| Permission |
|
|
"""
|
|
lien: str = href
|
|
if "href" in doctable:
|
|
lien: str = doctable.get("href")
|
|
nav: str = f"[{nom}]({'#'+lien})"
|
|
|
|
table: str = "|"
|
|
for string in [nav, method, doctable.get("permissions") or permission]:
|
|
table += f" {string} |"
|
|
|
|
return table
|
|
|
|
|
|
def _gen_table_head() -> str:
|
|
"""
|
|
Génère la première ligne du tableau markdown
|
|
"""
|
|
|
|
headers: str = "| Route | Méthode | Permission |"
|
|
line: str = "|---|---|---|"
|
|
|
|
return f"{headers}\n{line}\n"
|
|
|
|
|
|
def _gen_table(lines: list[dict]) -> str:
|
|
"""
|
|
Génère un tableau markdown à partir d'une liste de lignes
|
|
|
|
lines : liste de dictionnaire au format :
|
|
|
|
- doctable : dict généré par parse_doctable_doc
|
|
- nom : nom de la fonction associée à la route
|
|
- method : GET ou POST
|
|
- permission : Permissions de la route (auto récupérée)
|
|
|
|
"""
|
|
table = _gen_table_head()
|
|
table += "\n".join([_gen_table_line(**line) for line in lines])
|
|
return table
|
|
|
|
|
|
def _write_gen_table(table: str, filename: str = "/tmp/api_table.md"):
|
|
"""Ecriture du fichier md avec la table"""
|
|
with open(filename, "w", encoding="UTF-8") as f:
|
|
f.write(table)
|
|
print(
|
|
f"Le tableau a été généré avec succès. Vous pouvez le consulter à l'adresse suivante : {filename}"
|
|
)
|
|
|
|
|
|
def doc_route(doctable: dict) -> str:
|
|
"""Generate markdown doc for a route"""
|
|
jinja_obj: dict = {}
|
|
jinja_obj.update(doctable)
|
|
jinja_obj["nom"] = doctable["nom"].strip() # on retire les caractères blancs
|
|
|
|
if doctable.get("params"):
|
|
jinja_obj["params"] = []
|
|
for param in doctable["params"]:
|
|
frags = param.split(":", maxsplit=1)
|
|
if len(frags) == 2:
|
|
name, descr = frags
|
|
jinja_obj["params"].append(
|
|
{"nom": name.strip(), "description": descr.strip()}
|
|
)
|
|
else:
|
|
print(f"Warning: {doctable['nom']} : invalid PARAMS {param}")
|
|
if doctable.get("description"):
|
|
descr = "\n".join(s for s in doctable["description"])
|
|
jinja_obj["description"] = descr.strip()
|
|
|
|
jinja_obj["sample"] = {
|
|
"nom": f"{jinja_obj['nom']}.json",
|
|
"href": f"{jinja_obj['nom'].replace('_', '-')}.json.md",
|
|
}
|
|
|
|
return render_template("doc/apidoc.j2", doc=jinja_obj)
|
|
|
|
|
|
def gen_api_doc(app, endpoint_start="api."):
|
|
"commande gen-api-doc"
|
|
_, doctable_lines = analyze_api_routes(app, endpoint_start)
|
|
|
|
mddoc: str = ""
|
|
|
|
categories: dict = {}
|
|
for value in doctable_lines.values():
|
|
category = value["category"]
|
|
if category not in categories:
|
|
categories[category] = []
|
|
categories[category].append(value)
|
|
|
|
# sort categories by name
|
|
categories: dict = dict(sorted(categories.items(), key=lambda x: x[0].capitalize()))
|
|
|
|
category: str
|
|
routes: list[dict]
|
|
for category, routes in categories.items():
|
|
# sort routes by name
|
|
routes.sort(key=lambda x: x["nom"])
|
|
|
|
mddoc += f"### API {category.capitalize()}\n\n"
|
|
for route in routes:
|
|
mddoc += doc_route(route)
|
|
mddoc += "\n\n"
|
|
|
|
table_api = gen_api_map(app, endpoint_start=endpoint_start)
|
|
mdpage = render_template("doc/ScoDoc9API.j2", doc_api=mddoc, table_api=table_api)
|
|
|
|
fname = "/tmp/ScoDoc9API.md"
|
|
with open(fname, "w", encoding="utf-8") as f:
|
|
f.write(mdpage)
|
|
print(
|
|
"La documentation API a été générée avec succès. "
|
|
f"Vous pouvez la consulter à l'adresse suivante : {fname}"
|
|
)
|