From 0f6704fcb74501fe17233093ceda69ed2c0e108c Mon Sep 17 00:00:00 2001 From: Michael Brunner Date: Fri, 7 Aug 2026 13:06:15 +0200 Subject: [PATCH 1/2] chore: Add sync-cwapi3d-stubs skill Derives the binding inventory from CCwAPI3DPythonController.cpp and writes the stub declarations, cadwork types, docs pages and nav entries that are missing from this repo. Machine paths live in the git-ignored config.personal.toml. --- .claude/skills/sync-cwapi3d-stubs/SKILL.md | 153 +++++ .../config.personal.toml.example | 14 + .claude/skills/sync-cwapi3d-stubs/config.toml | 106 +++ .../sync-cwapi3d-stubs/scripts/_config.py | 235 +++++++ .../scripts/_cpp_bindings.py | 561 ++++++++++++++++ .../sync-cwapi3d-stubs/scripts/_doxygen.py | 241 +++++++ .../sync-cwapi3d-stubs/scripts/_emit.py | 634 ++++++++++++++++++ .../sync-cwapi3d-stubs/scripts/_files.py | 55 ++ .../sync-cwapi3d-stubs/scripts/_stubs.py | 192 ++++++ .../sync-cwapi3d-stubs/scripts/sync_stubs.py | 440 ++++++++++++ .gitignore | 3 + 11 files changed, 2634 insertions(+) create mode 100644 .claude/skills/sync-cwapi3d-stubs/SKILL.md create mode 100644 .claude/skills/sync-cwapi3d-stubs/config.personal.toml.example create mode 100644 .claude/skills/sync-cwapi3d-stubs/config.toml create mode 100644 .claude/skills/sync-cwapi3d-stubs/scripts/_config.py create mode 100644 .claude/skills/sync-cwapi3d-stubs/scripts/_cpp_bindings.py create mode 100644 .claude/skills/sync-cwapi3d-stubs/scripts/_doxygen.py create mode 100644 .claude/skills/sync-cwapi3d-stubs/scripts/_emit.py create mode 100644 .claude/skills/sync-cwapi3d-stubs/scripts/_files.py create mode 100644 .claude/skills/sync-cwapi3d-stubs/scripts/_stubs.py create mode 100644 .claude/skills/sync-cwapi3d-stubs/scripts/sync_stubs.py diff --git a/.claude/skills/sync-cwapi3d-stubs/SKILL.md b/.claude/skills/sync-cwapi3d-stubs/SKILL.md new file mode 100644 index 0000000..b583e66 --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/SKILL.md @@ -0,0 +1,153 @@ +--- +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 the version bump | + +## 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`** → `cadlib_root` points somewhere without + `CwAPI3D/CCwAPI3DPythonController.cpp`. Ask which checkout to use. + +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 bumps only +when something under `src/` actually changed. + +## Step 6 — Hand off + +Leave the changes **uncommitted** on the working branch. Report: + +1. The files written and the version bump (`33.322.0` → `33.322.1`). +2. Every warning, in full. The ones that need a human are: + - *"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. +- 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..c4b2687 --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/config.toml @@ -0,0 +1,106 @@ +# 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" + +[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. +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..c86ccff --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/_config.py @@ -0,0 +1,235 @@ +#!/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 + 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" + ) + 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}") + + 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, + 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..b7a87cb --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/_emit.py @@ -0,0 +1,634 @@ +#!/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 + + +def bump_patch_version(path: Path) -> tuple[str, str] | None: + 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), match.group(3), int(match.group(4)) + old = f"{major}.{minor}.{patch}" + new = f"{major}.{minor}.{patch + 1}" + source = source[: match.start()] + f'{match.group(1)}{new}{match.group(5)}' + source[match.end() :] + _files.write_text(path, source) + return (old, new) + + +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..f854c8f --- /dev/null +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/sync_stubs.py @@ -0,0 +1,440 @@ +#!/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 + + 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, + } + + +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: + bump = _emit.bump_patch_version(config.pyproject) + if bump is not None: + report.version_bump = bump + report.written.append(str(config.pyproject.relative_to(config.stub_repo))) + else: + report.warnings.append( + "could not bump [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: + 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) + + 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 From 8331bc7001611e1cab16fbae9ce54bd53716dc88 Mon Sep 17 00:00:00 2001 From: Michael Brunner Date: Fri, 7 Aug 2026 15:30:39 +0200 Subject: [PATCH 2/2] chore: Derive package version from CwAPI3D version header The stub sync bumped the pyproject patch blindly, so the published version never tracked the API it was generated against. Read the versionMinor tag in CwAPI3D/include/CwAPI3DVersion.h instead: a higher build number resets the patch (33.322.7 -> 33.328.0), the same one increments it (33.328.0 -> 33.328.1). versionMajor is ignored -- it carries the marketing year while the package major is the product major. A versionMinor below the packaged minor, or a header with no tag at all, falls back to a patch bump and warns rather than emitting a version PyPI has already seen. The missing header is a preflight config error. --- .claude/skills/sync-cwapi3d-stubs/SKILL.md | 45 ++++++++++-- .claude/skills/sync-cwapi3d-stubs/config.toml | 6 +- .../sync-cwapi3d-stubs/scripts/_config.py | 9 +++ .../sync-cwapi3d-stubs/scripts/_emit.py | 72 +++++++++++++++++-- .../sync-cwapi3d-stubs/scripts/sync_stubs.py | 15 ++-- 5 files changed, 131 insertions(+), 16 deletions(-) diff --git a/.claude/skills/sync-cwapi3d-stubs/SKILL.md b/.claude/skills/sync-cwapi3d-stubs/SKILL.md index b583e66..398443a 100644 --- a/.claude/skills/sync-cwapi3d-stubs/SKILL.md +++ b/.claude/skills/sync-cwapi3d-stubs/SKILL.md @@ -21,7 +21,7 @@ the full binding inventory from the C++ source and writes what is missing. | `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 the version bump | +| `pyproject.toml` | `packages` entry for a new module, and `[project].version` | ## Invocation @@ -49,8 +49,13 @@ Exit `2` means a config or parse error — read the message on stderr and stop: 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`** → `cadlib_root` points somewhere without - `CwAPI3D/CCwAPI3DPythonController.cpp`. Ask which checkout to use. +- **`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: @@ -112,15 +117,41 @@ error is reported as a `SYNTAX ERROR` warning and exits `2`. Nothing else in thi 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 bumps only -when something under `src/` actually changed. +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 bump (`33.322.0` → `33.322.1`). +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. @@ -145,6 +176,8 @@ Do not commit, push, or open a PR unless the operator asks. 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. diff --git a/.claude/skills/sync-cwapi3d-stubs/config.toml b/.claude/skills/sync-cwapi3d-stubs/config.toml index c4b2687..a488ca7 100644 --- a/.claude/skills/sync-cwapi3d-stubs/config.toml +++ b/.claude/skills/sync-cwapi3d-stubs/config.toml @@ -6,6 +6,8 @@ # 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). @@ -18,7 +20,9 @@ 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. +# 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] diff --git a/.claude/skills/sync-cwapi3d-stubs/scripts/_config.py b/.claude/skills/sync-cwapi3d-stubs/scripts/_config.py index c86ccff..6426bc7 100644 --- a/.claude/skills/sync-cwapi3d-stubs/scripts/_config.py +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/_config.py @@ -98,6 +98,7 @@ class Config: # --- resolved source files ------------------------------------------ python_controller: Path interface_include_dir: Path + version_header: Path enum_search_dirs: tuple[Path, ...] # --- resolved targets ------------------------------------------------ @@ -201,10 +202,17 @@ def load() -> Config: 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: @@ -226,6 +234,7 @@ def load() -> Config: 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"), diff --git a/.claude/skills/sync-cwapi3d-stubs/scripts/_emit.py b/.claude/skills/sync-cwapi3d-stubs/scripts/_emit.py index b7a87cb..a6f48f9 100644 --- a/.claude/skills/sync-cwapi3d-stubs/scripts/_emit.py +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/_emit.py @@ -609,17 +609,79 @@ def patch_pyproject_packages(path: Path, package: str) -> bool: return True -def bump_patch_version(path: Path) -> tuple[str, str] | None: +_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), match.group(3), int(match.group(4)) + major, minor, patch = match.group(2), int(match.group(3)), int(match.group(4)) old = f"{major}.{minor}.{patch}" - new = f"{major}.{minor}.{patch + 1}" - source = source[: match.start()] + f'{match.group(1)}{new}{match.group(5)}' + source[match.end() :] + 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 (old, new) + return VersionChange(old=old, new=new, warning=warning) def create_controller_package(src_dir: Path, module: str) -> Path: diff --git a/.claude/skills/sync-cwapi3d-stubs/scripts/sync_stubs.py b/.claude/skills/sync-cwapi3d-stubs/scripts/sync_stubs.py index f854c8f..1572b14 100644 --- a/.claude/skills/sync-cwapi3d-stubs/scripts/sync_stubs.py +++ b/.claude/skills/sync-cwapi3d-stubs/scripts/sync_stubs.py @@ -57,6 +57,7 @@ class Report: 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]: @@ -77,6 +78,7 @@ def rows(items: list[Gap]) -> list[dict]: "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, } @@ -290,13 +292,15 @@ def apply_changes( ) if touched_src and config.bump_version: - bump = _emit.bump_patch_version(config.pyproject) - if bump is not None: - report.version_bump = bump + 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 bump [project].version -- the publish workflow will reject " + "could not set [project].version -- the publish workflow will reject " "a duplicate upload" ) @@ -319,6 +323,8 @@ def syntax_check(paths: list[Path]) -> list[str]: 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) @@ -417,6 +423,7 @@ def main(argv: list[str] | None = None) -> int: 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)