#!/usr/bin/env python3 """ gen_api_reference.py — génère la référence API JsonRPC depuis introspect.json. Lit le dump d'introspection nymea (introspect.json) et produit : - Une page par namespace dans docs/api/metier/ ou docs/api/systeme/ - docs/api/notifications.md - docs/api/types.md - docs/api/index.md (page d'accueil statique — créée si absente, jamais écrasée) - docs/api/SUMMARY.md (nav literate-nav) Marqueurs gérés : Usage : python3 scripts/gen_api_reference.py --src introspect.json --docs docs python3 scripts/gen_api_reference.py --src introspect.json --docs docs --check """ from __future__ import annotations import argparse import json import re import sys from collections import defaultdict from pathlib import Path try: pass # stdlib only except ImportError: pass # ── Namespaces ──────────────────────────────────────────────────────────────── NS_METIER_ORDER = [ "JSONRPC", "Energy", "Integrations", "AirConditioning", "EvDash", "Rules", "Scripts", "Tags", "Logging", "AppData", ] NS_SYSTEM_ORDER = [ "Configuration", "System", "Users", "NetworkManager", "ModbusRtu", "Debug", "Transfers", "ZWave", "Zigbee", ] NS_METIER = set(NS_METIER_ORDER) NS_SYSTEM = set(NS_SYSTEM_ORDER) # ── Badges permission scope ─────────────────────────────────────────────────── PERM_BADGES: dict[str, str] = { "PermissionScopeNone": 'PUBLIC', "PermissionScopeControlThings": 'CONTROL', "PermissionScopeExecuteRules": 'EXECUTE', "PermissionScopeConfigureThings": 'CONFIGURE', "PermissionScopeConfigureRules": 'CONFIGURE-RULES', "PermissionScopeAdmin": 'ADMIN', } MARKER_RE = re.compile( r"()(?P
.*?)()", re.DOTALL, ) # ── Helpers ─────────────────────────────────────────────────────────────────── def ns_slug(ns: str) -> str: return ns.lower() def ns_folder(ns: str) -> str: return "metier" if ns in NS_METIER else "systeme" def parse_field_name(raw: str) -> tuple[str, bool, bool]: """Strip `o:` / `r:` prefixes. Returns (name, optional, readonly).""" name, optional, readonly = raw, False, False while True: if name.startswith("o:"): optional = True name = name[2:] elif name.startswith("r:"): readonly = True name = name[2:] else: break return name, optional, readonly def render_type(value: object, types_path: str = "../types.md") -> str: """Render a type value as Markdown, resolving $ref to a clickable link.""" if isinstance(value, list): inner = render_type(value[0], types_path) if value else "`?`" return f"{inner}[]" if isinstance(value, str): if value.startswith("$ref:"): type_name = value[5:] anchor = type_name.lower() return f"[{type_name}]({types_path}#{anchor})" return f"`{value}`" return f"`{value!s}`" def render_type_self(value: object) -> str: """Like render_type but for self-referencing anchors (types page).""" if isinstance(value, list): inner = render_type_self(value[0]) if value else "`?`" return f"{inner}[]" if isinstance(value, str): if value.startswith("$ref:"): type_name = value[5:] anchor = type_name.lower() return f"[{type_name}](#{anchor})" return f"`{value}`" return f"`{value!s}`" def render_fields_table(fields: dict, types_path: str = "../types.md") -> str: """Render a params/returns dict as a Markdown table. Returns trailing newline.""" if not fields: return "_Aucun paramètre._\n" rows = [] for raw_name in sorted(fields): value = fields[raw_name] name, optional, readonly = parse_field_name(raw_name) type_str = render_type(value, types_path) notes = [] if optional: notes.append("optionnel") if readonly: notes.append("lecture seule") rows.append([f"`{name}`", type_str, " · ".join(notes)]) lines = [ "| Champ | Type | Notes |", "| --- | --- | --- |", ] for r in rows: lines.append("| " + " | ".join(r) + " |") return "\n".join(lines) + "\n" # ── Rendu d'une méthode ─────────────────────────────────────────────────────── def render_method(name: str, method: dict, types_path: str) -> str: perm = method.get("permissionScope", "") badge = PERM_BADGES.get(perm) if not badge: badge = f'{perm or "inconnu"}' print(f" INFO : permissionScope inconnu '{perm}' pour {name}", file=sys.stderr) desc = method.get("description", "").strip() params = method.get("params") or {} returns = method.get("returns") or {} parts: list[str] = [] parts.append(f"### {name}") parts.append("") parts.append(badge) parts.append("") if desc: parts.append(desc) parts.append("") parts.append("**Paramètres :**") parts.append("") parts.append(render_fields_table(params, types_path).rstrip()) parts.append("") parts.append("**Retour :**") parts.append("") parts.append(render_fields_table(returns, types_path).rstrip()) parts.append("") parts.append("---") parts.append("") return "\n".join(parts) # ── Contenu des blocs générés ───────────────────────────────────────────────── def render_namespace_content( ns: str, methods: dict, notifications: dict, types_path: str, ) -> str: """Contenu du bloc BEGIN/END d'une page namespace.""" ns_methods = {k: v for k, v in methods.items() if k.startswith(ns + ".")} ns_notifs = {k: v for k, v in notifications.items() if k.startswith(ns + ".")} parts: list[str] = [] if ns_methods: parts.append("## Méthodes") parts.append("") for name in sorted(ns_methods): parts.append(render_method(name, ns_methods[name], types_path)) if ns_notifs: parts.append("## Notifications") parts.append("") for name in sorted(ns_notifs): notif = ns_notifs[name] desc = notif.get("description", "").strip() notif_params = notif.get("params") or {} parts.append(f"### {name}") parts.append("") if desc: parts.append(desc) parts.append("") parts.append("**Paramètres :**") parts.append("") parts.append(render_fields_table(notif_params, types_path).rstrip()) parts.append("") parts.append("---") parts.append("") return "\n".join(parts).rstrip() + "\n" def render_notifications_content(notifications: dict, types_path: str = "types.md") -> str: """Contenu du bloc BEGIN/END de la page Notifications.""" by_ns: dict[str, dict] = defaultdict(dict) for k, v in notifications.items(): ns = k.split(".")[0] by_ns[ns][k] = v all_ns_sorted = sorted( by_ns.keys(), key=lambda x: (0 if x in NS_METIER else 1, x), ) parts: list[str] = [] for ns in all_ns_sorted: parts.append(f"## {ns}") parts.append("") for name in sorted(by_ns[ns]): notif = by_ns[ns][name] desc = notif.get("description", "").strip() notif_params = notif.get("params") or {} parts.append(f"### {name}") parts.append("") if desc: parts.append(desc) parts.append("") parts.append("**Paramètres :**") parts.append("") parts.append(render_fields_table(notif_params, types_path).rstrip()) parts.append("") parts.append("---") parts.append("") return "\n".join(parts).rstrip() + "\n" def render_types_content(types: dict, enums: dict, flags: dict) -> str: """Contenu du bloc BEGIN/END de la page Types & Enums.""" parts: list[str] = [] # Types (objets et listes) parts.append("## Types") parts.append("") for name in sorted(types): anchor = name.lower() defn = types[name] parts.append(f'### {name} {{#{anchor}}}') parts.append("") if isinstance(defn, list): inner = render_type_self(defn[0]) if defn else "`?`" parts.append(f"Liste de {inner}.") parts.append("") elif isinstance(defn, dict): rows = [] for raw_name in sorted(defn): value = defn[raw_name] field_name, optional, readonly = parse_field_name(raw_name) type_str = render_type_self(value) notes = [] if optional: notes.append("optionnel") if readonly: notes.append("lecture seule") rows.append([f"`{field_name}`", type_str, " · ".join(notes)]) lines = ["| Champ | Type | Notes |", "| --- | --- | --- |"] for r in rows: lines.append("| " + " | ".join(r) + " |") parts.append("\n".join(lines)) parts.append("") else: parts.append(f"`{defn}`") parts.append("") parts.append("---") parts.append("") # Enums parts.append("## Enums") parts.append("") for name in sorted(enums): anchor = name.lower() values = enums[name] parts.append(f'### {name} {{#{anchor}}}') parts.append("") if isinstance(values, list): for v in values: if isinstance(v, str) and v.startswith("$ref:"): parts.append(f"- {render_type_self(v)}") else: parts.append(f"- `{v}`") parts.append("") parts.append("---") parts.append("") # Flags if flags: parts.append("## Flags") parts.append("") for name in sorted(flags): anchor = name.lower() values = flags[name] parts.append(f'### {name} {{#{anchor}}}') parts.append("") if isinstance(values, list): for v in values: if isinstance(v, str) and v.startswith("$ref:"): parts.append(f"- {render_type_self(v)}") else: parts.append(f"- `{v}`") parts.append("") parts.append("---") parts.append("") return "\n".join(parts).rstrip() + "\n" # ── Construction des pages complètes ───────────────────────────────────────── def build_namespace_page(ns: str, methods: dict, notifications: dict) -> str: """Page namespace complète (pour ensure_pages — doit correspondre à process()).""" gen = render_namespace_content(ns, methods, notifications, types_path="../types.md") block = f"\n{gen}\n" return f"# {ns}\n\n{block}\n" def build_notifications_page(notifications: dict) -> str: gen = render_notifications_content(notifications, types_path="types.md") block = f"\n{gen}\n" return f"# Notifications\n\n{block}\n" def build_types_page(types: dict, enums: dict, flags: dict) -> str: gen = render_types_content(types, enums, flags) block = f"\n{gen}\n" return f"---\nhide:\n - toc\n---\n\n# Types & Enums\n\n{block}\n" INDEX_PAGE_CONTENT = """\ --- hide: - toc --- # Référence API JsonRPC ETM PowerSync expose l'**API JSON-RPC de nymea** en local, y compris les extensions ETM (`Energy`, `AirConditioning`, `EvDash`). ## Connexion | Mode | Adresse | Notes | | --- | --- | --- | | **TCP brut** | `