diff --git a/.claude/skills/sync-cwapi3d-stubs/SKILL.md b/.claude/skills/sync-cwapi3d-stubs/SKILL.md new file mode 100644 index 0000000..398443a --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/SKILL.md @@ -0,0 +1,186 @@ +--- +name: sync-cwapi3d-stubs +description: "Sync this repo's .pyi stubs with the CwAPI3D pybind11 bindings in the cadwork 3d C++ source. Extracts every controller module, bound function and cadwork type from CCwAPI3DPythonController.cpp, diffs it against main, and writes the missing declarations with docstrings derived from the Doxygen contracts on the ICwAPI3D* interface headers. Use when the C++ API has gained functions or types that cwapi3d does not expose yet, or to audit how far the stubs have drifted." +allowed-tools: Read, Grep, Glob, Edit, Write, Bash +model: sonnet +--- + +# Sync CwAPI3D Python stubs + +The `cwapi3d` package is a **stub-only** distribution hand-maintained against +`CCwAPI3DPythonController.cpp`. Nothing keeps the two in sync, so a new `m.def(...)` +in C++ silently never reaches the stubs. This skill closes that gap: it re-derives +the full binding inventory from the C++ source and writes what is missing. + +**All C++-side access is read-only.** The only files written are inside this repo. + +| Writes | Role | +| ------ | ---- | +| `src//__init__.pyi` | the missing `def`s, appended | +| `src//` (+ `py.typed`) | a whole module the stubs never had | +| `src/cadwork/.pyi` | a registered `py::class_` / `py::enum_` with no stub | +| `src/cadwork/__init__.pyi` | re-export line + `__all__` entry for a new type | +| `docs/documentation/*.md`, `mkdocs.yml` | docs page + nav entry for a new module/type | +| `pyproject.toml` | `packages` entry for a new module, and `[project].version` | + +## Invocation + +``` +/sync-cwapi3d-stubs [--only ] [--report-only] [--on-main] +``` + +- `--only ` — restrict to one module (repeatable). `cadwork` covers the types. +- `--report-only` — stop after step 4; write nothing. +- `--on-main` — write the changes straight onto `main` instead of a working branch + (see step 3 for why that is not the default). + +## Step 1 — Preflight + +Resolve the config pair. `config.toml` ships with the skill; `config.personal.toml` +is git-ignored and holds only machine paths. + +```powershell +python "/scripts/sync_stubs.py" --dry-run --json +``` + +Exit `2` means a config or parse error — read the message on stderr and stop: + +- **`[paths].cadlib_root is not set`** → the operator has no `config.personal.toml`. + Tell them to copy `config.personal.toml.example` next to it and set `cadlib_root` + to their cadwork 3d source root (e.g. `D:\source\cadlib\v_33.0\3d`). Do not guess + the path and do not write the file for them without asking. +- **`binding source not found`** / **`version header not found`** → `cadlib_root` + points somewhere without `CwAPI3D/CCwAPI3DPythonController.cpp` or + `CwAPI3D/include/CwAPI3DVersion.h`. Ask which checkout to use. + +The first line of the report echoes the `versionMinor` the run read out of that +header. Sanity-check it against the checkout the operator meant to sync against — +it is what the published version will carry (step 5). + +Then check the working tree: + +```powershell +git -C "" status --porcelain +``` + +If it is dirty, **stop and report**. Never stash, reset, or check out over the +operator's uncommitted work. + +## Step 2 — Land on `main` + +`main` is the comparison baseline, so the run starts there regardless of the branch +the checkout is sitting on. Report the branch being left. + +```powershell +git -C "" fetch origin +git -C "" checkout main +git -C "" pull --ff-only +``` + +A non-fast-forward `pull` means local `main` has diverged — stop and report; do not +merge or rebase it. + +## Step 3 — Cut a working branch + +```powershell +git -C "" switch -c sync/cwapi3d-stubs- +``` + +The publish workflow uploads to **real PyPI on every push to `main`** that touches +`src/**`, so generated stubs do not land on `main` directly. Skip this step only when +the operator passed `--on-main`. + +## Step 4 — Report the gap + +Run the dry-run from step 1 again if needed and present the result as a table +grouped by controller: how many declarations are missing, which are whole missing +modules, which cadwork types have no stub. Also relay: + +- **blacklisted** — skipped by `[blacklist]` in `config.toml` (per-controller + `get_last_error` / `clear_errors` and similar plumbing). Say how many, not each one. + Name any **whole module** in `[blacklist].modules` explicitly, though: today + `event_controller` is skipped entirely, and a reader should not mistake that for + "already in sync". Enabling one is a config edit, never a hand-written stub. +- **orphans** — stub functions with no C++ binding. These are **reported, never + deleted**; they usually mean a binding was removed upstream or renamed. + +Stop here on `--report-only`. + +## Step 5 — Apply + +```powershell +python "/scripts/sync_stubs.py" --apply +``` + +The script re-parses every `.pyi` it touched with `ast` before returning; a syntax +error is reported as a `SYNTAX ERROR` warning and exits `2`. Nothing else in this +repo catches a broken stub — there are no tests, and setuptools does not compile +`.pyi`, so treat that exit as a hard failure and report it verbatim. + +Re-running is safe: the tool is additive and idempotent, and the version only moves +when something under `src/` actually changed — no `src/**` change means no publish +run, so no new version is needed. + +### The version comes from the C++ header + +`[project].version` in `pyproject.toml` is never invented and never carried over by +hand. Every run reads the `versionMinor` tag out of `[source].version_header` +(`CwAPI3D/include/CwAPI3DVersion.h`) and derives the new version from it: + +| `versionMinor` vs. the version in `pyproject.toml` | New version | +| -------------------------------------------------- | ----------- | +| higher — the stubs have not shipped for this build yet | `..0` (`33.322.7` → `33.328.0`) | +| **the same** — another sync against a build already shipped for | patch + 1 (`33.328.0` → `33.328.1`) | + +The major is **never** taken from the header. `versionMajor` there is the marketing +year (2026) while the package's major is the cadwork product major (`33`), and PyPI +accepts no version sorting below one already uploaded. Two cases produce a warning +instead of following the header, and both need a human: + +- **`versionMinor` is *lower* than the packaged minor** — the run is pointed at an + older cadlib checkout. The higher minor is kept and the patch bumped; fix + `[paths].cadlib_root` if that was not intended. +- **no `versionMinor` found** — the header moved or was reshaped. The run falls back + to a patch bump; the version is a guess until someone confirms it. + +## Step 6 — Hand off + +Leave the changes **uncommitted** on the working branch. Report: + +1. The files written, and the version change with the `versionMinor` it came from + (`33.322.0` → `33.328.0`, from `versionMinor = 328`). +2. Every warning, in full. The ones that need a human are: + - the two version warnings from step 5 — a `versionMinor` below the packaged + minor, or no `versionMinor` at all. + - *"no Doxygen @brief — docstring is a placeholder"* — the C++ side has no + documentation to derive from. The stub is syntactically fine but the prose is + a stand-in. + - *"carries a C++ @par Example that was NOT translated"* — the interface header + has a worked example in C++. It is deliberately not machine-translated; a wrong + example in the published docs is worse than none. Offer to port it by hand. + - *"C++ types with no Python mapping (annotated Any)"* — add an entry to + `[type_map]` or `[doxygen_hint_map]` in `config.toml` and re-run. + - *"no member of C++ enum ... could be resolved"* — the declaring header is + outside `[source].enum_search_dirs`. The enum is **not** written in that case. +3. For a brand-new module, the `TODO` module docstring left in + `src//__init__.pyi` — the bindings carry no module-level documentation, + so that one paragraph has to be written by a human. + +Do not commit, push, or open a PR unless the operator asks. + +## Constraints + +- **Run this skill on Sonnet.** The work is mechanical — a script run plus a report. + If the session is on another model, switch to Sonnet (`/model sonnet`) before step 1. +- **Additive only.** Never delete or rewrite an existing stub declaration; drift in + the other direction is reported, not resolved. +- **Never hand-edit the stubs to "fix" a generator gap.** Fix `config.toml` and + re-run, so the next sync stays correct. +- **Never hand-write `[project].version`.** It is derived from the C++ + `versionMinor` (step 5). If it looks wrong, the checkout or the header is wrong. +- Do not reformat, re-sort, or re-serialise `mkdocs.yml`, `pyproject.toml`, or + `src/cadwork/__init__.pyi`. The script splices single lines and preserves each + file's CRLF endings; a whole-file rewrite buries the real change. +- Do not touch anything under `cadlib_root`. The C++ side is the source of truth. +- Do not put paths, or anything else machine-specific, into the versioned + `config.toml` — that belongs in `config.personal.toml`. diff --git a/.claude/skills/sync-cwapi3d-stubs/config.personal.toml.example b/.claude/skills/sync-cwapi3d-stubs/config.personal.toml.example new file mode 100644 index 0000000..7899bb1 --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/config.personal.toml.example @@ -0,0 +1,14 @@ +# cwapi3d stub-sync — PERSONAL config. GIT-IGNORED. +# +# Copy to config.personal.toml (same directory) and edit the paths. +# Machine paths only: no secrets, no shared settings. Anything set here +# deep-merges OVER config.toml and wins on conflict. + +[paths] +# Root of the cadwork 3d source tree that contains CwAPI3D/. +# The [source] paths in config.toml are resolved relative to this. +cadlib_root = "D:\\source\\cadlib\\v_33.0\\3d" + +# Root of this repo (the cwapi3d stub checkout). Optional — when omitted the +# tool uses the git root discovered from the config file's location. +stub_repo = "D:\\MkDocs\\cwapi3dpython" diff --git a/.claude/skills/sync-cwapi3d-stubs/config.toml b/.claude/skills/sync-cwapi3d-stubs/config.toml new file mode 100644 index 0000000..a488ca7 --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/config.toml @@ -0,0 +1,110 @@ +# Shared stub-sync config (committed). Machine paths go in the git-ignored +# config.personal.toml, which is deep-merged over this file. +# Env overrides: CWSTUBS_CONFIG / CWSTUBS_PERSONAL_CONFIG. + +[source] +# Relative to [paths].cadlib_root. +python_controller = "CwAPI3D/CCwAPI3DPythonController.cpp" +interface_include_dir = "CwAPI3D/include" +# `versionMinor` here is the cadwork build number the package version tracks. +version_header = "CwAPI3D/include/CwAPI3DVersion.h" + +[target] +# Relative to [paths].stub_repo (defaults to this repo's git root). +src_dir = "src" +docs_dir = "docs/documentation" +mkdocs = "mkdocs.yml" +pyproject = "pyproject.toml" +compare_branch = "main" + +[emit] +# New enums are appended to this shared page instead of getting their own. +enums_page = "enums.md" +# Publish runs on every push touching src/**, so a stale version collides. The new +# version comes from [source].version_header: a fresh versionMinor resets the patch, +# the same one bumps it. The major is never taken from the header. +bump_version = true + +[blacklist] +# Whole modules: neither functions nor the package are created. +modules = ["event_controller"] + +# Per-controller error plumbing; the int32_t out-param does not survive pybind11. +methods = ["get_last_error", "clear_errors"] + +# . one-offs. +qualified = [ + "utility_controller.init_LxSDK", + "utility_controller.redirect_python_output_to_logger", + "utility_controller.print_error", + "utility_controller.print_message", + "utility_controller.print_to_console", + # "utility_controller.get_3d_hwnd", + # Unregistered C++ structs; trampolines return nullptr. + "visualization_controller.save_visibility_state", + "visualization_controller.restore_visibility_state", + "visualization_controller.save_activation_state", + "visualization_controller.restore_activation_state", + # Only bound in auto-attribute mode. + "cadwork.get_auto_attribute_elements", + "cadwork.set_auto_attribute", +] + +types = ["PythonLogger", "visibility_state", "activation_state"] + +# camelCase duplicates of the snake_case methods on cadwork.element_type. +class_method_patterns = ["^is[A-Z]"] + +[type_map] +# C++ (as written in the cwp_* trampoline) -> Python annotation. +# Registered cadwork types and enums are mapped automatically. +"void" = "None" +"bool" = "bool" +"double" = "float" +"float" = "float" +"int" = "int" +"int32_t" = "int" +"uint32_t" = "int" +"int64_t" = "int" +"size_t" = "int" +"uintptr_t" = "int" +"uint64_t" = "ElementId" +"std::string" = "str" +"CwAPI3D::character*" = "str" +"std::vector" = "list[ElementId]" +"std::vector" = "list[str]" +"std::vector" = "list[int]" +"std::vector" = "list[int]" +"std::vector" = "list[float]" +"std::vector" = "list[bool]" +"std::tuple" = "tuple[int, int]" +"py::function" = "Callable[..., None]" +"py::dict" = "dict" +"py::object" = "object" +"CwAPI3D::materialID" = "MaterialId" +"CwAPI3D::elementID" = "ElementId" + +[doxygen_hint_map] +# `@param[in] aSetId [@ref multiLayerSetID]` -> annotation, recovering the +# specific type the trampoline flattened to uint64_t. Unlisted hints fall back to +# a registered cadwork type of the same name, then a snake_case src/cadwork/*.pyi. +elementID = "ElementId" +materialID = "MaterialId" +colorID = "ColorId" +endtypeID = "EndtypeId" +axisID = "AxisId" +multiLayerSetID = "MultiLayerSetId" +userAttributeID = "UserAttributeId" +menuIndex = "MenuIndex" +referenceSide = "ReferenceSide" +# A bare integer hint is a count/index -- ids are spelled @ref elementID etc. +uint64_t = "UnsignedInt" +uint32_t = "UnsignedInt" +"ICwAPI3DElementIDList*" = "list[ElementId]" +"ICwAPI3DString*" = "str" + +[param_names] +# Fallback parameter names by annotation, when the interface has no @param. +"list[ElementId]" = "element_id_list" +"ElementId" = "element_id" +"MaterialId" = "material_id" diff --git a/.claude/skills/sync-cwapi3d-stubs/scripts/_config.py b/.claude/skills/sync-cwapi3d-stubs/scripts/_config.py new file mode 100644 index 0000000..6426bc7 --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/_config.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Config loader for the cwapi3d stub sync. + +Two files, discovered by independent upward walks from CWD (falling back to the +directory holding this script), deep-merged with the personal file winning: + + config.toml versioned, shared + config.personal.toml git-ignored, machine paths only + +Env overrides (absolute paths): CWSTUBS_CONFIG, CWSTUBS_PERSONAL_CONFIG. +Setting CWSTUBS_CONFIG suppresses the personal-file walk, so a pinned run cannot +pick up a stray personal file. + +Exit codes used by callers: 2 = missing/invalid config. +""" + +from __future__ import annotations + +import os +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +CONFIG_FILENAME = "config.toml" +PERSONAL_CONFIG_FILENAME = "config.personal.toml" + +_SKILL_DIR = Path(__file__).resolve().parent.parent + + +class ConfigError(Exception): + """Config could not be located, parsed, or validated.""" + + +def _walk_up(filename: str, start: Path) -> Path | None: + for directory in (start, *start.parents): + candidate = directory / filename + if candidate.is_file(): + return candidate + return None + + +def _find(filename: str, env_var: str) -> Path | None: + override = os.environ.get(env_var) + if override: + path = Path(override) + if not path.is_file(): + raise ConfigError(f"{env_var} points at a missing file: {path}") + return path + # The skill dir is checked first: it is where the pair actually lives. + beside_skill = _SKILL_DIR / filename + if beside_skill.is_file(): + return beside_skill + return _walk_up(filename, Path.cwd().resolve()) + + +def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Merge `override` INTO `base`, returning a new dict. + + Nested tables merge key-by-key so a personal file can override one field of a + section without restating it. Every other value -- scalars and arrays alike -- + is replaced wholesale. `override` wins on every conflict. + """ + merged = dict(base) + for key, value in override.items(): + existing = merged.get(key) + if isinstance(existing, dict) and isinstance(value, dict): + merged[key] = _deep_merge(existing, value) + else: + merged[key] = value + return merged + + +def _read_toml(path: Path) -> dict[str, Any]: + try: + return tomllib.loads(path.read_text(encoding="utf-8")) + except (tomllib.TOMLDecodeError, UnicodeDecodeError) as error: + raise ConfigError(f"{path}: {error}") from error + + +def _git_root(start: Path) -> Path | None: + for directory in (start, *start.parents): + if (directory / ".git").exists(): + return directory + return None + + +@dataclass(frozen=True) +class Config: + raw: dict[str, Any] + config_path: Path + personal_path: Path | None + + # --- resolved roots ------------------------------------------------- + cadlib_root: Path + stub_repo: Path + + # --- resolved source files ------------------------------------------ + python_controller: Path + interface_include_dir: Path + version_header: Path + enum_search_dirs: tuple[Path, ...] + + # --- resolved targets ------------------------------------------------ + src_dir: Path + docs_dir: Path + mkdocs: Path + pyproject: Path + compare_branch: str + + def section(self, name: str) -> dict[str, Any]: + value = self.raw.get(name, {}) + return value if isinstance(value, dict) else {} + + @property + def blacklist_modules(self) -> set[str]: + return set(self.section("blacklist").get("modules", [])) + + @property + def blacklist_methods(self) -> set[str]: + return set(self.section("blacklist").get("methods", [])) + + @property + def blacklist_qualified(self) -> set[str]: + return set(self.section("blacklist").get("qualified", [])) + + @property + def blacklist_types(self) -> set[str]: + return set(self.section("blacklist").get("types", [])) + + @property + def blacklist_class_method_patterns(self) -> list[str]: + return list(self.section("blacklist").get("class_method_patterns", [])) + + @property + def type_map(self) -> dict[str, str]: + return dict(self.section("type_map")) + + @property + def param_names(self) -> dict[str, str]: + return dict(self.section("param_names")) + + @property + def hint_map(self) -> dict[str, str]: + return dict(self.section("doxygen_hint_map")) + + @property + def enums_page(self) -> str: + return str(self.section("emit").get("enums_page", "enums.md")) + + @property + def bump_version(self) -> bool: + return bool(self.section("emit").get("bump_version", True)) + + +def load() -> Config: + config_path = _find(CONFIG_FILENAME, "CWSTUBS_CONFIG") + if config_path is None: + raise ConfigError( + f"no {CONFIG_FILENAME} found beside the skill or above {Path.cwd()}" + ) + raw = _read_toml(config_path) + + personal_path: Path | None = None + if not os.environ.get("CWSTUBS_CONFIG"): + personal_path = _find(PERSONAL_CONFIG_FILENAME, "CWSTUBS_PERSONAL_CONFIG") + if personal_path is not None: + raw = _deep_merge(raw, _read_toml(personal_path)) + + paths = raw.get("paths", {}) + cadlib_raw = paths.get("cadlib_root") + if not cadlib_raw: + raise ConfigError( + "[paths].cadlib_root is not set. Copy " + f"{PERSONAL_CONFIG_FILENAME}.example to {PERSONAL_CONFIG_FILENAME} " + f"in {_SKILL_DIR} and set it." + ) + cadlib_root = Path(cadlib_raw).resolve() + if not cadlib_root.is_dir(): + raise ConfigError(f"[paths].cadlib_root does not exist: {cadlib_root}") + + stub_raw = paths.get("stub_repo") + if stub_raw: + stub_repo = Path(stub_raw).resolve() + else: + discovered = _git_root(config_path.resolve().parent) + if discovered is None: + raise ConfigError( + "[paths].stub_repo is unset and no git root was found above " + f"{config_path}" + ) + stub_repo = discovered + if not stub_repo.is_dir(): + raise ConfigError(f"[paths].stub_repo does not exist: {stub_repo}") + + source = raw.get("source", {}) + target = raw.get("target", {}) + + python_controller = cadlib_root / source.get( + "python_controller", "CwAPI3D/CCwAPI3DPythonController.cpp" + ) + interface_include_dir = cadlib_root / source.get( + "interface_include_dir", "CwAPI3D/include" + ) + version_header = cadlib_root / source.get( + "version_header", "CwAPI3D/include/CwAPI3DVersion.h" + ) + if not python_controller.is_file(): + raise ConfigError(f"binding source not found: {python_controller}") + if not interface_include_dir.is_dir(): + raise ConfigError(f"interface include dir not found: {interface_include_dir}") + # The package version is derived from this header, so a wrong path is a config + # error rather than something to discover halfway through --apply. + if not version_header.is_file(): + raise ConfigError(f"version header not found: {version_header}") + + configured_enum_dirs = source.get("enum_search_dirs") + if configured_enum_dirs: + enum_dirs = tuple(cadlib_root / entry for entry in configured_enum_dirs) + else: + enum_dirs = (interface_include_dir, interface_include_dir.parent) + missing_enum_dirs = [str(path) for path in enum_dirs if not path.is_dir()] + if missing_enum_dirs: + raise ConfigError( + "[source].enum_search_dirs entries do not exist: " + + ", ".join(missing_enum_dirs) + ) + + return Config( + raw=raw, + config_path=config_path, + personal_path=personal_path, + cadlib_root=cadlib_root, + stub_repo=stub_repo, + python_controller=python_controller, + interface_include_dir=interface_include_dir, + version_header=version_header, + enum_search_dirs=enum_dirs, + src_dir=stub_repo / target.get("src_dir", "src"), + docs_dir=stub_repo / target.get("docs_dir", "docs/documentation"), + mkdocs=stub_repo / target.get("mkdocs", "mkdocs.yml"), + pyproject=stub_repo / target.get("pyproject", "pyproject.toml"), + compare_branch=str(target.get("compare_branch", "main")), + ) diff --git a/.claude/skills/sync-cwapi3d-stubs/scripts/_cpp_bindings.py b/.claude/skills/sync-cwapi3d-stubs/scripts/_cpp_bindings.py new file mode 100644 index 0000000..338a620 --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/_cpp_bindings.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +"""Extract the full pybind11 inventory from CCwAPI3DPythonController.cpp. + +Three passes over the single 14k-line translation unit: + + A trampoline index -- every `` cwp_()`` free function, plus + the C++ interface accessor + method its body forwards to. + B module bodies -- every ``PYBIND11_EMBEDDED_MODULE(, m)`` and the + ``m.def("", &)`` calls inside it. + C cadwork types -- ``py::class_(m, "name")`` / ``py::enum_(m, "name")`` + chains and the one ``m.attr("alias") = `` alias. + +Everything is resolved by SYMBOL, never by transforming the Python name: the +source contains genuine aliases (``set_framed_wall`` -> ``cwp_..._set_wall``) and +at least one upstream typo (``get_total_dimension`` -> ``..._get_total_direction``). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path + +_MODULE_RE = re.compile(r"^PYBIND11_EMBEDDED_MODULE\(\s*(\w+)\s*,\s*\w+\s*\)", re.M) +_TRAMPOLINE_START_RE = re.compile(r"^([A-Za-z_][\w:<>,*&\s]*?)\s+(cwp_\w+)\s*\(", re.M) +_FORWARD_RE = re.compile(r"getFactory\(\)\s*->\s*(\w+)\(\)\s*->\s*(\w+)\s*\(") +# Some trampolines cache the controller in a local first: +# auto* lController = ...getFactory()->getElementController(); +# const auto lResult = lController->getElementActivePoint(a0); +_ACCESSOR_RE = re.compile(r"getFactory\(\)\s*->\s*(get\w+Controller)\(\)") +_LOCAL_CALL_RE = re.compile(r"\bl[A-Z]\w*\s*->\s*(\w+)\s*\(") +_STATIC_CAST_RE = re.compile(r"static_cast\s*<\s*([\w:]+)\s*>\s*\(\s*(\w+)\s*\)") +_CLASS_RE = re.compile(r"py::class_\s*<") +_ENUM_RE = re.compile(r"py::enum_\s*<") +_ATTR_ALIAS_RE = re.compile(r'^\s*m\.attr\("(\w+)"\)\s*=\s*(\w+)\s*;', re.M) +_ENUM_VAR_RE = re.compile(r"^\s*auto\s+(\w+)\s*=\s*$|^\s*auto\s+(\w+)\s*=\s*py::enum_") + + +@dataclass(frozen=True) +class Trampoline: + """A ``cwp_*`` free function -- the signature carrier for one binding.""" + + symbol: str + return_type: str + param_types: tuple[str, ...] + param_names: tuple[str, ...] + accessor: str | None # e.g. "getBimController" + cpp_method: str | None # e.g. "getIfcGuid" + # Enums cross the pybind11 boundary as plain ints and are cast back inside the + # trampoline. This recovers the real type per parameter position: + # leaveWorkingPlane(static_cast(a0)) + param_casts: tuple[str | None, ...] = () + + +@dataclass(frozen=True) +class Binding: + """One ``m.def(...)`` inside a ``PYBIND11_EMBEDDED_MODULE`` body.""" + + module: str + python_name: str + symbol: str | None + arg_names: tuple[str, ...] + arg_defaults: tuple[str | None, ...] + is_lambda: bool + line: int + + +@dataclass +class CadworkType: + """A ``py::class_`` or ``py::enum_`` registration in the ``cadwork`` module.""" + + python_name: str + cpp_type: str + kind: str # "class" | "enum" + line: int + methods: list[tuple[str, str]] = field(default_factory=list) # (py_name, cpp_member) + fields: list[tuple[str, str, bool]] = field(default_factory=list) # (name, member, writable) + init_signatures: list[tuple[str, ...]] = field(default_factory=list) + # (python member name, C++ member expression) + values: list[tuple[str, str]] = field(default_factory=list) + aliases: list[str] = field(default_factory=list) + + +@dataclass +class Inventory: + trampolines: dict[str, Trampoline] + bindings: list[Binding] + types: list[CadworkType] + + def modules(self) -> list[str]: + seen: list[str] = [] + for binding in self.bindings: + if binding.module not in seen: + seen.append(binding.module) + return seen + + +# --------------------------------------------------------------------------- +# small scanning helpers +# --------------------------------------------------------------------------- + + +def _skip_string(text: str, index: int) -> int: + """Return the index just past the string literal starting at `index`.""" + quote = text[index] + index += 1 + while index < len(text): + if text[index] == "\\": + index += 2 + continue + if text[index] == quote: + return index + 1 + index += 1 + return index + + +def _match_parens(text: str, open_index: int) -> int: + """Index of the ``)`` matching the ``(`` at `open_index`, string/brace aware.""" + depth = 0 + index = open_index + while index < len(text): + char = text[index] + if char in "\"'": + index = _skip_string(text, index) + continue + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + return index + index += 1 + raise ValueError(f"unbalanced parentheses from offset {open_index}") + + +def _split_top_level(text: str, separator: str = ",") -> list[str]: + """Split on `separator` at nesting depth 0 of ``()``, ``<>``, ``[]``, ``{}``.""" + parts: list[str] = [] + depth = 0 + current: list[str] = [] + index = 0 + while index < len(text): + char = text[index] + if char in "\"'": + end = _skip_string(text, index) + current.append(text[index:end]) + index = end + continue + if char in "(<[{": + depth += 1 + elif char in ")>]}": + depth -= 1 + elif char == separator and depth == 0: + parts.append("".join(current).strip()) + current = [] + index += 1 + continue + current.append(char) + index += 1 + tail = "".join(current).strip() + if tail: + parts.append(tail) + return parts + + +def _normalize_type(raw: str) -> str: + """Strip cv/ref decoration and collapse whitespace on a C++ type.""" + text = raw.strip() + text = re.sub(r"\bconst\b", " ", text) + text = text.replace("&", " ") + text = re.sub(r"\s*([<>,*])\s*", r"\1", text) + text = re.sub(r",", ", ", text) + text = re.sub(r"\s+", " ", text).strip() + return text + + +def _split_param(raw: str) -> tuple[str, str]: + """Split one C++ parameter declaration into (type, name).""" + text = raw.strip() + if not text or text == "void": + return ("", "") + text = text.split("=", 1)[0].strip() + match = re.search(r"(\w+)\s*$", text) + if match and not re.fullmatch(r"[\w:]+", text): + name = match.group(1) + type_part = text[: match.start(1)] + return (_normalize_type(type_part), name) + return (_normalize_type(text), "") + + +# --------------------------------------------------------------------------- +# pass A -- trampolines +# --------------------------------------------------------------------------- + + +def _parse_trampolines(text: str) -> dict[str, Trampoline]: + result: dict[str, Trampoline] = {} + for match in _TRAMPOLINE_START_RE.finditer(text): + return_type = _normalize_type(match.group(1)) + symbol = match.group(2) + open_index = match.end() - 1 + try: + close_index = _match_parens(text, open_index) + except ValueError: + continue + # A declaration ends in ';', a definition in '{'. Only definitions carry a body. + tail = text[close_index + 1 : close_index + 200].lstrip() + if not tail.startswith("{"): + continue + params_raw = text[open_index + 1 : close_index] + types: list[str] = [] + names: list[str] = [] + for chunk in _split_top_level(params_raw): + param_type, param_name = _split_param(chunk) + if not param_type: + continue + types.append(param_type) + names.append(param_name) + + body_start = text.index("{", close_index) + body_end = text.find("\n}", body_start) + body = text[body_start : body_end if body_end != -1 else body_start + 4000] + forward = _FORWARD_RE.search(body) + if forward is not None: + accessor: str | None = forward.group(1) + cpp_method: str | None = forward.group(2) + else: + accessor_match = _ACCESSOR_RE.search(body) + local_match = _LOCAL_CALL_RE.search(body) + accessor = accessor_match.group(1) if accessor_match else None + cpp_method = local_match.group(1) if local_match else None + + casts = { + cast.group(2): cast.group(1) for cast in _STATIC_CAST_RE.finditer(body) + } + result[symbol] = Trampoline( + symbol=symbol, + return_type=return_type, + param_types=tuple(types), + param_names=tuple(names), + accessor=accessor, + cpp_method=cpp_method, + param_casts=tuple(casts.get(name) for name in names), + ) + return result + + +# --------------------------------------------------------------------------- +# pass B -- module bodies and m.def calls +# --------------------------------------------------------------------------- + + +def _module_spans(text: str) -> list[tuple[str, int, int]]: + """(module_name, body_start, body_end) for every embedded module.""" + spans: list[tuple[str, int, int]] = [] + for match in _MODULE_RE.finditer(text): + name = match.group(1) + brace = text.index("{", match.end()) + # Inner braces are always indented in this file; the body terminator is the + # first '}' at column 0 after the opening brace. + end = text.find("\n}", brace) + end = len(text) if end == -1 else end + 1 + spans.append((name, brace, end)) + return spans + + +def _parse_def_call(module: str, body: str, offset: int, base_line: int) -> Binding | None: + open_index = body.index("(", offset) + close_index = _match_parens(body, open_index) + inner = body[open_index + 1 : close_index] + parts = _split_top_level(inner) + if not parts: + return None + name_match = re.match(r'^"([^"]+)"$', parts[0].strip()) + if name_match is None: + return None + python_name = name_match.group(1) + + symbol: str | None = None + is_lambda = False + if len(parts) > 1: + target = parts[1].strip() + if target.startswith("[") or "->" in target[:3]: + is_lambda = True + inner_call = re.search(r"\b(cwp_\w+)\s*\(", target) + if inner_call: + symbol = inner_call.group(1) + else: + symbol_match = re.match(r"^&?\s*([\w:]+)\s*$", target) + if symbol_match: + symbol = symbol_match.group(1).split("::")[-1] + + arg_names: list[str] = [] + arg_defaults: list[str | None] = [] + for part in parts[2:]: + arg_match = re.match(r'py::arg\("(\w+)"\)\s*(?:=\s*(.+))?$', part.strip(), re.S) + if arg_match: + arg_names.append(arg_match.group(1)) + default = arg_match.group(2) + arg_defaults.append(default.strip() if default else None) + + line = base_line + body.count("\n", 0, offset) + return Binding( + module=module, + python_name=python_name, + symbol=symbol, + arg_names=tuple(arg_names), + arg_defaults=tuple(arg_defaults), + is_lambda=is_lambda, + line=line, + ) + + +def _parse_bindings(text: str, spans: list[tuple[str, int, int]]) -> list[Binding]: + bindings: list[Binding] = [] + for module, start, end in spans: + body = text[start:end] + base_line = text.count("\n", 0, start) + 1 + for match in re.finditer(r"\bm\.def\s*\(", body): + try: + binding = _parse_def_call(module, body, match.start(), base_line) + except ValueError: + continue + if binding is not None: + bindings.append(binding) + return bindings + + +# --------------------------------------------------------------------------- +# pass C -- cadwork classes and enums +# --------------------------------------------------------------------------- + + +def _chain_end(text: str, start: int) -> int: + """Index of the ``;`` closing a ``py::class_``/``py::enum_`` builder chain.""" + depth = 0 + index = start + while index < len(text): + char = text[index] + if char in "\"'": + index = _skip_string(text, index) + continue + if char in "([{": + depth += 1 + elif char in ")]}": + depth -= 1 + elif char == ";" and depth <= 0: + return index + index += 1 + return len(text) + + +def _template_arg(text: str, open_angle: int) -> tuple[str, int]: + depth = 0 + index = open_angle + while index < len(text): + if text[index] == "<": + depth += 1 + elif text[index] == ">": + depth -= 1 + if depth == 0: + return (text[open_angle + 1 : index], index) + index += 1 + raise ValueError("unbalanced template brackets") + + +def _parse_types(text: str, span: tuple[str, int, int]) -> list[CadworkType]: + _, start, end = span + body = text[start:end] + base_line = text.count("\n", 0, start) + 1 + types: list[CadworkType] = [] + by_variable: dict[str, CadworkType] = {} + + for kind, pattern in (("class", _CLASS_RE), ("enum", _ENUM_RE)): + for match in pattern.finditer(body): + angle = body.index("<", match.start()) + try: + template_args, close_angle = _template_arg(body, angle) + except ValueError: + continue + cpp_type = _split_top_level(template_args)[0].strip() + paren = body.index("(", close_angle) + call_end = _match_parens(body, paren) + call_args = _split_top_level(body[paren + 1 : call_end]) + if len(call_args) < 2: + continue + name_match = re.match(r'^"([^"]+)"$', call_args[1].strip()) + if name_match is None: + continue + entry = CadworkType( + python_name=name_match.group(1), + cpp_type=cpp_type, + kind=kind, + line=base_line + body.count("\n", 0, match.start()), + ) + chain = body[call_end : _chain_end(body, call_end)] + _fill_chain(entry, chain) + types.append(entry) + + # `auto = py::enum_<...>` -- remember for m.attr alias resolution. + line_start = body.rfind("\n", 0, match.start()) + 1 + prefix = body[line_start : match.start()] + var_match = re.search(r"\bauto\s+(\w+)\s*=\s*$", prefix) + if var_match: + by_variable[var_match.group(1)] = entry + + for alias_match in _ATTR_ALIAS_RE.finditer(body): + alias, variable = alias_match.group(1), alias_match.group(2) + target = by_variable.get(variable) + if target is not None: + target.aliases.append(alias) + + types.sort(key=lambda entry: entry.line) + return types + + +def _fill_chain(entry: CadworkType, chain: str) -> None: + for match in re.finditer(r"\.def_(readwrite|readonly)\s*\(", chain): + open_index = chain.index("(", match.start()) + args = _split_top_level(chain[open_index + 1 : _match_parens(chain, open_index)]) + if len(args) >= 2: + name_match = re.match(r'^"([^"]+)"$', args[0].strip()) + if name_match: + entry.fields.append( + ( + name_match.group(1), + args[1].strip().lstrip("&"), + match.group(1) == "readwrite", + ) + ) + + for match in re.finditer(r"\.def\s*\(", chain): + open_index = chain.index("(", match.start()) + try: + args = _split_top_level( + chain[open_index + 1 : _match_parens(chain, open_index)] + ) + except ValueError: + continue + if not args: + continue + first = args[0].strip() + if first.startswith("py::init"): + init_angle = first.find("<") + if init_angle != -1: + try: + template_args, _ = _template_arg(first, init_angle) + except ValueError: + continue + entry.init_signatures.append( + tuple( + _normalize_type(part) + for part in _split_top_level(template_args) + if part.strip() + ) + ) + continue + name_match = re.match(r'^"([^"]+)"$', first) + if name_match is None: + continue + member = args[1].strip().lstrip("&") if len(args) > 1 else "" + entry.methods.append((name_match.group(1), member)) + + for match in re.finditer(r'\.value\s*\(\s*"(\w+)"\s*,\s*([\w:]+)', chain): + entry.values.append((match.group(1), match.group(2))) + + +# --------------------------------------------------------------------------- +# C++ enum definitions -- the real numeric values and their trailing ///< docs +# --------------------------------------------------------------------------- + +_ENUM_DEF_RE = re.compile( + r"\benum\s+(?:class\s+|struct\s+)?(\w+)\s*(?::\s*[\w:]+\s*)?\{", re.M +) + + +@dataclass(frozen=True) +class EnumMember: + name: str + value: int + doc: str + + +def parse_enum_definitions(search_dirs: list[Path]) -> dict[str, list[EnumMember]]: + """Map bare C++ enum name -> its members with resolved values. + + Values follow the C++ rule: implicit members continue from the previous one, + an explicit ``= N`` (decimal or hex) resets the counter, and ``= OTHER`` + aliases an earlier member of the same enum. Anything else stops the enum + from being emitted rather than guessing. + + More than one directory is searched because not every enum a binding exposes + lives under include/ -- ``OnStateChange`` is declared in the project root's + ICwAPI3DEventObserver.h. + """ + result: dict[str, list[EnumMember]] = {} + headers = sorted( + {header for directory in search_dirs for header in directory.glob("*.h")} + ) + for header in headers: + text = header.read_text(encoding="utf-8", errors="replace") + for match in _ENUM_DEF_RE.finditer(text): + name = match.group(1) + brace = text.index("{", match.start()) + close = text.find("};", brace) + if close == -1: + continue + members: list[EnumMember] = [] + by_name: dict[str, int] = {} + counter = 0 + failed = False + for raw_line in text[brace + 1 : close].splitlines(): + line = raw_line.strip() + doc = "" + doc_match = re.search(r"///<\s*(.*)$", line) + if doc_match: + doc = doc_match.group(1).strip() + line = line[: doc_match.start()].strip() + line = re.sub(r"//.*$", "", line).strip().rstrip(",").strip() + if not line or line.startswith("/"): + continue + if "=" in line: + member, _, expression = line.partition("=") + member = member.strip() + expression = expression.strip() + if re.fullmatch(r"-?0[xX][0-9a-fA-F]+", expression): + counter = int(expression, 16) + elif re.fullmatch(r"-?\d+", expression): + counter = int(expression) + elif expression.split("::")[-1] in by_name: + counter = by_name[expression.split("::")[-1]] + else: + failed = True + break + else: + member = line + if not re.fullmatch(r"\w+", member): + failed = True + break + members.append(EnumMember(name=member, value=counter, doc=doc)) + by_name[member] = counter + counter += 1 + if not failed and members: + result.setdefault(name, members) + return result + + +# --------------------------------------------------------------------------- +# entry point +# --------------------------------------------------------------------------- + + +def parse(path: Path) -> Inventory: + text = path.read_text(encoding="utf-8", errors="replace") + spans = _module_spans(text) + cadwork_span = next((span for span in spans if span[0] == "cadwork"), None) + return Inventory( + trampolines=_parse_trampolines(text), + bindings=_parse_bindings(text, spans), + types=_parse_types(text, cadwork_span) if cadwork_span else [], + ) diff --git a/.claude/skills/sync-cwapi3d-stubs/scripts/_doxygen.py b/.claude/skills/sync-cwapi3d-stubs/scripts/_doxygen.py new file mode 100644 index 0000000..9406d18 --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/_doxygen.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Index the Doxygen contracts on the ICwAPI3D* interface headers. + +Those headers are the documentation surface plugin authors read, so they carry +everything a Python docstring needs that the pybind11 layer throws away: a +``@brief``, ``@param`` entries with REAL names and prose, a ``@return`` with +prose, and often a ``@par Example`` code block. + +The join key back to a binding is (interface accessor, C++ method name), both +recovered from the ``cwp_*`` trampoline body -- see _cpp_bindings.Trampoline. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path + +_VIRTUAL_RE = re.compile( + r"^\s*virtual\s+(?P[\w:<>,*&\s]+?)\s+(?P\w+)\s*\((?P[^;]*?)\)\s*" + r"(?:const\s*)?=\s*0\s*;", + re.M, +) +_PARAM_RE = re.compile( + r"@param\s*(?:\[[^\]]*\])?\s*(?P\w+)\s*(?:\[(?P[^\]]*)\])?\s*(?P.*)" +) +_RETURN_RE = re.compile( + r"@(?:return|result)s?\s*(?:\[(?P[^\]]*)\])?\s*(?P.*)" +) +_REF_RE = re.compile(r"@ref\s+") + + +@dataclass +class DocBlock: + interface: str + method: str + brief: str = "" + # (name, doxygen type hint, description) + params: list[tuple[str, str, str]] = field(default_factory=list) + returns: str = "" + returns_hint: str = "" + note: str = "" + example: list[str] = field(default_factory=list) + deprecated: str = "" + + @property + def is_thin(self) -> bool: + return not self.brief + + +def camel_to_snake(name: str) -> str: + """``aElementIDList`` -> ``element_id_list``; ``aP1`` -> ``p1``.""" + text = name + if re.match(r"^a[A-Z0-9]", text): + text = text[1:] + text = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", text) + text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", text) + return text.lower().strip("_") + + +def _clean(text: str) -> str: + text = _REF_RE.sub("", text) + text = text.replace("@li", "-") + return re.sub(r"\s+", " ", text).strip() + + +def _comment_lines_above(lines: list[str], index: int) -> list[str]: + """The contiguous ``///`` or ``/** */`` block immediately above `index`.""" + collected: list[str] = [] + cursor = index - 1 + while cursor >= 0: + stripped = lines[cursor].strip() + if stripped.startswith("///"): + collected.append(stripped[3:].strip()) + cursor -= 1 + continue + if stripped.endswith("*/"): + block: list[str] = [] + while cursor >= 0: + inner = lines[cursor].strip() + block.append(inner.removesuffix("*/").removeprefix("/**").lstrip("*").strip()) + if inner.startswith("/*"): + break + cursor -= 1 + collected.extend(block) + cursor -= 1 + continue + break + return list(reversed(collected)) + + +def _normalize(raw_lines: list[str]) -> list[str]: + """Neutralise the non-structural ``@`` commands before tag dispatch. + + A wrapped ``@param`` description routinely continues on a line that STARTS with + ``@ref`` (see stretchFacet in ICwAPI3DGeometryController.h). Left alone, the + dispatcher would read that as a new tag and drop the rest of the sentence. + """ + normalized: list[str] = [] + for line in raw_lines: + line = _REF_RE.sub("", line) + line = re.sub(r"^@li\b\s*", "- ", line) + line = line.replace("@li", "-") + normalized.append(line.strip()) + return normalized + + +def _parse_block(interface: str, method: str, raw_lines: list[str]) -> DocBlock: + block = DocBlock(interface=interface, method=method) + raw_lines = _normalize(raw_lines) + mode = "" + buffer: list[str] = [] + + def flush() -> None: + nonlocal buffer + text = _clean(" ".join(buffer)) + if text: + if mode == "brief" and not block.brief: + block.brief = text + elif mode == "note": + block.note = (block.note + " " + text).strip() + elif mode == "deprecated": + block.deprecated = (block.deprecated + " " + text).strip() + buffer = [] + + for line in raw_lines: + if line.startswith("@code"): + mode = "code" + continue + if line.startswith("@endcode"): + mode = "" + continue + if mode == "code": + block.example.append(line) + continue + if line.startswith("@brief"): + flush() + mode = "brief" + buffer = [line[len("@brief") :]] + continue + if line.startswith("@param"): + flush() + mode = "param" + match = _PARAM_RE.match(line) + if match: + block.params.append( + ( + match.group("name"), + _clean(match.group("type") or ""), + _clean(match.group("desc") or ""), + ) + ) + continue + if re.match(r"@(return|result)", line): + flush() + mode = "return" + match = _RETURN_RE.match(line) + if match: + block.returns = _clean(match.group("desc") or "") + block.returns_hint = _clean(match.group("type") or "") + continue + if line.startswith("@note"): + flush() + mode = "note" + buffer = [line[len("@note") :]] + continue + if line.startswith("@deprecated"): + flush() + mode = "deprecated" + buffer = [line[len("@deprecated") :]] + continue + if line.startswith("@par"): + flush() + mode = "" + continue + if line.startswith("@"): + # @since / @author / @date / @ingroup / @interface -- not docstring material. + flush() + mode = "" + continue + if not line: + flush() + continue + if mode == "param" and block.params: + name, hint, desc = block.params[-1] + block.params[-1] = (name, hint, _clean(f"{desc} {line}")) + continue + if mode == "return": + block.returns = _clean(f"{block.returns} {line}") + continue + if mode in ("brief", "note", "deprecated"): + buffer.append(line) + continue + if not block.brief: + mode = "brief" + buffer = [line] + + flush() + return block + + +@dataclass +class DoxygenIndex: + by_interface: dict[tuple[str, str], DocBlock] + by_method: dict[str, list[DocBlock]] + + def lookup(self, accessor: str | None, method: str | None) -> DocBlock | None: + if not method: + return None + if accessor: + interface = "ICwAPI3D" + accessor.removeprefix("get") + found = self.by_interface.get((interface, method)) + if found is not None: + return found + candidates = self.by_method.get(method, []) + if len(candidates) == 1: + return candidates[0] + return None + + +def parse(include_dir: Path) -> DoxygenIndex: + by_interface: dict[tuple[str, str], DocBlock] = {} + by_method: dict[str, list[DocBlock]] = {} + + for header in sorted(include_dir.glob("ICwAPI3D*.h")): + interface = header.stem + text = header.read_text(encoding="utf-8", errors="replace") + lines = text.splitlines() + for match in _VIRTUAL_RE.finditer(text): + method = match.group("name") + line_index = text.count("\n", 0, match.start()) + block = _parse_block( + interface, method, _comment_lines_above(lines, line_index) + ) + key = (interface, method) + # Overloads share a name; keep the first (richest) documented one. + if key not in by_interface or by_interface[key].is_thin: + by_interface[key] = block + by_method.setdefault(method, []).append(block) + + return DoxygenIndex(by_interface=by_interface, by_method=by_method) diff --git a/.claude/skills/sync-cwapi3d-stubs/scripts/_emit.py b/.claude/skills/sync-cwapi3d-stubs/scripts/_emit.py new file mode 100644 index 0000000..a6f48f9 --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/_emit.py @@ -0,0 +1,696 @@ +#!/usr/bin/env python3 +"""Render missing declarations and patch the surrounding repo files. + +Rendering follows the host repo's conventions exactly: + * no function bodies -- the Google docstring IS the body + * ``Parameters:`` (never ``Args:``), ``Returns:`` LAST, omitted for ``-> None`` + * blank lines between defs matched per host file + +Doxygen ``@par Example`` blocks are C++ and are deliberately NOT machine-translated +into Python doctests: a wrong example in the published docs is worse than none. +Methods that had one available are reported so a human can port it. + +Companion edits are all surgical line splices. mkdocs.yml in particular is never +round-tripped through a YAML writer -- the stdlib has none, and re-serialising +would destroy the file's comments and hand-tuned ordering. +""" + +from __future__ import annotations + +import keyword +import re +from dataclasses import dataclass, field +from pathlib import Path + +import _files +from _cpp_bindings import Binding, CadworkType, EnumMember, Trampoline +from _doxygen import DocBlock, camel_to_snake + +_INDENT = " " +_API_TYPE_ALIASES = { + "ElementId", + "MaterialId", + "ColorId", + "EndtypeId", + "AxisId", + "MenuIndex", + "ReferenceSide", + "MultiLayerSetId", + "UserAttributeId", + "UnsignedInt", +} + + +# --------------------------------------------------------------------------- +# type resolution +# --------------------------------------------------------------------------- + + +@dataclass +class TypeResolver: + type_map: dict[str, str] + cadwork_types: dict[str, str] # normalised C++ type -> python type name + hint_map: dict[str, str] = field(default_factory=dict) + stub_types: set[str] = field(default_factory=set) + unresolved: set[str] = field(default_factory=set) + + @classmethod + def build( + cls, + type_map: dict[str, str], + types: list[CadworkType], + hint_map: dict[str, str] | None = None, + stub_types: set[str] | None = None, + ) -> "TypeResolver": + registry: dict[str, str] = {} + for entry in types: + registry[entry.cpp_type] = entry.python_name + registry[entry.cpp_type.split("::")[-1]] = entry.python_name + registry[entry.python_name] = entry.python_name + return cls( + type_map=dict(type_map), + cadwork_types=registry, + hint_map=dict(hint_map or {}), + stub_types=set(stub_types or set()), + ) + + def resolve_hint(self, name: str) -> str | None: + """Resolve a Doxygen ``@ref`` name / static_cast target to an annotation. + + The interface headers name the SPECIFIC type a parameter carries + (``multiLayerSetID``, ``multiLayerSubType``) where the pybind11 trampoline + has already flattened it to ``uint64_t`` / ``int32_t``. That naming is + authored per parameter, so it beats the flattened type when both exist. + """ + bare = name.split("::")[-1].strip() + if not bare: + return None + if bare in self.hint_map: + return self.hint_map[bare] + if bare in self.cadwork_types: + return self.cadwork_types[bare] + snake = camel_to_snake(bare) + # A type the repo already ships a hand-written stub for, even though the + # bindings never register it (e.g. multi_layer_subtype). + if snake in self.stub_types: + return snake + return None + + def resolve(self, cpp_type: str, quiet: bool = False) -> tuple[str, str | None]: + """Return (python annotation, cadwork type name needing an import). + + `quiet` suppresses the unresolved-type warning for speculative lookups + (a static_cast target or a @param hint that may not name a real type). + """ + text = cpp_type.strip().removeprefix("const ").strip() + if text in self.type_map: + return (self.type_map[text], None) + + vector = re.fullmatch(r"std::vector<(.+)>", text) + if vector: + inner, needed = self.resolve(vector.group(1), quiet) + return (f"list[{inner}]", needed) + + for candidate in (text, text.rstrip("*").strip()): + if candidate in self.cadwork_types: + name = self.cadwork_types[candidate] + return (name, name) + if candidate in self.type_map: + return (self.type_map[candidate], None) + + if not quiet: + self.unresolved.add(text) + return ("Any", None) + + +# --------------------------------------------------------------------------- +# function rendering +# --------------------------------------------------------------------------- + + +def docstring_safe(text: str) -> str: + """Make prose safe to sit inside a ``\"\"\"`` docstring. + + Doxygen prose routinely contains quoted labels (``GUI label "X"``). A triple + quote inside, or a trailing double quote adjacent to the closer, would produce + a stub that does not parse. + """ + cleaned = text.replace('"""', "'''").replace("\\", "\\\\") + return cleaned[:-1] + "'" if cleaned.endswith('"') else cleaned + + +def _default_literal(cpp_default: str | None) -> str | None: + if cpp_default is None: + return None + text = cpp_default.strip() + if text == "true": + return "True" + if text == "false": + return "False" + if text == "nullptr": + return "None" + return text + + +def _safe_name(name: str, used: set[str], index: int) -> str: + candidate = name or f"arg{index}" + candidate = re.sub(r"\W", "_", candidate) + if not candidate or candidate[0].isdigit(): + candidate = f"arg{index}" + if keyword.iskeyword(candidate): + candidate = f"{candidate}_" + while candidate in used: + candidate = f"{candidate}_{index}" + used.add(candidate) + return candidate + + +_TYPING_NAMES = {"Any", "Callable", "Iterator", "Optional", "Union"} + + +@dataclass +class RenderedFunction: + text: str + imports: set[str] # cadwork type / api_types alias names + typing_names: set[str] # names needing `from typing import ...` + had_cpp_example: bool + thin_doc: bool + + +def _referenced_names(annotations: list[str], resolver: TypeResolver) -> tuple[set[str], set[str]]: + """Split the identifiers used in `annotations` into cadwork and typing names. + + Derived from the rendered annotation strings rather than threaded through the + resolver, so a name introduced anywhere -- inside ``list[...]``, via an enum + upgrade, or straight from the type map -- still gets its import. + """ + known = set(resolver.cadwork_types.values()) | _API_TYPE_ALIASES + identifiers: set[str] = set() + for annotation in annotations: + identifiers.update(re.findall(r"\w+", annotation)) + return (identifiers & known, identifiers & _TYPING_NAMES) + + +def render_function( + binding: Binding, + trampoline: Trampoline, + doc: DocBlock | None, + resolver: TypeResolver, + param_name_fallbacks: dict[str, str], +) -> RenderedFunction: + doc_params = doc.params if doc else [] + aligned_doc = doc_params if len(doc_params) == len(trampoline.param_types) else [] + + annotations: list[str] = [] + for index, cpp_type in enumerate(trampoline.param_types): + annotation, needed = resolver.resolve(cpp_type) + # Ids and enums cross the pybind11 boundary flattened to uint64_t/int32_t. + # When the primary type is one of those scalars, prefer the specific type + # named by the trampoline's static_cast, then by the interface header's + # @param hint. A parameter that already resolved to a real cadwork class is + # left alone -- nothing more specific exists. + if needed is None: + upgrades = [ + trampoline.param_casts[index] + if index < len(trampoline.param_casts) + else None, + aligned_doc[index][1] if aligned_doc else None, + ] + for candidate in upgrades: + upgraded = resolver.resolve_hint(candidate) if candidate else None + if upgraded: + annotation = upgraded + break + annotations.append(annotation) + + used: set[str] = set() + names: list[str] = [] + descriptions: list[str] = [] + for index, annotation in enumerate(annotations): + raw_name = "" + description = "" + if index < len(binding.arg_names): + raw_name = binding.arg_names[index] + if aligned_doc: + doc_name, _hint, doc_desc = aligned_doc[index] + raw_name = raw_name or camel_to_snake(doc_name) + description = doc_desc + if not raw_name: + raw_name = param_name_fallbacks.get(annotation, "") + if not raw_name and index < len(trampoline.param_names): + candidate = trampoline.param_names[index] + if not re.fullmatch(r"a\d+", candidate): + raw_name = camel_to_snake(candidate) + names.append(_safe_name(raw_name, used, index)) + descriptions.append(description) + + defaults = [ + _default_literal(binding.arg_defaults[index]) + if index < len(binding.arg_defaults) + else None + for index in range(len(annotations)) + ] + + signature_parts: list[str] = [] + for name, annotation, default in zip(names, annotations, defaults): + part = f"{name}: {annotation}" + if default is not None: + part += f" = {default}" + signature_parts.append(part) + + return_annotation, return_needed = resolver.resolve(trampoline.return_type) + if return_needed is None and doc and doc.returns_hint: + upgraded = resolver.resolve_hint(doc.returns_hint) + if upgraded: + return_annotation = upgraded + imports, typing_names = _referenced_names( + [*annotations, return_annotation], resolver + ) + + lines = [ + f"def {binding.python_name}({', '.join(signature_parts)}) -> {return_annotation}:" + ] + brief = docstring_safe( + (doc.brief if doc else "") or binding.python_name.replace("_", " ") + ) + if not brief.endswith((".", "!", "?")): + brief += "." + lines.append(f'{_INDENT}"""{brief}') + + if doc and doc.deprecated: + lines.append("") + lines.append(f"{_INDENT}Deprecated : ") + lines.append(f"{_INDENT * 2}{docstring_safe(doc.deprecated)}") + + if names: + lines.append("") + lines.append(f"{_INDENT}Parameters:") + for name, description in zip(names, descriptions): + text = docstring_safe(description) or name.replace("_", " ") + "." + lines.append(f"{_INDENT * 2}{name}: {text}") + + if doc and doc.note: + lines.append("") + lines.append(f"{_INDENT}Note:") + lines.append(f"{_INDENT * 2}{docstring_safe(doc.note)}") + + if return_annotation != "None": + lines.append("") + lines.append(f"{_INDENT}Returns:") + returns_text = docstring_safe(doc.returns if doc else "") or return_annotation + lines.append(f"{_INDENT * 2}{returns_text}") + + lines.append(f'{_INDENT}"""') + + return RenderedFunction( + text="\n".join(lines), + imports=imports, + typing_names=typing_names, + had_cpp_example=bool(doc and doc.example), + thin_doc=not (doc and doc.brief), + ) + + +def import_lines(names: set[str], stub_star_imports: bool, known: set[str]) -> list[str]: + """``from cadwork.x import x`` lines for the types this file now needs.""" + lines: list[str] = [] + for name in sorted(names): + if name in known: + continue + if name in _API_TYPE_ALIASES: + if stub_star_imports: + continue + lines.append(f"from cadwork.api_types import {name}") + continue + lines.append(f"from cadwork.{name} import {name}") + return lines + + +# --------------------------------------------------------------------------- +# cadwork type rendering +# --------------------------------------------------------------------------- + + +@dataclass +class RenderedType: + text: str + imports: set[str] + warnings: list[str] = field(default_factory=list) + # False when the type could not be rendered faithfully and must not be written. + ok: bool = True + + +def render_enum( + entry: CadworkType, definitions: dict[str, list[EnumMember]] +) -> RenderedType: + """Emit an IntEnum matching the shape of the repo's existing enum stubs. + + Numeric values come from the C++ enum definition, never from the + registration order -- ``py::enum_`` chains carry no values at all. + """ + warnings: list[str] = [] + bare = entry.cpp_type.split("::")[-1] + members = definitions.get(bare, []) + by_name = {member.name: member for member in members} + + resolved: list[tuple[str, int, str]] = [] + for python_name, cpp_expression in entry.values: + member = by_name.get(cpp_expression.split("::")[-1]) + if member is None: + warnings.append( + f"cadwork.{entry.python_name}.{python_name}: no C++ value found for " + f"{cpp_expression}" + ) + continue + resolved.append((python_name, member.value, member.doc)) + + if not resolved: + return RenderedType( + text="", + imports=set(), + ok=False, + warnings=[ + f"cadwork.{entry.python_name}: no member of C++ enum '{bare}' could be " + "resolved to a value -- NOT written. Add the declaring header's " + "directory to [source].enum_search_dirs." + ], + ) + if len(resolved) != len(entry.values): + warnings.append( + f"cadwork.{entry.python_name}: {len(resolved)}/{len(entry.values)} members " + "resolved -- review before publishing" + ) + + title = entry.python_name.replace("_", " ") + lines = [ + "from enum import IntEnum, unique", + "", + "", + "@unique", + f"class {entry.python_name}(IntEnum):", + f'{_INDENT}"""{title}', + ] + if resolved: + lines += [ + "", + f"{_INDENT}Examples:", + f"{_INDENT * 2}>>> cadwork.{entry.python_name}.{resolved[0][0]}", + f"{_INDENT * 2}{resolved[0][0]}", + ] + lines.append(f'{_INDENT}"""') + for python_name, value, doc in resolved: + lines.append(f"{_INDENT}{python_name} = {value}") + lines.append(f'{_INDENT}"""{docstring_safe(doc)}"""') + lines += ["", f"{_INDENT}def __int__(self) -> int:", f"{_INDENT * 2}return self.value"] + + return RenderedType(text="\n".join(lines) + "\n", imports=set(), warnings=warnings) + + +def render_class(entry: CadworkType, resolver: TypeResolver) -> RenderedType: + """Emit a value-object stub. + + ``.def_readwrite``/``.def`` registrations carry a member pointer, not a type, + so field and return annotations are not recoverable from the bindings. Those + land as ``Any`` and the type is reported for a human pass. + """ + imports: set[str] = set() + warnings: list[str] = [] + body: list[str] = [] + + for signature in entry.init_signatures: + if not signature: + continue + parts = [] + for index, cpp_type in enumerate(signature): + annotation, needed = resolver.resolve(cpp_type) + if needed: + imports.add(needed) + parts.append(f"arg{index}: {annotation}") + body.append(f"{_INDENT}def __init__(self, {', '.join(parts)}) -> None:") + body.append(f'{_INDENT * 2}"""Initialize a {entry.python_name}."""') + body.append("") + break + + if entry.fields: + for name, _member, writable in entry.fields: + body.append(f"{_INDENT}{name}: Any") + body.append(f'{_INDENT}"""{"read/write" if writable else "read-only"}."""') + body.append("") + warnings.append( + f"cadwork.{entry.python_name}: field types are not recoverable from the " + "bindings -- annotated Any" + ) + + for name, _member in entry.methods: + if name.startswith("__"): + continue + body.append(f"{_INDENT}def {name}(self) -> Any:") + body.append(f'{_INDENT * 2}"""{name.replace("_", " ")}."""') + body.append("") + if entry.methods: + warnings.append( + f"cadwork.{entry.python_name}: method signatures are not recoverable from " + "the bindings -- parameters omitted, returns annotated Any" + ) + + header = [ + f"class {entry.python_name}:", + f'{_INDENT}"""{entry.python_name.replace("_", " ")}."""', + "", + ] + prefix = ["from typing import Any"] + prefix += [f"from cadwork.{name} import {name}" for name in sorted(imports)] + lines = [*prefix, "", ""] + header + body + return RenderedType( + text="\n".join(lines).rstrip() + "\n", imports=imports, warnings=warnings + ) + + +# --------------------------------------------------------------------------- +# companion-file patches +# --------------------------------------------------------------------------- + + +def patch_cadwork_init(path: Path, name: str, kind: str) -> bool: + """Insert the re-export line and the ``__all__`` entry for a new type.""" + source = _files.read_text(path) + if f"from .{name} import {name}" in source: + return False + lines = source.splitlines() + section = "# --- Enumerations ---" if kind == "enum" else "# --- Data classes ---" + import_line = f"from .{name} import {name} as {name}" + + try: + section_index = lines.index(section) + except ValueError: + return False + + end = section_index + 1 + while end < len(lines) and lines[end].startswith("from ."): + end += 1 + block = lines[section_index + 1 : end] + position = section_index + 1 + sum( + 1 for line in block if line < import_line + ) + lines.insert(position, import_line) + + all_start = next( + (index for index, line in enumerate(lines) if line.startswith("__all__")), None + ) + if all_start is not None: + marker = "# Enumerations" if kind == "enum" else "# Data classes" + try: + marker_index = next( + index + for index in range(all_start, len(lines)) + if lines[index].strip() == marker + ) + except StopIteration: + marker_index = all_start + entry = f' "{name}",' + end = marker_index + 1 + while end < len(lines) and lines[end].strip().startswith('"'): + end += 1 + block = lines[marker_index + 1 : end] + position = marker_index + 1 + sum(1 for line in block if line < entry) + lines.insert(position, entry) + + _files.write_text(path, "\n".join(lines) + "\n") + return True + + +def write_docs_page(docs_dir: Path, slug: str, title: str, target: str) -> Path: + page = docs_dir / f"{slug}.md" + _files.write_text(page, f"# {title}\n\n::: {target}\n rendering:\n show_root_heading: false\n" + " show_source: true\n") + return page + + +def append_to_enums_page(docs_dir: Path, enums_page: str, name: str) -> Path: + page = docs_dir / enums_page + existing = _files.read_text(page) if page.is_file() else "# Enumerations\n" + if f"::: cadwork.{name}" in existing: + return page + block = f"\n## {name}\n\n::: cadwork.{name}\n" + _files.write_text(page, existing.rstrip("\n") + "\n" + block) + return page + + +def _title_case(slug: str) -> str: + return " ".join(word.capitalize() for word in slug.split("_")) + + +def patch_mkdocs_nav(path: Path, slug: str, title: str, under: str) -> bool: + """Splice one nav line into the ``Reference:`` or ``Cadwork:`` block. + + Line-based on purpose: the stdlib ships no YAML writer, and a round-trip + would drop every comment and the hand-tuned ordering in this file. + """ + lines = _files.read_text(path).splitlines() + entry_suffix = f"documentation/{slug}.md" + if any(entry_suffix in line for line in lines): + return False + + if under == "Cadwork": + anchor = next( + (index for index, line in enumerate(lines) if line.strip() == "- Cadwork:"), + None, + ) + else: + anchor = next( + (index for index, line in enumerate(lines) if line.strip() == "- Reference:"), + None, + ) + if anchor is None: + return False + + indent = len(lines[anchor]) - len(lines[anchor].lstrip()) + 4 + entry = f"{' ' * indent}- {title}: {entry_suffix}" + + insert_at = anchor + 1 + cursor = anchor + 1 + while cursor < len(lines): + line = lines[cursor] + if not line.strip(): + cursor += 1 + continue + current_indent = len(line) - len(line.lstrip()) + if current_indent < indent: + break + if current_indent == indent and line.strip().startswith("- "): + if line.strip() < entry.strip(): + insert_at = cursor + 1 + else: + insert_at = cursor + break + cursor += 1 + else: + insert_at = cursor + + lines.insert(insert_at, entry) + _files.write_text(path, "\n".join(lines) + "\n") + return True + + +def patch_pyproject_packages(path: Path, package: str) -> bool: + source = _files.read_text(path) + if f'"{package}"' in source: + return False + match = re.search(r"(packages\s*=\s*\[)(.*?)(\])", source, re.S) + if match is None: + return False + body = match.group(2) + entries = [item.strip() for item in body.split(",") if item.strip()] + entries.append(f'"{package}"') + entries.sort(key=lambda item: item.strip('"')) + rendered = "\n" + ",\n".join(f" {entry}" for entry in entries) + "\n" + source = source[: match.start(2)] + rendered + source[match.end(2) :] + _files.write_text(path, source) + return True + + +_VERSION_MINOR_RE = re.compile( + r"^[^\S\n]*(?:const\s+)?(?:uint32_t|unsigned\s+int|int)\s+versionMinor\s*=\s*(\d+)", + re.M, +) + + +def read_api_version_minor(path: Path) -> int | None: + """The ``versionMinor`` tag in CwAPI3DVersion.h -- the cadwork build number. + + ``versionMajor`` is deliberately ignored: it carries the marketing year (2026) + while the published package's major is the cadwork product major (33), and PyPI + accepts no version that sorts below one already uploaded. + """ + try: + source = _files.read_text(path) + except OSError: + return None + match = _VERSION_MINOR_RE.search(source) + return int(match.group(1)) if match else None + + +@dataclass +class VersionChange: + old: str + new: str + warning: str | None = None + + +def sync_version(path: Path, api_minor: int | None) -> VersionChange | None: + """Set ``[project].version`` from the C++ ``versionMinor``, keeping the major. + + A build number the stubs have not shipped for yet resets the patch + (``33.322.7`` -> ``33.328.0``). The same build number means this is another sync + against an API the package already ships for, so only the patch moves + (``33.328.0`` -> ``33.328.1``). Either way the version has to move: PyPI never + accepts a re-upload, and the publish workflow fires on every push touching + ``src/**``. + + Returns None when ``[project].version`` could not be found -- the caller warns. + """ + source = _files.read_text(path) + match = re.search(r'^(version\s*=\s*")(\d+)\.(\d+)\.(\d+)(")', source, re.M) + if match is None: + return None + major, minor, patch = match.group(2), int(match.group(3)), int(match.group(4)) + old = f"{major}.{minor}.{patch}" + warning: str | None = None + if api_minor is None: + new = f"{major}.{minor}.{patch + 1}" + warning = ( + "no versionMinor found in the CwAPI3D version header -- fell back to a " + f"patch bump ({old} -> {new}); confirm the version is right before release" + ) + elif api_minor > minor: + new = f"{major}.{api_minor}.0" + else: + new = f"{major}.{minor}.{patch + 1}" + if api_minor < minor: + # Syncing against an older cadlib checkout. Following it down would + # produce a version PyPI has already seen. + warning = ( + f"CwAPI3D versionMinor is {api_minor} but the package is already at " + f"{old} -- kept the higher minor and bumped the patch instead " + f"({old} -> {new}). Point [paths].cadlib_root at the newer source if " + "that is not intended." + ) + source = ( + source[: match.start()] + + f"{match.group(1)}{new}{match.group(5)}" + + source[match.end() :] + ) + _files.write_text(path, source) + return VersionChange(old=old, new=new, warning=warning) + + +def create_controller_package(src_dir: Path, module: str) -> Path: + package = src_dir / module + package.mkdir(parents=True, exist_ok=True) + (package / "py.typed").write_bytes(b"") + init = package / "__init__.pyi" + if not init.is_file(): + title = _title_case(module) + _files.write_text(init, f'"""{title}.\n\nTODO: describe this module\'s domain -- the C++ bindings carry no\n' + f'module-level documentation to derive it from.\n"""\n') + return init diff --git a/.claude/skills/sync-cwapi3d-stubs/scripts/_files.py b/.claude/skills/sync-cwapi3d-stubs/scripts/_files.py new file mode 100644 index 0000000..4a1566e --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/_files.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Text IO that preserves each file's existing line endings. + +Every tracked file in the stub repo is CRLF. Writing LF back would turn a +three-line insertion into a whole-file diff and bury the actual change, so reads +are newline-agnostic and writes re-apply whatever terminator the file already +used (LF for files this tool creates). +""" + +from __future__ import annotations + +from pathlib import Path + + +_DEFAULT_NEWLINE = "\n" + + +def set_default_newline(anchor: Path) -> str: + """Adopt `anchor`'s line ending for files this tool creates from scratch. + + New files should match the repo they land in, not the platform running the + generator. + """ + global _DEFAULT_NEWLINE + _DEFAULT_NEWLINE = detect_newline(anchor) + return _DEFAULT_NEWLINE + + +def detect_newline(path: Path, default: str | None = None) -> str: + default = _DEFAULT_NEWLINE if default is None else default + if not path.is_file(): + return default + with path.open("rb") as handle: + sample = handle.read(65536) + if b"\r\n" in sample: + return "\r\n" + if b"\n" in sample: + return "\n" + return default + + +def read_text(path: Path) -> str: + """Read with universal newlines: the returned text always uses ``\\n``.""" + return path.read_text(encoding="utf-8") + + +def write_text(path: Path, text: str, newline: str | None = None) -> None: + """Write `text` (LF-separated) back using the file's own line ending.""" + terminator = newline if newline is not None else detect_newline(path) + with path.open("w", encoding="utf-8", newline="") as handle: + handle.write(text.replace("\r\n", "\n").replace("\n", terminator)) + + +def write_lines(path: Path, lines: list[str], newline: str | None = None) -> None: + write_text(path, "\n".join(lines) + "\n", newline) diff --git a/.claude/skills/sync-cwapi3d-stubs/scripts/_stubs.py b/.claude/skills/sync-cwapi3d-stubs/scripts/_stubs.py new file mode 100644 index 0000000..e8b185e --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/_stubs.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Inventory the existing .pyi stubs. + +A .pyi is valid Python, so the declaration inventory comes from ``ast`` rather +than regex. Alongside the names, each file's local formatting habits are +measured -- blank lines between defs, import idiom -- because the repo is not +uniformly formatted and appended code has to match its host file. +""" + +from __future__ import annotations + +import ast +import re +from dataclasses import dataclass, field +from pathlib import Path + +import _files + + +@dataclass +class ControllerStub: + module: str + path: Path + functions: set[str] = field(default_factory=set) + imported_names: set[str] = field(default_factory=set) + star_imports_api_types: bool = False + blank_lines_between_defs: int = 1 + last_import_line: int = 0 # 1-based; 0 when the file has no imports + + +@dataclass +class TypeStub: + name: str + path: Path + classes: set[str] = field(default_factory=set) + members: dict[str, set[str]] = field(default_factory=dict) + + +@dataclass +class StubInventory: + controllers: dict[str, ControllerStub] + types: dict[str, TypeStub] + cadwork_init: Path + exported_types: set[str] + + +def _measure_blank_lines(source: str) -> int: + """Dominant number of blank lines between top-level ``def``s in this file.""" + lines = source.splitlines() + counts: dict[int, int] = {} + previous_def = None + for index, line in enumerate(lines): + if not line.startswith("def "): + continue + if previous_def is not None: + blanks = 0 + cursor = index - 1 + while cursor > previous_def and not lines[cursor].strip(): + blanks += 1 + cursor -= 1 + counts[blanks] = counts.get(blanks, 0) + 1 + previous_def = index + if not counts: + return 1 + return max(counts.items(), key=lambda item: (item[1], -item[0]))[0] + + +def _parse_controller(module: str, path: Path) -> ControllerStub: + source = _files.read_text(path) + tree = ast.parse(source) + stub = ControllerStub(module=module, path=path) + stub.blank_lines_between_defs = _measure_blank_lines(source) + + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + stub.functions.add(node.name) + elif isinstance(node, ast.ImportFrom): + stub.last_import_line = max(stub.last_import_line, node.end_lineno or 0) + for alias in node.names: + if alias.name == "*": + if node.module == "cadwork.api_types": + stub.star_imports_api_types = True + else: + stub.imported_names.add(alias.asname or alias.name) + elif isinstance(node, ast.Import): + stub.last_import_line = max(stub.last_import_line, node.end_lineno or 0) + for alias in node.names: + stub.imported_names.add(alias.asname or alias.name.split(".")[0]) + return stub + + +def _parse_type(path: Path) -> TypeStub: + stub = TypeStub(name=path.stem, path=path) + tree = ast.parse(_files.read_text(path)) + for node in tree.body: + if isinstance(node, ast.ClassDef): + stub.classes.add(node.name) + members: set[str] = set() + for child in node.body: + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): + members.add(child.name) + elif isinstance(child, ast.AnnAssign) and isinstance( + child.target, ast.Name + ): + members.add(child.target.id) + elif isinstance(child, ast.Assign): + for target in child.targets: + if isinstance(target, ast.Name): + members.add(target.id) + stub.members[node.name] = members + return stub + + +def _exported_from_cadwork_init(path: Path) -> set[str]: + if not path.is_file(): + return set() + tree = ast.parse(_files.read_text(path)) + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + if isinstance(node.value, (ast.List, ast.Tuple)): + return { + element.value + for element in node.value.elts + if isinstance(element, ast.Constant) + and isinstance(element.value, str) + } + return set() + + +def parse(src_dir: Path) -> StubInventory: + controllers: dict[str, ControllerStub] = {} + for init in sorted(src_dir.glob("*/__init__.pyi")): + module = init.parent.name + if module == "cadwork": + continue + controllers[module] = _parse_controller(module, init) + + cadwork_dir = src_dir / "cadwork" + types: dict[str, TypeStub] = {} + if cadwork_dir.is_dir(): + for stub_path in sorted(cadwork_dir.glob("*.pyi")): + if stub_path.stem == "__init__": + continue + types[stub_path.stem] = _parse_type(stub_path) + + cadwork_init = cadwork_dir / "__init__.pyi" + return StubInventory( + controllers=controllers, + types=types, + cadwork_init=cadwork_init, + exported_types=_exported_from_cadwork_init(cadwork_init), + ) + + +def append_block(path: Path, block: str, blank_lines: int) -> None: + """Append `block` to `path`, separated by `blank_lines` blank lines.""" + existing = _files.read_text(path) if path.is_file() else "" + trimmed = existing.rstrip("\n") + separator = "\n" * (blank_lines + 1) if trimmed else "" + _files.write_text(path, f"{trimmed}{separator}{block.rstrip()}\n") + + +def insert_imports(path: Path, imports: list[str]) -> None: + """Insert `imports` after the file's existing import block, skipping dupes.""" + if not imports: + return + source = _files.read_text(path) + lines = source.splitlines() + wanted = [line for line in imports if line not in lines] + if not wanted: + return + + insert_at = 0 + for index, line in enumerate(lines): + if re.match(r"^(from|import)\s", line): + insert_at = index + 1 + if insert_at == 0: + # No imports yet: land just after the module docstring. + tree = ast.parse(source) + if ( + tree.body + and isinstance(tree.body[0], ast.Expr) + and isinstance(tree.body[0].value, ast.Constant) + ): + insert_at = (tree.body[0].end_lineno or 1) + lines.insert(insert_at, "") + insert_at += 1 + + lines[insert_at:insert_at] = wanted + _files.write_text(path, "\n".join(lines) + "\n") diff --git a/.claude/skills/sync-cwapi3d-stubs/scripts/sync_stubs.py b/.claude/skills/sync-cwapi3d-stubs/scripts/sync_stubs.py new file mode 100644 index 0000000..1572b14 --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/sync_stubs.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +"""Sync the cwapi3d Python stubs with the CwAPI3D pybind11 bindings. + +Extracts every controller module, bound function and cadwork type from +CCwAPI3DPythonController.cpp, diffs it against the .pyi stubs in this repo, and +writes the missing declarations -- docstrings derived from the Doxygen contracts +on the ICwAPI3D* interface headers. + +Usage: + python sync_stubs.py --dry-run human-readable gap report, no writes + python sync_stubs.py --dry-run --json machine-readable gap report + python sync_stubs.py --apply write the missing declarations + python sync_stubs.py --apply --only bim_controller [--only cadwork] + +Exit codes: + 0 in sync (--dry-run), or the requested writes were applied (--apply) + 1 gaps found (--dry-run only) + 2 configuration, path, or parse error +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import _config +import _cpp_bindings +import _doxygen +import _emit +import _files +import _stubs + +EXIT_OK = 0 +EXIT_GAPS = 1 +EXIT_ERROR = 2 + + +@dataclass +class Gap: + kind: str # "function" | "module" | "type" + module: str + name: str + detail: str = "" + + +@dataclass +class Report: + missing: list[Gap] = field(default_factory=list) + blacklisted: list[Gap] = field(default_factory=list) + orphans: list[Gap] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + written: list[str] = field(default_factory=list) + version_bump: tuple[str, str] | None = None + api_version_minor: int | None = None + + def as_dict(self) -> dict: + def rows(items: list[Gap]) -> list[dict]: + return [ + { + "kind": gap.kind, + "module": gap.module, + "name": gap.name, + "detail": gap.detail, + } + for gap in items + ] + + return { + "missing": rows(self.missing), + "blacklisted": rows(self.blacklisted), + "orphans": rows(self.orphans), + "warnings": self.warnings, + "written": self.written, + "version_bump": list(self.version_bump) if self.version_bump else None, + "api_version_minor": self.api_version_minor, + } + + +def _is_blacklisted(config: _config.Config, module: str, name: str) -> bool: + return ( + module in config.blacklist_modules + or name in config.blacklist_methods + or f"{module}.{name}" in config.blacklist_qualified + ) + + +def build_report( + config: _config.Config, + inventory: _cpp_bindings.Inventory, + stubs: _stubs.StubInventory, + only: set[str], +) -> Report: + report = Report() + + # A --only that names a blacklisted module would otherwise report "in sync", + # which reads as "nothing to do" rather than "deliberately skipped". + for module in sorted(only & config.blacklist_modules): + report.warnings.append( + f"{module} is blacklisted in [blacklist].modules -- nothing will be " + "generated for it. Remove the entry to start syncing it." + ) + + for binding in inventory.bindings: + if binding.module == "cadwork": + continue + if only and binding.module not in only: + continue + gap = Gap(kind="function", module=binding.module, name=binding.python_name) + stub = stubs.controllers.get(binding.module) + already_present = stub is not None and binding.python_name in stub.functions + if already_present: + continue + # Only absent entries are worth reporting as skipped -- a blacklisted name the + # stubs already carry is not a decision this run made. + if _is_blacklisted(config, binding.module, binding.python_name): + report.blacklisted.append(gap) + continue + if stub is None: + gap.detail = "module missing" + report.missing.append(gap) + + bound_by_module: dict[str, set[str]] = {} + for binding in inventory.bindings: + bound_by_module.setdefault(binding.module, set()).add(binding.python_name) + for module, stub in stubs.controllers.items(): + if (only and module not in only) or module in config.blacklist_modules: + continue + for name in sorted(stub.functions - bound_by_module.get(module, set())): + report.orphans.append( + Gap(kind="function", module=module, name=name, detail="no C++ binding") + ) + + if not only or "cadwork" in only: + known = set(stubs.types) | stubs.exported_types + for entry in inventory.types: + names = {entry.python_name, *entry.aliases} + if entry.python_name in config.blacklist_types: + report.blacklisted.append( + Gap(kind="type", module="cadwork", name=entry.python_name) + ) + continue + if names & known: + continue + report.missing.append( + Gap(kind="type", module="cadwork", name=entry.python_name, detail=entry.kind) + ) + + for module in bound_by_module: + if module == "cadwork" or (only and module not in only): + continue + if module in stubs.controllers: + continue + gap = Gap(kind="module", module=module, name=module, detail="package missing") + if module in config.blacklist_modules: + report.blacklisted.append(gap) + else: + report.missing.append(gap) + + return report + + +def apply_changes( + config: _config.Config, + inventory: _cpp_bindings.Inventory, + stubs: _stubs.StubInventory, + doxygen: _doxygen.DoxygenIndex, + enum_definitions: dict[str, list[_cpp_bindings.EnumMember]], + report: Report, +) -> None: + _files.set_default_newline(config.pyproject) + resolver = _emit.TypeResolver.build( + config.type_map, + inventory.types, + hint_map=config.hint_map, + stub_types=set(stubs.types), + ) + touched_src = False + + new_modules = {gap.name for gap in report.missing if gap.kind == "module"} + for module in sorted(new_modules): + init = _emit.create_controller_package(config.src_dir, module) + stubs.controllers[module] = _stubs.ControllerStub(module=module, path=init) + report.written.append(str(init.relative_to(config.stub_repo))) + if _emit.patch_pyproject_packages(config.pyproject, module): + report.written.append(str(config.pyproject.relative_to(config.stub_repo))) + page = _emit.write_docs_page( + config.docs_dir, module, _emit._title_case(module), module + ) + report.written.append(str(page.relative_to(config.stub_repo))) + if _emit.patch_mkdocs_nav( + config.mkdocs, module, _emit._title_case(module), "Reference" + ): + report.written.append(str(config.mkdocs.relative_to(config.stub_repo))) + touched_src = True + + by_module: dict[str, list[str]] = {} + imports_by_module: dict[str, set[str]] = {} + typing_by_module: dict[str, set[str]] = {} + wanted = { + (gap.module, gap.name) for gap in report.missing if gap.kind == "function" + } + for binding in inventory.bindings: + if (binding.module, binding.python_name) not in wanted: + continue + trampoline = inventory.trampolines.get(binding.symbol or "") + if trampoline is None: + report.warnings.append( + f"{binding.module}.{binding.python_name}: no signature found -- skipped" + ) + continue + doc = doxygen.lookup(trampoline.accessor, trampoline.cpp_method) + rendered = _emit.render_function( + binding, trampoline, doc, resolver, config.param_names + ) + by_module.setdefault(binding.module, []).append(rendered.text) + imports_by_module.setdefault(binding.module, set()).update(rendered.imports) + typing_by_module.setdefault(binding.module, set()).update(rendered.typing_names) + if rendered.thin_doc: + report.warnings.append( + f"{binding.module}.{binding.python_name}: no Doxygen @brief -- " + "docstring is a placeholder" + ) + if rendered.had_cpp_example: + report.warnings.append( + f"{binding.module}.{binding.python_name}: the interface carries a C++ " + "@par Example that was NOT translated -- port it by hand" + ) + + for module, blocks in sorted(by_module.items()): + stub = stubs.controllers[module] + lines = _emit.import_lines( + imports_by_module.get(module, set()), + stub.star_imports_api_types, + stub.imported_names, + ) + wanted_typing = sorted(typing_by_module.get(module, set()) - stub.imported_names) + if wanted_typing: + lines.insert(0, f"from typing import {', '.join(wanted_typing)}") + _stubs.insert_imports(stub.path, lines) + separator = "\n" * (stub.blank_lines_between_defs + 1) + _stubs.append_block( + stub.path, separator.join(blocks), stub.blank_lines_between_defs + ) + report.written.append(str(stub.path.relative_to(config.stub_repo))) + touched_src = True + + wanted_types = {gap.name for gap in report.missing if gap.kind == "type"} + for entry in inventory.types: + if entry.python_name not in wanted_types: + continue + if entry.kind == "enum": + rendered = _emit.render_enum(entry, enum_definitions) + else: + rendered = _emit.render_class(entry, resolver) + report.warnings.extend(rendered.warnings) + if not rendered.ok: + continue + target = config.src_dir / "cadwork" / f"{entry.python_name}.pyi" + _files.write_text(target, rendered.text) + report.written.append(str(target.relative_to(config.stub_repo))) + if _emit.patch_cadwork_init(stubs.cadwork_init, entry.python_name, entry.kind): + report.written.append(str(stubs.cadwork_init.relative_to(config.stub_repo))) + if entry.kind == "enum": + page = _emit.append_to_enums_page( + config.docs_dir, config.enums_page, entry.python_name + ) + report.written.append(str(page.relative_to(config.stub_repo))) + else: + page = _emit.write_docs_page( + config.docs_dir, + entry.python_name, + entry.python_name, + f"cadwork.{entry.python_name}", + ) + report.written.append(str(page.relative_to(config.stub_repo))) + if _emit.patch_mkdocs_nav( + config.mkdocs, entry.python_name, entry.python_name, "Cadwork" + ): + report.written.append(str(config.mkdocs.relative_to(config.stub_repo))) + touched_src = True + + if resolver.unresolved: + report.warnings.append( + "C++ types with no Python mapping (annotated Any): " + + ", ".join(sorted(resolver.unresolved)) + ) + + if touched_src and config.bump_version: + change = _emit.sync_version(config.pyproject, report.api_version_minor) + if change is not None: + report.version_bump = (change.old, change.new) + report.written.append(str(config.pyproject.relative_to(config.stub_repo))) + if change.warning: + report.warnings.append(change.warning) + else: + report.warnings.append( + "could not set [project].version -- the publish workflow will reject " + "a duplicate upload" + ) + + report.written = sorted(set(report.written)) + + +def syntax_check(paths: list[Path]) -> list[str]: + """Re-parse every touched .pyi. Nothing else in this repo catches a broken stub.""" + import ast + + problems: list[str] = [] + for path in paths: + if path.suffix != ".pyi": + continue + try: + ast.parse(_files.read_text(path)) + except SyntaxError as error: + problems.append(f"{path}: {error}") + return problems + + +def print_report(report: Report, applied: bool) -> None: + if report.api_version_minor is not None: + print(f"CwAPI3D versionMinor: {report.api_version_minor}\n") + by_module: dict[str, list[Gap]] = {} + for gap in report.missing: + by_module.setdefault(gap.module, []).append(gap) + + if not report.missing: + print("In sync: no missing declarations.") + else: + total = len(report.missing) + print(f"{total} missing declaration(s):\n") + for module in sorted(by_module): + gaps = by_module[module] + print(f" {module} ({len(gaps)})") + for gap in sorted(gaps, key=lambda item: item.name): + suffix = f" [{gap.detail}]" if gap.detail else "" + print(f" {gap.kind:8} {gap.name}{suffix}") + print() + + if report.blacklisted: + skipped_modules = sorted( + gap.module for gap in report.blacklisted if gap.kind == "module" + ) + suffix = ( + f" (whole module{'s' if len(skipped_modules) > 1 else ''}: " + f"{', '.join(skipped_modules)})" + if skipped_modules + else "" + ) + print(f"{len(report.blacklisted)} blacklisted entr(ies) skipped{suffix}.") + if report.orphans: + print(f"\n{len(report.orphans)} stub function(s) with no C++ binding (kept):") + for gap in report.orphans: + print(f" {gap.module}.{gap.name}") + + if applied: + if report.version_bump: + print(f"\nversion {report.version_bump[0]} -> {report.version_bump[1]}") + print(f"\n{len(report.written)} file(s) written:") + for path in report.written: + print(f" {path}") + + if report.warnings: + print(f"\n{len(report.warnings)} warning(s):") + for warning in report.warnings: + print(f" - {warning}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Sync cwapi3d .pyi stubs with the CwAPI3D pybind11 bindings." + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--dry-run", + action="store_true", + help="report gaps without writing (default)", + ) + mode.add_argument("--apply", action="store_true", help="write missing declarations") + parser.add_argument("--json", action="store_true", help="machine-readable output") + parser.add_argument( + "--only", + action="append", + default=[], + metavar="MODULE", + help="restrict to one module (repeatable); 'cadwork' covers the types", + ) + args = parser.parse_args(argv) + + try: + config = _config.load() + except _config.ConfigError as error: + print(f"config error: {error}", file=sys.stderr) + return EXIT_ERROR + + try: + inventory = _cpp_bindings.parse(config.python_controller) + doxygen = _doxygen.parse(config.interface_include_dir) + enum_definitions = _cpp_bindings.parse_enum_definitions( + list(config.enum_search_dirs) + ) + stubs = _stubs.parse(config.src_dir) + except (OSError, ValueError, SyntaxError) as error: + print(f"parse error: {error}", file=sys.stderr) + return EXIT_ERROR + + only = set(args.only) + known_modules = set(inventory.modules()) | set(stubs.controllers) + unknown = sorted(only - known_modules) + if unknown: + print( + "unknown --only module(s): " + + ", ".join(unknown) + + "\nknown: " + + ", ".join(sorted(known_modules)), + file=sys.stderr, + ) + return EXIT_ERROR + + report = build_report(config, inventory, stubs, only) + report.api_version_minor = _emit.read_api_version_minor(config.version_header) + + if args.apply: + apply_changes(config, inventory, stubs, doxygen, enum_definitions, report) + problems = syntax_check([config.stub_repo / path for path in report.written]) + if problems: + report.warnings.extend(f"SYNTAX ERROR {problem}" for problem in problems) + + if args.json: + print(json.dumps(report.as_dict(), indent=2)) + else: + print_report(report, applied=args.apply) + + if args.apply: + return EXIT_ERROR if any( + warning.startswith("SYNTAX ERROR") for warning in report.warnings + ) else EXIT_OK + return EXIT_GAPS if report.missing else EXIT_OK + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.gitignore b/.gitignore index 9c3bdb1..1c41005 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ cython_debug/ .idea/ .vscode/ + +# sync-cwapi3d-stubs: machine paths only, never shared +.claude/skills/sync-cwapi3d-stubs/config.personal.toml