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.
-| Code | Meaning |
|---|
| 0 | Success |
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage; also an active object existed after the data-API build |
| 3 | Custom ID property did not land |
| 4 | property_unset on a custom ID key did not TypeError, or it deleted the key |
| 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) |
| 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).
+| Code | Meaning |
|---|
| 0 | Success |
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage; also an active object existed after the data-API build |
| 3 | Custom ID property did not land |
| 4 | property_unset on a custom ID key did not TypeError, or it deleted the key |
| 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 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}]},