From 59a0981139ae4c996086448fa45076c2ec97b6af Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:16:13 -0400 Subject: [PATCH] fix(skills): document that registered props are not ID properties on 5.0+ 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 --- .../cross-version-property-delete/index.html | 9 ++- .../cross-version-property-delete/README.md | 22 ++++++- .../cross_version_property_delete.py | 61 +++++++++++++++++++ skills/custom-properties/SKILL.md | 23 ++++++- skills/drivers-and-app-handlers/SKILL.md | 2 +- snippets/cross-version-property-delete.py | 4 ++ tests/smoke/catalog.json | 2 +- 7 files changed, 116 insertions(+), 7 deletions(-) diff --git a/docs/gallery/cross-version-property-delete/index.html b/docs/gallery/cross-version-property-delete/index.html index e9cef393..160f27ad 100644 --- a/docs/gallery/cross-version-property-delete/index.html +++ b/docs/gallery/cross-version-property-delete/index.html @@ -64,8 +64,11 @@

Cross Version Property Delete

A pair of stage lamps that witnesses the custom ID-property delete contract from cross-version-property-delete.py and custom-properties — not the snippet's __main__, which keys off context.active_object and prints nothing in --background.

The IDs are two stage-lamp housings built with bpy.data.objects.new. The Keep lamp still carries ["accession"] = 42; the Clear lamp had del obj["accession"]. Same del on 4.5 LTS and 5.x; there is no version branch.

What it witnesses: property_unset is a TypeError on a custom ID key and does not remove it. del id_block[key] does. After factory-empty, there is no active_object.

+

It also witnesses the other half of the split: a registered bpy.props value (Scene.bdt_counter = IntProperty()) is not an ID property on 5.0+. Measured on 4.5.11, 5.1.2 and 5.2.1:

+
On a registered prop4.5 LTS5.0+
scene.bdt_counter55
scene["bdt_counter"]5KeyError
"bdt_counter" in scene.keys()TrueFalse
del scene["bdt_counter"]resets itKeyError
path_resolve('["bdt_counter"]') (driver path)5ValueError
path_resolve("bdt_counter"), property_unset, is_property_setworkwork
+

On 5.0+ the raw storage is scene.bl_system_properties_get(). A value saved by 4.5 still loads as the attribute on 5.2.

What failure each check would catch:

- +

Staging#

Both lamps hang in U-yokes from one T-stand. The render path reads each housing's keys() 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's state. Only render-side geometry (stand, yokes, lenses, placards) is added in the render path; the check reads ID properties, never geometry.

Run#

@@ -75,8 +78,8 @@

Run

The --output render path measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.

Exit codes#

Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.

-
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage; also an active object existed after the data-API build
3Custom ID property did not land
4property_unset on a custom ID key did not TypeError, or it deleted the key
5del did not report removal
6Keep lamp lost the ID property
7Clear lamp still has the ID property (--skip-delete / --unset-instead land here)
10Gallery framing violation
12--output produced no file
-

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 does not pass --output or --unset-instead. Its catalog falsifier is --skip-delete (expects exit 7).

+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage; also an active object existed after the data-API build
3Custom ID property did not land
4property_unset on a custom ID key did not TypeError, or it deleted the key
5del did not report removal
6Keep lamp lost the ID property
7Clear lamp still has the ID property (--skip-delete / --unset-instead land here)
8Registered-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)
10Gallery framing violation
12--output produced no file
+

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 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).

Source

