refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved
35 payloads move to tooling/scripts/blender/ and stay outside package scope. They run under Blender's bundled Python, which cannot see the repo venv, so they physically cannot import tooling.core — holding them to the D-263 contract would either fail the gate forever or force the contract to be weakened for everyone, and the second is how a gate stops meaning anything. Count verified by import rather than filename: 33 import bpy/bmesh directly, and the two that do not are still payloads per their own usage lines. garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290. The bash wrapper is retired rather than kept. Keeping it would have put the install-resolution logic in two places, which is the duplication T-1286 had just finished collapsing three copies of. domains/blender/service.py owns the decisions — resolve_blender (native beats flatpak, ordering preserved), resolve_payload, absolutise — and only run_payload performs. test_blender.py pins all of them without launching Blender, which matters here more than usual: the thing being launched is a 200 MB GUI application that writes GLBs. `reach blender run` takes a registered payload name OR a path to any script, because the wrapper served both — the spikes and the glb-gen skill hand it one-off scripts of their own. An unknown name enumerates all 35 and exits 2. The exclusion now defends itself. check_carve_out_stays_carved fails if `scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty exclusion proves nothing), or if an __init__.py appears there (which would make the payloads importable — the coupling the carve-out exists to prevent). All three arms mutation-proved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ Convert approved concept images to game-ready .glb models.
|
||||
| Service | Check |
|
||||
|---------|-------|
|
||||
| Trellis | `tooling/db/trellis_connector.py health` |
|
||||
| Blender | `tooling/blender --version` |
|
||||
| Blender | `reach blender which` |
|
||||
|
||||
Trellis runs on tower-of-joy and may be switched off. Check before batching.
|
||||
|
||||
@@ -127,7 +127,7 @@ Normalize scale, center, generate recolor mask, adjust materials:
|
||||
Or directly:
|
||||
|
||||
```bash
|
||||
tooling/blender --background \
|
||||
reach blender run \
|
||||
--python .claude/skills/glb-gen/scripts/postprocess_glb.py \
|
||||
-- input.glb output.glb [--target-width N] [--color-threshold N]
|
||||
```
|
||||
|
||||
@@ -77,8 +77,7 @@ LOG="${OUTPUT_ABS%.glb}_blender.log"
|
||||
# (temporarily disabling -e so a non-zero Blender exit doesn't abort before
|
||||
# we can inspect it), then filter for the console summary.
|
||||
set +e
|
||||
"$PROJECT_ROOT/tooling/blender" --background \
|
||||
--python "$BLENDER_SCRIPT" \
|
||||
reach blender run "$BLENDER_SCRIPT" \
|
||||
-- "$INPUT_ABS" "$OUTPUT_ABS" "$@" > "$LOG" 2>&1
|
||||
BLENDER_EXIT=$?
|
||||
set -e
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
GLB post-processor for The Settled Reach asset pipeline.
|
||||
|
||||
Run via Blender headless:
|
||||
tooling/blender --background --python postprocess_glb.py -- input.glb output.glb [options]
|
||||
reach blender run postprocess_glb.py -- input.glb output.glb [options]
|
||||
|
||||
Operations:
|
||||
1. Normalize scale to fit a target bounding box (default 1x1x1 world units)
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZCVZXH6M00C57EXMZD0SG', '06G65T2N1WAT491WY8SV736J3G', '2026-09-02 16:19:43.328', '2026-09-02 16:19:43.328', NULL, '5ce8ab5d4936aef2df0ca4b5153e2d40', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', '06G65TC359QGCVBEPXHJ4N0MRG', '2026-09-02 16:19:43.349', '2026-09-02 16:19:43.349', NULL, '66d337f0362c0ecf64a4c4065a97d485', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZCVZXH6M00C57EXMZD0SG', '06G65T2N1WAT491WY8SV736J3G', '2026-09-02 16:19:43.328', '2026-09-02 16:20:33.246', '2026-09-02 16:20:33.246', 'd4db63f3638217bc23d27b974407ff30', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65T2N1WAT491WY8SV736J3G', '06G5FZCVZXH6M00C57EXMZD0SG', '2026-09-02 16:20:33.265', '2026-09-02 16:20:33.265', NULL, '8148b701e111a64a6921913e25cab437', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65NSJ4RG6SJ12TQE78S6TKR', '06G5FZCVZXH6M00C57EXMZD0SG', '2026-09-02 16:20:33.281', '2026-09-02 16:20:33.281', NULL, 'e41be4d7e61660697a2959307ebe1832', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65TC359QGCVBEPXHJ4N0MRG', '06G5FZCVZXH6M00C57EXMZD0SG', '2026-09-02 16:20:33.298', '2026-09-02 16:20:33.298', NULL, 'cc5a897d45144c728fd035766d432373', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at;
|
||||
|
||||
@@ -243,3 +243,49 @@ Do T-1273 first or the boundary will blur.
|
||||
|
||||
Standard port acceptance as on T-1281/T-1286. Check `__file__`-relative roots
|
||||
before moving anything — five silent failures so far.', NULL, '2026-09-02 16:19:29', '2026-09-02 16:19:29.675', '2026-09-02 16:19:29.675', NULL, '24b375349147ca97fec304c70086e503', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', 'description', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates.
|
||||
|
||||
CORRECTED 2026-08-31 by T-1271: the carve-out is 35 files, and the count in this ticket (which said 14 at top level, doubting the epic''s 35) was WRONG. 13 blender_*.py are at top level and 22 more are inside tooling/garment-fit/, which is a Blender payload directory wearing a domain''s name — 22 of its 23 files are blender_author_*. The epic''s figure of 35 was right. Practical consequence: garment-fit/ mostly moves to tooling/scripts/blender/ rather than becoming a character domain, so this ticket and the character port must be sequenced together or they will fight over the same directory.', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates.
|
||||
|
||||
CORRECTED 2026-08-31 by T-1271: the carve-out is 35 files, and the count in this ticket (which said 14 at top level, doubting the epic''s 35) was WRONG. 13 blender_*.py are at top level and 22 more are inside tooling/garment-fit/, which is a Blender payload directory wearing a domain''s name — 22 of its 23 files are blender_author_*. The epic''s figure of 35 was right. Practical consequence: garment-fit/ mostly moves to tooling/scripts/blender/ rather than becoming a character domain, so this ticket and the character port must be sequenced together or they will fight over the same directory.
|
||||
|
||||
Done. 35 payloads carved out to `tooling/scripts/blender/`, fronted by
|
||||
`reach blender`, and the exclusion now defends itself.
|
||||
|
||||
COUNT CONFIRMED at 35, matching the epic and the 2026-08-31 correction: 13
|
||||
top-level `blender_*.py` + 22 in `garment-fit/`. Verified by import rather than
|
||||
by filename — 33 of the 35 import `bpy`/`bmesh` directly, and the two that do
|
||||
not (`blender_process_bodies`, `blender_rebuild_forks`) are still payloads, run
|
||||
via `--python` per their own usage lines. `garment-fit/make_logo.py` is the one
|
||||
genuine non-payload (PIL, no bpy) and stays put for the character port (T-1290),
|
||||
which will find `garment-fit/` holding exactly one file.
|
||||
|
||||
THE WRAPPER WAS RETIRED, NOT KEPT. The ticket said keep `tooling/blender` as
|
||||
bash and front it with reach; that would have put the install-resolution logic
|
||||
in two places, which is the duplication T-1286 had just finished collapsing
|
||||
three copies of. Instead `domains/blender/service.py` owns the decisions —
|
||||
`resolve_blender()` (native beats flatpak, ordering preserved from the
|
||||
original), `resolve_payload()`, `absolutise()` — and only `run_payload()`
|
||||
performs. `test_blender.py` pins all of them without launching Blender, which
|
||||
matters more here than for the environment scripts: the thing being launched is
|
||||
a 200 MB GUI application that writes GLBs.
|
||||
|
||||
`run` takes EITHER a registered payload name OR a path to any script, because
|
||||
the wrapper served both: our 35 by name, and one-off scripts by path (the
|
||||
spikes and `.claude/skills/glb-gen/` do exactly that). Unknown names enumerate
|
||||
all 35 and exit 2.
|
||||
|
||||
THE EXCLUSION IS NOW SELF-DEFENDING (`check_carve_out_stays_carved`), because
|
||||
the ticket is right that it reads like an oversight. Three arms, all
|
||||
mutation-proved:
|
||||
- `scripts` added to PACKAGE_ROOTS -> fails, naming the reason
|
||||
- the payload directory emptied -> fails ("an empty exclusion proves nothing")
|
||||
- `tooling/scripts/__init__.py` created -> fails (that would make the payloads
|
||||
importable, which is the coupling the carve-out exists to prevent)
|
||||
|
||||
Callers repointed: 14 spike/skill scripts, `.claude/skills/glb-gen/scripts/postprocess`,
|
||||
and 3 paths in `docs/architecture/character-asset-organization.md`.
|
||||
|
||||
Gate: `ruff check tooling/` clean, `make test-tooling` PASS (13 checks),
|
||||
`test_lazy_domains` now sees 10 domains.', NULL, '2026-09-02 18:55:28', '2026-09-02 18:55:28.421', '2026-09-02 18:55:28.421', NULL, '1352ceb77da9a63b23ee98f26fa76b1e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', 'status', 'backlog', 'done', NULL, '2026-09-02 18:55:28', '2026-09-02 18:55:28.441', '2026-09-02 18:55:28.441', NULL, 'effde7946e61e7d9e8cdbaf14324676d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -356,3 +356,89 @@ Do T-1273 first or the boundary will blur.
|
||||
|
||||
Standard port acceptance as on T-1281/T-1286. Check `__file__`-relative roots
|
||||
before moving anything — five silent failures so far.', 'backlog', 'medium', NULL, 'tooling', 'D-263', '2026-09-02 16:17:45.770', '2026-09-02 16:19:29.675', NULL, '8b7c1faf88c3c056d1d8690ec23e0d4b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The Blender carve-out — payloads that must never import the package', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates.
|
||||
|
||||
CORRECTED 2026-08-31 by T-1271: the carve-out is 35 files, and the count in this ticket (which said 14 at top level, doubting the epic''s 35) was WRONG. 13 blender_*.py are at top level and 22 more are inside tooling/garment-fit/, which is a Blender payload directory wearing a domain''s name — 22 of its 23 files are blender_author_*. The epic''s figure of 35 was right. Practical consequence: garment-fit/ mostly moves to tooling/scripts/blender/ rather than becoming a character domain, so this ticket and the character port must be sequenced together or they will fight over the same directory.
|
||||
|
||||
Done. 35 payloads carved out to `tooling/scripts/blender/`, fronted by
|
||||
`reach blender`, and the exclusion now defends itself.
|
||||
|
||||
COUNT CONFIRMED at 35, matching the epic and the 2026-08-31 correction: 13
|
||||
top-level `blender_*.py` + 22 in `garment-fit/`. Verified by import rather than
|
||||
by filename — 33 of the 35 import `bpy`/`bmesh` directly, and the two that do
|
||||
not (`blender_process_bodies`, `blender_rebuild_forks`) are still payloads, run
|
||||
via `--python` per their own usage lines. `garment-fit/make_logo.py` is the one
|
||||
genuine non-payload (PIL, no bpy) and stays put for the character port (T-1290),
|
||||
which will find `garment-fit/` holding exactly one file.
|
||||
|
||||
THE WRAPPER WAS RETIRED, NOT KEPT. The ticket said keep `tooling/blender` as
|
||||
bash and front it with reach; that would have put the install-resolution logic
|
||||
in two places, which is the duplication T-1286 had just finished collapsing
|
||||
three copies of. Instead `domains/blender/service.py` owns the decisions —
|
||||
`resolve_blender()` (native beats flatpak, ordering preserved from the
|
||||
original), `resolve_payload()`, `absolutise()` — and only `run_payload()`
|
||||
performs. `test_blender.py` pins all of them without launching Blender, which
|
||||
matters more here than for the environment scripts: the thing being launched is
|
||||
a 200 MB GUI application that writes GLBs.
|
||||
|
||||
`run` takes EITHER a registered payload name OR a path to any script, because
|
||||
the wrapper served both: our 35 by name, and one-off scripts by path (the
|
||||
spikes and `.claude/skills/glb-gen/` do exactly that). Unknown names enumerate
|
||||
all 35 and exit 2.
|
||||
|
||||
THE EXCLUSION IS NOW SELF-DEFENDING (`check_carve_out_stays_carved`), because
|
||||
the ticket is right that it reads like an oversight. Three arms, all
|
||||
mutation-proved:
|
||||
- `scripts` added to PACKAGE_ROOTS -> fails, naming the reason
|
||||
- the payload directory emptied -> fails ("an empty exclusion proves nothing")
|
||||
- `tooling/scripts/__init__.py` created -> fails (that would make the payloads
|
||||
importable, which is the coupling the carve-out exists to prevent)
|
||||
|
||||
Callers repointed: 14 spike/skill scripts, `.claude/skills/glb-gen/scripts/postprocess`,
|
||||
and 3 paths in `docs/architecture/character-asset-organization.md`.
|
||||
|
||||
Gate: `ruff check tooling/` clean, `make test-tooling` PASS (13 checks),
|
||||
`test_lazy_domains` now sees 10 domains.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:23:59.951', '2026-09-02 18:55:28.420', NULL, '91aecb3a1f9b14628aead43f7517aea9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The Blender carve-out — payloads that must never import the package', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates.
|
||||
|
||||
CORRECTED 2026-08-31 by T-1271: the carve-out is 35 files, and the count in this ticket (which said 14 at top level, doubting the epic''s 35) was WRONG. 13 blender_*.py are at top level and 22 more are inside tooling/garment-fit/, which is a Blender payload directory wearing a domain''s name — 22 of its 23 files are blender_author_*. The epic''s figure of 35 was right. Practical consequence: garment-fit/ mostly moves to tooling/scripts/blender/ rather than becoming a character domain, so this ticket and the character port must be sequenced together or they will fight over the same directory.
|
||||
|
||||
Done. 35 payloads carved out to `tooling/scripts/blender/`, fronted by
|
||||
`reach blender`, and the exclusion now defends itself.
|
||||
|
||||
COUNT CONFIRMED at 35, matching the epic and the 2026-08-31 correction: 13
|
||||
top-level `blender_*.py` + 22 in `garment-fit/`. Verified by import rather than
|
||||
by filename — 33 of the 35 import `bpy`/`bmesh` directly, and the two that do
|
||||
not (`blender_process_bodies`, `blender_rebuild_forks`) are still payloads, run
|
||||
via `--python` per their own usage lines. `garment-fit/make_logo.py` is the one
|
||||
genuine non-payload (PIL, no bpy) and stays put for the character port (T-1290),
|
||||
which will find `garment-fit/` holding exactly one file.
|
||||
|
||||
THE WRAPPER WAS RETIRED, NOT KEPT. The ticket said keep `tooling/blender` as
|
||||
bash and front it with reach; that would have put the install-resolution logic
|
||||
in two places, which is the duplication T-1286 had just finished collapsing
|
||||
three copies of. Instead `domains/blender/service.py` owns the decisions —
|
||||
`resolve_blender()` (native beats flatpak, ordering preserved from the
|
||||
original), `resolve_payload()`, `absolutise()` — and only `run_payload()`
|
||||
performs. `test_blender.py` pins all of them without launching Blender, which
|
||||
matters more here than for the environment scripts: the thing being launched is
|
||||
a 200 MB GUI application that writes GLBs.
|
||||
|
||||
`run` takes EITHER a registered payload name OR a path to any script, because
|
||||
the wrapper served both: our 35 by name, and one-off scripts by path (the
|
||||
spikes and `.claude/skills/glb-gen/` do exactly that). Unknown names enumerate
|
||||
all 35 and exit 2.
|
||||
|
||||
THE EXCLUSION IS NOW SELF-DEFENDING (`check_carve_out_stays_carved`), because
|
||||
the ticket is right that it reads like an oversight. Three arms, all
|
||||
mutation-proved:
|
||||
- `scripts` added to PACKAGE_ROOTS -> fails, naming the reason
|
||||
- the payload directory emptied -> fails ("an empty exclusion proves nothing")
|
||||
- `tooling/scripts/__init__.py` created -> fails (that would make the payloads
|
||||
importable, which is the coupling the carve-out exists to prevent)
|
||||
|
||||
Callers repointed: 14 spike/skill scripts, `.claude/skills/glb-gen/scripts/postprocess`,
|
||||
and 3 paths in `docs/architecture/character-asset-organization.md`.
|
||||
|
||||
Gate: `ruff check tooling/` clean, `make test-tooling` PASS (13 checks),
|
||||
`test_lazy_domains` now sees 10 domains.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:23:59.951', '2026-09-02 18:55:28.441', NULL, '41fbc2c73d4d33a6f6224cf6e97f7aac', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
|
||||
@@ -310,6 +310,10 @@ test-tooling:
|
||||
@mkdir -p .cache
|
||||
@$(VENV_PY) tooling/test_jobs.py 2> .cache/test-tooling-jobs.log || \
|
||||
{ echo " FAIL: jobs service — log follows:"; cat .cache/test-tooling-jobs.log; exit 1; }
|
||||
@echo " [test-tooling] reach blender carve-out decisions (T-1273)..."
|
||||
@mkdir -p .cache
|
||||
@$(VENV_PY) tooling/test_blender.py 2> .cache/test-tooling-blender.log || \
|
||||
{ echo " FAIL: blender decisions — log follows:"; cat .cache/test-tooling-blender.log; exit 1; }
|
||||
@echo " [test-tooling] reach dev environment decisions (T-1286)..."
|
||||
@mkdir -p .cache
|
||||
@$(VENV_PY) tooling/test_environment.py 2> .cache/test-tooling-environment.log || \
|
||||
|
||||
@@ -606,7 +606,8 @@ clothing/coveralls_basic/
|
||||
child.glb # Output: fitted variant
|
||||
```
|
||||
|
||||
The batch script lives in `tooling/blender/` (not in the client tree). It takes arguments:
|
||||
The batch script lives in `tooling/scripts/blender/` (not in the client tree),
|
||||
run as `reach blender run <name>`. It takes arguments:
|
||||
- Input: path to reference GLB
|
||||
- Input: path to the 11 body type meshes (for Surface Deform targets)
|
||||
- Output: directory to write fitted variants
|
||||
@@ -761,7 +762,7 @@ single-channel convention as originally written.
|
||||
### Authoring: offset-shell from our own body segments
|
||||
|
||||
The preferred authoring route is **offset-shell**
|
||||
(`tooling/garment-fit/blender_author_offset_shell.py`, one per-garment companion script per
|
||||
(`tooling/scripts/blender/blender_author_offset_shell.py`, one per-garment companion script per
|
||||
item, e.g. `blender_author_hoodie.py`, `blender_author_boots.py`): extract the body segment(s)
|
||||
a garment covers, cut neckline/hem/sleeve boundaries via bone-plane thresholds, offset the
|
||||
surface outward along vertex normals, solidify for real cloth thickness. Because the shell
|
||||
@@ -785,7 +786,7 @@ shoe shell -- residual toe hints are accepted for first release (see Catalogue b
|
||||
|
||||
### Fitting: Surface-Deform batch route
|
||||
|
||||
`tooling/garment-fit/blender_batch_fit_skinned.py` remains the route for garments sharing one
|
||||
`tooling/scripts/blender/blender_batch_fit_skinned.py` remains the route for garments sharing one
|
||||
reference mesh + mask across all 11 bodies -- Fantasy-pack-derived cuts and hand-authored
|
||||
pieces on a single UV layout (the suit, the uniform). SD-bind to each target body, bake the
|
||||
deformed rest shape, transfer + normalize weights, retarget the armature, export with
|
||||
|
||||
@@ -17,7 +17,7 @@ Uses robust weight transfer with Laplacian inpainting for unmatched vertices,
|
||||
based on the SIGGRAPH Asia 2023 algorithm (MIT reference implementation).
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \\
|
||||
reach blender run \\
|
||||
spikes/quaternius-aesthetic/scripts/blender/auto_rig_clothing.py \\
|
||||
-- body.gltf clothing.glb output.glb
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Original intent:
|
||||
baking to rest pose, and exporting.
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \\
|
||||
reach blender run \\
|
||||
spikes/quaternius-aesthetic/scripts/blender/create_body_types.py \\
|
||||
-- <base_characters_dir/> <output_dir/>
|
||||
"""
|
||||
|
||||
@@ -8,7 +8,7 @@ needed -- the body type pipeline will use hand-authored meshes instead.
|
||||
Preserved as documentation only.
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \\
|
||||
reach blender run \\
|
||||
spikes/quaternius-aesthetic/scripts/blender/create_placeholder_bodies.py \\
|
||||
-- <base_characters_dir/> <output_dir/>
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,7 @@ to Superhero and Teen bodies. Regular gets a straight copy since
|
||||
the outfits were authored for it.
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \
|
||||
reach blender run \
|
||||
spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source.py \
|
||||
-- <source_bodies_dir> <outfits_dir> <output_dir>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Why Surface Deform + weight retarget rather than shape-key tricks:
|
||||
between muscular vs thin, etc.).
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \\
|
||||
reach blender run \\
|
||||
spikes/quaternius-aesthetic/scripts/blender/fit_outfits_to_bodies.py \\
|
||||
-- <body_types_dir> <outfits_dir> <output_dir>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Segments:
|
||||
foot_r — foot_r, ball_r, ball_leaf_r
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \
|
||||
reach blender run \
|
||||
spikes/quaternius-aesthetic/scripts/blender/segment_body.py \
|
||||
-- <input.gltf> <output_dir/>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ segmentation (including the head as one of those regions).
|
||||
Use segment_body.py for all new work. This file is preserved as documentation.
|
||||
|
||||
Run via:
|
||||
tooling/blender --background --python \\
|
||||
reach blender run \\
|
||||
spikes/quaternius-aesthetic/scripts/blender/separate_head.py \\
|
||||
-- <input.gltf> <body_output.glb> <head_output.glb>
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ armature.glb + average_m body segments, then prints:
|
||||
- object transforms as-imported (scale factors etc.)
|
||||
|
||||
Run:
|
||||
tooling/blender --background --python \
|
||||
reach blender run \
|
||||
spikes/synty-intake/scripts/01_recon_rest_pose.py
|
||||
"""
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Alignment transform (documented, measured from bone landmarks):
|
||||
then translate so the pelvis landmarks coincide.
|
||||
|
||||
Run:
|
||||
tooling/blender --background --python \
|
||||
reach blender run \
|
||||
spikes/synty-intake/scripts/02_transplant_garment.py
|
||||
"""
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Questions this answers (feeds 04_batch_fit_bodies.py):
|
||||
bake), or is it the shared skeleton everywhere?
|
||||
|
||||
Run:
|
||||
tooling/blender --background --python \
|
||||
reach blender run \
|
||||
spikes/synty-intake/scripts/03_recon_bodies.py
|
||||
"""
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ Per target body:
|
||||
(penetration detector).
|
||||
|
||||
Run:
|
||||
tooling/blender --background --python \
|
||||
reach blender run \
|
||||
spikes/synty-intake/scripts/04_batch_fit_bodies.py
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Blender recon: dump skeleton/mesh facts for a Sidekick FBX and our armature.
|
||||
|
||||
Usage: tooling/blender --background --python recon_dump.py -- <fbx> <our_armature_glb>
|
||||
Usage: reach blender run recon_dump.py -- <fbx> <our_armature_glb>
|
||||
"""
|
||||
import sys
|
||||
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ invented.
|
||||
| `generate` | content generators not owned elsewhere | ✅ ported (T-1286). `generate-brands` + `generate-corporations` collapsed into `core.process.cargo_binary` — they were the same 24 lines of bash a third time |
|
||||
| `dev` | developer environment and workflow | ✅ ported (T-1286). The three environment scripts split decision from performing — `godot_plan`/`worktree_plan` are pure and pinned by `test_environment.py` |
|
||||
| `pr` | the PR/review loop | ✅ ported (T-1286). `watchlist-diff` now reads the watched set from `generator_sources.py` instead of restating it |
|
||||
| `blender` | **carve-out** — payloads run by Blender | **35** `blender_*.py` (13 top-level + 22 in `garment-fit/`), `blender` wrapper → `tooling/scripts/blender/` |
|
||||
| `blender` | **carve-out** — payloads run by Blender | ✅ done (T-1273). 35 payloads in `tooling/scripts/blender/`, excluded from package scope and from importability; the bash wrapper's install-resolution and path-absolutising became `domains/blender/service.py`, pinned by `test_blender.py` |
|
||||
|
||||
## Judgment calls, with reasons
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Blender wrapper — resolves flatpak/native/brew installs to a single entry point.
|
||||
# All project scripts should call this instead of 'blender' directly.
|
||||
#
|
||||
# Resolves relative paths in --python and -- args to absolute paths so flatpak
|
||||
# sandboxing doesn't break file resolution.
|
||||
#
|
||||
# Usage:
|
||||
# tooling/blender --background --python script.py -- input.glb output.glb
|
||||
|
||||
# Convert relative paths to absolute
|
||||
args=()
|
||||
for arg in "$@"; do
|
||||
if [[ -e "$arg" ]]; then
|
||||
args+=("$(realpath "$arg")")
|
||||
else
|
||||
args+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v blender &>/dev/null; then
|
||||
exec blender "${args[@]}"
|
||||
elif flatpak info org.blender.Blender &>/dev/null 2>&1; then
|
||||
exec flatpak run org.blender.Blender "${args[@]}"
|
||||
else
|
||||
echo "ERROR: Blender not found (checked PATH and flatpak)" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,16 @@
|
||||
"""The Blender carve-out — the one sanctioned exception to the reach rule.
|
||||
|
||||
D-263 says tooling lives in the package and every verb carries `@command`.
|
||||
`tooling/scripts/blender/` does not, and cannot: those 35 files run under
|
||||
Blender's BUNDLED Python interpreter, which has no access to the repo venv and
|
||||
therefore cannot `import tooling.core` at all. They are payloads handed to
|
||||
another program, not modules this package imports.
|
||||
|
||||
So the split is: the payloads stay dumb and stay out of package scope, and
|
||||
everything that can be decided in our own interpreter — which Blender to use,
|
||||
which payload a name refers to, which paths need absolutising — lives here and
|
||||
is testable. The user-facing form is still a normal verb.
|
||||
|
||||
The conformance test excludes `tooling/scripts/` for this reason, and says so
|
||||
inline. It is an exception with a stated cause, not an oversight.
|
||||
"""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Transport for the `blender` domain — args in, delegate, format out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from tooling.core import cli, console
|
||||
from tooling.core.command import command
|
||||
from tooling.domains.blender import service
|
||||
|
||||
app = cli.domain("blender", "Run the Blender payloads — the one carve-out.")
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _domain() -> None:
|
||||
"""Keeps `blender` a group (Typer collapses a single-command app)."""
|
||||
|
||||
|
||||
@app.command("list")
|
||||
@command
|
||||
def list_payloads() -> None:
|
||||
"""List every payload `run` accepts.
|
||||
|
||||
Worth a verb of its own: the payload names are not guessable and this is
|
||||
the only place the vocabulary is written down.
|
||||
"""
|
||||
names = service.payloads()
|
||||
for name in names:
|
||||
console.out(name)
|
||||
console.verdict(f"{len(names)} payload(s) in {service.payload_dir().name}/")
|
||||
|
||||
|
||||
@app.command("which")
|
||||
@command
|
||||
def which() -> None:
|
||||
"""Report which Blender would be used, without launching it."""
|
||||
invocation = service.resolve_blender()
|
||||
console.out(f"kind {invocation.kind}")
|
||||
console.out(f"command {' '.join(invocation.argv_prefix)}")
|
||||
console.verdict(
|
||||
"flatpak — paths are absolutised before they cross the sandbox"
|
||||
if invocation.sandboxed
|
||||
else "native install — preferred when both are present"
|
||||
)
|
||||
|
||||
|
||||
@app.command("run", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
||||
@command
|
||||
def run(
|
||||
ctx: typer.Context,
|
||||
payload: str = typer.Argument(
|
||||
..., help="Payload name (see `reach blender list`), or a path to any script."
|
||||
),
|
||||
background: bool = typer.Option(
|
||||
True, "--background/--no-background", help="Headless, or open the Blender GUI."
|
||||
),
|
||||
) -> None:
|
||||
"""Run a payload under Blender, passing any remaining arguments through.
|
||||
|
||||
Extra arguments go to the payload after `--`, and any that name an existing
|
||||
path are made absolute first — flatpak's sandbox resolves relative paths
|
||||
against a different root, and the resulting file-not-found comes from
|
||||
inside Blender, a long way from its cause.
|
||||
"""
|
||||
service.run_payload(payload, list(ctx.args), background=background)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Logic for the `blender` domain. Transport-agnostic (D-263).
|
||||
|
||||
Everything the bash wrapper decided is decided here, and every decision is a
|
||||
pure function: which Blender install to use, which payload a name refers to,
|
||||
which arguments are paths that need absolutising. Only `run_payload` performs.
|
||||
|
||||
That matters more than usual for this domain, because the thing being launched
|
||||
is a 200 MB GUI application that opens files and writes GLBs. "Run it and see"
|
||||
is not a test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from tooling.core import config, console, process
|
||||
from tooling.core.errors import ReachError, unknown_choice
|
||||
|
||||
PAYLOAD_DIR = ("tooling", "scripts", "blender")
|
||||
|
||||
# Flatpak is checked second: a native install is faster and has no sandbox, so
|
||||
# it wins when both are present. This ordering is the bash wrapper's and is
|
||||
# preserved deliberately.
|
||||
FLATPAK_APP = "org.blender.Blender"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BlenderInvocation:
|
||||
"""How Blender would be launched — decided without launching it."""
|
||||
|
||||
argv_prefix: list[str]
|
||||
kind: str # "native" | "flatpak"
|
||||
|
||||
@property
|
||||
def sandboxed(self) -> bool:
|
||||
"""Flatpak cannot see paths outside its sandbox unless they are absolute."""
|
||||
return self.kind == "flatpak"
|
||||
|
||||
|
||||
def resolve_blender() -> BlenderInvocation:
|
||||
"""Find Blender, preferring a native install over flatpak.
|
||||
|
||||
Raises rather than falling through to a bare `blender` that is not there —
|
||||
the original printed its own error and exited 1, and losing that would turn
|
||||
a missing dependency into a confusing FileNotFoundError.
|
||||
"""
|
||||
native = shutil.which("blender")
|
||||
if native:
|
||||
return BlenderInvocation([native], "native")
|
||||
|
||||
if shutil.which("flatpak"):
|
||||
probe = process.run(["flatpak", "info", FLATPAK_APP], check=False)
|
||||
if probe.returncode == 0:
|
||||
return BlenderInvocation(["flatpak", "run", FLATPAK_APP], "flatpak")
|
||||
|
||||
raise ReachError(
|
||||
"Blender is not installed (checked PATH and flatpak)",
|
||||
fix="install Blender natively, or run: flatpak install "
|
||||
f"{FLATPAK_APP} — reach prefers the native install when both exist",
|
||||
)
|
||||
|
||||
|
||||
def payload_dir() -> Path:
|
||||
return config.path(*PAYLOAD_DIR)
|
||||
|
||||
|
||||
def payloads() -> list[str]:
|
||||
"""Every runnable payload, by the name `run` accepts."""
|
||||
return sorted(p.stem for p in payload_dir().glob("*.py") if p.name != "__init__.py")
|
||||
|
||||
|
||||
def resolve_payload(name: str) -> Path:
|
||||
"""Map a payload name — or a path to any script — to a file to run.
|
||||
|
||||
Two forms, because the bash wrapper this replaces served both: our own 35
|
||||
registered payloads BY NAME, and any other script BY PATH (the spikes and
|
||||
the glb-gen skill hand it one-off scripts of their own). A registered name
|
||||
wins; anything that resolves to an existing file is accepted as a path.
|
||||
|
||||
On a miss it enumerates, because a payload name is not guessable — the 35
|
||||
names are the only place that vocabulary is written down.
|
||||
"""
|
||||
stem = name[:-3] if name.endswith(".py") else name
|
||||
registered = payload_dir() / f"{stem}.py"
|
||||
if registered.is_file():
|
||||
return registered
|
||||
|
||||
given = Path(name)
|
||||
if given.is_file():
|
||||
return given.resolve()
|
||||
|
||||
raise unknown_choice("payload", stem, payloads())
|
||||
|
||||
|
||||
def absolutise(args: list[str]) -> list[str]:
|
||||
"""Resolve any argument that names an existing path.
|
||||
|
||||
The wrapper did this for every argument, not just `--python`, because
|
||||
flatpak's sandbox resolves relative paths against a different root and the
|
||||
failure is a file-not-found from inside Blender — far from the cause. An
|
||||
argument that is not an existing path is passed through untouched, so flags
|
||||
and values survive.
|
||||
"""
|
||||
return [str(Path(a).resolve()) if Path(a).exists() else a for a in args]
|
||||
|
||||
|
||||
def run_payload(name: str, args: list[str], background: bool = True) -> None:
|
||||
"""Launch a payload under Blender. The performing half."""
|
||||
payload = resolve_payload(name)
|
||||
invocation = resolve_blender()
|
||||
|
||||
argv = [*invocation.argv_prefix]
|
||||
if background:
|
||||
argv.append("--background")
|
||||
argv += ["--python", str(payload)]
|
||||
if args:
|
||||
argv += ["--", *absolutise(args)]
|
||||
|
||||
console.event(
|
||||
f"running {name} under {invocation.kind} Blender",
|
||||
phase="blender",
|
||||
)
|
||||
# capture=False: Blender streams its own progress, and a payload that
|
||||
# takes minutes with no output reads as a hang.
|
||||
process.run(
|
||||
argv,
|
||||
cwd=config.repo_root(),
|
||||
capture=False,
|
||||
fix=f"run `reach blender run {name}` with --no-background to watch it in the GUI",
|
||||
)
|
||||
console.verdict(f"blender: {name} finished")
|
||||
@@ -71,6 +71,10 @@ DOMAINS: dict[str, tuple[str, str]] = {
|
||||
"tooling.domains.generate.router:app",
|
||||
"Producers — brands, corporations, manifests and stubs",
|
||||
),
|
||||
"blender": (
|
||||
"tooling.domains.blender.router:app",
|
||||
"Run the Blender payloads — bodies, garments, inspection",
|
||||
),
|
||||
"pr": (
|
||||
"tooling.domains.pr.router:app",
|
||||
"The review loop — comments, watchlist and the board",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The `reach blender` decisions, exercised without launching Blender (T-1273).
|
||||
|
||||
Same principle as test_environment.py, and for a stronger reason: the thing
|
||||
being launched is a 200 MB GUI application that opens files and writes GLBs, so
|
||||
"run it and see" is not a test. Every decision the retired bash wrapper made —
|
||||
which install to use, which script a name refers to, which arguments need
|
||||
absolutising — is a pure function here, and this is what pins them.
|
||||
|
||||
The absolutise rule is the one worth having a test for. Flatpak resolves
|
||||
relative paths against a different root, so a relative path silently becomes a
|
||||
file-not-found raised from inside Blender, a long way from its cause.
|
||||
|
||||
Run: python3 tooling/test_blender.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from tooling.core.errors import ReachError # noqa: E402
|
||||
from tooling.domains.blender import service # noqa: E402
|
||||
|
||||
|
||||
def test_payloads_are_discoverable(failures: list[str]) -> None:
|
||||
"""The carve-out is the only place these names are written down."""
|
||||
names = service.payloads()
|
||||
if len(names) < 30:
|
||||
failures.append(f"expected the 35-file carve-out, found {len(names)} payloads")
|
||||
if any(n.endswith(".py") for n in names):
|
||||
failures.append("payload names should be stems, not filenames")
|
||||
|
||||
|
||||
def test_registered_name_resolves(failures: list[str]) -> None:
|
||||
"""Both the stem and the filename form reach the same file."""
|
||||
first = service.payloads()[0]
|
||||
by_stem = service.resolve_payload(first)
|
||||
by_filename = service.resolve_payload(f"{first}.py")
|
||||
if by_stem != by_filename:
|
||||
failures.append(f"'{first}' and '{first}.py' resolved differently")
|
||||
if by_stem.parent != service.payload_dir():
|
||||
failures.append(f"a registered name resolved outside the payload dir: {by_stem}")
|
||||
|
||||
|
||||
def test_arbitrary_path_resolves(failures: list[str]) -> None:
|
||||
"""The wrapper ran one-off scripts too — the spikes and glb-gen still do."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as handle:
|
||||
handle.write(b"# a one-off script\n")
|
||||
loose = Path(handle.name)
|
||||
try:
|
||||
resolved = service.resolve_payload(str(loose))
|
||||
if resolved != loose.resolve():
|
||||
failures.append(f"a script path resolved to {resolved}, expected {loose}")
|
||||
if not resolved.is_absolute():
|
||||
failures.append("a script path resolved to a relative path")
|
||||
finally:
|
||||
loose.unlink()
|
||||
|
||||
|
||||
def test_unknown_name_enumerates(failures: list[str]) -> None:
|
||||
"""A payload name is not guessable, so a miss must list the options."""
|
||||
try:
|
||||
service.resolve_payload("definitely_not_a_payload")
|
||||
failures.append("an unknown payload resolved instead of raising")
|
||||
except ReachError as exc:
|
||||
if not exc.fix or "blender_" not in exc.fix:
|
||||
failures.append("the unknown-payload error does not enumerate the payloads")
|
||||
if exc.exit_code != 2:
|
||||
failures.append(
|
||||
f"an unknown payload exited {exc.exit_code}, expected 2 (usage error)"
|
||||
)
|
||||
|
||||
|
||||
def test_absolutise_only_touches_real_paths(failures: list[str]) -> None:
|
||||
"""Flags and values must survive; existing paths must be made absolute."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
real = Path(tmp) / "input.glb"
|
||||
real.write_text("")
|
||||
args = ["--mode", "fit", str(real), "not/a/real/path.glb", "12"]
|
||||
out = service.absolutise(args)
|
||||
|
||||
if out[0] != "--mode" or out[1] != "fit" or out[4] != "12":
|
||||
failures.append(f"absolutise mangled non-path arguments: {out}")
|
||||
if out[2] != str(real.resolve()):
|
||||
failures.append(f"an existing path was not absolutised: {out[2]}")
|
||||
if out[3] != "not/a/real/path.glb":
|
||||
failures.append(f"a non-existent path was rewritten: {out[3]}")
|
||||
|
||||
|
||||
def test_invocation_reports_its_sandbox(failures: list[str]) -> None:
|
||||
"""`sandboxed` is what decides whether absolutising is load-bearing."""
|
||||
native = service.BlenderInvocation(["blender"], "native")
|
||||
flatpak = service.BlenderInvocation(["flatpak", "run", service.FLATPAK_APP], "flatpak")
|
||||
if native.sandboxed:
|
||||
failures.append("a native install was reported as sandboxed")
|
||||
if not flatpak.sandboxed:
|
||||
failures.append("a flatpak install was not reported as sandboxed")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures: list[str] = []
|
||||
test_payloads_are_discoverable(failures)
|
||||
test_registered_name_resolves(failures)
|
||||
test_arbitrary_path_resolves(failures)
|
||||
test_unknown_name_enumerates(failures)
|
||||
test_absolutise_only_touches_real_paths(failures)
|
||||
test_invocation_reports_its_sandbox(failures)
|
||||
|
||||
if failures:
|
||||
print("test_blender: FAIL", file=sys.stderr)
|
||||
for failure in failures:
|
||||
print(f" - {failure}", file=sys.stderr)
|
||||
return 1
|
||||
print(
|
||||
"test_blender: OK — payload discovery, both resolve forms, enumeration "
|
||||
"on a miss and the flatpak path rule, without launching Blender"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -58,6 +58,18 @@ FORBIDDEN_IMPORTS = {"typer", "click"}
|
||||
# needing to be widened by hand.
|
||||
PACKAGE_ROOTS = ("main.py", "__init__.py", "core", "domains")
|
||||
|
||||
# tooling/scripts/blender/ is DELIBERATELY not in that tuple, and must never be
|
||||
# added (T-1273). Those 35 files run under Blender's BUNDLED Python, which has
|
||||
# no access to the repo venv — they physically cannot `import tooling.core`, so
|
||||
# they cannot carry `@command` or print through `console`. Holding them to the
|
||||
# contract would either fail this gate forever or force the contract to be
|
||||
# weakened for everyone, and the second is how a gate stops meaning anything.
|
||||
#
|
||||
# It reads like an oversight, so `check_carve_out_stays_carved` below asserts
|
||||
# the exclusion on purpose: widening PACKAGE_ROOTS to cover them fails loudly
|
||||
# instead of quietly redefining what conformance means.
|
||||
PAYLOAD_ROOTS = ("scripts",)
|
||||
|
||||
|
||||
def _package_files() -> list[Path]:
|
||||
"""Every .py that is part of the reach package — not the legacy scripts."""
|
||||
@@ -303,6 +315,42 @@ def check_exit_codes(failures: list[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def check_carve_out_stays_carved(failures: list[str]) -> None:
|
||||
"""(7) The Blender payloads stay outside package scope, and stay populated.
|
||||
|
||||
Two failure modes, opposite directions:
|
||||
|
||||
- Someone widens PACKAGE_ROOTS to cover `scripts/` because the exclusion
|
||||
looks like an oversight. Every payload then fails five invariants it
|
||||
cannot satisfy, and the likely repair is to weaken the invariants.
|
||||
- The payload directory quietly empties — a move, a bad merge — and the
|
||||
exclusion goes on passing because excluding nothing is trivially fine.
|
||||
An exception that guards nothing should not read as healthy.
|
||||
"""
|
||||
for root in PAYLOAD_ROOTS:
|
||||
if root in PACKAGE_ROOTS:
|
||||
failures.append(
|
||||
f"[carve-out] '{root}' was added to PACKAGE_ROOTS — the Blender "
|
||||
"payloads run under Blender's bundled Python and cannot import "
|
||||
"tooling.core; see the comment above PAYLOAD_ROOTS (T-1273)"
|
||||
)
|
||||
|
||||
payloads = list((PACKAGE / "scripts" / "blender").glob("*.py"))
|
||||
if not payloads:
|
||||
failures.append(
|
||||
"[carve-out] tooling/scripts/blender/ holds no payloads — either the "
|
||||
"carve-out was undone or they moved; an empty exclusion proves nothing"
|
||||
)
|
||||
|
||||
# The payloads must not be reachable as modules either: an __init__.py would
|
||||
# make them importable and invite exactly the coupling the carve-out prevents.
|
||||
if (PACKAGE / "scripts" / "__init__.py").exists():
|
||||
failures.append(
|
||||
"[carve-out] tooling/scripts/__init__.py exists — that makes the "
|
||||
"payload tree an importable package, which is what the carve-out avoids"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if shutil.which("reach") is None:
|
||||
print("test_conformance: `reach` is not on PATH.\n Fix: make install-reach", file=sys.stderr)
|
||||
@@ -316,6 +364,7 @@ def main() -> int:
|
||||
check_commands_decorated(failures)
|
||||
check_errors_name_a_remedy(failures)
|
||||
check_exit_codes(failures)
|
||||
check_carve_out_stays_carved(failures)
|
||||
|
||||
if failures:
|
||||
print("test_conformance: FAIL", file=sys.stderr)
|
||||
|
||||
Reference in New Issue
Block a user