Skip to content

Commit b612f3a

Browse files
TMHSDigitalclaude
andauthored
fix(skills): document that registered props are not ID properties on 5.0+ (#420)
On 4.x a registered bpy.props value was stored as an ID property, so scene["my_prop"], "my_prop" in scene.keys(), del scene["my_prop"] and a '["my_prop"]' driver path all reached it. On 5.0+ it lives in a separate system-property group and every one of those raises KeyError/ValueError or silently misses it (measured on 4.5.11, 5.1.2, 5.2.1). Legacy add-on code and AI-written code break here without a clear error, and the custom-properties skill called its operations "version-stable". custom-properties now has the measured table and the branch-free recipe (attribute access, property_unset, is_property_set, plain data path), plus a common-mistakes entry; drivers-and-app-handlers distinguishes '["custom"]' from a registered prop's plain path; the delete snippet warns that del id["registered"] raises on 5.0+. examples/cross-version-property-delete proves it in CI: a new registered-prop check (exit 8) runs on every version, and a new --subscript-registered falsifier (min_version 5.0) reads the prop the 4.x way and must exit 8. Closes #354 Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7d79572 commit b612f3a

7 files changed

Lines changed: 116 additions & 7 deletions

File tree

‎docs/gallery/cross-version-property-delete/index.html‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,11 @@ <h1>Cross Version Property Delete</h1>
6464
<p>A pair of stage lamps that witnesses the custom <strong>ID-property</strong> delete contract from <a href="https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/snippets/cross-version-property-delete.py"><code>cross-version-property-delete.py</code></a> and <a href="https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/skills/custom-properties/SKILL.md"><code>custom-properties</code></a> — not the snippet&#x27;s <code>__main__</code>, which keys off <code>context.active_object</code> and prints nothing in <code>--background</code>.</p>
6565
<p>The IDs are two stage-lamp housings built with <code>bpy.data.objects.new</code>. The Keep lamp still carries <code>[&quot;accession&quot;] = 42</code>; the Clear lamp had <code>del obj[&quot;accession&quot;]</code>. Same <code>del</code> on 4.5 LTS and 5.x; there is no version branch.</p>
6666
<p><strong>What it witnesses:</strong> <code>property_unset</code> is a TypeError on a custom ID key and does <strong>not</strong> remove it. <code>del id_block[key]</code> does. After factory-empty, there is no <code>active_object</code>.</p>
67+
<p>It also witnesses the other half of the split: a <strong>registered</strong> <code>bpy.props</code> value (<code>Scene.bdt_counter = IntProperty()</code>) is not an ID property on 5.0+. Measured on 4.5.11, 5.1.2 and 5.2.1:</p>
68+
<div class="table-wrap"><table><thead><tr><th scope="col">On a registered prop</th><th scope="col">4.5 LTS</th><th scope="col">5.0+</th></tr></thead><tbody><tr><td><code>scene.bdt_counter</code></td><td>5</td><td>5</td></tr><tr><td><code>scene[&quot;bdt_counter&quot;]</code></td><td>5</td><td><code>KeyError</code></td></tr><tr><td><code>&quot;bdt_counter&quot; in scene.keys()</code></td><td>True</td><td>False</td></tr><tr><td><code>del scene[&quot;bdt_counter&quot;]</code></td><td>resets it</td><td><code>KeyError</code></td></tr><tr><td><code>path_resolve(&#x27;[&quot;bdt_counter&quot;]&#x27;)</code> (driver path)</td><td>5</td><td><code>ValueError</code></td></tr><tr><td><code>path_resolve(&quot;bdt_counter&quot;)</code>, <code>property_unset</code>, <code>is_property_set</code></td><td>work</td><td>work</td></tr></tbody></table></div>
69+
<p>On 5.0+ the raw storage is <code>scene.bl_system_properties_get()</code>. A value saved by 4.5 still loads as the attribute on 5.2.</p>
6770
<p><strong>What failure each check would catch:</strong></p>
68-
<ul><li>exit 2 — someone set an active object; the snippet <code>__main__</code> would have been the only path</li><li>exit 3 — the ID property never landed</li><li>exit 4 — <code>property_unset</code> removed the key or failed to raise</li><li>exit 7 — <code>del</code> did not run (<code>--skip-delete</code> / <code>--unset-instead</code> falsify this: measured <code>clear_has=True</code>)</li></ul>
71+
<ul><li>exit 2 — someone set an active object; the snippet <code>__main__</code> would have been the only path</li><li>exit 3 — the ID property never landed</li><li>exit 4 — <code>property_unset</code> removed the key or failed to raise</li><li>exit 7 — <code>del</code> did not run (<code>--skip-delete</code> / <code>--unset-instead</code> falsify this: measured <code>clear_has=True</code>)</li><li>exit 8 — a registered prop was read or reset the 4.x way, or sits on the wrong side of the ID-property split for this version (<code>--subscript-registered</code> falsifies this on 5.0+)</li></ul>
6972
<h2 id="staging">Staging<a class="anchor" href="#staging" aria-label="Link to this section">#</a></h2>
7073
<p>Both lamps hang in U-yokes from one T-stand. The render path reads each housing&#x27;s <code>keys()</code> after the check has run, so the still is driven by the checked IDs, not staged from the expected outcome. A lamp that still carries the property gets an emissive lens and a real SPOT light along its beam axis, which pools warm light on the placard below it. A lamp without the property keeps a dark lens and casts nothing. Each placard is set in the bundled DejaVu Sans Mono and shows the line of code that produced its lamp&#x27;s state. Only render-side geometry (stand, yokes, lenses, placards) is added in the render path; the check reads ID properties, never geometry.</p>
7174
<h2 id="run">Run<a class="anchor" href="#run" aria-label="Link to this section">#</a></h2>
@@ -75,8 +78,8 @@ <h2 id="run">Run<a class="anchor" href="#run" aria-label="Link to this section">
7578
<p>The <code>--output</code> render path measures framing against the Layer 1 band via <code>examples/gallery_framing.py</code> (exit 10 on violation) before writing the still.</p>
7679
<h2 id="exit-codes">Exit codes<a class="anchor" href="#exit-codes" aria-label="Link to this section">#</a></h2>
7780
<p>Per-script sequential checks. <code>9</code> is a valid check code; there is no rule against it. <code>10</code> is the shared framing helper.</p>
78-
<div class="table-wrap"><table><thead><tr><th scope="col">Code</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td>0</td><td>Success</td></tr><tr><td>1</td><td>Uncaught exception (FATAL wrapper)</td></tr><tr><td>2</td><td>argparse / usage; also an active object existed after the data-API build</td></tr><tr><td>3</td><td>Custom ID property did not land</td></tr><tr><td>4</td><td><code>property_unset</code> on a custom ID key did not TypeError, or it deleted the key</td></tr><tr><td>5</td><td><code>del</code> did not report removal</td></tr><tr><td>6</td><td>Keep lamp lost the ID property</td></tr><tr><td>7</td><td>Clear lamp still has the ID property (<code>--skip-delete</code> / <code>--unset-instead</code> land here)</td></tr><tr><td>10</td><td>Gallery framing violation</td></tr><tr><td>12</td><td><code>--output</code> produced no file</td></tr></tbody></table></div>
79-
<p>The <code>blender-smoke</code> workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the <code>needs-5.1</code> PR label, or manual dispatch). Smoke does not pass <code>--output</code> or <code>--unset-instead</code>. Its catalog falsifier is <code>--skip-delete</code> (expects exit 7).</p>
81+
<div class="table-wrap"><table><thead><tr><th scope="col">Code</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td>0</td><td>Success</td></tr><tr><td>1</td><td>Uncaught exception (FATAL wrapper)</td></tr><tr><td>2</td><td>argparse / usage; also an active object existed after the data-API build</td></tr><tr><td>3</td><td>Custom ID property did not land</td></tr><tr><td>4</td><td><code>property_unset</code> on a custom ID key did not TypeError, or it deleted the key</td></tr><tr><td>5</td><td><code>del</code> did not report removal</td></tr><tr><td>6</td><td>Keep lamp lost the ID property</td></tr><tr><td>7</td><td>Clear lamp still has the ID property (<code>--skip-delete</code> / <code>--unset-instead</code> land here)</td></tr><tr><td>8</td><td>Registered-prop contract broke: attribute/<code>property_unset</code> failed, the prop sits on the wrong side of the 5.0 ID-property split, or <code>scene[&quot;name&quot;]</code> raised <code>KeyError</code> (<code>--subscript-registered</code> on 5.0+; exits 0 on 4.5, where subscript access still works)</td></tr><tr><td>10</td><td>Gallery framing violation</td></tr><tr><td>12</td><td><code>--output</code> produced no file</td></tr></tbody></table></div>
82+
<p>The <code>blender-smoke</code> workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the <code>needs-5.1</code> PR label, or manual dispatch). Smoke does not pass <code>--output</code> or <code>--unset-instead</code>. Its catalog falsifiers are <code>--skip-delete</code> (expects exit 7); <code>--subscript-registered</code> (expects exit 8; 5.0+ only; SKIP on older Blender).</p>
8083
</section>
8184
<section class="detail-section src" id="source">
8285
<h2>Source</h2>