diff --git a/examples/cross-version-property-delete/README.md b/examples/cross-version-property-delete/README.md index c1e376c4..2335c7cb 100644 --- a/examples/cross-version-property-delete/README.md +++ b/examples/cross-version-property-delete/README.md @@ -16,6 +16,22 @@ branch. does **not** remove it. `del id_block[key]` does. After factory-empty, there is no `active_object`. +It also witnesses the other half of the split: a **registered** `bpy.props` +value (`Scene.bdt_counter = IntProperty()`) is not an ID property on 5.0+. +Measured on 4.5.11, 5.1.2 and 5.2.1: + +| On a registered prop | 4.5 LTS | 5.0+ | +| --- | --- | --- | +| `scene.bdt_counter` | 5 | 5 | +| `scene["bdt_counter"]` | 5 | `KeyError` | +| `"bdt_counter" in scene.keys()` | True | False | +| `del scene["bdt_counter"]` | resets it | `KeyError` | +| `path_resolve('["bdt_counter"]')` (driver path) | 5 | `ValueError` | +| `path_resolve("bdt_counter")`, `property_unset`, `is_property_set` | work | work | + +On 5.0+ the raw storage is `scene.bl_system_properties_get()`. A value saved +by 4.5 still loads as the attribute on 5.2. + **What failure each check would catch:** - exit 2 — someone set an active object; the snippet `__main__` would have @@ -24,6 +40,9 @@ no `active_object`. - exit 4 — `property_unset` removed the key or failed to raise - exit 7 — `del` did not run (`--skip-delete` / `--unset-instead` falsify this: measured `clear_has=True`) +- 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 + (`--subscript-registered` falsifies this on 5.0+) ## Staging @@ -65,9 +84,10 @@ against it. `10` is the shared framing helper. | 5 | `del` did not report removal | | 6 | Keep lamp lost the ID property | | 7 | Clear lamp still has the ID property (`--skip-delete` / `--unset-instead` land here) | +| 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) | | 10 | Gallery framing violation | | 12 | `--output` produced no file | 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 does not pass `--output` or `--unset-instead`. Its catalog falsifier is `--skip-delete` (expects exit 7). +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). diff --git a/examples/cross-version-property-delete/cross_version_property_delete.py b/examples/cross-version-property-delete/cross_version_property_delete.py index 2ba98527..08bca2b1 100644 --- a/examples/cross-version-property-delete/cross_version_property_delete.py +++ b/examples/cross-version-property-delete/cross_version_property_delete.py @@ -9,6 +9,13 @@ the ID property — that RNA call resets a registered property to default. 3. ``del obj["accession"]`` removes it. Same on 4.5 LTS and 5.x; no version branch. +4. A *registered* ``bpy.props`` value is a different thing on 5.0+: it is no + longer an ID property, so ``scene["name"]``, ``"name" in scene.keys()``, + ``del scene["name"]`` and a driver path of ``'["name"]'`` all stop seeing + it. Attribute access, ``property_unset()``, ``is_property_set()`` and the + plain ``"name"`` path work on every version. ``--subscript-registered`` + reads it the 4.x way and exits 8 on 5.0+ (exit 0 on 4.5, where it still + works). By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: @@ -25,6 +32,7 @@ KEY = "accession" VALUE = 42 +REG = "bdt_counter" # registered bpy.props IntProperty on Scene def beveled_box(name, size, bevel, segments=2): @@ -413,6 +421,52 @@ def light(name, loc, energy, size, col, rot): return 0 +def check_registered(subscript=False): + """Registered bpy.props vs ID properties: the 5.0 split (#354).""" + split = bpy.app.version >= (5, 0, 0) + bpy.types.Scene.bdt_counter = bpy.props.IntProperty(default=1) + try: + scene = bpy.context.scene + scene.bdt_counter = 5 + if subscript: + # The 4.x idiom: worked when registered values were ID properties. + value = scene[REG] + else: + value = scene.bdt_counter + in_keys = REG in scene.keys() + sub_path = True + try: + scene.path_resolve(f'["{REG}"]') + except ValueError: + sub_path = False + print(f"registered value={value} in_keys={in_keys} " + f"subscript_path={sub_path} split={split}") + if value != 5 or not scene.is_property_set(REG): + print("ERROR: registered property did not read back by attribute", + file=sys.stderr) + return 8 + if in_keys == split or sub_path == split: + print(f"ERROR: expected registered props {'outside' if split else 'inside'} " + f"the ID-property group on {bpy.app.version_string}", file=sys.stderr) + return 8 + if scene.path_resolve(REG) != 5: + print("ERROR: plain data path did not resolve the registered prop", + file=sys.stderr) + return 8 + scene.property_unset(REG) + if scene.bdt_counter != 1 or scene.is_property_set(REG): + print("ERROR: property_unset did not reset the registered prop", + file=sys.stderr) + return 8 + except KeyError as exc: + print(f"ERROR: KeyError {exc}: on 5.0+ a registered bpy.props value is " + f"not an ID property; read it as scene.{REG}", file=sys.stderr) + return 8 + finally: + del bpy.types.Scene.bdt_counter + return 0 + + def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser() @@ -426,6 +480,10 @@ def main(): "--unset-instead", action="store_true", help="falsification: call property_unset instead of del", ) + p.add_argument( + "--subscript-registered", action="store_true", + help="falsification: read a registered prop as scene['name'] (exit 8 on 5.0+)", + ) args = p.parse_args(argv) keep, clear = build() @@ -433,6 +491,9 @@ def main(): keep, clear, skip_delete=args.skip_delete, unset_instead=args.unset_instead, ) + if code: + return code + code = check_registered(subscript=args.subscript_registered) if code: return code diff --git a/skills/custom-properties/SKILL.md b/skills/custom-properties/SKILL.md index 7e470b64..575094d0 100644 --- a/skills/custom-properties/SKILL.md +++ b/skills/custom-properties/SKILL.md @@ -231,14 +231,35 @@ This is a frequent AI mistake when generating quick scripts. Always wrap state i 6. **Confusing `subtype='FILE_PATH'` and `'DIR_PATH'`**. `'FILE_PATH'` lets the user pick a file, `'DIR_PATH'` a directory. +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. + ## Compatibility paths (4.5 LTS vs 5.0+) +### Registered properties are not ID properties on 5.0+ + +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`: + +| Access | 4.5 LTS | 5.0+ (5.1.2, 5.2.1) | +| --- | --- | --- | +| `scene.my_prop` | 5 | 5 | +| `scene["my_prop"]` | 5 | `KeyError` | +| `scene.get("my_prop")` | 5 | `None` | +| `"my_prop" in scene.keys()` | True | False | +| `del scene["my_prop"]` | resets to default | `KeyError` | +| `scene.path_resolve('["my_prop"]')` (driver / keyframe path) | 5 | `ValueError` | +| `scene.path_resolve("my_prop")` | 5 | 5 | +| `scene.property_unset("my_prop")`, `scene.is_property_set("my_prop")` | work | work | + +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`). + +### Deleting + Two distinct operations, both version-stable via `del`: - **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+. - **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. -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. +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+. ## Related diff --git a/skills/drivers-and-app-handlers/SKILL.md b/skills/drivers-and-app-handlers/SKILL.md index a49a53c9..03eb6845 100644 --- a/skills/drivers-and-app-handlers/SKILL.md +++ b/skills/drivers-and-app-handlers/SKILL.md @@ -45,7 +45,7 @@ def add_simple_driver(obj, data_path, index, expression): `driver_add(data_path, index)`: -- `data_path` is the RNA path of the property, like `"location"` or `"scale"` or `'["my_custom_prop"]'`. +- `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. - `index` is the array index for vector or color properties (0 = X, 1 = Y, 2 = Z), or `-1` for scalar properties. - Returns the `FCurve` whose `.driver` is now active. diff --git a/snippets/cross-version-property-delete.py b/snippets/cross-version-property-delete.py index c3d10d30..398238a0 100644 --- a/snippets/cross-version-property-delete.py +++ b/snippets/cross-version-property-delete.py @@ -6,6 +6,10 @@ # RNA property to its default value; it does not remove a custom ID property. # That is a different operation. # +# The reverse also holds: a REGISTERED bpy.props value is not an ID property +# on 5.0+, so `del id_block["my_prop"]` raises KeyError there (it reset the +# value on 4.x). Reset those with id_block.property_unset("my_prop"). +# # Reference: # https://docs.blender.org/api/current/bpy.types.bpy_struct.html diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index bc12cffe..5d2b2df2 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -57,7 +57,7 @@ {"name": "lightmap-uv-channel", "script": "examples/lightmap-uv-channel/lightmap_uv_channel.py", "falsifiers": [{"args": ["--overlap-islands"], "expect_exit": 7}]}, {"name": "socket-attach-points", "script": "examples/socket-attach-points/socket_attach_points.py", "falsifiers": [{"args": ["--skip-mpi"], "expect_exit": 3}]}, {"name": "vertex-color-ao", "script": "examples/vertex-color-ao/vertex_color_ao.py", "falsifiers": [{"args": ["--uniform-hemisphere"], "expect_exit": 3}]}, - {"name": "cross-version-property-delete", "script": "examples/cross-version-property-delete/cross_version_property_delete.py", "falsifiers": [{"args": ["--skip-delete"], "expect_exit": 7}]}, + {"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"}]}, {"name": "usd-export-evaluation-mode", "script": "examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py", "falsifiers": [{"args": ["--subdivision", "BEST_MATCH"], "expect_exit": 4}]}, {"name": "gn-zone-iterate", "script": "examples/gn-zone-iterate/gn_zone_iterate.py", "falsifiers": [{"args": ["--unpair-repeat"], "expect_exit": 3}]}, {"name": "gn-sim-fountain", "script": "examples/gn-sim-fountain/gn_sim_fountain.py", "falsifiers": [{"args": ["--unpair"], "expect_exit": 3}]},