Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ json_file = StringIO("""

df = pl.read_json(json_file)
columns_to_explode = [col for col in df.columns if df[col].dtype == pl.List(pl.List)]
df = df.explode(columns_to_explode)
df = df.explode(columns_to_explode, empty_as_null=True)

vertices = np.zeros((len(df), 3), dtype=np.float32)
bob = db.create_bob(vertices, name="DinoStar")
Expand Down
2 changes: 1 addition & 1 deletion README.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ json_file = StringIO("""

df = pl.read_json(json_file)
columns_to_explode = [col for col in df.columns if df[col].dtype == pl.List(pl.List)]
df = df.explode(columns_to_explode)
df = df.explode(columns_to_explode, empty_as_null=True)

vertices = np.zeros((len(df), 3), dtype=np.float32)
bob = db.create_bob(vertices, name="DinoStar")
Expand Down
105 changes: 104 additions & 1 deletion docs/changelog.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,110 @@ title: Changelog
toc: true
---

## 0.9.0 (unreleased)
## 0.10.0 (unreleased)

A robustness release, in preparation for wider use of databpy across projects. Node
related functionality is deprecated in favour of
[nodebpy](https://pypi.org/project/nodebpy/), objects are tracked in a way that
survives renames, reallocation and other add-ons, and attribute data is validated
instead of being silently corrupted.

databpy follows semantic versioning. Deprecated functionality raises a
`FutureWarning` and is kept for at least two minor releases before removal. Everything
deprecated in this release is removed in 0.12.0.

### Deprecated

- `databpy.nodes`: `new_tree`, `swap_tree`, `custom_string_iswitch`,
`append_from_blend`, `DuplicatePrevention`, `cleanup_duplicates`,
`deduplicate_node_trees`, `get_input`, `get_output`, `MaintainConnections`,
`tree_interface`, `new_socket`, `input_socket`, `output_socket`, `socket_value` and
`set_socket_value`. Node functionality is moving to nodebpy.
- `register()` is no longer required. `unregister()` is now a no-op: it previously
removed `Object.uuid`, which broke every other add-on using databpy.
- The registered `Object.uuid` property. The uuid is now stored as the custom property
`obj["_databpy_uuid"]`, which needs no registration. `Object.uuid` is kept in sync
and values from existing .blend files are migrated automatically.
- `NamedAttributeError` subclassing `AttributeError`, as `hasattr()` and `getattr()`
silently swallow it. Catch `NamedAttributeError` or `DatabpyError` instead.

### Added

- `DatabpyError`, the base class for all databpy errors.
- `AttributeNotFoundError`, raised for missing attributes. It is both a
`NamedAttributeError` and a `KeyError`, and its message lists the available
attributes.
- `get_from_uuid(name_hint=...)`, to choose between duplicated objects that share a
uuid.
- `py.typed`, so type checkers use databpy's type hints.

### Changed

- `BlenderObject` tracks its object by `session_uid` within a session, surviving
renames and memory reallocation, and by its persistent uuid after a file is loaded.
As before, a wrapper stops resolving its object once another wrapper stores a
different uuid on it.
- **Breaking**: accessing a `BlenderObject` whose object was removed raises
`LinkedObjectError`, instead of resolving to a duplicate that shares its uuid.
- `databpy.object.get_uuid()` / `set_uuid()` read and write the new custom property,
falling back to and keeping in sync the registered `Object.uuid` property.
- `AttributeArray` tracks its object by `session_uid` instead of holding a direct
reference. Syncing raises `LinkedObjectError` if the object was removed or a file
was loaded, instead of warning or writing to stale data.
- `AttributeArray` syncs every in-place modification: all in-place operators, `out=`,
`ufunc.at`, `fill`, `sort`, `put`, `partition`, `np.copyto`, `np.place`,
`np.putmask` and `np.fill_diagonal`. Augmented assignment on a view
(`pos[:, 2] += 1`) writes to Blender once instead of twice.
- **Breaking**: the `domain` argument of `store_named_attribute()` defaults to `None`,
using the domain of an existing attribute, otherwise `POINT`. A `domain` that doesn't
match an existing attribute raises `NamedAttributeError` instead of being ignored.
- Writing to an existing attribute without `atype` uses the attribute's type, instead
of raising if the guessed type differed.
- **Breaking**: 1D unsigned integer arrays are stored as `INT` (`uint8` was stored as
`INT8`, wrapping values above 127) and `(n, 2)` unsigned arrays as `INT32_2D`.
- **Breaking**: attribute type guessing raises `ValueError` when no type matches (0-d,
`(n, 5)`, `(n, 2)` bool, complex or non-numeric multi-column arrays), instead of
falling back to `FLOAT`.
- **Breaking**: values that can't be represented by an integer attribute (overflow,
NaN or inf) and complex data raise `AttributeMismatchError`, instead of being
silently wrapped or truncated.
- **Breaking**: removing a required attribute such as `position` raises
`NamedAttributeError` instead of Blender's `RuntimeError`.
- `bob["name"]`, `named_attribute()`, `remove_named_attribute()`, `AttributeArray()`
and `GeometrySet.named_attribute()` raise `AttributeNotFoundError` for missing
attributes. `bob["name"]` previously raised a bare `KeyError`, which it remains a
subclass of.
- `store_named_attribute()` and `Attribute.from_array()` accept any array-like data.
- `create_pointcloud_object()` creates the point cloud directly instead of converting a
mesh with an operator, so it no longer depends on the context. The `.selection`
attribute added by the conversion is no longer present.
- `create_curves_object()` raises `ValueError` if only one of `positions` and
`curve_sizes` is given, instead of creating an empty object.
- `create_mesh_object()` and `BlenderObject.new_from_pydata()` raise `ValueError` for
out of range edge or face indices, instead of creating an invalid mesh.
- `ObjectTracker` identifies new objects by `session_uid`. `new_objects()` is ordered
from oldest to newest and `latest()` returns the most recently created object.
- Object data is refreshed with `update_tag()` after writing attributes, replacing the
workaround of reassigning a vertex position.
- The string attribute warning points at the calling code, so it is shown once per
call site.
- `AttributeArray` no longer has the private `_attribute` and `_attr_name` attributes.
`_blender_object` is kept as a read-only lookup.

### Fixed

- In-place operations other than `+=`, `-=`, `*=` and `/=` modified an
`AttributeArray` without syncing to Blender.
- Writing attributes to empty geometry raised `IndexError` or `KeyError`.
- `create_pointcloud_object()` returned a mesh when given a collection not linked to
the scene, and left an orphan mesh behind.
- `create_mesh_object()` raised for faces with different numbers of vertices.
- `BlenderObject.centroid()` ignored boolean masks and unsigned index arrays, returning
the unweighted centroid. Unsupported dtypes now raise `TypeError`.
- `ObjectTracker` reported renamed objects as new.
- After a uuid mismatch, every access of `BlenderObject.object` searched all objects.

## 0.9.0 (2026-09-14)

A full static-typing pass over the package and test suite. The code base now
passes [ty](https://docs.astral.sh/ty/) with zero errors and zero suppression
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "databpy"
version = "0.9.0"
version = "0.10.0"
description = "A data-oriented wrapper library for the Blender Python API"
readme = "README.md"
dependencies = [
Expand Down Expand Up @@ -37,7 +37,6 @@ dev = [
"quarto-cli>=1.10",
"quartodoc",
"jupyter",
"polars",
"pytest",
"pytest-cov",
"nodebpy>=520.24.0",
Expand Down
4 changes: 4 additions & 0 deletions src/databpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Attribute,
AttributeDomains,
AttributeMismatchError,
AttributeNotFoundError,
AttributeType,
AttributeTypes,
NamedAttributeError,
Expand All @@ -15,6 +16,7 @@
store_named_attribute,
)
from .collection import create_collection, move_to_collection
from .errors import DatabpyError
from .geometry import GeometrySet
from .object import (
BOB,
Expand Down Expand Up @@ -50,11 +52,13 @@
"AttributeArray",
"AttributeDomains",
"AttributeMismatchError",
"AttributeNotFoundError",
"AttributeType",
"AttributeTypes",
"BlenderObject",
"BlenderObjectAttribute",
"BlenderObjectBase",
"DatabpyError",
"GeometrySet",
"LinkedObjectError",
"NamedAttributeError",
Expand Down
139 changes: 124 additions & 15 deletions src/databpy/addon.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,132 @@
import uuid
import warnings

import bpy
from bpy.app.handlers import persistent

# Persistent identifier for objects tracked by databpy. Stored as a plain custom
# property so it needs no registration and is saved with the .blend file. The leading
# underscore hides it from the Custom Properties panel.
UUID_KEY = "_databpy_uuid"

# the `uuid` property is registered dynamically on `bpy.types.Object`, so it isn't
# part of the static type information for `Object`. All runtime access goes through
# `setattr` / `getattr` with this constant (see `databpy.object.get_uuid` / `set_uuid`)
# databpy < 0.9 stored the uuid in a `uuid` property registered on `bpy.types.Object`.
# It isn't part of the static type information for `Object`, so it is accessed through
# `getattr` / `setattr` with this constant
UUID_PROP_NAME = "uuid"

LEGACY_REMOVAL_VERSION = "0.12.0"

# Changes on every file load and is unique per process, so `session_uid` values cached
# in a previous session (or a pickled wrapper) are never trusted in the current one.
_session_token = uuid.uuid4().hex


def session_token() -> str:
return _session_token


def find_by_session_uid(
session_uid: int, name_hint: str = ""
) -> bpy.types.Object | None:
"""Find an object by its `session_uid`, checking `name_hint` first as a fast path."""
obj = bpy.data.objects.get(name_hint)
if obj is not None and obj.session_uid == session_uid:
return obj
for obj in bpy.data.objects:
if obj.session_uid == session_uid:
return obj
return None


@persistent
def _databpy_load_post(*args) -> None:
global _session_token
_session_token = uuid.uuid4().hex


def _install_load_handler() -> None:
handlers = bpy.app.handlers.load_post
# replace a handler left over from a previous import of this module (add-on reload)
for handler in list(handlers):
if getattr(handler, "__name__", None) == _databpy_load_post.__name__:
handlers.remove(handler)
handlers.append(_databpy_load_post)


def register():
setattr(
bpy.types.Object,
UUID_PROP_NAME,
bpy.props.StringProperty(
name="UUID",
description="Unique identifier for the object",
default="",
options={"HIDDEN"},
),
_install_load_handler()


def _ensure_legacy_property() -> None:
# databpy < 0.9 stored the uuid in a registered `Object.uuid` property. It is kept
# registered (and in sync) during the deprecation window so values in existing
# .blend files can be migrated and downstream code reading `obj.uuid` keeps working
if not hasattr(bpy.types.Object, UUID_PROP_NAME):
setattr(
bpy.types.Object,
UUID_PROP_NAME,
bpy.props.StringProperty(
name="UUID",
description="Unique identifier for the object",
default="",
options={"HIDDEN"},
),
)


def get_uuid(obj: bpy.types.Object) -> str:
"""Return the persistent databpy uuid of an object, or "" if it has none."""
value = obj.get(UUID_KEY)
if isinstance(value, str):
return value

_ensure_legacy_property()
value = getattr(obj, UUID_PROP_NAME, "")
if not isinstance(value, str):
return ""
if value:
try:
obj[UUID_KEY] = value
except (AttributeError, TypeError):
# linked library data or a restricted context can't be written to, the
# legacy value is still valid for this lookup
pass
return value


def set_uuid(obj: bpy.types.Object, value: str) -> None:
"""Set the persistent databpy uuid of an object."""
obj[UUID_KEY] = value
_ensure_legacy_property()
setattr(obj, UUID_PROP_NAME, value)


def register() -> None:
"""
Deprecated: databpy no longer needs registering.

Objects are identified through a plain custom property, which requires no
registration.
"""
warnings.warn(
"`databpy.register()` is no longer required and will be removed in databpy "
f"{LEGACY_REMOVAL_VERSION}.",
FutureWarning,
stacklevel=2,
)
_ensure_legacy_property()


def unregister() -> None:
"""
Deprecated: this is now a no-op.

def unregister():
delattr(bpy.types.Object, UUID_PROP_NAME)
databpy is shared between add-ons, so removing `Object.uuid` from one add-on would
break every other add-on that uses databpy.
"""
warnings.warn(
"`databpy.unregister()` no longer does anything and will be removed in databpy "
f"{LEGACY_REMOVAL_VERSION}. It previously removed `Object.uuid`, which broke "
"other add-ons using databpy.",
FutureWarning,
stacklevel=2,
)
Loading
Loading