diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe4a450..8fc20d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -259,12 +259,27 @@ When requesting features, please include: - Update CHANGELOG.md following Keep a Changelog format - Add examples for new features -### Documentation Style +### Generating API Documentation -- Write clear, concise documentation -- Include code examples -- Explain the "why" not just the "what" -- Keep documentation up to date with code +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: + +```bash +python scripts/generate_astro_docs.py +``` + +By default the output is written to `dist/reference.md`. To write elsewhere: + +```bash +python scripts/generate_astro_docs.py --output /path/to/output/dir +``` + +**What it generates:** + +| File | Content | +|------|---------| +| `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 new file mode 100644 index 0000000..d3fb76f --- /dev/null +++ b/scripts/generate_astro_docs.py @@ -0,0 +1,564 @@ +#!/usr/bin/env python3 +""" +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. + +Run from the python-plugin repo root: + python scripts/generate_astro_docs.py + python scripts/generate_astro_docs.py --output /path/to/output/dir +""" + +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 TypeAlias + +############################################################################### +# Configuration +REPO_ROOT: Path = Path(__file__).parent.parent +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: TypeAlias = 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)})" + + +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, strict=True): + if kw_default is not None: + result[kw_arg.arg] = ast.unparse(kw_default) + return result + + +############################################################################### +# 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[str, str]] = field(default_factory=list) # {"name", "type", "desc"} + returns: str = "" + 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 + 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:", ""] + out += ["| Name | Type | Description |", "|------|------|-------------|"] + for a in doc.args: + name = f"`{a['name']}`" + 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: + 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, 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"{'#' * 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) + 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 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 + " " + doc = parse_docstring(get_docstring(node)) + out: list[str] = [] + if doc.description: + out += [doc.description, ""] + attrs = doc.attributes or get_class_fields(node) + if attrs: + out += [f"{hn}Attributes", ""] + for a in attrs: + out.append(f"{'#' * (h + 1)} **{a['name']}**") + 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, h=h + 1) + out.append("") + if statics: + out += [f"{hn}Static Methods", ""] + for m in statics: + 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, h=h + 1) + out.append("") + if publics: + out += [f"{hn}Methods", ""] + for m in publics: + 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, h=h + 1) + out.append("") + return out + + +############################################################################### +# Heading helpers + + +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) -> 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}" + lines = [f"# {class_name}", ""] + lines += render_class_body(node, h=2) + return "\n".join(lines) + "\n" + + +def page_multi_class( + title: str, + description: str, + classes: list[tuple[str, str]], +) -> str: + 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}" + lines += [f"## {class_name}", ""] + lines += render_class_body(node, h=3) + lines += [""] + return "\n".join(lines) + "\n" + + +def page_functions( + title: str, + description: str, + functions: list[tuple[str, str]], +) -> str: + 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}" + 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)) + 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, ""] + lines += render_doc_sections(doc) + lines += [""] + return "\n".join(lines) + "\n" + + +############################################################################### +# Page manifest + +SECTIONS: list = [ + lambda: page_multi_class( + "Publish", + "", + [ + ("DesignerPlugin", "designer_plugin.py"), + ], + ), + lambda: page_multi_class( + "Session", + "Sync and async session classes for communicating with Designer.", + [ + ("D3Session", "d3sdk/session.py"), + ("D3AsyncSession", "d3sdk/session.py"), + ], + ), + lambda: page_multi_class( + "Client", + "", + [ + ("D3PluginClient", "d3sdk/client.py"), + ], + ), + lambda: page_functions( + "Decorators", + "Decorators for registering Designer plugin functions.", + [ + ("d3function", "d3sdk/function.py"), + ("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"), + ], + ), +] + + +############################################################################### +# Entry point + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate a single reference.md for the designer-plugin package.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--output", + default=str(DEFAULT_OUTPUT_DIR), + help="Output directory for reference.md (default: %(default)s)", + ) + args = parser.parse_args() + + out_dir = Path(args.output) + out_dir.mkdir(parents=True, exist_ok=True) + + sections = [builder() for builder in SECTIONS] + body = "\n\n---\n\n".join(sections) + toc = generate_toc(body) + 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" + out_file.write_text(output, encoding="utf-8") + print(f" wrote {out_file}") + + print(f"\nGenerated {out_file}") + + +if __name__ == "__main__": + main() 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..c5b8b4d 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..fe7d140 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( @@ -59,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", diff --git a/src/designer_plugin/models.py b/src/designer_plugin/models.py index 2b608ed..80411e8 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 @@ -162,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)}