From cb3bd6153f1562a1adcf25b22c6027021185d813 Mon Sep 17 00:00:00 2001 From: Taegyun Ha Date: Thu, 2 Apr 2026 08:49:17 +0100 Subject: [PATCH 1/7] initial format --- CONTRIBUTING.md | 33 +++ scripts/generate_astro_docs.py | 481 +++++++++++++++++++++++++++++++++ 2 files changed, 514 insertions(+) create mode 100644 scripts/generate_astro_docs.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe4a450..55798b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -259,6 +259,39 @@ When requesting features, please include: - Update CHANGELOG.md following Keep a Changelog format - Add examples for new features +### Generating API Documentation + +The API reference pages for the [developer.disguise.one](https://developer.disguise.one) documentation site are generated from docstrings using `scripts/generate_astro_docs.py`. + +Run it from the repo root, pointing at your local clone of the `d3_doc_dev` docs repository: + +```bash +python scripts/generate_astro_docs.py --docs-repo /path/to/d3_doc_dev +``` + +The default `--docs-repo` path is `C:/dev/d3docs/d3_doc_dev`. If your clone is at that location you can omit the flag: + +```bash +python scripts/generate_astro_docs.py +``` + +The script requires no additional dependencies — it uses only the Python standard library. + +**When to re-run:** +- After adding or modifying docstrings on any public API +- After adding a new public class or function + +**What it generates** (output directory: `src/pages/plugins/designer-plugin/` inside the docs repo): + +| File | Content | +|------|---------| +| `index.md` | Overview and quick-reference table | +| `designer-plugin.md` | `DesignerPlugin` class | +| `models.md` | All request/response models | +| `d3session.md` | `D3Session` and `D3AsyncSession` | +| `d3pluginclient.md` | `D3PluginClient` base class | +| `d3sdk.md` | `@d3function`, `@d3pythonscript`, and utility functions | + ### Documentation Style - Write clear, concise documentation diff --git a/scripts/generate_astro_docs.py b/scripts/generate_astro_docs.py new file mode 100644 index 0000000..c789238 --- /dev/null +++ b/scripts/generate_astro_docs.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Generate Astro documentation pages for the designer-plugin package. + +Parses Python docstrings with ast (no import required) and writes .md files +that match the format used by the d3_doc_dev Astro documentation site. + +Run from the python-plugin repo root: + python scripts/generate_astro_docs.py + python scripts/generate_astro_docs.py --docs-repo /path/to/d3_doc_dev +""" + +import argparse +import ast +import re +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from textwrap import dedent +from typing import Union + +# ── Configuration ───────────────────────────────────────────────────────────── + +DEFAULT_DOCS_REPO = Path("C:/dev/d3docs/d3_doc_dev") +OUTPUT_SUBDIR = Path("src/pages/plugins/designer-plugin") +LAYOUT = "../../../layouts/HeroLayout.astro" +URL_BASE = "plugins/designer-plugin" +AUTHOR = "Disguise" +DATE = datetime.now().strftime("%d-%b-%Y") +SRC = Path("src/designer_plugin") + +FuncNode = Union[ast.FunctionDef, ast.AsyncFunctionDef] + +# ── AST helpers ─────────────────────────────────────────────────────────────── + + +def load_tree(rel_path: Path) -> ast.Module: + return ast.parse((SRC / rel_path).read_text(encoding="utf-8")) + + +def find_class(tree: ast.Module, name: str) -> ast.ClassDef | None: + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == name: + return node + return None + + +def find_function(tree: ast.Module, name: str) -> FuncNode | None: + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name: + return node + return None + + +def get_docstring(node: ast.AST) -> str: + if ( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module)) + and node.body + and isinstance(node.body[0], ast.Expr) + and isinstance(node.body[0].value, ast.Constant) + and isinstance(node.body[0].value.value, str) + ): + return dedent(node.body[0].value.value).strip() + return "" + + +def unparse_type(node: ast.expr | None) -> str: + if node is None: + return "" + try: + return ast.unparse(node) + except Exception: + return "" + + +def func_signature(func: FuncNode, skip_self: bool = True) -> str: + """Return formatted signature like (arg: *type* = default, ...)""" + a = func.args + params: list[str] = [] + offset = len(a.args) - len(a.defaults) + for i, arg in enumerate(a.args): + if skip_self and arg.arg in ("self", "cls"): + continue + s = arg.arg + if arg.annotation: + s += f": *{unparse_type(arg.annotation)}*" + di = i - offset + if 0 <= di < len(a.defaults): + s += f" = {ast.unparse(a.defaults[di])}" + params.append(s) + if a.vararg: + s = f"*{a.vararg.arg}" + if a.vararg.annotation: + s += f": *{unparse_type(a.vararg.annotation)}*" + params.append(s) + if a.kwarg: + s = f"**{a.kwarg.arg}" + if a.kwarg.annotation: + s += f": *{unparse_type(a.kwarg.annotation)}*" + params.append(s) + return f"({', '.join(params)})" + + +# ── Google docstring parser ──────────────────────────────────────────────────── + +_SECT_RE = re.compile( + r"^(Args|Returns|Raises|Attributes|Class Attributes|Examples?|Note|Usage|Yields):\s*$", + re.I, +) + + +@dataclass +class DocSection: + description: str = "" + args: list[dict] = field(default_factory=list) # {"name", "type", "desc"} + returns: str = "" + raises: list[dict] = field(default_factory=list) # {"type", "desc"} + attributes: list[dict] = field(default_factory=list) # {"name", "type", "desc"} + examples: str = "" + + @property + def first_line(self) -> str: + return self.description.split("\n")[0].strip() + + +def parse_docstring(raw: str) -> DocSection: + d = DocSection() + if not raw: + return d + lines = raw.split("\n") + i, desc = 0, [] + while i < len(lines): + if _SECT_RE.match(lines[i].strip()): + break + desc.append(lines[i]) + i += 1 + d.description = "\n".join(line.lstrip() for line in desc).rstrip() + + section, cur = None, None + while i < len(lines): + line, stripped = lines[i], lines[i].strip() + m = _SECT_RE.match(stripped) + if m: + key = m.group(1).lower() + if "attribute" in key: + section = "attr" + elif key.startswith("arg"): + section = "args" + elif key.startswith("raise"): + section = "raises" + elif key in ("returns", "yields"): + section = "returns" + elif key.startswith("example"): + section = "examples" + else: + section = None + cur = None + i += 1 + continue + if not stripped: + i += 1 + continue + if section in ("args", "attr"): + m2 = re.match(r"^\s{4,}(\w+)\s*(?:\(([^)]+)\))?\s*:\s*(.*)", line) + if m2: + cur = {"name": m2.group(1), "type": m2.group(2) or "", "desc": m2.group(3).strip()} + (d.args if section == "args" else d.attributes).append(cur) + elif cur and re.match(r"^\s{8,}", line): + cur["desc"] += " " + stripped + elif section == "returns": + d.returns = (d.returns + " " + stripped).strip() + elif section == "raises": + m2 = re.match(r"^\s{4,}(\w+)\s*:\s*(.*)", line) + if m2: + cur = {"type": m2.group(1), "desc": m2.group(2).strip()} + d.raises.append(cur) + elif cur and re.match(r"^\s{8,}", line): + cur["desc"] += " " + stripped + elif section == "examples": + d.examples += line + "\n" + i += 1 + d.examples = dedent(d.examples).rstrip() + return d + + +# ── Markdown renderers ───────────────────────────────────────────────────────── + + +def render_doc_sections(doc: DocSection) -> list[str]: + """Render Args / Returns / Raises / Examples sections.""" + out: list[str] = [] + if doc.args: + out += ["", "**Parameters:**", ""] + for a in doc.args: + s = f"- `{a['name']}`" + if a["type"]: + s += f" (*{a['type']}*)" + if a["desc"]: + s += f" — {a['desc']}" + out.append(s) + if doc.returns: + out += ["", f"**Returns:** {doc.returns}"] + if doc.raises: + out += ["", "**Raises:**", ""] + for r in doc.raises: + s = f"- `{r['type']}`" + if r["desc"]: + s += f" — {r['desc']}" + out.append(s) + if doc.examples: + out += ["", "**Example:**", "", doc.examples] + return out + + +def render_method(func: FuncNode, is_method: bool = True) -> list[str]: + is_async = isinstance(func, ast.AsyncFunctionDef) + sig = func_signature(func, skip_self=is_method) + ret = f" → *{unparse_type(func.returns)}*" if func.returns else "" + prefix = "*async* " if is_async else "" + out = [f"#### {prefix}**{func.name}**{sig}{ret}", ""] + doc = parse_docstring(get_docstring(func)) + if doc.description: + out += [doc.description, ""] + out += render_doc_sections(doc) + return out + + +def _is_decorator(func: FuncNode, name: str) -> bool: + return any( + (isinstance(d, ast.Name) and d.id == name) + for d in func.decorator_list + ) + + +def render_class_body(node: ast.ClassDef, h: int = 2) -> list[str]: + """Render class internals (no class-name heading). h = heading level for sections.""" + hn = "#" * h + " " + doc = parse_docstring(get_docstring(node)) + out: list[str] = [] + if doc.description: + out += [doc.description, ""] + if doc.attributes: + out += [f"{hn}Attributes", ""] + for a in doc.attributes: + s = f"#### **{a['name']}**" + if a["type"]: + s += f" : *{a['type']}*" + out.append(s) + if a["desc"]: + out += ["", a["desc"], ""] + + constructor, statics, props, publics, ctxmgr = [], [], [], [], [] + for child in node.body: + if not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if child.name == "__init__": + constructor.append(child) + elif child.name in ("__enter__", "__exit__", "__aenter__", "__aexit__"): + ctxmgr.append(child) + elif child.name.startswith("_"): + continue + elif _is_decorator(child, "staticmethod") or _is_decorator(child, "classmethod"): + statics.append(child) + elif _is_decorator(child, "property"): + props.append(child) + else: + publics.append(child) + + if constructor: + out += [f"{hn}Constructor", ""] + for m in constructor: + out += render_method(m) + out.append("") + if statics: + out += [f"{hn}Static Methods", ""] + for m in statics: + out += render_method(m, is_method=False) + out.append("") + if props: + out += [f"{hn}Properties", ""] + for m in props: + out += render_method(m) + out.append("") + if publics: + out += [f"{hn}Methods", ""] + for m in publics: + out += render_method(m) + out.append("") + if ctxmgr: + out += [f"{hn}Context Manager", ""] + out.append("Supports use as a context manager (`with` / `async with`):") + out.append("") + for m in ctxmgr: + out += render_method(m) + out.append("") + return out + + +# ── Frontmatter ─────────────────────────────────────────────────────────────── + + +def frontmatter(title: str, description: str, url_slug: str) -> list[str]: + safe_desc = description.replace('"', "'").split("\n")[0].strip() + return [ + "---", + f'title: "{title}"', + f'description: "{safe_desc}"', + f"layout: {LAYOUT}", + f'author: "{AUTHOR}"', + f'date: "{DATE}"', + f'url: "{URL_BASE}/{url_slug}"', + "---", + "", + ] + + +# ── Page builders ───────────────────────────────────────────────────────────── + + +def page_single_class(class_name: str, src_file: str, url_slug: str) -> str: + tree = load_tree(Path(src_file)) + node = find_class(tree, class_name) + assert node, f"Class {class_name!r} not found in {src_file}" + doc = parse_docstring(get_docstring(node)) + lines = frontmatter(class_name, doc.first_line, url_slug) + lines += [f"# {class_name}", ""] + lines += render_class_body(node, h=2) + return "\n".join(lines) + "\n" + + +def page_multi_class( + title: str, + description: str, + url_slug: str, + classes: list[tuple[str, str]], +) -> str: + lines = frontmatter(title, description, url_slug) + lines += [f"# {title}", "", description, ""] + for class_name, src_file in classes: + tree = load_tree(Path(src_file)) + node = find_class(tree, class_name) + assert node, f"Class {class_name!r} not found in {src_file}" + doc = parse_docstring(get_docstring(node)) + lines += [f"## {class_name}", ""] + lines += render_class_body(node, h=3) + lines += ["---", ""] + return "\n".join(lines) + "\n" + + +def page_functions( + title: str, + description: str, + url_slug: str, + functions: list[tuple[str, str]], +) -> str: + lines = frontmatter(title, description, url_slug) + lines += [f"# {title}", "", description, ""] + for func_name, src_file in functions: + tree = load_tree(Path(src_file)) + node = find_function(tree, func_name) + assert node, f"Function {func_name!r} not found in {src_file}" + lines += render_method(node, is_method=False) + lines += ["---", ""] + return "\n".join(lines) + "\n" + + +def page_index() -> str: + lines = frontmatter( + "Python SDK", + "Python SDK for creating and communicating with Disguise Designer plugins.", + "index", + ) + lines += [ + "# Python SDK", + "", + "The `designer-plugin` Python package provides tools for building and communicating", + "with Disguise Designer plugins.", + "", + "```bash", + "pip install designer-plugin", + "```", + "", + "## Reference", + "", + f"| Page | Description |", + f"|------|-------------|", + f"| [DesignerPlugin](/{URL_BASE}/designer-plugin) | DNS-SD plugin discovery and registration |", + f"| [Models](/{URL_BASE}/models) | Request/response payload models |", + f"| [D3Session](/{URL_BASE}/d3session) | Sync and async session management |", + f"| [D3PluginClient](/{URL_BASE}/d3pluginclient) | Class-based remote plugin execution |", + f"| [d3sdk](/{URL_BASE}/d3sdk) | `@d3function` and `@d3pythonscript` decorators |", + "", + ] + return "\n".join(lines) + "\n" + + +# ── Page manifest ───────────────────────────────────────────────────────────── + +PAGES: list[tuple[str, callable]] = [ + ("index.md", lambda: page_index()), + ( + "designer-plugin.md", + lambda: page_single_class("DesignerPlugin", "designer_plugin.py", "designer-plugin"), + ), + ( + "models.md", + lambda: page_multi_class( + "Models", + "Pydantic models and types used in the Designer Plugin API.", + "models", + [ + ("PluginPayload", "models.py"), + ("PluginResponse", "models.py"), + ("PluginError", "models.py"), + ("PluginRegisterResponse", "models.py"), + ("PluginStatus", "models.py"), + ("PluginStatusDetail", "models.py"), + ("RegisterPayload", "models.py"), + ("PluginException", "models.py"), + ], + ), + ), + ( + "d3session.md", + lambda: page_multi_class( + "D3Session", + "Sync and async session classes for communicating with Designer.", + "d3session", + [ + ("D3Session", "d3sdk/session.py"), + ("D3AsyncSession", "d3sdk/session.py"), + ], + ), + ), + ( + "d3pluginclient.md", + lambda: page_single_class("D3PluginClient", "d3sdk/client.py", "d3pluginclient"), + ), + ( + "d3sdk.md", + lambda: page_functions( + "d3sdk Decorators & Functions", + "Decorators and utilities for registering and executing Designer functions.", + "d3sdk", + [ + ("d3function", "d3sdk/function.py"), + ("d3pythonscript", "d3sdk/function.py"), + ("get_register_payload", "d3sdk/function.py"), + ("get_all_d3functions", "d3sdk/function.py"), + ("get_all_modules", "d3sdk/function.py"), + ], + ), + ), +] + + +# ── Entry point ─────────────────────────────────────────────────────────────── + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--docs-repo", + default=str(DEFAULT_DOCS_REPO), + help="Path to the d3_doc_dev Astro docs repository (default: %(default)s)", + ) + args = parser.parse_args() + + out_dir = Path(args.docs_repo) / OUTPUT_SUBDIR + out_dir.mkdir(parents=True, exist_ok=True) + + for filename, builder in PAGES: + content = builder() + path = out_dir / filename + path.write_text(content, encoding="utf-8") + print(f" wrote {path.relative_to(Path(args.docs_repo))}") + + print(f"\nGenerated {len(PAGES)} pages in {out_dir}") + + +if __name__ == "__main__": + main() From b813d1c28d58bac2539fdc8d02cb14daab3c9803 Mon Sep 17 00:00:00 2001 From: Taegyun Ha Date: Thu, 2 Apr 2026 09:29:28 +0100 Subject: [PATCH 2/7] add examples --- src/designer_plugin/api.py | 60 ++++++++++++++ src/designer_plugin/d3sdk/client.py | 104 ++++++++++++++++++------- src/designer_plugin/d3sdk/function.py | 94 ++++++++++++++++++---- src/designer_plugin/d3sdk/session.py | 101 ++++++++++++++++++++++++ src/designer_plugin/designer_plugin.py | 67 +++++++++++++++- src/designer_plugin/models.py | 19 +++++ 6 files changed, 398 insertions(+), 47 deletions(-) diff --git a/src/designer_plugin/api.py b/src/designer_plugin/api.py index 65cd9d0..2f2f4b8 100644 --- a/src/designer_plugin/api.py +++ b/src/designer_plugin/api.py @@ -84,6 +84,13 @@ def d3_api_request( Returns: JSON response from the API. + + Examples: + ```python + from designer_plugin.api import d3_api_request, Method + + response = d3_api_request(Method.GET, "localhost", 80, "api/session/status") + ``` """ url: str = f"http://{hostname}:{port}/{url_endpoint.lstrip('/')}" response = requests.request( @@ -112,6 +119,13 @@ async def d3_api_arequest( Returns: JSON response from the API. + + Examples: + ```python + from designer_plugin.api import d3_api_arequest, Method + + response = await d3_api_arequest(Method.GET, "localhost", 80, "api/session/status") + ``` """ url: str = f"http://{hostname}:{port}/{url_endpoint.lstrip('/')}" async with aiohttp.ClientSession() as session: @@ -144,6 +158,16 @@ async def d3_api_aexecute( Raises: PluginException: If the plugin execution fails. + + Examples: + ```python + from designer_plugin.api import d3_api_aexecute + from designer_plugin import PluginPayload + + payload = PluginPayload(script="return 1 + 1") + response = await d3_api_aexecute("localhost", 80, payload) + print(response.returnValue) # 2 + ``` """ if logger.isEnabledFor(logging.DEBUG): logger.debug(f"Send plugin api:{payload.debug_string()}") @@ -190,6 +214,19 @@ async def d3_api_aregister_module( Raises: Exception: If the network request fails. PluginException: If module registration fails on Designer side. + + Examples: + ```python + from designer_plugin.api import d3_api_aregister_module + from designer_plugin import RegisterPayload + + payload = RegisterPayload( + moduleName="mymodule", + contents="def hello(): return 'Hello, World!'" # your python script + ) + response = await d3_api_aregister_module("localhost", 80, payload) + print(response.status.code) # 0 if successful + ``` """ try: if logger.isEnabledFor(logging.DEBUG): @@ -238,6 +275,16 @@ def d3_api_execute( Raises: PluginException: If the plugin execution fails. + + Examples: + ```python + from designer_plugin.api import d3_api_execute + from designer_plugin import PluginPayload + + payload = PluginPayload(script="return 1 + 1") + response = d3_api_execute("localhost", 80, payload) + print(response.returnValue) # 2 + ``` """ if logger.isEnabledFor(logging.DEBUG): @@ -289,6 +336,19 @@ def d3_api_register_module( Raises: Exception: If the network request fails. PluginException: If module registration fails on Designer side. + + Examples: + ```python + from designer_plugin.api import d3_api_register_module + from designer_plugin import RegisterPayload + + payload = RegisterPayload( + moduleName="mymodule", + contents="def hello(): return 'Hello, World!'" # your python script + ) + response = d3_api_register_module("localhost", 80, payload) + print(response.status.code) # 0 if successful + ``` """ try: if logger.isEnabledFor(logging.DEBUG): diff --git a/src/designer_plugin/d3sdk/client.py b/src/designer_plugin/d3sdk/client.py index bdac3e7..f5e4bfa 100644 --- a/src/designer_plugin/d3sdk/client.py +++ b/src/designer_plugin/d3sdk/client.py @@ -343,37 +343,47 @@ class D3PluginClient(metaclass=D3PluginClientMeta): - Wraps all your methods to execute remotely - Manages module registration with Designer - Usage: - ```python - from typing import TYPE_CHECKING - if TYPE_CHECKING: - from d3blobgen.scripts.d3 import * - - class MyPlugin(D3PluginClient): - def __init__(self, arg1: int, arg2: str): - # Passed argument will be cached and used on register - self.arg1: int = arg1 - self.arg2: str = arg2 - - def get_surface_uid(self, surface_name: str) -> dict[str, str]: - surface: Screen2 = resourceManager.load( - Path('objects/screen2/{}.apx'.format(surface_name)), - Screen2 - ) - return { - "name": surface.description, - "uid": surface.uid, - } - - # Instantiate MyPlugin - plugin = MyPlugin(1, "myplugin") - - # Use as sync context manager - with plugin.session("localhost", 80): - result = plugin.get_surface_uid("surface 1") - ``` Attributes: instance_code: The code used to instantiate the plugin remotely (set on init) + + Examples: + Sync usage: + + ```python + from designer_plugin.d3sdk import D3PluginClient + from designer_plugin.pystub import * + + class MyPlugin(D3PluginClient): + def get_surface_uid(self, surface_name: str) -> str: + surface: Screen2 = resourceManager.load( + Path(f'objects/screen2/{surface_name}.apx'), Screen2) + return str(surface.uid) + + plugin = MyPlugin() + with plugin.session('localhost', 80): + uid = plugin.get_surface_uid("surface 1") + ``` + + Async usage: + + ```python + import asyncio + from designer_plugin.d3sdk import D3PluginClient + from designer_plugin.pystub import * + + class MyPlugin(D3PluginClient): + async def get_surface_uid(self, surface_name: str) -> str: + surface: Screen2 = resourceManager.load( + Path(f'objects/screen2/{surface_name}.apx'), Screen2) + return str(surface.uid) + + async def main(): + plugin = MyPlugin() + async with plugin.async_session('localhost', 80): + uid = await plugin.get_surface_uid("surface 1") + + asyncio.run(main()) + ``` """ def __init__(self) -> None: @@ -408,6 +418,26 @@ async def async_session( # type: ignore Yields: The plugin client instance with active session. + + Examples: + ```python + import asyncio + from designer_plugin.d3sdk import D3PluginClient + from designer_plugin.pystub import * + + class MyPlugin(D3PluginClient): + async def get_surface_uid(self, surface_name: str) -> str: + surface: Screen2 = resourceManager.load( + Path(f'objects/screen2/{surface_name}.apx'), Screen2) + return str(surface.uid) + + async def main(): + plugin = MyPlugin() + async with plugin.async_session('localhost', 80): + uid = await plugin.get_surface_uid("surface 1") + + asyncio.run(main()) + ``` """ try: if module_name: @@ -446,6 +476,22 @@ def session( # type: ignore Yields: The plugin client instance with active session. + + Examples: + ```python + from designer_plugin.d3sdk import D3PluginClient + from designer_plugin.pystub import * + + class MyPlugin(D3PluginClient): + def get_surface_uid(self, surface_name: str) -> str: + surface: Screen2 = resourceManager.load( + Path(f'objects/screen2/{surface_name}.apx'), Screen2) + return str(surface.uid) + + plugin = MyPlugin() + with plugin.session('localhost', 80): + uid = plugin.get_surface_uid("surface 1") + ``` """ try: if module_name: diff --git a/src/designer_plugin/d3sdk/function.py b/src/designer_plugin/d3sdk/function.py index 0418101..458da42 100644 --- a/src/designer_plugin/d3sdk/function.py +++ b/src/designer_plugin/d3sdk/function.py @@ -136,6 +136,27 @@ def extract_function_info(func: Callable[..., Any]) -> FunctionInfo: class D3PythonScript(Generic[P, T]): + """Wrapper for standalone Designer script execution using the @d3pythonscript decorator. + + The function body is inlined with argument assignments into a single script payload, + so no module registration is required before execution. + + Examples: + ```python + from designer_plugin.d3sdk import d3pythonscript, D3Session + from designer_plugin.pystub import * + + @d3pythonscript + def rename_surface(surface_name: str, new_name: str): + surface: Screen2 = resourceManager.load( + Path(f'objects/screen2/{surface_name}.apx'), Screen2) + surface.rename(surface.path.replaceFilename(new_name)) + + with D3Session('localhost', 80) as session: + session.rpc(rename_surface.payload("surface 1", "surface 2")) + ``` + """ + def __init__(self, func: Callable[P, T]): """Initialise a D3PythonScript wrapper around a Python function. @@ -244,6 +265,22 @@ class D3Function(D3PythonScript[P, T]): Class Attributes: _available_packages: Registry mapping module names to their required import packages. _available_d3functions: Registry mapping module names to their D3Function instances. + + Examples: + ```python + from designer_plugin.d3sdk import d3function, D3Session + from designer_plugin.pystub import * + + @d3function("surface_utils") + def get_surface_uid(surface_name: str) -> str: + surface: Screen2 = resourceManager.load( + Path(f'objects/screen2/{surface_name}.apx'), Screen2) + return str(surface.uid) + + # "surface_utils" module is auto-registered on first execute + with D3Session('localhost', 80) as session: + uid = session.rpc(get_surface_uid.payload("surface 1")) + ``` """ _available_packages: defaultdict[str, set[str]] = defaultdict(set) @@ -420,6 +457,8 @@ def d3pythonscript(func: Callable[P, T]) -> D3PythonScript[P, T]: Examples: ```python + from designer_plugin.d3sdk import d3pythonscript, D3Session + @d3pythonscript def my_add(a: int, b: int) -> int: return a + b @@ -427,11 +466,13 @@ def my_add(a: int, b: int) -> int: # Generate payload for execution payload = my_add.payload(5, 3) # The payload.script will contain: - ''' - a=5 - b=3 - return a + b - ''' + # a=5 + # b=3 + # return a + b + + with D3Session('localhost', 80) as session: + result = session.rpc(my_add.payload(5, 3)) + print(result) # 8 ``` """ return D3PythonScript(func) @@ -458,20 +499,24 @@ def d3function(module_name: str = "") -> Callable[[Callable[P, T]], D3Function[P Examples: ```python + from designer_plugin.d3sdk import d3function, D3Session + from designer_plugin.pystub import * + @d3function("my_d3module") - def capture_image(cam_name: str) -> str: - camera = d3.resourceManager.load( - d3.Path('objects/camera/{cam_name}.apx'), - d3.Camera + def get_camera_uid(cam_name: str) -> str: + camera = resourceManager.load( + Path(f'objects/camera/{cam_name}.apx'), + Camera ) - return camera.uid + return str(camera.uid) # Generate payload for execution (calls the function by name) - payload = capture_image.payload("camera1") - # The payload.script will contain: - ''' - return capture_image('camera1') - ''' + payload = get_camera_uid.payload("camera1") + # payload.script == "return get_camera_uid('camera1')" + + # "my_d3module" is auto-registered when entering the session + with D3Session('localhost', 80, ["my_d3module"]) as session: + uid = session.rpc(get_camera_uid.payload("camera1")) ``` """ @@ -489,6 +534,13 @@ def get_register_payload(module_name: str) -> RegisterPayload | None: Returns: RegisterPayload for the module, or None if the module has no registered d3function. + + Examples: + ```python + payload = get_register_payload("mymodule") + if payload: + print(payload.contents) + ``` """ return D3Function.get_module_register_payload(module_name) @@ -498,6 +550,12 @@ def get_all_d3functions() -> list[tuple[str, str]]: Returns: List of tuples containing (module_name, function_name) for all registered d3function. + + Examples: + ```python + functions = get_all_d3functions() + # [("mymodule", "get_time"), ("mymodule", "get_surface_uid")] + ``` """ module_function_pairs: list[tuple[str, str]] = [] for module_name, funcs in D3Function._available_d3functions.items(): @@ -512,5 +570,11 @@ def get_all_modules() -> list[str]: Returns: List of module names that have registered d3function. + + Examples: + ```python + modules = get_all_modules() + # ["mymodule", "surface_utils"] + ``` """ return list(D3Function._available_d3functions.keys()) diff --git a/src/designer_plugin/d3sdk/session.py b/src/designer_plugin/d3sdk/session.py index b0eead5..564f221 100644 --- a/src/designer_plugin/d3sdk/session.py +++ b/src/designer_plugin/d3sdk/session.py @@ -48,6 +48,20 @@ class D3Session(D3SessionBase): Manages connection to a Designer instance and provides synchronous API for plugin execution, module registration, and generic HTTP requests. + + Examples: + ```python + from designer_plugin.d3sdk import D3Session, d3pythonscript + + @d3pythonscript + def get_time() -> str: + import datetime + return str(datetime.datetime.now()) + + with D3Session('localhost', 80) as session: + time = session.rpc(get_time.payload()) + print(time) + ``` """ def __init__( @@ -100,6 +114,12 @@ def rpc( Raises: PluginException: If the plugin execution fails. + + Examples: + ```python + with D3Session('localhost', 80) as session: + time: str = session.rpc(get_time.payload()) + ``` """ return self.execute(payload, timeout_sec).returnValue @@ -117,6 +137,15 @@ def execute( Raises: PluginException: If the plugin execution fails. + + Examples: + ```python + with D3Session('localhost', 80) as session: + response = session.execute(get_time.payload()) + print(f"Status: {response.status.code}") + print(f"Python log: {response.pythonLog}") + print(f"Value: {response.returnValue}") + ``` """ if payload.moduleName and payload.moduleName not in self.registered_modules: self.register_module(payload.moduleName) @@ -141,6 +170,9 @@ def register_module( ) -> bool: """Register a module with Designer. + Note that session already auto-registers modules in lazy manner on execute. + Call this method directly only when you need to register eagerly. + Args: module_name: Name of the module to register. timeout_sec: Optional timeout in seconds for the request. @@ -150,6 +182,13 @@ def register_module( Raises: PluginException: If module registration fails on Designer side. + + Examples: + ```python + with D3Session('localhost', 80) as session: + success = session.register_module("mymodule") + print(f"success: {success}") + ``` """ payload: RegisterPayload | None = D3Function.get_module_register_payload( module_name @@ -163,6 +202,9 @@ def register_module( def register_all_modules(self, timeout_sec: float | None = None) -> dict[str, bool]: """Register all modules decorated with @d3function. + Note that session already auto-registers modules in lazy manner on execute. + Call this method directly only when you need to register all modules eagerly. + Args: timeout_sec: Optional timeout in seconds for each registration request. @@ -171,6 +213,13 @@ def register_all_modules(self, timeout_sec: float | None = None) -> dict[str, bo Raises: PluginException: If any module registration fails on Designer side. + + Examples: + ```python + with D3Session('localhost', 80) as session: + results = session.register_all_modules() + # {"mymodule": True, "utilities": True} + ``` """ modules: list[str] = list(D3Function._available_d3functions.keys()) register_success: dict[str, bool] = {} @@ -185,6 +234,24 @@ class D3AsyncSession(D3SessionBase): Manages connection to a Designer instance and provides asynchronous API for plugin execution, module registration, and generic HTTP requests. + + Examples: + ```python + import asyncio + from designer_plugin.d3sdk import D3AsyncSession, d3pythonscript + + @d3pythonscript + def get_time() -> str: + import datetime + return str(datetime.datetime.now()) + + async def main(): + async with D3AsyncSession('localhost', 80) as session: + time = await session.rpc(get_time.payload()) + print(time) + + asyncio.run(main()) + ``` """ def __init__( @@ -257,6 +324,12 @@ async def rpc( Raises: PluginException: If the plugin execution fails. + + Examples: + ```python + async with D3AsyncSession('localhost', 80) as session: + time: str = await session.rpc(get_time.payload()) + ``` """ return (await self.execute(payload, timeout_sec)).returnValue @@ -274,6 +347,14 @@ async def execute( Raises: PluginException: If the plugin execution fails. + + Examples: + ```python + async with D3AsyncSession('localhost', 80) as session: + response = await session.execute(get_time.payload()) + print(f"Status: {response.status.code}") + print(f"Value: {response.returnValue}") + ``` """ if payload.moduleName and payload.moduleName not in self.registered_modules: await self.register_module(payload.moduleName) @@ -285,6 +366,9 @@ async def register_module( ) -> bool: """Register a module with Designer asynchronously. + Note that session already auto-registers modules in lazy manner on execute. + Call this method directly only when you need to register eagerly. + Args: module_name: Name of the module to register. timeout_sec: Optional timeout in seconds for the request. @@ -294,6 +378,13 @@ async def register_module( Raises: PluginException: If module registration fails on Designer side. + + Examples: + ```python + async with D3AsyncSession('localhost', 80) as session: + success = await session.register_module("mymodule") + print(f"success: {success}") + ``` """ payload: RegisterPayload | None = D3Function.get_module_register_payload( module_name @@ -311,6 +402,9 @@ async def register_all_modules( ) -> dict[str, bool]: """Register all modules decorated with @d3function asynchronously. + Note that session already auto-registers modules in lazy manner on execute. + Call this method directly only when you need to register all modules eagerly. + Args: timeout_sec: Optional timeout in seconds for each registration request. @@ -319,6 +413,13 @@ async def register_all_modules( Raises: PluginException: If any module registration fails on Designer side. + + Examples: + ```python + async with D3AsyncSession('localhost', 80) as session: + results = await session.register_all_modules() + # {"mymodule": True, "utilities": True} + ``` """ modules: list[str] = list(D3Function._available_d3functions.keys()) register_success: dict[str, bool] = {} diff --git a/src/designer_plugin/designer_plugin.py b/src/designer_plugin/designer_plugin.py index 64228da..3e1a42a 100644 --- a/src/designer_plugin/designer_plugin.py +++ b/src/designer_plugin/designer_plugin.py @@ -12,7 +12,35 @@ class DesignerPlugin: - """When used as a context manager (using the `with` statement), publish a plugin using DNS-SD for the Disguise Designer application""" + """Publish a plugin via DNS-SD so the Disguise Designer application can discover it. + + Use as a context manager (sync or async) to register and unregister the service + automatically. + + Examples: + Sync context manager: + + ```python + from designer_plugin import DesignerPlugin + + with DesignerPlugin("MyPlugin", 9999) as plugin: + # Plugin is now discoverable via DNS-SD by Designer + input("Press Enter to stop...") + ``` + + Async context manager: + + ```python + import asyncio + from designer_plugin import DesignerPlugin + + async def main(): + async with DesignerPlugin("MyPlugin", 9999) as plugin: + await asyncio.sleep(60) # Keep plugin discoverable for 60 seconds + + asyncio.run(main()) + ``` + """ def __init__( self, @@ -36,7 +64,25 @@ def __init__( @staticmethod def default_init(port: int, hostname: str | None = None) -> "DesignerPlugin": - """Initialize the plugin options with the values in d3plugin.json.""" + """Initialize the plugin options with the values in d3plugin.json. + + Reads `name`, `url`, `requiresSession`, and `isDisguise` from `./d3plugin.json` + in the current working directory. + + Args: + port: The port number to publish the plugin on. + hostname: Optional hostname override. Defaults to the machine hostname. + + Returns: + A DesignerPlugin instance configured from d3plugin.json. + + Examples: + ```python + # Reads name/url from ./d3plugin.json, uses provided port + with DesignerPlugin.default_init(port=9999) as plugin: + input("Press Enter to stop...") + ``` + """ return DesignerPlugin.from_json_file( file_path="./d3plugin.json", port=port, hostname=hostname ) @@ -45,7 +91,22 @@ def default_init(port: int, hostname: str | None = None) -> "DesignerPlugin": def from_json_file( file_path: str, port: int, hostname: str | None = None ) -> "DesignerPlugin": - """Convert a JSON file (expected d3plugin.json) to PluginOptions. hostname and port are required.""" + """Load plugin options from a JSON file (d3plugin.json format). + + Args: + file_path: Path to the JSON configuration file. + port: The port number to publish the plugin on. + hostname: Optional hostname override. Defaults to the machine hostname. + + Returns: + A DesignerPlugin instance configured from the JSON file. + + Examples: + ```python + with DesignerPlugin.from_json_file("config/my_plugin.json", port=9999) as plugin: + input("Press Enter to stop...") + ``` + """ with open(file_path) as f: options = json_load(f) return DesignerPlugin( diff --git a/src/designer_plugin/models.py b/src/designer_plugin/models.py index 2b608ed..7d4de66 100644 --- a/src/designer_plugin/models.py +++ b/src/designer_plugin/models.py @@ -79,6 +79,13 @@ def returnCastValue(self, castType: type[RetCastType]) -> RetCastType: Raises: ValidationError: If the return value cannot be validated as the specified type. + + Examples: + ```python + response = session.execute(get_surface_info.payload("surface 1")) + info = response.returnCastValue(dict[str, str]) + print(info["uid"]) + ``` """ adapter = TypeAdapter(castType) return adapter.validate_python(self.returnValue) @@ -114,6 +121,18 @@ class PluginException(Exception): status: The status information from the failed plugin call d3Log: Designer console log output pythonLog: Python-specific log output + + Examples: + ```python + from designer_plugin import PluginException + + try: + result = session.rpc(my_func.payload()) + except PluginException as e: + print(f"Error code: {e.status.code}") + print(f"Message: {e.status.message}") + print(f"Python log: {e.pythonLog}") + ``` """ status: PluginStatus From 35e81924d0cec01271a87befbb01cb8aec530275 Mon Sep 17 00:00:00 2001 From: Taegyun Ha Date: Thu, 2 Apr 2026 12:05:26 +0100 Subject: [PATCH 3/7] format the reference page --- scripts/generate_astro_docs.py | 316 ++++++++++++++++----------------- 1 file changed, 158 insertions(+), 158 deletions(-) diff --git a/scripts/generate_astro_docs.py b/scripts/generate_astro_docs.py index c789238..41839a7 100644 --- a/scripts/generate_astro_docs.py +++ b/scripts/generate_astro_docs.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -"""Generate Astro documentation pages for the designer-plugin package. +"""Generate a single reference.md for the designer-plugin package. -Parses Python docstrings with ast (no import required) and writes .md files -that match the format used by the d3_doc_dev Astro documentation site. +Parses Python docstrings with ast (no import required) and writes a single +reference.md file with a table of contents. Run from the python-plugin repo root: python scripts/generate_astro_docs.py - python scripts/generate_astro_docs.py --docs-repo /path/to/d3_doc_dev + python scripts/generate_astro_docs.py --output /path/to/output/dir """ import argparse @@ -19,9 +19,8 @@ from typing import Union # ── Configuration ───────────────────────────────────────────────────────────── - -DEFAULT_DOCS_REPO = Path("C:/dev/d3docs/d3_doc_dev") -OUTPUT_SUBDIR = Path("src/pages/plugins/designer-plugin") +REPO_ROOT: Path = Path(__file__).parent.parent +DEFAULT_OUTPUT_DIR = REPO_ROOT / "dist" LAYOUT = "../../../layouts/HeroLayout.astro" URL_BASE = "plugins/designer-plugin" AUTHOR = "Disguise" @@ -46,14 +45,19 @@ def find_class(tree: ast.Module, name: str) -> ast.ClassDef | None: def find_function(tree: ast.Module, name: str) -> FuncNode | None: for node in tree.body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name: + if ( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == name + ): return node return None def get_docstring(node: ast.AST) -> str: if ( - isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module)) + isinstance( + node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module) + ) and node.body and isinstance(node.body[0], ast.Expr) and isinstance(node.body[0].value, ast.Constant) @@ -82,7 +86,7 @@ def func_signature(func: FuncNode, skip_self: bool = True) -> str: continue s = arg.arg if arg.annotation: - s += f": *{unparse_type(arg.annotation)}*" + s += f": {unparse_type(arg.annotation)}" di = i - offset if 0 <= di < len(a.defaults): s += f" = {ast.unparse(a.defaults[di])}" @@ -90,12 +94,12 @@ def func_signature(func: FuncNode, skip_self: bool = True) -> str: if a.vararg: s = f"*{a.vararg.arg}" if a.vararg.annotation: - s += f": *{unparse_type(a.vararg.annotation)}*" + s += f": {unparse_type(a.vararg.annotation)}" params.append(s) if a.kwarg: s = f"**{a.kwarg.arg}" if a.kwarg.annotation: - s += f": *{unparse_type(a.kwarg.annotation)}*" + s += f": {unparse_type(a.kwarg.annotation)}" params.append(s) return f"({', '.join(params)})" @@ -111,9 +115,9 @@ def func_signature(func: FuncNode, skip_self: bool = True) -> str: @dataclass class DocSection: description: str = "" - args: list[dict] = field(default_factory=list) # {"name", "type", "desc"} + args: list[dict] = field(default_factory=list) # {"name", "type", "desc"} returns: str = "" - raises: list[dict] = field(default_factory=list) # {"type", "desc"} + raises: list[dict] = field(default_factory=list) # {"type", "desc"} attributes: list[dict] = field(default_factory=list) # {"name", "type", "desc"} examples: str = "" @@ -162,7 +166,11 @@ def parse_docstring(raw: str) -> DocSection: if section in ("args", "attr"): m2 = re.match(r"^\s{4,}(\w+)\s*(?:\(([^)]+)\))?\s*:\s*(.*)", line) if m2: - cur = {"name": m2.group(1), "type": m2.group(2) or "", "desc": m2.group(3).strip()} + cur = { + "name": m2.group(1), + "type": m2.group(2) or "", + "desc": m2.group(3).strip(), + } (d.args if section == "args" else d.attributes).append(cur) elif cur and re.match(r"^\s{8,}", line): cur["desc"] += " " + stripped @@ -189,34 +197,34 @@ def render_doc_sections(doc: DocSection) -> list[str]: """Render Args / Returns / Raises / Examples sections.""" out: list[str] = [] if doc.args: - out += ["", "**Parameters:**", ""] + out += ["", "Parameters:", ""] for a in doc.args: s = f"- `{a['name']}`" if a["type"]: s += f" (*{a['type']}*)" if a["desc"]: - s += f" — {a['desc']}" + s += f": {a['desc']}" out.append(s) if doc.returns: - out += ["", f"**Returns:** {doc.returns}"] + out += ["", f"Returns: {doc.returns}"] if doc.raises: - out += ["", "**Raises:**", ""] + out += ["", "Raises:", ""] for r in doc.raises: s = f"- `{r['type']}`" if r["desc"]: - s += f" — {r['desc']}" + s += f": {r['desc']}" out.append(s) if doc.examples: - out += ["", "**Example:**", "", doc.examples] + out += ["", "Example:", "", doc.examples] return out -def render_method(func: FuncNode, is_method: bool = True) -> list[str]: +def render_method(func: FuncNode, is_method: bool = True, h: int = 4) -> list[str]: is_async = isinstance(func, ast.AsyncFunctionDef) sig = func_signature(func, skip_self=is_method) - ret = f" → *{unparse_type(func.returns)}*" if func.returns else "" - prefix = "*async* " if is_async else "" - out = [f"#### {prefix}**{func.name}**{sig}{ret}", ""] + ret = f" → {unparse_type(func.returns)}" if func.returns else "" + prefix = "async " if is_async else "" + out = [f"{'#' * h} **{func.name}**", "", "```py", f"{prefix}{func.name}{sig}{ret}", "```", ""] doc = parse_docstring(get_docstring(func)) if doc.description: out += [doc.description, ""] @@ -225,10 +233,7 @@ def render_method(func: FuncNode, is_method: bool = True) -> list[str]: def _is_decorator(func: FuncNode, name: str) -> bool: - return any( - (isinstance(d, ast.Name) and d.id == name) - for d in func.decorator_list - ) + return any((isinstance(d, ast.Name) and d.id == name) for d in func.decorator_list) def render_class_body(node: ast.ClassDef, h: int = 2) -> list[str]: @@ -241,10 +246,7 @@ def render_class_body(node: ast.ClassDef, h: int = 2) -> list[str]: if doc.attributes: out += [f"{hn}Attributes", ""] for a in doc.attributes: - s = f"#### **{a['name']}**" - if a["type"]: - s += f" : *{a['type']}*" - out.append(s) + out.append(f"{'#' * (h + 1)} **{a['name']}**") if a["desc"]: out += ["", a["desc"], ""] @@ -258,7 +260,9 @@ def render_class_body(node: ast.ClassDef, h: int = 2) -> list[str]: ctxmgr.append(child) elif child.name.startswith("_"): continue - elif _is_decorator(child, "staticmethod") or _is_decorator(child, "classmethod"): + elif _is_decorator(child, "staticmethod") or _is_decorator( + child, "classmethod" + ): statics.append(child) elif _is_decorator(child, "property"): props.append(child) @@ -268,61 +272,76 @@ def render_class_body(node: ast.ClassDef, h: int = 2) -> list[str]: if constructor: out += [f"{hn}Constructor", ""] for m in constructor: - out += render_method(m) + out += render_method(m, h=h + 1) out.append("") if statics: out += [f"{hn}Static Methods", ""] for m in statics: - out += render_method(m, is_method=False) + out += render_method(m, is_method=False, h=h + 1) out.append("") if props: out += [f"{hn}Properties", ""] for m in props: - out += render_method(m) + out += render_method(m, h=h + 1) out.append("") if publics: out += [f"{hn}Methods", ""] for m in publics: - out += render_method(m) + out += render_method(m, h=h + 1) out.append("") if ctxmgr: out += [f"{hn}Context Manager", ""] out.append("Supports use as a context manager (`with` / `async with`):") out.append("") for m in ctxmgr: - out += render_method(m) + out += render_method(m, h=h + 1) out.append("") return out -# ── Frontmatter ─────────────────────────────────────────────────────────────── +# ── Heading helpers ─────────────────────────────────────────────────────────── -def frontmatter(title: str, description: str, url_slug: str) -> list[str]: - safe_desc = description.replace('"', "'").split("\n")[0].strip() - return [ - "---", - f'title: "{title}"', - f'description: "{safe_desc}"', - f"layout: {LAYOUT}", - f'author: "{AUTHOR}"', - f'date: "{DATE}"', - f'url: "{URL_BASE}/{url_slug}"', - "---", - "", - ] +def demote_headings(content: str) -> str: + """Demote all headings by one level (H1→H2, H2→H3, etc.).""" + return re.sub(r"^(#+)", lambda m: "#" + m.group(1), content, flags=re.MULTILINE) + + +def _to_anchor(text: str) -> str: + text = text.lower() + text = re.sub(r"[^\w\s-]", "", text) + return re.sub(r"\s+", "-", text.strip()) + + +def generate_toc(content: str) -> str: + """Build a TOC from H2 and H3 headings in *content*, skipping code fences.""" + entries: list[str] = [] + in_fence = False + for line in content.splitlines(): + if line.startswith("```"): + in_fence = not in_fence + continue + if in_fence: + continue + m = re.match(r"^(#{1,2})\s+(.*)", line) + if not m: + continue + level = len(m.group(1)) + title = m.group(2).strip() + anchor = _to_anchor(title) + indent = " " * (level - 1) + entries.append(f"{indent}- [{title}](#{anchor})") + return "## Table of Contents\n\n" + "\n".join(entries) # ── Page builders ───────────────────────────────────────────────────────────── -def page_single_class(class_name: str, src_file: str, url_slug: str) -> str: +def page_single_class(class_name: str, src_file: str) -> str: tree = load_tree(Path(src_file)) node = find_class(tree, class_name) assert node, f"Class {class_name!r} not found in {src_file}" - doc = parse_docstring(get_docstring(node)) - lines = frontmatter(class_name, doc.first_line, url_slug) - lines += [f"# {class_name}", ""] + lines = [f"# {class_name}", ""] lines += render_class_body(node, h=2) return "\n".join(lines) + "\n" @@ -330,125 +349,88 @@ def page_single_class(class_name: str, src_file: str, url_slug: str) -> str: def page_multi_class( title: str, description: str, - url_slug: str, classes: list[tuple[str, str]], ) -> str: - lines = frontmatter(title, description, url_slug) - lines += [f"# {title}", "", description, ""] + lines = [f"# {title}", "", description, ""] for class_name, src_file in classes: tree = load_tree(Path(src_file)) node = find_class(tree, class_name) assert node, f"Class {class_name!r} not found in {src_file}" - doc = parse_docstring(get_docstring(node)) lines += [f"## {class_name}", ""] lines += render_class_body(node, h=3) - lines += ["---", ""] + lines += [""] return "\n".join(lines) + "\n" def page_functions( title: str, description: str, - url_slug: str, functions: list[tuple[str, str]], ) -> str: - lines = frontmatter(title, description, url_slug) - lines += [f"# {title}", "", description, ""] + lines = [f"# {title}", "", description, ""] for func_name, src_file in functions: tree = load_tree(Path(src_file)) node = find_function(tree, func_name) assert node, f"Function {func_name!r} not found in {src_file}" - lines += render_method(node, is_method=False) - lines += ["---", ""] - return "\n".join(lines) + "\n" - - -def page_index() -> str: - lines = frontmatter( - "Python SDK", - "Python SDK for creating and communicating with Disguise Designer plugins.", - "index", - ) - lines += [ - "# Python SDK", - "", - "The `designer-plugin` Python package provides tools for building and communicating", - "with Disguise Designer plugins.", - "", - "```bash", - "pip install designer-plugin", - "```", - "", - "## Reference", - "", - f"| Page | Description |", - f"|------|-------------|", - f"| [DesignerPlugin](/{URL_BASE}/designer-plugin) | DNS-SD plugin discovery and registration |", - f"| [Models](/{URL_BASE}/models) | Request/response payload models |", - f"| [D3Session](/{URL_BASE}/d3session) | Sync and async session management |", - f"| [D3PluginClient](/{URL_BASE}/d3pluginclient) | Class-based remote plugin execution |", - f"| [d3sdk](/{URL_BASE}/d3sdk) | `@d3function` and `@d3pythonscript` decorators |", - "", - ] + is_async = isinstance(node, ast.AsyncFunctionDef) + sig = func_signature(node, skip_self=False) + ret = f" → {unparse_type(node.returns)}" if node.returns else "" + prefix = "async " if is_async else "" + doc = parse_docstring(get_docstring(node)) + lines += [f"## @{func_name}", "", f"{prefix}{func_name}{sig}{ret}", ""] + if doc.description: + lines += [doc.description, ""] + lines += render_doc_sections(doc) + lines += [""] return "\n".join(lines) + "\n" # ── Page manifest ───────────────────────────────────────────────────────────── -PAGES: list[tuple[str, callable]] = [ - ("index.md", lambda: page_index()), - ( - "designer-plugin.md", - lambda: page_single_class("DesignerPlugin", "designer_plugin.py", "designer-plugin"), +SECTIONS: list = [ + lambda: page_multi_class( + "Publish", + "", + [ + ("DesignerPlugin", "designer_plugin.py"), + ] ), - ( - "models.md", - lambda: page_multi_class( - "Models", - "Pydantic models and types used in the Designer Plugin API.", - "models", - [ - ("PluginPayload", "models.py"), - ("PluginResponse", "models.py"), - ("PluginError", "models.py"), - ("PluginRegisterResponse", "models.py"), - ("PluginStatus", "models.py"), - ("PluginStatusDetail", "models.py"), - ("RegisterPayload", "models.py"), - ("PluginException", "models.py"), - ], - ), + lambda: page_multi_class( + "Models", + "Pydantic models and types used in the Designer Plugin API.", + [ + ("PluginPayload", "models.py"), + ("PluginResponse", "models.py"), + ("PluginError", "models.py"), + ("PluginRegisterResponse", "models.py"), + ("PluginStatus", "models.py"), + ("PluginStatusDetail", "models.py"), + ("RegisterPayload", "models.py"), + ("PluginException", "models.py"), + ], ), - ( - "d3session.md", - lambda: page_multi_class( - "D3Session", - "Sync and async session classes for communicating with Designer.", - "d3session", - [ - ("D3Session", "d3sdk/session.py"), - ("D3AsyncSession", "d3sdk/session.py"), - ], - ), + lambda: page_multi_class( + "Session", + "Sync and async session classes for communicating with Designer.", + [ + ("D3Session", "d3sdk/session.py"), + ("D3AsyncSession", "d3sdk/session.py"), + ], ), - ( - "d3pluginclient.md", - lambda: page_single_class("D3PluginClient", "d3sdk/client.py", "d3pluginclient"), + lambda: page_multi_class( + "Client", + "", + [ + ("D3PluginClient", "d3sdk/client.py"), + ] ), - ( - "d3sdk.md", - lambda: page_functions( - "d3sdk Decorators & Functions", - "Decorators and utilities for registering and executing Designer functions.", - "d3sdk", - [ - ("d3function", "d3sdk/function.py"), - ("d3pythonscript", "d3sdk/function.py"), - ("get_register_payload", "d3sdk/function.py"), - ("get_all_d3functions", "d3sdk/function.py"), - ("get_all_modules", "d3sdk/function.py"), - ], - ), + lambda: page_functions( + "Decorators", + "Decorators for registering Designer plugin functions.", + [ + ("d3function", "d3sdk/function.py"), + ("d3pythonscript", "d3sdk/function.py"), + ], ), ] @@ -457,24 +439,42 @@ def page_index() -> str: def main() -> None: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) parser.add_argument( - "--docs-repo", - default=str(DEFAULT_DOCS_REPO), - help="Path to the d3_doc_dev Astro docs repository (default: %(default)s)", + "--output", + default=str(DEFAULT_OUTPUT_DIR), + help="Output directory for reference.md (default: %(default)s)", ) args = parser.parse_args() - out_dir = Path(args.docs_repo) / OUTPUT_SUBDIR + out_dir = Path(args.output) out_dir.mkdir(parents=True, exist_ok=True) - for filename, builder in PAGES: - content = builder() - path = out_dir / filename - path.write_text(content, encoding="utf-8") - print(f" wrote {path.relative_to(Path(args.docs_repo))}") + sections = [builder() for builder in SECTIONS] + body = "\n\n---\n\n".join(sections) + toc = generate_toc(body) + fm = "\n".join([ + "---", + 'title: "Python SDK Reference"', + 'description: "Python SDK reference."', + f"layout: {LAYOUT}", + f'author: "{AUTHOR}"', + f'date: "{DATE}"', + f'url: "{URL_BASE}/reference"', + "---", + "", + "# Python SDK Reference", + "", + ]) + output = f"{fm}\n{toc}\n\n---\n\n{body}" + + out_file = out_dir / "reference.md" + out_file.write_text(output, encoding="utf-8") + print(f" wrote {out_file}") - print(f"\nGenerated {len(PAGES)} pages in {out_dir}") + print(f"\nGenerated {out_file}") if __name__ == "__main__": From a40fe73a715acbf5f993dc524429da8404991170 Mon Sep 17 00:00:00 2001 From: Taegyun Ha Date: Thu, 2 Apr 2026 17:09:34 +0100 Subject: [PATCH 4/7] update format --- scripts/generate_astro_docs.py | 47 ++++++++++++++++++++++---- src/designer_plugin/d3sdk/function.py | 2 +- src/designer_plugin/designer_plugin.py | 2 +- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/scripts/generate_astro_docs.py b/scripts/generate_astro_docs.py index 41839a7..9fa3021 100644 --- a/scripts/generate_astro_docs.py +++ b/scripts/generate_astro_docs.py @@ -104,6 +104,30 @@ def func_signature(func: FuncNode, skip_self: bool = True) -> str: return f"({', '.join(params)})" +def _func_types(func: FuncNode) -> dict[str, str]: + """Return {arg_name: type_string} from the function's type annotations.""" + result = {} + for arg in func.args.args + func.args.posonlyargs + func.args.kwonlyargs: + if arg.annotation: + result[arg.arg] = unparse_type(arg.annotation) + return result + + +def _func_defaults(func: FuncNode) -> dict[str, str]: + """Return {arg_name: default_value_string} for args that have defaults.""" + a = func.args + offset = len(a.args) - len(a.defaults) + result = {} + for i, arg in enumerate(a.args): + di = i - offset + if 0 <= di < len(a.defaults): + result[arg.arg] = ast.unparse(a.defaults[di]) + for kw_default, kw_arg in zip(a.kw_defaults, a.kwonlyargs): + if kw_default is not None: + result[kw_arg.arg] = ast.unparse(kw_default) + return result + + # ── Google docstring parser ──────────────────────────────────────────────────── _SECT_RE = re.compile( @@ -198,13 +222,12 @@ def render_doc_sections(doc: DocSection) -> list[str]: out: list[str] = [] if doc.args: out += ["", "Parameters:", ""] + out += ["| Name | Type | Description |", "|------|------|-------------|"] for a in doc.args: - s = f"- `{a['name']}`" - if a["type"]: - s += f" (*{a['type']}*)" - if a["desc"]: - s += f": {a['desc']}" - out.append(s) + name = f"`{a['name']}`" + type_ = f"`{a['type'].replace('|', '\\|')}`" if a["type"] else "" + desc = (a["desc"] or "").replace("|", "\\|") + out.append(f"| {name} | {type_} | {desc} |") if doc.returns: out += ["", f"Returns: {doc.returns}"] if doc.raises: @@ -226,6 +249,12 @@ def render_method(func: FuncNode, is_method: bool = True, h: int = 4) -> list[st prefix = "async " if is_async else "" out = [f"{'#' * h} **{func.name}**", "", "```py", f"{prefix}{func.name}{sig}{ret}", "```", ""] doc = parse_docstring(get_docstring(func)) + defaults = _func_defaults(func) + types = _func_types(func) + for a in doc.args: + a["default"] = defaults.get(a["name"], "") + if not a["type"]: + a["type"] = types.get(a["name"], "") if doc.description: out += [doc.description, ""] out += render_doc_sections(doc) @@ -377,6 +406,12 @@ def page_functions( ret = f" → {unparse_type(node.returns)}" if node.returns else "" prefix = "async " if is_async else "" doc = parse_docstring(get_docstring(node)) + defaults = _func_defaults(node) + types = _func_types(node) + for a in doc.args: + a["default"] = defaults.get(a["name"], "") + if not a["type"]: + a["type"] = types.get(a["name"], "") lines += [f"## @{func_name}", "", f"{prefix}{func_name}{sig}{ret}", ""] if doc.description: lines += [doc.description, ""] diff --git a/src/designer_plugin/d3sdk/function.py b/src/designer_plugin/d3sdk/function.py index 458da42..c5b8b4d 100644 --- a/src/designer_plugin/d3sdk/function.py +++ b/src/designer_plugin/d3sdk/function.py @@ -515,7 +515,7 @@ def get_camera_uid(cam_name: str) -> str: # payload.script == "return get_camera_uid('camera1')" # "my_d3module" is auto-registered when entering the session - with D3Session('localhost', 80, ["my_d3module"]) as session: + with D3Session('localhost', 80, {"my_d3module"}) as session: uid = session.rpc(get_camera_uid.payload("camera1")) ``` """ diff --git a/src/designer_plugin/designer_plugin.py b/src/designer_plugin/designer_plugin.py index 3e1a42a..fe7d140 100644 --- a/src/designer_plugin/designer_plugin.py +++ b/src/designer_plugin/designer_plugin.py @@ -120,7 +120,7 @@ def from_json_file( @property def service_info(self) -> ServiceInfo: - """Convert the options to a dictionary suitable for DNS-SD service properties.""" + """Get the ServiceInfo object suitable for DNS-SD service registration.""" properties = { b"t": b"web", b"s": b"true" if self.requires_session else b"false", From 41699c753da999842796eeadb80e9adef9151e1b Mon Sep 17 00:00:00 2001 From: Taegyun Ha Date: Tue, 7 Apr 2026 11:54:08 +0100 Subject: [PATCH 5/7] update generate_astro_docs --- CONTRIBUTING.md | 32 +++------- MANIFEST.in | 1 + scripts/generate_astro_docs.py | 103 ++++++++++++++++++++------------- 3 files changed, 72 insertions(+), 64 deletions(-) create mode 100644 MANIFEST.in diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55798b4..8fc20d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -261,43 +261,25 @@ When requesting features, please include: ### Generating API Documentation -The API reference pages for the [developer.disguise.one](https://developer.disguise.one) documentation site are generated from docstrings using `scripts/generate_astro_docs.py`. +The API reference page for the [developer.disguise.one](https://developer.disguise.one) documentation site is generated from docstrings using `scripts/generate_astro_docs.py`. -Run it from the repo root, pointing at your local clone of the `d3_doc_dev` docs repository: +Run it from the repo root: ```bash -python scripts/generate_astro_docs.py --docs-repo /path/to/d3_doc_dev +python scripts/generate_astro_docs.py ``` -The default `--docs-repo` path is `C:/dev/d3docs/d3_doc_dev`. If your clone is at that location you can omit the flag: +By default the output is written to `dist/reference.md`. To write elsewhere: ```bash -python scripts/generate_astro_docs.py +python scripts/generate_astro_docs.py --output /path/to/output/dir ``` -The script requires no additional dependencies — it uses only the Python standard library. - -**When to re-run:** -- After adding or modifying docstrings on any public API -- After adding a new public class or function - -**What it generates** (output directory: `src/pages/plugins/designer-plugin/` inside the docs repo): +**What it generates:** | File | Content | |------|---------| -| `index.md` | Overview and quick-reference table | -| `designer-plugin.md` | `DesignerPlugin` class | -| `models.md` | All request/response models | -| `d3session.md` | `D3Session` and `D3AsyncSession` | -| `d3pluginclient.md` | `D3PluginClient` base class | -| `d3sdk.md` | `@d3function`, `@d3pythonscript`, and utility functions | - -### Documentation Style - -- Write clear, concise documentation -- Include code examples -- Explain the "why" not just the "what" -- Keep documentation up to date with code +| `reference.md` | Full API reference with table of contents, covering `DesignerPlugin`, models, sessions, client, and decorators | ## Release Process diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f1aa587 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +prune scripts diff --git a/scripts/generate_astro_docs.py b/scripts/generate_astro_docs.py index 9fa3021..bdab515 100644 --- a/scripts/generate_astro_docs.py +++ b/scripts/generate_astro_docs.py @@ -1,5 +1,9 @@ #!/usr/bin/env python3 -"""Generate a single reference.md for the designer-plugin package. +""" +MIT License +Copyright (c) 2025 Disguise Technologies ltd + +Generate a single reference.md for the designer-plugin package. Parses Python docstrings with ast (no import required) and writes a single reference.md file with a table of contents. @@ -16,20 +20,22 @@ from datetime import datetime from pathlib import Path from textwrap import dedent -from typing import Union +from typing import TypeAlias -# ── Configuration ───────────────────────────────────────────────────────────── +############################################################################### +# Configuration REPO_ROOT: Path = Path(__file__).parent.parent -DEFAULT_OUTPUT_DIR = REPO_ROOT / "dist" -LAYOUT = "../../../layouts/HeroLayout.astro" -URL_BASE = "plugins/designer-plugin" -AUTHOR = "Disguise" -DATE = datetime.now().strftime("%d-%b-%Y") -SRC = Path("src/designer_plugin") +DEFAULT_OUTPUT_DIR: Path = REPO_ROOT / "dist" +LAYOUT: str = "../../../layouts/HeroLayout.astro" +URL_BASE: str = "plugins/designer-plugin" +AUTHOR: str = "Disguise" +DATE: str = datetime.now().strftime("%d-%b-%Y") +SRC: Path = Path("src/designer_plugin") -FuncNode = Union[ast.FunctionDef, ast.AsyncFunctionDef] +FuncNode: TypeAlias = ast.FunctionDef | ast.AsyncFunctionDef -# ── AST helpers ─────────────────────────────────────────────────────────────── +############################################################################### +# AST helpers def load_tree(rel_path: Path) -> ast.Module: @@ -122,13 +128,14 @@ def _func_defaults(func: FuncNode) -> dict[str, str]: di = i - offset if 0 <= di < len(a.defaults): result[arg.arg] = ast.unparse(a.defaults[di]) - for kw_default, kw_arg in zip(a.kw_defaults, a.kwonlyargs): + for kw_default, kw_arg in zip(a.kw_defaults, a.kwonlyargs, strict=True): if kw_default is not None: result[kw_arg.arg] = ast.unparse(kw_default) return result -# ── Google docstring parser ──────────────────────────────────────────────────── +############################################################################### +# Google docstring parser _SECT_RE = re.compile( r"^(Args|Returns|Raises|Attributes|Class Attributes|Examples?|Note|Usage|Yields):\s*$", @@ -139,10 +146,12 @@ def _func_defaults(func: FuncNode) -> dict[str, str]: @dataclass class DocSection: description: str = "" - args: list[dict] = field(default_factory=list) # {"name", "type", "desc"} + args: list[dict[str, str]] = field(default_factory=list) # {"name", "type", "desc"} returns: str = "" - raises: list[dict] = field(default_factory=list) # {"type", "desc"} - attributes: list[dict] = field(default_factory=list) # {"name", "type", "desc"} + raises: list[dict[str, str]] = field(default_factory=list) # {"type", "desc"} + attributes: list[dict[str, str]] = field( + default_factory=list + ) # {"name", "type", "desc"} examples: str = "" @property @@ -214,7 +223,8 @@ def parse_docstring(raw: str) -> DocSection: return d -# ── Markdown renderers ───────────────────────────────────────────────────────── +############################################################################### +# Markdown renderers def render_doc_sections(doc: DocSection) -> list[str]: @@ -225,7 +235,8 @@ def render_doc_sections(doc: DocSection) -> list[str]: out += ["| Name | Type | Description |", "|------|------|-------------|"] for a in doc.args: name = f"`{a['name']}`" - type_ = f"`{a['type'].replace('|', '\\|')}`" if a["type"] else "" + escaped_type = a["type"].replace("|", "\\|") + type_ = f"`{escaped_type}`" if a["type"] else "" desc = (a["desc"] or "").replace("|", "\\|") out.append(f"| {name} | {type_} | {desc} |") if doc.returns: @@ -247,7 +258,14 @@ def render_method(func: FuncNode, is_method: bool = True, h: int = 4) -> list[st sig = func_signature(func, skip_self=is_method) ret = f" → {unparse_type(func.returns)}" if func.returns else "" prefix = "async " if is_async else "" - out = [f"{'#' * h} **{func.name}**", "", "```py", f"{prefix}{func.name}{sig}{ret}", "```", ""] + out = [ + f"{'#' * h} **{func.name}**", + "", + "```py", + f"{prefix}{func.name}{sig}{ret}", + "```", + "", + ] doc = parse_docstring(get_docstring(func)) defaults = _func_defaults(func) types = _func_types(func) @@ -328,7 +346,8 @@ def render_class_body(node: ast.ClassDef, h: int = 2) -> list[str]: return out -# ── Heading helpers ─────────────────────────────────────────────────────────── +############################################################################### +# Heading helpers def demote_headings(content: str) -> str: @@ -363,7 +382,8 @@ def generate_toc(content: str) -> str: return "## Table of Contents\n\n" + "\n".join(entries) -# ── Page builders ───────────────────────────────────────────────────────────── +############################################################################### +# Page builders def page_single_class(class_name: str, src_file: str) -> str: @@ -420,7 +440,8 @@ def page_functions( return "\n".join(lines) + "\n" -# ── Page manifest ───────────────────────────────────────────────────────────── +############################################################################### +# Page manifest SECTIONS: list = [ lambda: page_multi_class( @@ -428,7 +449,7 @@ def page_functions( "", [ ("DesignerPlugin", "designer_plugin.py"), - ] + ], ), lambda: page_multi_class( "Models", @@ -457,7 +478,7 @@ def page_functions( "", [ ("D3PluginClient", "d3sdk/client.py"), - ] + ], ), lambda: page_functions( "Decorators", @@ -470,12 +491,14 @@ def page_functions( ] -# ── Entry point ─────────────────────────────────────────────────────────────── +############################################################################### +# Entry point def main() -> None: parser = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + description="Generate a single reference.md for the designer-plugin package.", + formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--output", @@ -490,19 +513,21 @@ def main() -> None: sections = [builder() for builder in SECTIONS] body = "\n\n---\n\n".join(sections) toc = generate_toc(body) - fm = "\n".join([ - "---", - 'title: "Python SDK Reference"', - 'description: "Python SDK reference."', - f"layout: {LAYOUT}", - f'author: "{AUTHOR}"', - f'date: "{DATE}"', - f'url: "{URL_BASE}/reference"', - "---", - "", - "# Python SDK Reference", - "", - ]) + fm = "\n".join( + [ + "---", + 'title: "designer-plugin Reference"', + 'description: "designer-plugin reference."', + f"layout: {LAYOUT}", + f'author: "{AUTHOR}"', + f'date: "{DATE}"', + f'url: "{URL_BASE}/reference"', + "---", + "", + "# designer-plugin Reference", + "", + ] + ) output = f"{fm}\n{toc}\n\n---\n\n{body}" out_file = out_dir / "reference.md" From e7f820405c4b0ad56da10bcf5585885971aa277a Mon Sep 17 00:00:00 2001 From: Taegyun Ha Date: Tue, 7 Apr 2026 12:52:22 +0100 Subject: [PATCH 6/7] update date format --- scripts/generate_astro_docs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/generate_astro_docs.py b/scripts/generate_astro_docs.py index bdab515..5381ff0 100644 --- a/scripts/generate_astro_docs.py +++ b/scripts/generate_astro_docs.py @@ -29,7 +29,7 @@ LAYOUT: str = "../../../layouts/HeroLayout.astro" URL_BASE: str = "plugins/designer-plugin" AUTHOR: str = "Disguise" -DATE: str = datetime.now().strftime("%d-%b-%Y") +DATE: str = datetime.now().strftime("%d %b %Y") SRC: Path = Path("src/designer_plugin") FuncNode: TypeAlias = ast.FunctionDef | ast.AsyncFunctionDef From 310ce622e939ebf78ef5c4c2fffbce87e90c7b30 Mon Sep 17 00:00:00 2001 From: Taegyun Ha Date: Tue, 7 Apr 2026 18:31:14 +0100 Subject: [PATCH 7/7] extract Field for the document --- scripts/generate_astro_docs.py | 55 ++++++++++++++++++++++++---------- src/designer_plugin/models.py | 10 +++++++ 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/scripts/generate_astro_docs.py b/scripts/generate_astro_docs.py index 5381ff0..d3fb76f 100644 --- a/scripts/generate_astro_docs.py +++ b/scripts/generate_astro_docs.py @@ -283,6 +283,28 @@ def _is_decorator(func: FuncNode, name: str) -> bool: return any((isinstance(d, ast.Name) and d.id == name) for d in func.decorator_list) +def get_class_fields(node: ast.ClassDef) -> list[dict[str, str]]: + """Extract {name, type, desc} from class-level annotated Field() assignments.""" + fields = [] + for child in node.body: + if not isinstance(child, ast.AnnAssign) or not isinstance( + child.target, ast.Name + ): + continue + name = child.target.id + if name.startswith("_"): + continue + type_str = unparse_type(child.annotation) if child.annotation else "" + desc = "" + if child.value and isinstance(child.value, ast.Call): + for kw in child.value.keywords: + if kw.arg == "description" and isinstance(kw.value, ast.Constant): + desc = kw.value.value + break + fields.append({"name": name, "type": type_str, "desc": desc}) + return fields + + def render_class_body(node: ast.ClassDef, h: int = 2) -> list[str]: """Render class internals (no class-name heading). h = heading level for sections.""" hn = "#" * h + " " @@ -290,9 +312,10 @@ def render_class_body(node: ast.ClassDef, h: int = 2) -> list[str]: out: list[str] = [] if doc.description: out += [doc.description, ""] - if doc.attributes: + attrs = doc.attributes or get_class_fields(node) + if attrs: out += [f"{hn}Attributes", ""] - for a in doc.attributes: + for a in attrs: out.append(f"{'#' * (h + 1)} **{a['name']}**") if a["desc"]: out += ["", a["desc"], ""] @@ -451,20 +474,6 @@ def page_functions( ("DesignerPlugin", "designer_plugin.py"), ], ), - lambda: page_multi_class( - "Models", - "Pydantic models and types used in the Designer Plugin API.", - [ - ("PluginPayload", "models.py"), - ("PluginResponse", "models.py"), - ("PluginError", "models.py"), - ("PluginRegisterResponse", "models.py"), - ("PluginStatus", "models.py"), - ("PluginStatusDetail", "models.py"), - ("RegisterPayload", "models.py"), - ("PluginException", "models.py"), - ], - ), lambda: page_multi_class( "Session", "Sync and async session classes for communicating with Designer.", @@ -488,6 +497,20 @@ def page_functions( ("d3pythonscript", "d3sdk/function.py"), ], ), + lambda: page_multi_class( + "Models", + "Pydantic models and types used in the Designer Plugin API.", + [ + ("PluginPayload", "models.py"), + ("PluginResponse", "models.py"), + ("PluginError", "models.py"), + ("PluginRegisterResponse", "models.py"), + ("PluginStatus", "models.py"), + ("PluginStatusDetail", "models.py"), + ("RegisterPayload", "models.py"), + ("PluginException", "models.py"), + ], + ), ] diff --git a/src/designer_plugin/models.py b/src/designer_plugin/models.py index 7d4de66..80411e8 100644 --- a/src/designer_plugin/models.py +++ b/src/designer_plugin/models.py @@ -181,9 +181,19 @@ class PluginPayload(BaseModel, Generic[RetType]): script: str = Field(description="Script to run on Designer.") def is_module_payload(self) -> bool: + """Return True if this payload targets a named module. + + Returns: + True if moduleName is set, False otherwise. + """ return bool(self.moduleName) def debug_string(self) -> str: + """Return a human-readable debug representation of this payload. + + Returns: + A formatted string showing the JSON serialisation and raw script content. + """ return f""" {"json ":{'='}<60} {self.model_dump_json(indent=2)}