‎examples/cross-version-property-delete/README.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ branch.
1616
does **not** remove it. `del id_block[key]` does. After factory-empty, there is
1717
no `active_object`.
1818

19+
It also witnesses the other half of the split: a **registered** `bpy.props`
20+
value (`Scene.bdt_counter = IntProperty()`) is not an ID property on 5.0+.
21+
Measured on 4.5.11, 5.1.2 and 5.2.1:
22+
23+
| On a registered prop | 4.5 LTS | 5.0+ |
24+
| --- | --- | --- |
25+
| `scene.bdt_counter` | 5 | 5 |
26+
| `scene["bdt_counter"]` | 5 | `KeyError` |
27+
| `"bdt_counter" in scene.keys()` | True | False |
28+
| `del scene["bdt_counter"]` | resets it | `KeyError` |
29+
| `path_resolve('["bdt_counter"]')` (driver path) | 5 | `ValueError` |
30+
| `path_resolve("bdt_counter")`, `property_unset`, `is_property_set` | work | work |
31+
32+
On 5.0+ the raw storage is `scene.bl_system_properties_get()`. A value saved
33+
by 4.5 still loads as the attribute on 5.2.
34+
1935
**What failure each check would catch:**
2036

2137
- exit 2 — someone set an active object; the snippet `__main__` would have
@@ -24,6 +40,9 @@ no `active_object`.
2440
- exit 4 — `property_unset` removed the key or failed to raise
2541
- exit 7 — `del` did not run (`--skip-delete` / `--unset-instead` falsify
2642
this: measured `clear_has=True`)
43+
- exit 8 — a registered prop was read or reset the 4.x way, or sits on the
44+
wrong side of the ID-property split for this version
45+
(`--subscript-registered` falsifies this on 5.0+)
2746

