diff --git a/app/api/assiduites.py b/app/api/assiduites.py index e31cb0f7b..5471d0a83 100644 --- a/app/api/assiduites.py +++ b/app/api/assiduites.py @@ -64,6 +64,11 @@ def assiduite(assiduite_id: int = None): "est_just": False or True, } ``` + + SAMPLES + ------- + /assiduite/1; + """ return get_model_api_object(Assiduite, assiduite_id, Identite) @@ -93,6 +98,11 @@ def assiduite_justificatifs(assiduite_id: int = None, long: bool = False): ... ] ``` + SAMPLES + ------- + /assiduite/1/justificatifs; + /assiduite/1/justificatifs/long; + """ return get_assiduites_justif(assiduite_id, long) @@ -156,6 +166,13 @@ def assiduites_count( metric: la/les métriques de comptage (journee, demi, heure, compte) split: divise le comptage par état + SAMPLES + ------- + /assiduites/1/count; + /assiduites/1/count/query?etat=retard; + /assiduites/1/count/query?split; + /assiduites/1/count/query?etat=present,retard&metric=compte,heure; + """ # Récupération de l'étudiant @@ -221,6 +238,7 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False) date_fin: etat: formsemestre_id: + with_justifs: PARAMS ----- @@ -231,6 +249,14 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False) date_fin:date de fin de l'assiduité (inférieur ou égal) etat:etat de l'étudiant → absent, present ou retard formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité + with_justif:ajoute les justificatifs liés à l'assiduité + + SAMPLES + ------- + /assiduites/1; + /assiduites/1/query?etat=retard; + /assiduites/1/query?moduleimpl_id=1; + /assiduites/1/query?with_justifs=; """ @@ -300,6 +326,11 @@ def assiduites_evaluations(etudid: int = None, nip=None, ine=None): ] } ] + + SAMPLES + ------- + /assiduites/1/evaluations; + ``` """ @@ -344,7 +375,7 @@ def evaluation_assiduites(evaluation_id): CATEGORY -------- - evaluations + Évaluations """ # Récupération de l'évaluation try: @@ -384,6 +415,7 @@ def assiduites_group(with_query: bool = False): etat: etudids: formsemestre_id: + with_justif: PARAMS ----- @@ -395,6 +427,11 @@ def assiduites_group(with_query: bool = False): etat:etat de l'étudiant → absent, present ou retard etudids:liste des ids des étudiants concernés par la recherche formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité + with_justifs:ajoute les justificatifs liés à l'assiduité + + SAMPLES + ------- + /assiduites/group/query?etudids=1,2,3; """ @@ -474,6 +511,13 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False): date_fin:date de fin de l'assiduité (inférieur ou égal) etat:etat de l'étudiant → absent, present ou retard formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité + + SAMPLES + ------- + /assiduites/formsemestre/1; + /assiduites/formsemestre/1/query?etat=retard; + /assiduites/formsemestre/1/query?moduleimpl_id=1; + """ # Récupération du formsemestre à partir du formsemestre_id @@ -549,6 +593,13 @@ def assiduites_formsemestre_count( formsemestre_id:l'identifiant du formsemestre concerné par l'assiduité metric: la/les métriques de comptage (journee, demi, heure, compte) split: divise le comptage par état + + SAMPLES + ------- + /assiduites/formsemestre/1/count; + /assiduites/formsemestre/1/count/query?etat=retard; + /assiduites/formsemestre/1/count/query?etat=present,retard&metric=compte,heure; + """ # Récupération du formsemestre à partir du formsemestre_id @@ -621,6 +672,11 @@ def assiduite_create(etudid: int = None, nip=None, ine=None): ] ``` + SAMPLES + ------- + /assiduite/1/create;[{""date_debut"": ""2023-10-27T08:00"",""date_fin"": ""2023-10-27T10:00"",""etat"": ""absent""}] + /assiduite/1/create;[{""date_debut"": ""2023-10-27T08:00"",""date_fin"": ""2023-10-27T10:00"",""etat"": ""absent""}] + """ # Récupération de l'étudiant etud: Identite = tools.get_etud(etudid, nip, ine) @@ -696,6 +752,11 @@ def assiduites_create(): ] ``` + SAMPLES + ------- + /assiduites/create;[{""etudid"":1,""date_debut"": ""2023-10-26T08:00"",""date_fin"": ""2023-10-26T10:00"",""etat"": ""absent""}] + /assiduites/create;[{""etudid"":-1,""date_debut"": ""2023-10-26T08:00"",""date_fin"": ""2023-10-26T10:00"",""etat"": ""absent""}] + """ create_list: list[object] = request.get_json(force=True) @@ -874,6 +935,10 @@ def assiduite_delete(): ] ``` + SAMPLES + ------- + /assiduite/delete;[2,2,3] + """ # Récupération des ids envoyés dans la liste assiduites_list: list[int] = request.get_json(force=True) @@ -958,6 +1023,13 @@ def assiduite_edit(assiduite_id: int): "est_just"?: bool } ``` + + SAMPLES + ------- + /assiduite/1/edit;{""etat"":""absent""} + /assiduite/1/edit;{""moduleimpl_id"":2} + /assiduite/1/edit;{""etat"": ""retard"",""moduleimpl_id"":3} + """ # Récupération de l'assiduité à modifier @@ -1013,6 +1085,12 @@ def assiduites_edit(): } ] ``` + SAMPLES + ------- + /assiduites/edit;[{""etat"":""absent"",""assiduite_id"":1}] + /assiduites/edit;[{""moduleimpl_id"":2,""assiduite_id"":1}] + /assiduites/edit;[{""etat"": ""retard"",""moduleimpl_id"":3,""assiduite_id"":1}] + """ edit_list: list[object] = request.get_json(force=True) diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index 2c129f7e8..cde11c050 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -55,6 +55,11 @@ def justificatif(justif_id: int = None): "user_id": 1 or null, } ``` + + SAMPLES + ------- + /justificatif/1; + """ return get_model_api_object( @@ -114,6 +119,12 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal order:retourne les justificatifs dans l'ordre décroissant (non vide = True) courant:retourne les justificatifs de l'année courante (bool : v/t ou f) group_id: + + SAMPLES + ------- + /justificatifs/1; + /justificatifs/1/query?etat=attente; + """ # Récupération de l'étudiant etud: Identite = tools.get_etud(etudid, nip, ine) @@ -176,6 +187,11 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False): order:retourne les justificatifs dans l'ordre décroissant (non vide = True) courant:retourne les justificatifs de l'année courante (bool : v/t ou f) group_id: + + SAMPLES + ------- + /justificatifs/dept/1; + """ # Récupération du département et des étudiants du département @@ -269,6 +285,11 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False): order:retourne les justificatifs dans l'ordre décroissant (non vide = True) courant:retourne les justificatifs de l'année courante (bool : v/t ou f) group_id: + + SAMPLES + ------- + /justificatifs/formsemestre/1; + """ # Récupération du formsemestre @@ -336,6 +357,9 @@ def justif_create(etudid: int = None, nip=None, ine=None): ... ] ``` + SAMPLES + ------- + /justificatif/1/create;[{""date_debut"": ""2023-10-27T08:00"",""date_fin"": ""2023-10-27T10:00"",""etat"": ""attente""}] """ @@ -477,6 +501,12 @@ def justif_edit(justif_id: int): "date_fin"?: str } ``` + + SAMPLES + ------- + /justificatif/1/edit;{""etat"":""valide""} + /justificatif/1/edit;{""raison"":""MEDIC""} + """ # Récupération du justificatif à modifier @@ -591,6 +621,11 @@ def justif_delete(): ... ] ``` + + SAMPLES + ------- + /justificatif/delete;[2, 2, 3] + """ # Récupération des justif_ids @@ -836,6 +871,11 @@ def justif_remove(justif_id: int = None): def justif_list(justif_id: int = None): """ Liste les fichiers du justificatif + + SAMPLES + ------- + /justificatif/1/list; + """ # Récupération du justificatif concerné @@ -878,6 +918,11 @@ def justif_list(justif_id: int = None): def justif_justifies(justif_id: int = None): """ Liste assiduite_id justifiées par le justificatif + + SAMPLES + ------- + /justificatif/1/justifies; + """ # On récupère le justificatif concerné diff --git a/app/templates/doc/ScoDoc9API.j2 b/app/templates/doc/ScoDoc9API.j2 index 102c00e46..032c2280f 100644 --- a/app/templates/doc/ScoDoc9API.j2 +++ b/app/templates/doc/ScoDoc9API.j2 @@ -244,13 +244,13 @@ permet de rechercher une entrée à partir du résultat attendu. -(carte générée avec `flask gen-api-map -e "api."`) +(carte générée avec `flask gen-api-doc`) ### Tableau récapitulatif des entrées de l'API {{table_api|safe}} -(table générée avec `flask gen-api-map -e "api."`) +(table générée avec `flask gen-api-doc`) #### Note sur les exemples d'utilisation diff --git a/scodoc.py b/scodoc.py index 8c198e236..955113991 100755 --- a/scodoc.py +++ b/scodoc.py @@ -740,19 +740,6 @@ def generate_ens_calendars(): # generate-ens-calendars edt_ens.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(endpoint): # gen-api-map - """Génère la carte des routes de l'API.""" - tools.gen_api_map(app, endpoint_start=endpoint) - - @app.cli.command() @click.option( "-e", diff --git a/tests/ressources/samples/assiduites_samples.csv b/tests/ressources/samples/assiduites_samples.csv index 039c4c91c..6e772510a 100644 --- a/tests/ressources/samples/assiduites_samples.csv +++ b/tests/ressources/samples/assiduites_samples.csv @@ -1,36 +1,38 @@ "entry_name";"url";"permission";"method";"content" "assiduite";"/assiduite/1";"ScoView";"GET"; -"assiduites";"/assiduites/1";"ScoView";"GET"; -"assiduites";"/assiduites/1/query?etat=retard";"ScoView";"GET"; -"assiduites";"/assiduites/1/query?moduleimpl_id=1";"ScoView";"GET"; -"assiduites";"/assiduites/1/query?with_justifs=";"ScoView";"GET"; +"assiduite_justificatifs";"/assiduite/1/justificatifs";"ScoView";"GET"; +"assiduite_justificatifs";"/assiduite/1/justificatifs/long";"ScoView";"GET"; "assiduites_count";"/assiduites/1/count";"ScoView";"GET"; "assiduites_count";"/assiduites/1/count/query?etat=retard";"ScoView";"GET"; "assiduites_count";"/assiduites/1/count/query?split";"ScoView";"GET"; "assiduites_count";"/assiduites/1/count/query?etat=present,retard&metric=compte,heure";"ScoView";"GET"; +"assiduites";"/assiduites/1";"ScoView";"GET"; +"assiduites";"/assiduites/1/query?etat=retard";"ScoView";"GET"; +"assiduites";"/assiduites/1/query?moduleimpl_id=1";"ScoView";"GET"; +"assiduites";"/assiduites/1/query?with_justifs=";"ScoView";"GET"; +"assiduites_evaluations";"/assiduites/1/evaluations";"ScoView";"GET"; +"assiduites_group";"/assiduites/group/query?etudids=1,2,3";"ScoView";"GET"; "assiduites_formsemestre";"/assiduites/formsemestre/1";"ScoView";"GET"; "assiduites_formsemestre";"/assiduites/formsemestre/1/query?etat=retard";"ScoView";"GET"; "assiduites_formsemestre";"/assiduites/formsemestre/1/query?moduleimpl_id=1";"ScoView";"GET"; "assiduites_formsemestre_count";"/assiduites/formsemestre/1/count";"ScoView";"GET"; "assiduites_formsemestre_count";"/assiduites/formsemestre/1/count/query?etat=retard";"ScoView";"GET"; "assiduites_formsemestre_count";"/assiduites/formsemestre/1/count/query?etat=present,retard&metric=compte,heure";"ScoView";"GET"; -"assiduites_group";"/assiduites/group/query?etudids=1,2,3";"ScoView";"GET"; -"assiduites_justificatifs";"/assiduite/1/justificatifs";"ScoView";"GET"; -"assiduites_justificatifs";"/assiduite/1/justificatifs/long";"ScoView";"GET"; "assiduite_create";"/assiduite/1/create";"UsersAdmin";"POST";"[{""date_debut"": ""2023-10-27T08:00"",""date_fin"": ""2023-10-27T10:00"",""etat"": ""absent""}]" "assiduite_create";"/assiduite/1/create";"UsersAdmin";"POST";"[{""date_debut"": ""2023-10-27T08:00"",""date_fin"": ""2023-10-27T10:00"",""etat"": ""absent""}]" "assiduites_create";"/assiduites/create";"UsersAdmin";"POST";"[{""etudid"":1,""date_debut"": ""2023-10-26T08:00"",""date_fin"": ""2023-10-26T10:00"",""etat"": ""absent""}]" "assiduites_create";"/assiduites/create";"UsersAdmin";"POST";"[{""etudid"":-1,""date_debut"": ""2023-10-26T08:00"",""date_fin"": ""2023-10-26T10:00"",""etat"": ""absent""}]" +"assiduite_delete";"/assiduite/delete";"UsersAdmin";"POST";"[2,2,3]" "assiduite_edit";"/assiduite/1/edit";"UsersAdmin";"POST";"{""etat"":""absent""}" "assiduite_edit";"/assiduite/1/edit";"UsersAdmin";"POST";"{""moduleimpl_id"":2}" "assiduite_edit";"/assiduite/1/edit";"UsersAdmin";"POST";"{""etat"": ""retard"",""moduleimpl_id"":3}" -"assiduite_delete";"/assiduite/delete";"UsersAdmin";"POST";"[2,2,3]" "justificatif";"/justificatif/1";"ScoView";"GET"; "justificatifs";"/justificatifs/1";"ScoView";"GET"; "justificatifs";"/justificatifs/1/query?etat=attente";"ScoView";"GET"; "justificatifs_dept";"/justificatifs/dept/1";"ScoView";"GET"; "justificatifs_formsemestre";"/justificatifs/formsemestre/1";"ScoView";"GET"; -"justificatif_create";"/justificatif/1/create";"UsersAdmin";"POST";"[{""date_debut"": ""2023-10-27T08:00"",""date_fin"": ""2023-10-27T10:00"",""etat"": ""attente""}]" -"justificatif_edit";"/justificatif/1/edit";"UsersAdmin";"POST";"{""etat"":""valide""}" -"justificatif_edit";"/justificatif/1/edit";"UsersAdmin";"POST";"{""raison"":""MEDIC""}" -"justificatif_delete";"/justificatif/delete";"UsersAdmin";"POST";"[2,2,3]" \ No newline at end of file +"justif_edit";"/justificatif/1/edit";"UsersAdmin";"POST";"{""etat"":""valide""}" +"justif_edit";"/justificatif/1/edit";"UsersAdmin";"POST";"{""raison"":""MEDIC""}" +"justif_delete";"/justificatif/delete";"UsersAdmin";"POST";"[2, 2, 3]" +"justif_list";"/justificatif/1/list";"ScoView";"GET"; +"justif_justifies";"/justificatif/1/justifies";"UsersAdmin";"GET"; \ No newline at end of file diff --git a/tools/create_api_map.py b/tools/create_api_map.py index 1bd65df6d..cdf03c733 100644 --- a/tools/create_api_map.py +++ b/tools/create_api_map.py @@ -4,12 +4,14 @@ 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 +import sys +import unicodedata +import xml.etree.ElementTree as ET + +from flask import render_template from app.auth.models import Permission -from flask import render_template class COLORS: @@ -270,6 +272,13 @@ class Token: return group +def strip_accents(s): + """Retourne la chaîne s séparant les accents et les caractères de base.""" + return "".join( + c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn" + ) + + def _create_svg_element(text, color="rgb(230,156,190)"): """ Fonction générale pour créer un élément SVG simple @@ -503,8 +512,6 @@ def analyze_api_routes(app, endpoint_start: str) -> tuple: child.method = method current_token.add_child(child) - # Gestion de doctable - doctable = parse_doctable_doc(func.__doc__ or "") href = func_name if child.query and not href.endswith("-query"): href += "-query" @@ -527,14 +534,15 @@ def analyze_api_routes(app, endpoint_start: str) -> tuple: if func_name not in doctable_lines: doctable_lines[func_name] = { - "doctable": doctable, "method": method, "nom": func_name, "href": href, + "query": doc_dict.get("QUERY", "") != "", "permission": permissions, "description": doc_dict.get("", ""), "params": doc_dict.get("PARAMS", ""), "category": doc_dict.get("CATEGORY", [False])[0] or category, + "samples": doc_dict.get("SAMPLES"), } # On met à jour le token courant pour le prochain segment @@ -547,15 +555,13 @@ def analyze_api_routes(app, endpoint_start: str) -> tuple: # point d'entrée de la commande `flask gen-api-map` -def gen_api_map(app, endpoint_start="api.") -> str: +def gen_api_map(api_map: Token, doctable_lines: dict[str, dict]) -> 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( @@ -745,23 +751,6 @@ def _get_doc_lines(keyword, doc_string: str) -> list[str]: 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: } (ex: {assiduite_id : }) @@ -795,45 +784,28 @@ def parse_query_doc(doc_string: str) -> dict[str, str]: 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 -): +def _gen_table_line(doctable: dict = None): """ 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") + + nom, method, permission = ( + doctable.get("nom", ""), + doctable.get("method", ""), + doctable.get("permission", ""), + ) + + if doctable is None: + doctable = {} + + lien: str = doctable.get("href", nom) + + doctable["query"]: bool + if doctable.get("query") and not lien.endswith("-query"): + lien += "-query" + nav: str = f"[{nom}]({'#'+lien})" table: str = "|" @@ -858,19 +830,58 @@ 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) - + lines : liste de dictionnaire au format doc_lines. """ table = _gen_table_head() - table += "\n".join([_gen_table_line(**line) for line in lines]) + table += "\n".join([_gen_table_line(line) for line in lines]) return table +def _gen_csv_line(doc_line: dict) -> str: + """ + Génère les lignes de tableau csv en fonction d'une route (doc_line) + + format : + "entry_name";"url";"permission";"method";"content" + """ + + entry_name: str = doc_line.get("nom", "") + method: str = doc_line.get("method", "GET") + permission: str = ( + "UsersAdmin" if doc_line.get("permission") != "ScoView" else "ScoView" + ) + + samples: list[str] = doc_line.get("samples", []) + csv_lines: list[str] = [] + for sample in samples: + url, content = sample.split(";", maxsplit=1) + csv_line = f'"{entry_name}";"{url}";"{permission}";"{method}";' + if content: + csv_line += f'"{content}"' + csv_lines.append(csv_line) + + return "\n".join(csv_lines) + + +def _gen_csv(lines: list[dict], filename: str = "/tmp/samples.csv") -> str: + """ + Génère un fichier csv à partir d'une liste de lignes + + lines : liste de dictionnaire au format doc_lines. + """ + csv = '"entry_name";"url";"permission";"method";"content"\n' + csv += "\n".join( + [_gen_csv_line(line) for line in lines if line.get("samples") is not None] + ) + + with open(filename, "w", encoding="UTF-8") as f: + f.write(csv) + + print( + f"Les samples ont été générés avec succès. Vous pouvez le consulter à l'adresse suivante : {filename}" + ) + + 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: @@ -886,6 +897,16 @@ def doc_route(doctable: dict) -> str: jinja_obj.update(doctable) jinja_obj["nom"] = doctable["nom"].strip() # on retire les caractères blancs + if doctable.get("samples") is not None: + jinja_obj["sample"] = { + "nom": f"{jinja_obj['nom']}.json", + "href": f"{jinja_obj['nom']}.json.md", + } + + jinja_obj["query"]: bool + if jinja_obj["query"]: + jinja_obj["nom"] += "(-query)" + if doctable.get("params"): jinja_obj["params"] = [] for param in doctable["params"]: @@ -901,43 +922,41 @@ def doc_route(doctable: dict) -> str: 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) + api_map, doctable_lines = analyze_api_routes(app, endpoint_start) mddoc: str = "" categories: dict = {} for value in doctable_lines.values(): - category = value["category"] + category = value["category"].capitalize() 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())) + categories: dict = dict( + sorted(categories.items(), key=lambda x: strip_accents(x[0])) + ) category: str routes: list[dict] for category, routes in categories.items(): # sort routes by name - routes.sort(key=lambda x: x["nom"]) + routes.sort(key=lambda x: strip_accents(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) + table_api = gen_api_map(api_map, doctable_lines) mdpage = render_template("doc/ScoDoc9API.j2", doc_api=mddoc, table_api=table_api) + _gen_csv(list(doctable_lines.values())) fname = "/tmp/ScoDoc9API.md" with open(fname, "w", encoding="utf-8") as f: