diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 6aa6305d..6cf6ea14 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -23,6 +23,7 @@ "skills/ai-mesh-cleanup/SKILL.md", "skills/bake-high-to-low/SKILL.md", "skills/engine-export-presets/SKILL.md", + "skills/extension-runtime-and-packaging/SKILL.md", "skills/operators/SKILL.md", "skills/ui-panels/SKILL.md", "skills/custom-properties/SKILL.md", diff --git a/AGENTS.md b/AGENTS.md index ffe1e49f..221fa7a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. -The content base is 16 skills, 9 rules, 3 templates, 27 snippets, 64 +The content base is 17 skills, 9 rules, 3 templates, 28 snippets, 65 examples, and 76 showcase pieces (counts are CI-enforced against README.md) and the manifest). The full inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`; @@ -32,7 +32,7 @@ in `docs/VISUAL-STYLE.md`; the canonical run prompt is ``` Blender-Developer-Tools/ - skills//SKILL.md # 16 skill files + skills//SKILL.md # 17 skill files rules/.mdc # 9 rule files templates// # 3 starter templates snippets/.py # 27 standalone Python snippets diff --git a/CLAUDE.md b/CLAUDE.md index 782129b1..e9b5e2cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,11 +17,11 @@ The **Blender Developer Tools** repository is at **v0.144.3**. It packages skill ## Repository Architecture ``` -skills//SKILL.md - AI workflow definitions, 16 total +skills//SKILL.md - AI workflow definitions, 17 total rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total -snippets/.py - Standalone code patterns, 27 total -examples// - Runnable smoke-gated examples, 64 total (+ gallery.json) +snippets/.py - Standalone code patterns, 28 total +examples// - Runnable smoke-gated examples, 65 total (+ gallery.json) showcase// - Budget-conformance props, 76 pieces, each with a gallery `category` (sibling of examples/; see showcase/README.md § Categories) scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2); tokens.css is the shared palette @@ -30,7 +30,7 @@ docs/gallery/ - Committed generated gallery pages + hero render VERSION - Source of truth for the repo version ``` -## Skills (16) +## Skills (17) | Skill | Purpose | | --- | --- | @@ -50,6 +50,7 @@ VERSION - Source of truth for the repo version | drivers-and-app-handlers | Driver expressions, `driver_namespace`, application handlers including the new 5.1 `exit_pre` | | bl-info-migration | Three-step migration from legacy `bl_info` to Extensions Platform, dual-format pattern | | vse-python | VSE timeline from Python: `.strips` vs `.sequences`, `new_effect` kwargs, 5.2 COLOR `width`/`height` bake | +| extension-runtime-and-packaging | Bundled wheels, `extension_path_user` data that survives upgrades, `online_access`, `bl_ext` names, `--command extension` build in CI | ## Rules (9) @@ -95,7 +96,7 @@ matching its `globs` does. Changing a glob changes when the rule fires. - Unity / Godot / Unreal glTF export via the engine-export-presets contract - Explicit exit codes matching `headless-batch-script-template` (0, then 2+) -## Snippets (27) +## Snippets (28) Small standalone `.py` files at `snippets/.py`, each 5 to 75 lines. @@ -105,13 +106,15 @@ v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namesp AI asset pipeline track: `decimate_to_budget.py`, `convex_hull_collider.py`, `lod_chain.py` (helper duplicated, not imported), `gltf_draco_export.py`, `export_preset_unity.py`, `export_preset_godot.py`, `export_preset_unreal.py`, `setup_bake_target_image.py`, `bake_normal_high_to_low.py`, `save_baked_image.py`. -## Examples (64) +Extensions runtime: `extension-user-data.py` (`extension_path_user` settings file and an `online_access` guard). + +## Examples (65) Runnable scripts at `examples//`, each asserting a real API contract with deterministic checks (exit non-zero on failure) and optionally rendering a still via `--output`. All of them run headless on Blender 5.2 LTS and 4.5 LTS in `blender-smoke.yml` (5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch); -**56 of the 64 ship a render in the site gallery** at `docs/gallery/`. The other -eight are **check-only**: they carry no `--output` path, no gallery entry, and no +**56 of the 65 ship a render in the site gallery** at `docs/gallery/`. The other +nine are **check-only**: they carry no `--output` path, no gallery entry, and no hero asset. The criterion is whether the contract is expressible in pixels. An example is check-only when its witness is a data or state fact that no scene redesign can make visible — a datablock name, an RNA attribute's presence, a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3435f0c2..71a37642 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -276,7 +276,7 @@ The drift-check workflow enforces these on every push and PR. ## Aggregate Counts -`README.md` declares aggregate counts (e.g. "16 skills, 9 rules, 3 templates, 27 snippets, 64 examples, and 76 showcase pieces"). The `validate-counts` job in `.github/workflows/validate.yml` enforces these substrings against the filesystem on every push and PR. Showcase pieces are counted separately from examples. When you add or remove content, update the README counts in the same commit. +`README.md` declares aggregate counts (e.g. "17 skills, 9 rules, 3 templates, 28 snippets, 65 examples, and 76 showcase pieces"). The `validate-counts` job in `.github/workflows/validate.yml` enforces these substrings against the filesystem on every push and PR. Showcase pieces are counted separately from examples. When you add or remove content, update the README counts in the same commit. ## Pull Request Process diff --git a/README.md b/README.md index 1f38b29c..d8620b4a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  64 examples  •  76 showcase pieces + 17 skills  •  9 rules  •  3 templates  •  28 snippets  •  65 examples  •  76 showcase pieces