2847
## Staging
2948

@@ -65,9 +84,10 @@ against it. `10` is the shared framing helper.
6584
| 5 | `del` did not report removal |
6685
| 6 | Keep lamp lost the ID property |
6786
| 7 | Clear lamp still has the ID property (`--skip-delete` / `--unset-instead` land here) |
87+
| 8 | Registered-prop contract broke: attribute/`property_unset` failed, the prop sits on the wrong side of the 5.0 ID-property split, or `scene["name"]` raised `KeyError` (`--subscript-registered` on 5.0+; exits 0 on 4.5, where subscript access still works) |
6888
| 10 | Gallery framing violation |
6989
| 12 | `--output` produced no file |
7090

7191
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
7292
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
73-
Smoke does not pass `--output` or `--unset-instead`. Its catalog falsifier is `--skip-delete` (expects exit 7).
93+
Smoke does not pass `--output` or `--unset-instead`. Its catalog falsifiers are `--skip-delete` (expects exit 7); `--subscript-registered` (expects exit 8; 5.0+ only; SKIP on older Blender).

‎examples/cross-version-property-delete/cross_version_property_delete.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
the ID property — that RNA call resets a registered property to default.
1010
3. ``del obj["accession"]`` removes it. Same on 4.5 LTS and 5.x; no version
1111
branch.
12+
4. A *registered* ``bpy.props`` value is a different thing on 5.0+: it is no
13+
longer an ID property, so ``scene["name"]``, ``"name" in scene.keys()``,
14+
``del scene["name"]`` and a driver path of ``'["name"]'`` all stop seeing
15+
it. Attribute access, ``property_unset()``, ``is_property_set()`` and the
16+
plain ``"name"`` path work on every version. ``--subscript-registered``
17+
reads it the 4.x way and exits 8 on 5.0+ (exit 0 on 4.5, where it still
18+
works).
1219
1320
By default it runs only the correctness check (no render) — the CI smoke
1421
check. Pass --output to also render a still:
@@ -25,6 +32,7 @@
2532

2633
KEY = "accession"
2734
VALUE = 42
35+
REG = "bdt_counter" # registered bpy.props IntProperty on Scene
2836

2937

3038
def beveled_box(name, size, bevel, segments=2):
@@ -413,6 +421,52 @@ def light(name, loc, energy, size, col, rot):
413421
return 0
414422

415423

