diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index eb2a2de..be09fce 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -74,6 +74,7 @@ "examples/degenerate-bevel-weld", "examples/depsgraph-export", "examples/driver-wave", + "examples/exit-pre-sidecar", "examples/gltf-export-roundtrip", "examples/gltf-skin-roundtrip", "examples/gn-bundle-roundtrip", diff --git a/AGENTS.md b/AGENTS.md index 73c9e56..102b049 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 13 skills, 6 rules, 2 templates, 17 snippets, and 49 +The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 50 examples (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/`; the render look is specified @@ -35,7 +35,7 @@ Blender-Developer-Tools/ rules/.mdc # 6 rule files templates// # 2 starter templates snippets/.py # 17 standalone Python snippets - examples// # 49 runnable smoke-gated examples (+ gallery.json) + examples// # 50 runnable smoke-gated examples (+ gallery.json) examples/gallery_framing.py # shared Layer 1 framing measurement (render path only) scripts/build_gallery.py # generates docs/gallery/ (stdlib only) scripts/site/ # vendored landing-page build (build_site.py + template) diff --git a/CLAUDE.md b/CLAUDE.md index c579902..6a74b93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ skills//SKILL.md - AI workflow definitions, 13 total rules/.mdc - Anti-pattern rules, 6 total templates// - Starter projects, 2 total snippets/.py - Standalone code patterns, 17 total -examples// - Runnable smoke-gated examples, 49 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 50 total (+ gallery.json) scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only) scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders @@ -83,7 +83,7 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`. -## Examples (49) +## Examples (50) Runnable scripts at `examples//`, each asserting a real API contract with deterministic checks (exit non-zero on failure) and optionally rendering a still via diff --git a/README.md b/README.md index 658144d..33be696 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 13 skills  •  6 rules  •  2 templates  •  17 snippets  •  49 examples + 13 skills  •  6 rules  •  2 templates  •  17 snippets  •  50 examples

@@ -459,7 +459,7 @@ fails creating the 5.x RNA.

-Animation, rigging & constraints — 5 examples +Animation, rigging & constraints — 6 examples @@ -488,6 +488,21 @@ A `driver_namespace` function driving sixteen column heights through SCRIPTED dr Witnesses the evaluation contract: driven values appear after a view-layer update on the evaluated copy **and** the flushed-back original, and both must match the closed form. + + + + + diff --git a/ROADMAP.md b/ROADMAP.md index ec84b9a..f42d7ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,6 +96,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Repeat Zone / For Each Element pairing witness~~ **SHIPPED** as `examples/gn-zone-iterate/` — `pair_with_output` is load-bearing (unpaired evaluates 0 verts); Repeat `8×(1+N)` with X-centers at `k×STEP`; For Each `8×P` with Z-centers at `i×STEP`; For Each main Geometry is a passthrough (6 verts); count-only is insufficient (`--no-offset` still 32 verts, one X-center) - ~~Geometry Nodes bundle round-trip~~ **SHIPPED** as `examples/gn-bundle-roundtrip/` — 5.x RNA `NodeCombineBundle` (`GeometryNodeCombineBundle` undefined on 5.2); closed form 8/6 + x `[0.5, 2.5]` + `bundle_mark=0.314159`; 4.5 SKIP via catalog `min_version` 5.0 (experimental flag off evaluates empty; `--force-run` hits 5.x RNA undefined) - Tighten inverted smoke canaries: assert the expected `[FAIL]` marker text (`skipped on ... should run` / `missing post-exit sidecar`) alongside wrapper exit 1, so a canary that dies for the wrong reason does not satisfy the gate +- Falsification flags on shipped examples (Phases 6–8 `--skip-delete` / `--unpair-*` / `--bypass`, Phase 9 `--silent-handler` / `--wrong-text`) were proven local-Windows only — chat and `.scratch`, not CI history. Decide later whether a cron should exercise them or whether flags in the scripts are sufficient record. +- ~~exit_pre sidecar witness~~ **SHIPPED** as `examples/exit-pre-sidecar/` — 5.1+ `handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR`; harness `--expect-sidecar` + `sidecar_contains=exit_pre-ok` after process exit; 4.5 SKIP `min_version` 5.1; `--force-run` is AttributeError; `--silent-handler` / `--no-handler` missing sidecar; `--wrong-text` / `--write-in-main` / `--atexit-instead` wrong contents - ~~Fleet Pages facelift + examples support~~ **RESOLVED differently (2026-07-03)**: the meta-repo migration was dropped — the fleet template only scaffolds new tools, and each tool's site evolves independently after that. This repo vendored the site build into `scripts/site/`, redesigned landing + gallery as the Blender-viewport system (see `docs/gallery/DESIGN_NOTES.md`), added the examples grid, nav link, and full hero stats locally. `scripts/build_gallery.py` and `examples/gallery.json` are now permanent, not a prototype awaiting lift-and-shift. - `modal-operators` skill -- `invoke` returning `RUNNING_MODAL`, the `modal()` event handler, modal cancellation patterns - `usd-pipelines` skill -- USD export options, `evaluation_mode`, instancing, the USD vs glTF tradeoffs diff --git a/examples/exit-pre-sidecar/README.md b/examples/exit-pre-sidecar/README.md new file mode 100644 index 0000000..2286044 --- /dev/null +++ b/examples/exit-pre-sidecar/README.md @@ -0,0 +1,35 @@ +# exit_pre sidecar + +`bpy.app.handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR` as Blender dies. +Witnesses [`drivers-and-app-handlers`](../../skills/drivers-and-app-handlers/SKILL.md). +`main` does not write the file. The host runner asserts it after the +process exits (`tests/smoke/run_example.py --expect-sidecar`). + +5.1+. 4.5 LTS: `AttributeError` — no `exit_pre`. Skip +(`SMOKE_SKIP: exit_pre requires Blender 5.1+`, exit 77, catalog +`min_version` 5.1). `--force-run` bypasses the skip so 4.5 fails +accessing the handler list. + +No gallery still. There is no geometry. + +**What failure each check would catch:** + +- exit 77 — Blender < 5.1 and not `--force-run` +- exit 2 — `--force-run` on 4.5 (`exit_pre` missing) +- harness FAIL missing sidecar — `--silent-handler` / `--no-handler` +- harness FAIL wrong contents — `--wrong-text` (`nope`), `--write-in-main` + (`from-main`), `--atexit-instead` (`atexit-ok`) + +The harness checks **contents** (`sidecar_contains=exit_pre-ok`), not +existence only. A file written from `main` or `atexit` is red. + +## Run + +Via the harness (sets `$BDT_SMOKE_SIDECAR`): + +```bash +python tests/smoke/run_example.py --name exit-pre-sidecar \ + --blender blender --script examples/exit-pre-sidecar/exit_pre_sidecar.py \ + --series 5.2 --min-version 5.1 \ + --expect-sidecar /tmp/exit-pre.sidecar --sidecar-contains exit_pre-ok +``` diff --git a/examples/exit-pre-sidecar/exit_pre_sidecar.py b/examples/exit-pre-sidecar/exit_pre_sidecar.py new file mode 100644 index 0000000..3fbfac0 --- /dev/null +++ b/examples/exit-pre-sidecar/exit_pre_sidecar.py @@ -0,0 +1,167 @@ +"""exit_pre post-exit sidecar — a runnable example. + +Witnesses ``bpy.app.handlers.exit_pre`` from +``skills/drivers-and-app-handlers``. The callback fires as Blender dies. +This script does **not** write ``$BDT_SMOKE_SIDECAR`` in ``main``; the +handler writes it. The host runner (``tests/smoke/run_example.py``) +asserts the file after the process exits. + +5.1+. 4.5 LTS has no ``exit_pre`` (AttributeError). Catalog +``min_version`` 5.0 is wrong — the floor is 5.1. Skip: ``SMOKE_SKIP``. +``--force-run`` bypasses the skip so 4.5 fails accessing ``exit_pre``. + +No gallery still. There is no geometry. + + blender --background --python exit_pre_sidecar.py -- +""" +import argparse +import atexit +import os +import sys + +import bpy +from bpy.app.handlers import persistent + +SKIP_REASON = "exit_pre requires Blender 5.1+" +MARKER = "exit_pre-ok" +MAIN_MARKER = "from-main" +WRONG_MARKER = "nope" +ATEXIT_MARKER = "atexit-ok" + + +def sidecar_path(): + path = os.environ.get("BDT_SMOKE_SIDECAR") + if not path: + print("ERROR: BDT_SMOKE_SIDECAR unset", file=sys.stderr) + return None + return path + + +def write_sidecar(text): + path = sidecar_path() + if not path: + return False + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) + if not text.endswith("\n"): + fh.write("\n") + return True + + +@persistent +def on_exit_write(*args): + write_sidecar(MARKER) + + +@persistent +def on_exit_silent(*args): + return + + +@persistent +def on_exit_wrong(*args): + write_sidecar(WRONG_MARKER) + + +def on_atexit(): + write_sidecar(ATEXIT_MARKER) + + +def maybe_skip(force_run): + if bpy.app.version >= (5, 1, 0): + return 0 + if force_run: + return 0 + print(f"SMOKE_SKIP: {SKIP_REASON}", flush=True) + return 77 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument( + "--force-run", + action="store_true", + help="bypass the 5.1 skip; 4.5 fails accessing handlers.exit_pre", + ) + p.add_argument( + "--silent-handler", + action="store_true", + help="falsification: register exit_pre that writes nothing", + ) + p.add_argument( + "--wrong-text", + action="store_true", + help="falsification: handler writes nope, not exit_pre-ok", + ) + p.add_argument( + "--no-handler", + action="store_true", + help="falsification: do not register exit_pre, do not write in main", + ) + p.add_argument( + "--write-in-main", + action="store_true", + help="falsification: write from-main in main without exit_pre", + ) + p.add_argument( + "--atexit-instead", + action="store_true", + help="falsification: atexit writes atexit-ok, not exit_pre", + ) + args = p.parse_args(argv) + + skipped = maybe_skip(args.force_run) + if skipped: + return skipped + + if args.force_run and bpy.app.version < (5, 1, 0): + try: + bpy.app.handlers.exit_pre.append(on_exit_write) + except AttributeError as exc: + print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr) + return 2 + print("ERROR: exit_pre existed on this Blender; 4.5 should AttributeError", file=sys.stderr) + return 2 + + if sidecar_path() is None: + return 1 + + if args.write_in_main: + write_sidecar(MAIN_MARKER) + print("wrote sidecar from main (no exit_pre)", flush=True) + return 0 + + if args.no_handler: + print("no exit_pre registered", flush=True) + return 0 + + if args.atexit_instead: + atexit.register(on_atexit) + print("registered atexit, not exit_pre", flush=True) + return 0 + + if args.silent_handler: + bpy.app.handlers.exit_pre.append(on_exit_silent) + print("registered silent exit_pre", flush=True) + return 0 + + if args.wrong_text: + bpy.app.handlers.exit_pre.append(on_exit_wrong) + print("registered exit_pre writing nope", flush=True) + return 0 + + bpy.app.handlers.exit_pre.append(on_exit_write) + print("registered exit_pre", flush=True) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: + print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/skills/drivers-and-app-handlers/SKILL.md b/skills/drivers-and-app-handlers/SKILL.md index 4912085..11022ed 100644 --- a/skills/drivers-and-app-handlers/SKILL.md +++ b/skills/drivers-and-app-handlers/SKILL.md @@ -265,6 +265,7 @@ The `exit_pre` handler list is new in Blender 5.1. On 4.5 LTS, fall back to OS-l - **Recursively modifying the scene from a depsgraph handler.** The modification triggers another depsgraph evaluation, which calls the handler, which modifies the scene. Infinite loop, often manifesting as a hang. - **Asymmetric register/unregister.** The handler is appended on register but not removed on unregister. Disabling the add-on leaves the callback in place. After enable/disable cycles, the callback runs N times per event. - **Treating the `save_pre` argument as a Scene.** The save/load handlers receive the **file path string** (empty for the startup file), not a Scene. Name the parameter `filepath` (or take `*args`), and reach scenes via `bpy.context.scene` / `bpy.data.scenes`. A membership test like `'key' in arg0` against the path string is silently wrong, and `del arg0['key']` raises `TypeError`. +- **Doing exit cleanup in the script body instead of `exit_pre`.** A `--background --python` script can `sys.exit(0)` without the handler firing if you never registered it. The witness is a sidecar written from `exit_pre`, asserted after the process dies. ## Version correctness @@ -279,6 +280,7 @@ The `exit_pre` handler list is new in Blender 5.1. On 4.5 LTS, fall back to OS-l - Snippet `driver-with-custom-function.py` for the driver_namespace pattern. - Snippet `app-handler-registration.py` for save_pre with proper unregister. +- Example `exit-pre-sidecar` for `exit_pre` writing a post-exit sidecar (5.1+; skip 4.5). - Skill `custom-properties` for the data the driver might be reading. ## References diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 4902bef..a11d421 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -57,5 +57,12 @@ {"name": "cross-version-property-delete", "script": "examples/cross-version-property-delete/cross_version_property_delete.py"}, {"name": "usd-export-evaluation-mode", "script": "examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py"}, {"name": "gn-zone-iterate", "script": "examples/gn-zone-iterate/gn_zone_iterate.py"}, - {"name": "gn-bundle-roundtrip", "script": "examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py", "min_version": "5.0"} + {"name": "gn-bundle-roundtrip", "script": "examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py", "min_version": "5.0"}, + { + "name": "exit-pre-sidecar", + "script": "examples/exit-pre-sidecar/exit_pre_sidecar.py", + "min_version": "5.1", + "expect_sidecar": "$OUT/exit-pre-sidecar.sidecar", + "sidecar_contains": "exit_pre-ok" + } ] diff --git a/tests/smoke/run_catalog.py b/tests/smoke/run_catalog.py index 9b0d1a8..ef7ee46 100644 --- a/tests/smoke/run_catalog.py +++ b/tests/smoke/run_catalog.py @@ -53,6 +53,13 @@ def main(argv=None): cmd.append("--xvfb") if item.get("min_version"): cmd.extend(["--min-version", item["min_version"]]) + if item.get("expect_sidecar"): + cmd.extend([ + "--expect-sidecar", + item["expect_sidecar"].replace("$OUT", args.out), + ]) + if item.get("sidecar_contains"): + cmd.extend(["--sidecar-contains", item["sidecar_contains"]]) if extra: cmd.append("--") cmd.extend(extra) diff --git a/tests/smoke/test_harness.py b/tests/smoke/test_harness.py index 58b8e0e..2cafbb2 100644 --- a/tests/smoke/test_harness.py +++ b/tests/smoke/test_harness.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import json import os import sys import tempfile @@ -159,5 +160,16 @@ def test_empty_is_not_green(self): self.assertEqual(code, 2) +class CatalogWiring(unittest.TestCase): + def test_exit_pre_row_has_sidecar_and_floor(self): + here = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(here, "catalog.json"), encoding="utf-8") as fh: + catalog = json.load(fh) + row = next(i for i in catalog if i["name"] == "exit-pre-sidecar") + self.assertEqual(row["min_version"], "5.1") + self.assertIn("$OUT", row["expect_sidecar"]) + self.assertEqual(row["sidecar_contains"], "exit_pre-ok") + + if __name__ == "__main__": unittest.main()
+check-only, no gallery still — no geometry + + +### [exit-pre-sidecar](examples/exit-pre-sidecar/) + +`bpy.app.handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR` as Blender dies; the +harness asserts `exit_pre-ok` after the process exits. Skips 4.5 +(`min_version` 5.1). `--silent-handler` / `--no-handler` miss the file; +`--wrong-text` / `--write-in-main` fail the content check. +