@@ -44,7 +44,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 64 examples, and 76 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **17 skills, 9 rules, 3 templates, 28 snippets, 65 examples, and 76 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. The content is consumed by AI coding agents reading these files directly from a checkout — **there is no MCP server in this repository, and none is required**. Both agents install it as a plugin (see [Quick start](#quick-start)): Cursor loads the skills and applies `rules/*.mdc` wherever their scope globs match; Claude Code loads the skills and gets the rules through a generated `blender-rules` skill, since it does not read Cursor `.mdc` files. An agent picks a skill up when its description matches the task. Any agent that can read files in a workspace can use it the same way. There is no build step for the content — edit the Markdown and Python files directly. @@ -63,14 +63,14 @@ The content is consumed by AI coding agents reading these files directly from a git clone https://github.com/TMHSDigital/Blender-Developer-Tools.git ``` -- **Cursor** — install it as a local plugin from the slim `plugin-dist` branch, then run **Developer: Reload Window**. **Customize** lists the 16 skills and 9 rules; the rules apply by glob scope and the agent loads a skill when its description matches the task. +- **Cursor** — install it as a local plugin from the slim `plugin-dist` branch, then run **Developer: Reload Window**. **Customize** lists the 17 skills and 9 rules; the rules apply by glob scope and the agent loads a skill when its description matches the task. ```bash git clone --branch plugin-dist --single-branch https://github.com/TMHSDigital/Blender-Developer-Tools.git ~/.cursor/plugins/local/blender-developer-tools ``` (On Windows the folder is `%USERPROFILE%\.cursor\plugins\local\`. Cursor skips symlinks that point outside that folder, so clone or copy rather than link.) Teams can import the repository as a team marketplace instead; it carries `.cursor-plugin/marketplace.json`. For one project only, copy `skills/*` into the project's `.cursor/skills/` and `rules/*.mdc` into `.cursor/rules/` from a checkout. Copying only the rules gives you no skills. -- **Claude Code** — install as a plugin, then run `/skills` to see all 16 skills plus `blender-rules`: +- **Claude Code** — install as a plugin, then run `/skills` to see all 17 skills plus `blender-rules`: ```text /plugin marketplace add TMHSDigital/Blender-Developer-Tools@plugin-dist @@ -105,7 +105,7 @@ Releases ship often; [CHANGELOG.md](CHANGELOG.md) lists what each one changed. ## Falsifiers -Every one of the 64 examples carries a **falsifier**: a flag that changes the +Every one of the 65 examples carries a **falsifier**: a flag that changes the input so a real assertion fails. It never disables the assertion, skips the check, or short-circuits to an error — it feeds the script something the contract says must not pass, and the same check that guards the happy path @@ -133,7 +133,7 @@ per-script exit-code model, are in ## Examples and showcase -**64 examples** in [`examples/`](examples/) are runnable, self-checking scripts: each asserts one API contract, carries a falsifier, and runs headless on Blender 5.2 LTS and 4.5 LTS in the `blender-smoke` workflow (5.1 on the weekly cron or by manual dispatch). Those whose contract is visible also render a still. **76 showcase pieces** in [`showcase/`](showcase/) exist to show the skills working together at production scale: each is a single headless script that models a game prop, then runs it through the same cleanup, bake, LOD, collider and export steps the skills teach, and fails if a measured budget (triangle counts, colliders, materials, real-world size) is missed. They are not API examples. Conventions: [`showcase/README.md`](showcase/README.md). +**65 examples** in [`examples/`](examples/) are runnable, self-checking scripts: each asserts one API contract, carries a falsifier, and runs headless on Blender 5.2 LTS and 4.5 LTS in the `blender-smoke` workflow (5.1 on the weekly cron or by manual dispatch). Those whose contract is visible also render a still. **76 showcase pieces** in [`showcase/`](showcase/) exist to show the skills working together at production scale: each is a single headless script that models a game prop, then runs it through the same cleanup, bake, LOD, collider and export steps the skills teach, and fails if a measured budget (triangle counts, colliders, materials, real-world size) is missed. They are not API examples. Conventions: [`showcase/README.md`](showcase/README.md). Browse both, with filters, full-size renders and each script's README, in the **[gallery](https://tmhsdigital.github.io/Blender-Developer-Tools/gallery/)**. @@ -151,11 +151,11 @@ Browse both, with filters, full-size renders and each script's README, in the ** ## How content is organized ``` -skills//SKILL.md - 16 skill files, YAML frontmatter, one canonical pattern each +skills//SKILL.md - 17 skill files, YAML frontmatter, one canonical pattern each rules/.mdc - 9 rule files, anti-pattern + correction templates// - 3 template directories (extension-addon-template, headless-batch-script-template, ai-asset-pipeline-template) -snippets/.py - 27 standalone Python snippets, 5 to 75 lines each -examples// - 64 example directories: script, README with exit codes and falsifier +snippets/.py - 28 standalone Python snippets, 5 to 75 lines each +examples// - 65 example directories: script, README with exit codes and falsifier showcase// - 76 showcase pieces: budget-gated game props, script and README claude/ - generated Claude Code copies of the rules (blender-rules skill + import file) ``` diff --git a/docs/new-example-prompt.md b/docs/new-example-prompt.md index e950d083..128e4276 100644 --- a/docs/new-example-prompt.md +++ b/docs/new-example-prompt.md @@ -59,7 +59,7 @@ metadata — so the render would be identical whether the API held or broke. The "redesign the scene until failure would be visible" instruction above is the test: attempt it first, and only when it cannot succeed in principle does the example become check-only. The exception is for contracts that are invisible, not for -renders that are hard. Eight of the 64 examples currently qualify, and `CLAUDE.md` +renders that are hard. Nine of the 65 examples currently qualify, and `CLAUDE.md` carries the same rule. A check-only example is otherwise a full example: it still asserts a real contract, still exits non-zero on failure, still carries a falsifier, and still takes a `tests/smoke/catalog.json` row so it runs on every PR. diff --git a/examples/extension-package-lifecycle/README.md b/examples/extension-package-lifecycle/README.md new file mode 100644 index 00000000..9bda8ed1 --- /dev/null +++ b/examples/extension-package-lifecycle/README.md @@ -0,0 +1,100 @@ +# Extension package lifecycle + +Takes [`templates/extension-addon-template`](../../templates/extension-addon-template/) +through what an extension meets after its first run: validate, build, a +repository listing, install, an upgrade over the top, removal, and the +online-access gate. Every step runs a child Blender against a throwaway user +directory (`BLENDER_USER_RESOURCES`), so the real install is never touched. +Check-only: the witnesses are exit codes, zip members and paths, so a render +would look the same whether the contract held or broke. + +Follows [`extension-runtime-and-packaging`](../../skills/extension-runtime-and-packaging/SKILL.md), +whose snippet is [`extension-user-data.py`](../../snippets/extension-user-data.py). +Scaffolding matches [`eval-mesh-datablock-name`](../eval-mesh-datablock-name/) +(`check()` steps return codes, argparse falsifier flags, FATAL wrapper). + +**What it witnesses:** + +- `blender --command extension validate` and `build` accept the template, and + the zip carries `blender_manifest.toml` and `__init__.py` at its root. +- **`validate` is not a release gate.** With `wheels = ["./wheels/not_shipped-1.0-py3-none-any.whl"]` + appended and no such file, `validate` exits 0 and `build` exits 1 with no + zip. Only the build opens the files the manifest names. +- `extension server-generate --repo-dir` writes `index.json` listing the + package with `archive_url` `./example_addon-0.1.0.zip`. +- Installed with `extension install-file -r user_default`, the add-on imports + as `bl_ext.user_default.example_addon`, and + `bpy.utils.extension_path_user(__package__)` names + `extensions/.user/user_default/example_addon`, beside the install tree + `extensions/user_default/example_addon`, not inside it. +- Installing 0.2.0 over 0.1.0 replaces the install tree (a file written + beside `__file__` is gone) and keeps the user directory (`settings.json` + survives). `extension remove` deletes the user directory too. +- `bpy.app.online_access` is `False` in a factory-startup child and `True` + under `--online-mode`. + +**What failure each check would catch:** + +- exit 3 — the template stopped validating or building, or the zip lost its manifest +- exit 4 — a package whose wheel does not exist passed the release gate + (`--validate-only` lands here: it gates on `validate` alone); also exit 4 if + `validate` starts rejecting the missing wheel, because the trap this example + names would then be gone +- exit 5 — `server-generate` wrote no listing, or the listing does not name the package +- exit 6 — install, import, upgrade or remove failed, or `__package__` is not + `bl_ext.user_default.example_addon` +- exit 7 — user data lives inside the install tree, did not survive the + upgrade, or survived removal (`--data-next-to-file` lands here: it stores + data beside `__file__`) +- exit 8 — `online_access` is not `False` by default and `True` under `--online-mode` + +## Re-verified + +| Measurement | 4.5.11 | 5.1.2 | 5.2.1 | +| --- | --- | --- | --- | +| validate / build, template | 0 / 0 | 0 / 0 | 0 / 0 | +| validate / build, missing wheel | 0 / 1 | 0 / 1 | 0 / 1 | +| `__package__` | `bl_ext.user_default.example_addon` | same | same | +| user data kept by 0.2.0 upgrade | yes | yes | yes | +| file beside `__file__` kept by upgrade | no | no | no | +| user data after `remove` | deleted | deleted | deleted | +| `online_access` default / `--online-mode` | False / True | False / True | False / True | +| default exit | 0 | 0 | 0 | +| `--validate-only` / `--data-next-to-file` exit | 4 / 7 | 4 / 7 | 4 / 7 | + +## API reference + +- [`bpy.utils.extension_path_user`](https://docs.blender.org/api/current/bpy.utils.html#bpy.utils.extension_path_user) + ([4.5 LTS](https://docs.blender.org/api/4.5/bpy.utils.html#bpy.utils.extension_path_user)) +- [`bpy.app.online_access`](https://docs.blender.org/api/current/bpy.app.html#bpy.app.online_access) +- [Extensions command line arguments](https://docs.blender.org/manual/en/latest/advanced/command_line/extension_arguments.html) + +## Run + +```bash +blender --background --python extension_package_lifecycle.py -- +blender --background --python extension_package_lifecycle.py -- --validate-only +blender --background --python extension_package_lifecycle.py -- --data-next-to-file +``` + +The script starts about a dozen child Blender processes, so it takes longer +than most examples. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Template does not validate or build, or the zip lacks its manifest | +| 4 | Missing-wheel package passed the release gate (`--validate-only` lands here) | +| 5 | `server-generate` listing missing or wrong | +| 6 | Install, import, upgrade or remove failed, or `__package__` wrong | +| 7 | User data inside the install tree, lost on upgrade, or kept after removal (`--data-next-to-file` lands here) | +| 8 | `online_access` default or `--online-mode` value wrong | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke passes no extra flags on the happy path. Its catalog falsifiers are +`--validate-only` (expects exit 4) and `--data-next-to-file` (expects exit 7). diff --git a/examples/extension-package-lifecycle/extension_package_lifecycle.py b/examples/extension-package-lifecycle/extension_package_lifecycle.py new file mode 100644 index 00000000..ca666147 --- /dev/null +++ b/examples/extension-package-lifecycle/extension_package_lifecycle.py @@ -0,0 +1,263 @@ +"""Extension package lifecycle — a runnable example. + +Takes the repo's extension template through what an extension meets after +its first run, each step in a throwaway Blender user directory +(BLENDER_USER_RESOURCES) so nothing touches the real install: + +1. ``blender --command extension validate`` then ``build`` produce a zip. +2. ``validate`` passes a manifest whose ``wheels`` entry names a file that + does not exist; only ``build`` rejects it. CI must build, not just + validate. +3. ``server-generate`` writes the repository listing (index.json) for a + static extension server. +4. Installed, the add-on imports as ``bl_ext.user_default.`` and + ``bpy.utils.extension_path_user(__package__)`` names a directory beside + the install tree, not inside it, so data written there survives the + package being replaced on update. +5. ``bpy.app.online_access`` is False unless Blender runs with + ``--online-mode`` (or the user allows online access). + +Check-only: no gallery still. The witnesses are exit codes and paths. + + blender --background --python extension_package_lifecycle.py -- + blender --background --python extension_package_lifecycle.py -- --validate-only + blender --background --python extension_package_lifecycle.py -- --data-next-to-file +""" +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +import zipfile + +import bpy + +HERE = os.path.dirname(os.path.abspath(__file__)) +TEMPLATE = os.path.normpath(os.path.join(HERE, "..", "..", "templates", "extension-addon-template")) +EXT_ID = "example_addon" +REPO = "user_default" +PACKAGE = f"bl_ext.{REPO}.{EXT_ID}" +MISSING_WHEEL = './wheels/not_shipped-1.0-py3-none-any.whl' +TIMEOUT = 300 + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def blender(args, user_dir=None): + """Run a child Blender; return (exit code, combined output).""" + env = dict(os.environ) + if user_dir: + env["BLENDER_USER_RESOURCES"] = user_dir + r = subprocess.run([bpy.app.binary_path, "--factory-startup", *args], + capture_output=True, text=True, env=env, timeout=TIMEOUT) + return r.returncode, r.stdout + r.stderr + + +def child_python(expr, user_dir, extra=()): + """Run `expr` in a background child Blender; return the lines it tagged with RESULT.""" + code, out = blender(["--background", *extra, "--python-expr", expr], user_dir) + return code, [l[len("RESULT "):] for l in out.splitlines() if l.startswith("RESULT ")], out + + +def copy_template(dst): + shutil.copytree(TEMPLATE, dst, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + return dst + + +def check_build(work): + src = copy_template(os.path.join(work, "src")) + dist = os.path.join(work, "dist") + os.makedirs(dist) + code, out = blender(["--command", "extension", "validate", src]) + print(f"validate template: exit {code}") + if code != 0: + print(out) + return fail("the template does not validate", 3), None + code, out = blender(["--command", "extension", "build", "--source-dir", src, "--output-dir", dist]) + zips = [f for f in os.listdir(dist) if f.endswith(".zip")] + print(f"build template: exit {code} -> {zips}") + if code != 0 or zips != [f"{EXT_ID}-0.1.0.zip"]: + print(out) + return fail(f"build produced {zips}, expected {EXT_ID}-0.1.0.zip", 3), None + with zipfile.ZipFile(os.path.join(dist, zips[0])) as z: + names = set(z.namelist()) + print(f"zip members: {sorted(names)}") + if not {"blender_manifest.toml", "__init__.py"} <= names: + return fail("the zip lacks blender_manifest.toml or __init__.py at its root", 3), None + return 0, dist + + +def check_wheel_trap(work, validate_only): + """validate parses the manifest; only build opens the files it names.""" + src = copy_template(os.path.join(work, "wheel_src")) + manifest = os.path.join(src, "blender_manifest.toml") + with open(manifest, "a", encoding="utf-8") as fh: + fh.write(f'\nwheels = ["{MISSING_WHEEL}"]\n') + out_dir = os.path.join(work, "wheel_dist") + os.makedirs(out_dir) + v_code, _ = blender(["--command", "extension", "validate", src]) + b_code, b_out = blender(["--command", "extension", "build", "--source-dir", src, "--output-dir", out_dir]) + shipped = os.listdir(out_dir) + print(f"missing wheel: validate exit {v_code}, build exit {b_code}, output {shipped}") + if v_code != 0: + return fail("validate now rejects a missing wheel; the trap this example names is gone", 4) + gate_passed = v_code == 0 if validate_only else (b_code == 0 and bool(shipped)) + if gate_passed: + return fail("a package whose wheel does not exist passed the release gate " + f"({'validate only' if validate_only else 'build'})", 4) + return 0 + + +def check_listing(dist): + code, out = blender(["--command", "extension", "server-generate", "--repo-dir", dist]) + index = os.path.join(dist, "index.json") + if code != 0 or not os.path.isfile(index): + print(out) + return fail("server-generate wrote no index.json", 5) + with open(index, encoding="utf-8") as fh: + listing = json.load(fh) + entries = [(e.get("id"), e.get("archive_url")) for e in listing.get("data", [])] + print(f"listing: {entries}") + if entries != [(EXT_ID, f"./{EXT_ID}-0.1.0.zip")]: + return fail(f"listing {entries} does not name the built package", 5) + return 0 + + +def check_install(dist, user_dir, data_next_to_file): + zip_path = os.path.join(dist, f"{EXT_ID}-0.1.0.zip") + code, out = blender(["--command", "extension", "install-file", "-r", REPO, "-e", zip_path], user_dir) + if code != 0: + print(out) + return fail("install-file failed", 6), None + pick = "os.path.dirname(m.__file__)" if data_next_to_file else \ + "bpy.utils.extension_path_user(m.__package__, create=True)" + expr = ( + "import bpy, importlib, os; " + f"m = importlib.import_module('{PACKAGE}'); " + "print('RESULT', m.__package__); " + "print('RESULT', os.path.dirname(m.__file__)); " + f"d = {pick}; " + "open(os.path.join(d, 'settings.json'), 'w').write('{\"kept\": true}'); " + "print('RESULT', d)" + ) + code, res, out = child_python(expr, user_dir) + if code != 0 or len(res) != 3: + print(out) + return fail("the installed extension did not import and report its paths", 6), None + pkg, install_dir, data_dir = res + print(f"installed package={pkg!r}\n install dir={install_dir}\n data dir={data_dir}") + if pkg != PACKAGE: + return fail(f"__package__ is {pkg!r}, expected {PACKAGE!r}", 6), None + inside = os.path.normcase(os.path.abspath(data_dir)).startswith( + os.path.normcase(os.path.abspath(install_dir))) + if inside: + return fail("user data lives inside the extension's install directory, which an update replaces", 7), None + expected_tail = os.path.join("extensions", ".user", REPO, EXT_ID) + if not os.path.normcase(data_dir).endswith(os.path.normcase(expected_tail)): + return fail(f"data dir {data_dir} is not .../{expected_tail}", 7), None + + # Upgrade in place: install a 0.2.0 build over 0.1.0, as an update does. + marker = os.path.join(install_dir, "written_at_runtime.txt") + with open(marker, "w", encoding="utf-8") as fh: + fh.write("lost on upgrade") + code, v2_zip = build_version(dist, "0.2.0") + if code: + return code, None + code_up, out = blender(["--command", "extension", "install-file", "-r", REPO, "-e", v2_zip], user_dir) + kept = os.path.isfile(os.path.join(data_dir, "settings.json")) + marker_kept = os.path.isfile(marker) + print(f"upgrade to 0.2.0 (exit {code_up}): user data kept={kept}, file beside __file__ kept={marker_kept}") + if code_up != 0: + print(out) + return fail("installing 0.2.0 over 0.1.0 failed", 6), None + if marker_kept: + return fail("the upgrade did not replace the install directory; the premise is gone", 7), None + if not kept: + return fail("user data did not survive the upgrade", 7), None + + # Removing the extension deletes its user directory too. + code_rm, _ = blender(["--command", "extension", "remove", f"{REPO}.{EXT_ID}"], user_dir) + gone = not os.path.exists(data_dir) + print(f"remove (exit {code_rm}): user data deleted={gone}") + if code_rm != 0: + return fail("extension remove failed", 6), None + if not gone: + return fail("remove did not delete the extension's user directory", 7), None + return 0, data_dir + + +def build_version(dist, version): + """Build the template again with a bumped version; return the new zip.""" + src = copy_template(os.path.join(os.path.dirname(dist), f"src_{version}")) + manifest = os.path.join(src, "blender_manifest.toml") + with open(manifest, encoding="utf-8") as fh: + text = fh.read() + with open(manifest, "w", encoding="utf-8") as fh: + fh.write(text.replace('version = "0.1.0"', f'version = "{version}"', 1)) + out_dir = os.path.join(os.path.dirname(dist), f"dist_{version}") + os.makedirs(out_dir) + code, out = blender(["--command", "extension", "build", "--source-dir", src, "--output-dir", out_dir]) + zip_path = os.path.join(out_dir, f"{EXT_ID}-{version}.zip") + if code != 0 or not os.path.isfile(zip_path): + print(out) + return fail(f"could not build {version}", 3), None + return 0, zip_path + + +def check_online(user_dir): + expr = "import bpy; print('RESULT', bpy.app.online_access)" + _, default, _ = child_python(expr, user_dir) + _, online, _ = child_python(expr, user_dir, extra=["--online-mode"]) + print(f"online_access: default={default} --online-mode={online}") + if default != ["False"] or online != ["True"]: + return fail("online_access is not False by default and True under --online-mode", 8) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--validate-only", action="store_true", + help="falsification: gate the release on validate alone") + p.add_argument("--data-next-to-file", action="store_true", + help="falsification: store user data beside __file__") + args = p.parse_args(argv) + print(f"blender={bpy.app.version_string} template={TEMPLATE}") + + work = tempfile.mkdtemp(prefix="bdt_ext_") + try: + user_dir = os.path.join(work, "user") + code, dist = check_build(work) + if code: + return code + code = check_wheel_trap(work, args.validate_only) + if code: + return code + code = check_listing(dist) + if code: + return code + code, _ = check_install(dist, user_dir, args.data_next_to_file) + if code: + return code + code = check_online(user_dir) + if code: + return code + finally: + shutil.rmtree(work, ignore_errors=True) + print("extension-package-lifecycle OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + import traceback + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/examples/index.json b/examples/index.json index bc65cb48..758d8c03 100644 --- a/examples/index.json +++ b/examples/index.json @@ -690,6 +690,44 @@ "12": "Render path: no axis-aligned rotation maps the source onto a re-import, so the gizmo frame could not be measured" } }, + { + "name": "extension-package-lifecycle", + "path": "examples/extension-package-lifecycle/", + "script": "examples/extension-package-lifecycle/extension_package_lifecycle.py", + "skills": [ + "extension-runtime-and-packaging", + "headless-batch-scripting" + ], + "render": false, + "min_version": "4.5", + "run": "blender --background --python examples/extension-package-lifecycle/extension_package_lifecycle.py", + "summary": "Takes templates/extension-addon-template through what an extension meets after its first run.", + "falsifiers": [ + { + "args": [ + "--validate-only" + ], + "expect_exit": 4 + }, + { + "args": [ + "--data-next-to-file" + ], + "expect_exit": 7 + } + ], + "exit_codes": { + "0": "Success", + "1": "Uncaught exception (FATAL wrapper)", + "2": "argparse / usage", + "3": "Template does not validate or build, or the zip lacks its manifest", + "4": "Missing-wheel package passed the release gate (`--validate-only` lands here)", + "5": "`server-generate` listing missing or wrong", + "6": "Install, import, upgrade or remove failed, or `__package__` wrong", + "7": "User data inside the install tree, lost on upgrade, or kept after removal (`--data-next-to-file` lands here)", + "8": "`online_access` default or `--online-mode` value wrong" + } + }, { "name": "gltf-export-roundtrip", "path": "examples/gltf-export-roundtrip/", diff --git a/examples/skills.json b/examples/skills.json index ea03329f..33b7009c 100644 --- a/examples/skills.json +++ b/examples/skills.json @@ -20,6 +20,7 @@ "eval-mesh-datablock-name": ["depsgraph-and-evaluated-data"], "exit-pre-sidecar": ["drivers-and-app-handlers"], "export-preset-axis": ["engine-export-presets"], + "extension-package-lifecycle": ["extension-runtime-and-packaging", "headless-batch-scripting"], "gltf-export-roundtrip": ["depsgraph-and-evaluated-data", "engine-export-presets", "headless-batch-scripting"], "gltf-skin-roundtrip": ["depsgraph-and-evaluated-data", "engine-export-presets"], "gn-bundle-roundtrip": ["geometry-nodes-python"], diff --git a/scripts/build_examples_index.py b/scripts/build_examples_index.py index 2ef6eacd..9855f6aa 100644 --- a/scripts/build_examples_index.py +++ b/scripts/build_examples_index.py @@ -54,7 +54,10 @@ def _summary(readme: str, teaches: str | None) -> str: else: paras = [p for p in re.split(r"\n\s*\n", readme) if p.strip()] body = next((p for p in paras if not p.lstrip().startswith(("#", "<", "!", "|", "```"))), "") - text = MD_LINK.sub(r"\1", " ".join(body.split())) + # Keep a link's text, but unquote a path: a backticked repo path in a skill + # must be a live link (tests/check_skill_refs.py), and the link is dropped here. + text = MD_LINK.sub(lambda m: m.group(1).strip("`") if "/" in m.group(1) else m.group(1), + " ".join(body.split())) m = SENTENCE.match(text) text = (m.group(1) if m else text).strip() if len(text) <= MAX_SUMMARY: diff --git a/scripts/build_plugin_dist.py b/scripts/build_plugin_dist.py index bdcd2f34..3ed52e57 100644 --- a/scripts/build_plugin_dist.py +++ b/scripts/build_plugin_dist.py @@ -51,7 +51,7 @@ ``` Cursor: clone this branch into `~/.cursor/plugins/local/blender-developer-tools` -and reload the window (Customize then lists the 16 skills and 9 rules). +and reload the window (Customize then lists the 17 skills and 9 rules). Skills reference bundled files as `${CLAUDE_PLUGIN_ROOT}/snippets/...`, which Claude Code expands to this plugin's install directory. In Cursor, read it as diff --git a/site.json b/site.json index 60ef2bc7..7e7aa836 100644 --- a/site.json +++ b/site.json @@ -18,8 +18,8 @@ "github": "https://github.com/TMHSDigital/Blender-Developer-Tools" }, "installSteps": [ - "Claude Code: /plugin marketplace add TMHSDigital/Blender-Developer-Tools@plugin-dist then /plugin install blender-developer-tools@blender-developer-tools. The 16 skills load when a task matches; the rules come along as the blender-rules skill", - "Cursor: git clone --branch plugin-dist --single-branch https://github.com/TMHSDigital/Blender-Developer-Tools.git ~/.cursor/plugins/local/blender-developer-tools, then reload the window; Customize lists the 16 skills and 9 rules. This clones only the 0.3 MB plugin build", + "Claude Code: /plugin marketplace add TMHSDigital/Blender-Developer-Tools@plugin-dist then /plugin install blender-developer-tools@blender-developer-tools. The 17 skills load when a task matches; the rules come along as the blender-rules skill", + "Cursor: git clone --branch plugin-dist --single-branch https://github.com/TMHSDigital/Blender-Developer-Tools.git ~/.cursor/plugins/local/blender-developer-tools, then reload the window; Customize lists the 17 skills and 9 rules. This clones only the 0.3 MB plugin build", "Other agents (Codex, Copilot, Windsurf): clone the repo and copy skills/* into your agent's skills folder (for example .agents/skills/); point it at claude/blender-rules.md for the rules", "Grab snippets/ and templates/ as starting points for add-ons and headless batch jobs" ], diff --git a/skills/extension-runtime-and-packaging/SKILL.md b/skills/extension-runtime-and-packaging/SKILL.md new file mode 100644 index 00000000..15155604 --- /dev/null +++ b/skills/extension-runtime-and-packaging/SKILL.md @@ -0,0 +1,154 @@ +--- +name: extension-runtime-and-packaging +description: "Ship and run a Blender extension after its first install: bundled wheels, user data that survives upgrades via bpy.utils.extension_path_user, the network permission and bpy.app.online_access, bl_ext package names, and blender --command extension validate/build/server-generate in CI. Use when the user bundles third-party Python packages, pip-installs into Blender's Python, writes files next to __file__, needs a settings or cache directory, makes network calls from an add-on, or builds and publishes extension zips. Targets 5.2 LTS with 4.5 LTS fallback." +standards-version: 1.10.0 +--- + +# Extension Runtime and Packaging + +## Trigger + +Use this skill when the user: + +- Needs a third-party Python package inside an extension (`requests`, `numpy`, `Pillow`) +- Writes settings, caches or downloads from an add-on and asks where they should go +- Makes network calls from an add-on, or asks about the `network` permission +- Builds, validates or publishes extension zips, locally or in CI +- Hits `ValueError: The "package" does not name an extension`, or imports that work in a folder add-on but break once installed as an extension + +`addon-scaffolding` covers the manifest fields, file layout and register symmetry. This skill covers what breaks after that: dependencies, data, network and packaging. + +## Required inputs + +- **Extension `id`** from `blender_manifest.toml` (the installed package is `bl_ext..`) +- **Target Blender versions**. The bundled Python differs: 3.11 on 4.5 LTS, 3.13 on 5.2 LTS (`sys.version_info` measured on 4.5.11 and 5.2.1) +- **Third-party packages** the code imports, and whether any ship compiled code +- **Whether the add-on touches the network** + +## Where things live once installed + +Measured with `blender --command extension install-file -r user_default` into a throwaway `BLENDER_USER_RESOURCES`, identical on 4.5.11, 5.1.2 and 5.2.1: + +| What | Path under the user `extensions/` directory | Lifetime | +| --- | --- | --- | +| The package (`__file__`) | `user_default//` | Replaced on every upgrade | +| `extension_path_user(__package__)` | `.user/user_default//` | Survives upgrades; deleted by `extension remove` | +| Bundled wheels | `.local/lib/python3.X/site-packages/` | Outside every package directory, keyed by Python version | + +The import name is `bl_ext.user_default.`, not ``, and `__package__` carries that full name. + +## Bundling dependencies: wheels, never pip + +Blender does not run `pip` for an extension, and an extension should not run it either: `pip install` into Blender's Python changes the interpreter every add-on shares and needs write access to the Blender install. The supported mechanism is wheels shipped inside the package. Per the manual, they must be bundled unmodified from PyPI and must include their own dependencies: + +```toml +# blender_manifest.toml +wheels = [ + "./wheels/requests-2.32.3-py3-none-any.whl", + "./wheels/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", +] +``` + +Download them with pip on the build machine, matched to Blender's Python and each platform: + +```bash +pip download requests --dest ./wheels --only-binary=:all: --python-version 3.11 --platform win_amd64 +``` + +- A pure-Python wheel (`py3-none-any`) works everywhere. A compiled one needs a wheel per Python (`cp311` for 4.5 LTS, `cp313` for 5.2 LTS) and per platform. +- `build --split-platforms` writes one zip per platform listed in the manifest's `platforms` key, so a user downloads only their own wheels. +- `build` copies the listed wheels into the zip under `wheels/`. On install, Blender extracts them into the `site-packages` directory above, which is already on `sys.path`, so `import requests` works with no path code (measured with a hand-built wheel on 4.5.11 and 5.2.1). + +## User data: `extension_path_user`, never `__file__` + +```python +import os +import bpy + +def settings_path(): + directory = bpy.utils.extension_path_user(__package__, path="", create=True) + return os.path.join(directory, "settings.json") +``` + +- Installing a newer version replaces the whole install directory. A file written next to `__file__` is gone after the upgrade; `settings.json` in the user directory is still there (measured, 0.1.0 → 0.2.0). +- `extension remove` deletes the user directory too. If users must keep data across a reinstall, offer an explicit export. +- `extension_path_user("my_addon")` raises `ValueError: The "package" does not name an extension` for anything not shaped `bl_ext..`. A legacy folder add-on cannot use it, so code shared with a `bl_info` fallback needs its own path, such as `bpy.utils.user_resource('CONFIG', path=...)`. +- Use relative imports inside the package (`from . import ops`). The installed module is `bl_ext.user_default.my_addon`, and the repository part changes with where the user installs it, so a hardcoded absolute name is wrong somewhere. + +## Network: the permission and `bpy.app.online_access` + +Declaring the permission does not grant access, and code must check both: + +```toml +[permissions] +network = "Downloads material presets from the project server" +``` + +```python +if not bpy.app.online_access: + return None # the user has not allowed online access; do nothing online +``` + +- `bpy.app.online_access` is `False` in a fresh factory-startup Blender and `True` under `blender --online-mode` (measured on all three versions). Users turn it on with **Preferences → System → Network → Allow Online Access**. +- The manifest validator accepts only `files`, `network`, `clipboard`, `camera` and `microphone` as permission keys. The permission is a declaration shown to users, not a sandbox. + +## Validate is not a release gate: build in CI + +`blender --command extension validate DIR` parses the manifest. It rejects a missing `id`, a non-semver `version`, an unknown `type`, an unknown permission key and a malformed `blender_version_min`. It does **not** open the files the manifest names, and does not check every value: + +| Manifest defect | `validate` | `build` | +| --- | --- | --- | +| `wheels` entry whose file does not exist | exit 0 | exit 1, no zip | +| `platforms = ["win64"]` (not a platform name) | exit 0 | — | +| `schema_version = "9.9.9"` | exit 0 | — | +| missing `id` | exit 1 | exit 1 | + +All four rows were measured on 4.5.11 and 5.2.1; "—" means not measured. Gate CI on `build`, which fails on what validate lets through: + +```bash +blender --factory-startup --command extension validate ./my_addon +blender --factory-startup --command extension build --source-dir ./my_addon --output-dir ./dist +blender --factory-startup --command extension server-generate --repo-dir ./dist # writes dist/index.json +``` + +`server-generate` writes the `index.json` listing a static extension repository needs. Each entry carries the package `id` and an `archive_url` relative to the listing (`./my_addon-0.1.0.zip`). Host `dist/` anywhere that serves files and add it as a remote repository in Blender. + +To smoke-test an installed build without touching a real profile, point `BLENDER_USER_RESOURCES` at a temporary directory, then run `extension install-file -r user_default -e dist/` and a `--background --python-expr` import of `bl_ext.user_default.`. + +## Common AI mistakes + +1. **`subprocess.run([sys.executable, "-m", "pip", "install", ...])` from `register()`**. It mutates the interpreter every add-on shares and fails without write access to the install. Bundle wheels. +2. **Writing caches or settings next to `__file__`**. The next upgrade deletes them. Use `extension_path_user(__package__, create=True)`. +3. **Calling `extension_path_user("my_addon")`** with the bare id. It raises `ValueError`; pass `__package__`. +4. **Absolute self-imports** (`import my_addon.utils`). The installed package is `bl_ext..my_addon`, with a repository part that varies; use relative imports. +5. **Network calls gated only on the manifest permission**. Check `bpy.app.online_access` at call time; it is `False` until the user allows online access. +6. **A CI job that runs only `extension validate`**. A missing wheel passes it; run `extension build` and fail on its exit code. +7. **Compiled wheels for one Python only**. A `cp311` wheel does not import on 5.2's Python 3.13. Ship one per supported Python and platform. + +## Compatibility paths + +The `--command extension` subcommands, `extension_path_user`, `online_access`, the `.user` and `.local/lib/pythonX.Y/site-packages` layout, and the upgrade and remove behaviour above are the same on 4.5 LTS, 5.1 and 5.2 LTS (all measured). What changes between them is the bundled Python (3.11 → 3.13), which decides the wheel tags. Branch on `bpy.app.version` only for API differences, never for these paths. + +## Related + +- `addon-scaffolding`: the manifest fields, file layout and `register_classes_factory` +- `headless-batch-scripting`: running Blender unattended, which is how the CI commands above run +- The `extension-addon-template` template under [`templates/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/templates), which the example below builds, installs and upgrades +- Snippet: [`snippets/extension-user-data.py`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/snippets/extension-user-data.py) + + +## Runnable examples + +Each example runs headless, asserts the contract, and exits non-zero when it breaks. Run one with `blender --background --python