424+
def check_registered(subscript=False):
425+
"""Registered bpy.props vs ID properties: the 5.0 split (#354)."""
426+
split = bpy.app.version >= (5, 0, 0)
427+
bpy.types.Scene.bdt_counter = bpy.props.IntProperty(default=1)
428+
try:
429+
scene = bpy.context.scene
430+
scene.bdt_counter = 5
431+
if subscript:
432+
# The 4.x idiom: worked when registered values were ID properties.
433+
value = scene[REG]
434+
else:
435+
value = scene.bdt_counter
436+
in_keys = REG in scene.keys()
437+
sub_path = True
438+
try:
439+
scene.path_resolve(f'["{REG}"]')
440+
except ValueError:
441+
sub_path = False
442+
print(f"registered value={value} in_keys={in_keys} "
443+
f"subscript_path={sub_path} split={split}")
444+
if value != 5 or not scene.is_property_set(REG):
445+
print("ERROR: registered property did not read back by attribute",
446+
file=sys.stderr)
447+
return 8
448+
if in_keys == split or sub_path == split:
449+
print(f"ERROR: expected registered props {'outside' if split else 'inside'} "
450+
f"the ID-property group on {bpy.app.version_string}", file=sys.stderr)
451+
return 8
452+
if scene.path_resolve(REG) != 5:
453+
print("ERROR: plain data path did not resolve the registered prop",
454+
file=sys.stderr)
455+
return 8
456+
scene.property_unset(REG)
457+
if scene.bdt_counter != 1 or scene.is_property_set(REG):
458+
print("ERROR: property_unset did not reset the registered prop",
459+
file=sys.stderr)
460+
return 8
461+
except KeyError as exc:
462+
print(f"ERROR: KeyError {exc}: on 5.0+ a registered bpy.props value is "
463+
f"not an ID property; read it as scene.{REG}", file=sys.stderr)
464+
return 8
465+
finally:
466+
del bpy.types.Scene.bdt_counter
467+
return 0
468+
469+
416470
def main():
417471
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
418472
p = argparse.ArgumentParser()
@@ -426,13 +480,20 @@ def main():
426480
"--unset-instead", action="store_true",
427481
help="falsification: call property_unset instead of del",
428482
)
483+
p.add_argument(
484+
"--subscript-registered", action="store_true",
485+
help="falsification: read a registered prop as scene['name'] (exit 8 on 5.0+)",
486+
)
429487
args = p.parse_args(argv)
430488

431489
keep, clear = build()
432490
code = check(
433491
keep, clear,
434492
skip_delete=args.skip_delete, unset_instead=args.unset_instead,
435493
)
494+
if code:
495+
return code
496+
code = check_registered(subscript=args.subscript_registered)
436497
if code:
437498
return code
438499

‎skills/custom-properties/SKILL.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,14 +231,35 @@ This is a frequent AI mistake when generating quick scripts. Always wrap state i
231231

232232
6. **Confusing `subtype='FILE_PATH'` and `'DIR_PATH'`**. `'FILE_PATH'` lets the user pick a file, `'DIR_PATH'` a directory.
233233

234+
7. **Reading a registered property as an ID property on 5.x**: `scene["my_prop"]`, `"my_prop" in scene.keys()`, `del scene["my_prop"]`, or a driver path of `'["my_prop"]'`. These worked on 4.x and raise `KeyError` / `ValueError` (or silently skip the prop) on 5.0+. Use `scene.my_prop`, `scene.property_unset("my_prop")`, `scene.is_property_set("my_prop")` and the plain `"my_prop"` path. See the compatibility section below.
235+
234236
## Compatibility paths (4.5 LTS vs 5.0+)
235237

238+
### Registered properties are not ID properties on 5.0+
239+
240+
On 4.x a value assigned to a registered `bpy.props` property was stored as an ID property, so dict-style access reached it. On 5.0+ it lives in a separate system-property group. Measured with `bpy.types.Scene.my_prop = IntProperty(default=1)` and `scene.my_prop = 5`:
241+
242+
| Access | 4.5 LTS | 5.0+ (5.1.2, 5.2.1) |
243+
| --- | --- | --- |
244+
| `scene.my_prop` | 5 | 5 |
245+
| `scene["my_prop"]` | 5 | `KeyError` |
246+
| `scene.get("my_prop")` | 5 | `None` |
247+
| `"my_prop" in scene.keys()` | True | False |
248+
| `del scene["my_prop"]` | resets to default | `KeyError` |
249+
| `scene.path_resolve('["my_prop"]')` (driver / keyframe path) | 5 | `ValueError` |
250+
| `scene.path_resolve("my_prop")` | 5 | 5 |
251+
| `scene.property_unset("my_prop")`, `scene.is_property_set("my_prop")` | work | work |
252+
253+
So the cross-version code needs no branch: use attribute access to read and write, `property_unset()` to reset, `is_property_set()` to test, and the plain `"my_prop"` path for drivers and keyframes. `keys()` lists only user custom properties on 5.0+; the raw storage is `id.bl_system_properties_get()` (5.0+ only), which you should rarely need. A value saved by 4.5 still loads as the attribute on 5.2. Witness: [`examples/cross-version-property-delete/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/cross-version-property-delete) (exit 8, falsifier `--subscript-registered`).
254+
255+
### Deleting
256+
236257
Two distinct operations, both version-stable via `del`:
237258

238259
- **Unbind a type-level property** (e.g. a `PointerProperty` bound to `bpy.types.Scene`): `del bpy.types.Scene.my_addon`. Same on 4.5 LTS and 5.0+.
239260
- **Remove a custom ID property** (the dict-style `obj["key"]`): `del obj["key"]`. Same on 4.5 LTS and 5.0+. The snippet `cross-version-property-delete.py` shows this.
240261

241-
Do not reach for `property_unset()` here. It resets a registered RNA property to its default, which is neither unbinding a type property nor removing an ID property.
262+
Do not reach for `property_unset()` here. It resets a registered RNA property to its default, which is neither unbinding a type property nor removing an ID property. Conversely, `del id["name"]` is not a way to reset a *registered* property: it raises `KeyError` on 5.0+.
242263

243264
## Related
244265

‎skills/drivers-and-app-handlers/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def add_simple_driver(obj, data_path, index, expression):
4545

4646
`driver_add(data_path, index)`:
4747

48-
- `data_path` is the RNA path of the property, like `"location"` or `"scale"` or `'["my_custom_prop"]'`.
48+
- `data_path` is the RNA path of the property, like `"location"` or `"scale"`. A user custom (ID) property set with `obj["my_custom_prop"] = 1.0` is `'["my_custom_prop"]'`; a registered `bpy.props` property is its plain name, `"my_prop"`. On 5.0+ `'["my_prop"]'` no longer resolves a registered property (`ValueError`), though it did on 4.x.
4949
- `index` is the array index for vector or color properties (0 = X, 1 = Y, 2 = Z), or `-1` for scalar properties.
5050
- Returns the `FCurve` whose `.driver` is now active.
5151

‎snippets/cross-version-property-delete.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
# RNA property to its default value; it does not remove a custom ID property.
77
# That is a different operation.
88
#
9+
# The reverse also holds: a REGISTERED bpy.props value is not an ID property
10+
# on 5.0+, so `del id_block["my_prop"]` raises KeyError there (it reset the
11+
# value on 4.x). Reset those with id_block.property_unset("my_prop").
12+
#
913
# Reference:
1014
# https://docs.blender.org/api/current/bpy.types.bpy_struct.html
1115

‎tests/smoke/catalog.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
{"name": "lightmap-uv-channel", "script": "examples/lightmap-uv-channel/lightmap_uv_channel.py", "falsifiers": [{"args": ["--overlap-islands"], "expect_exit": 7}]},
5858
{"name": "socket-attach-points", "script": "examples/socket-attach-points/socket_attach_points.py", "falsifiers": [{"args": ["--skip-mpi"], "expect_exit": 3}]},
5959
{"name": "vertex-color-ao", "script": "examples/vertex-color-ao/vertex_color_ao.py", "falsifiers": [{"args": ["--uniform-hemisphere"], "expect_exit": 3}]},
60-
{"name": "cross-version-property-delete", "script": "examples/cross-version-property-delete/cross_version_property_delete.py", "falsifiers": [{"args": ["--skip-delete"], "expect_exit": 7}]},
60+
{"name": "cross-version-property-delete", "script": "examples/cross-version-property-delete/cross_version_property_delete.py", "falsifiers": [{"args": ["--skip-delete"], "expect_exit": 7}, {"args": ["--subscript-registered"], "expect_exit": 8, "min_version": "5.0"}]},
6161
{"name": "usd-export-evaluation-mode", "script": "examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py", "falsifiers": [{"args": ["--subdivision", "BEST_MATCH"], "expect_exit": 4}]},
6262
{"name": "gn-zone-iterate", "script": "examples/gn-zone-iterate/gn_zone_iterate.py", "falsifiers": [{"args": ["--unpair-repeat"], "expect_exit": 3}]},
6363
{"name": "gn-sim-fountain", "script": "examples/gn-sim-fountain/gn_sim_fountain.py", "falsifiers": [{"args": ["--unpair"], "expect_exit": 3}]},

0 commit comments

Comments
 (0)