diff --git a/README.md b/README.md index 2a25251..7e21aea 100644 --- a/README.md +++ b/README.md @@ -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") diff --git a/README.qmd b/README.qmd index b7fd19e..fbc4785 100644 --- a/README.qmd +++ b/README.qmd @@ -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") diff --git a/docs/changelog.qmd b/docs/changelog.qmd index 1c3086c..7214c71 100644 --- a/docs/changelog.qmd +++ b/docs/changelog.qmd @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 28d89a3..48d0e39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ @@ -37,7 +37,6 @@ dev = [ "quarto-cli>=1.10", "quartodoc", "jupyter", - "polars", "pytest", "pytest-cov", "nodebpy>=520.24.0", diff --git a/src/databpy/__init__.py b/src/databpy/__init__.py index ef14ff8..d1d356b 100644 --- a/src/databpy/__init__.py +++ b/src/databpy/__init__.py @@ -5,6 +5,7 @@ Attribute, AttributeDomains, AttributeMismatchError, + AttributeNotFoundError, AttributeType, AttributeTypes, NamedAttributeError, @@ -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, @@ -50,11 +52,13 @@ "AttributeArray", "AttributeDomains", "AttributeMismatchError", + "AttributeNotFoundError", "AttributeType", "AttributeTypes", "BlenderObject", "BlenderObjectAttribute", "BlenderObjectBase", + "DatabpyError", "GeometrySet", "LinkedObjectError", "NamedAttributeError", diff --git a/src/databpy/addon.py b/src/databpy/addon.py index cefaaef..94ab810 100644 --- a/src/databpy/addon.py +++ b/src/databpy/addon.py @@ -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, + ) diff --git a/src/databpy/array.py b/src/databpy/array.py index 0a3901d..c1fe0dd 100644 --- a/src/databpy/array.py +++ b/src/databpy/array.py @@ -1,10 +1,54 @@ -import warnings from typing import Self import bpy import numpy as np -from .attribute import Attribute, _attribute_data, store_named_attribute +from .addon import find_by_session_uid, session_token +from .attribute import ( + Attribute, + AttributeDomains, + AttributeNotFoundError, + AttributeTypes, + _attribute_data, + store_named_attribute, +) +from .errors import LinkedObjectError + + +class _AttributeLink: + """Session-stable link from an AttributeArray to the attribute it was read from. + + The object is tracked by its `session_uid` rather than a direct reference, which + can become invalid when Blender reallocates or renames the object. + """ + + def __init__(self, obj: bpy.types.Object, attribute: Attribute): + self.object_name: str = obj.name + self.session_uid: int = obj.session_uid + self.session_token: str = session_token() + self.name: str = attribute.name + self.atype: AttributeTypes = attribute.atype + self.domain: AttributeDomains = attribute.domain + + def resolve(self) -> bpy.types.Object: + if self.session_token != session_token(): + raise LinkedObjectError( + f"AttributeArray '{self.name}' was read from '{self.object_name}' before " + "a file was loaded, and can no longer be synced to Blender." + ) + obj = find_by_session_uid(self.session_uid, self.object_name) + if obj is None: + raise LinkedObjectError( + f"The object '{self.object_name}' for AttributeArray '{self.name}' has " + "been removed." + ) + self.object_name = obj.name + return obj + + +# numpy functions that write into their first argument without going through a ufunc, +# `__setitem__` or one of the synced methods +_WRITING_FUNCTIONS = {np.copyto, np.place, np.putmask, np.fill_diagonal} class AttributeArray(np.ndarray): @@ -19,6 +63,17 @@ class AttributeArray(np.ndarray): This is the high-level interface for attribute manipulation. For low-level control, see the `Attribute` class which provides manual get/set operations without auto-sync. + Syncing + ------- + Changes are written back to Blender by item assignment (`pos[0] = ...`), in-place + operators (`pos += 1`, `pos[:, 2] *= 2`), ufuncs with `out=` or `.at`, the + `fill`, `sort`, `put` and `partition` methods, and `np.copyto`, `np.place`, + `np.putmask` and `np.fill_diagonal`. Writes through `.flat` are not synced. + + Other operations (`pos + 1`, `pos.copy()`, `pos[mask]`) return plain or detached + arrays that don't sync. If the object is removed, or a file is loaded, syncing + raises a `LinkedObjectError`. + Performance Characteristics --------------------------- - Every modification syncs the ENTIRE attribute array to Blender, not just changed values @@ -37,12 +92,8 @@ class AttributeArray(np.ndarray): Attributes ---------- - _blender_object : bpy.types.Object - Reference to the Blender object for syncing changes. - _attribute : Attribute - The underlying Attribute instance with type information. - _attr_name : str - Name of the attribute being wrapped. + _link : _AttributeLink | None + Link to the Blender object and attribute, or None for detached copies. _root : AttributeArray Reference to the root array for handling views/slices correctly. @@ -103,11 +154,6 @@ class AttributeArray(np.ndarray): named_attribute : Function to read attribute data as regular arrays """ - _blender_object: bpy.types.Object | None - _attribute: Attribute | None - _attr_name: str | None - _root: np.ndarray - def __new__(cls, obj: bpy.types.Object, name: str) -> Self: """Create a new AttributeArray that wraps a Blender attribute. @@ -122,16 +168,31 @@ def __new__(cls, obj: bpy.types.Object, name: str) -> Self: ------- AttributeArray A numpy array subclass that syncs changes back to Blender. + + Raises + ------ + AttributeNotFoundError + If the attribute doesn't exist on the object. """ - attr = Attribute(_attribute_data(obj).attributes[name]) + data = _attribute_data(obj) + try: + attr = Attribute(data.attributes[name]) + except KeyError: + raise AttributeNotFoundError( + f"The attribute '{name}' does not exist on '{obj.name}'. " + f"Available attributes: {sorted(data.attributes.keys())}" + ) from None arr = np.asarray(attr.as_array()).view(cls) - arr._blender_object = obj - arr._attribute = attr - arr._attr_name = name + arr._link = _AttributeLink(obj, attr) # Track the root array so that views can sync the full data arr._root = arr return arr + @property + def _blender_object(self) -> bpy.types.Object | None: + """The linked Blender object, or None for detached copies.""" + return None if self._link is None else self._link.resolve() + def __array_finalize__(self, obj): """Initialize attributes when array is created through operations.""" if obj is None: @@ -141,66 +202,102 @@ def __array_finalize__(self, obj): # Blender; copies (e.g. `pos.copy()`, `pos[mask]`) become detached arrays # that no longer sync if np.may_share_memory(self, obj): - self._blender_object = getattr(obj, "_blender_object", None) - self._attribute = getattr(obj, "_attribute", None) - self._attr_name = getattr(obj, "_attr_name", None) - # Preserve reference to the root array for syncing + self._link = getattr(obj, "_link", None) self._root = getattr(obj, "_root", self) else: - self._blender_object = None - self._attribute = None - self._attr_name = None + self._link = None self._root = self def __array_wrap__(self, out_arr, context=None, return_scalar=False): - """Return plain numpy arrays from operations like `pos + 1`. - - Only in-place operations (`pos += 1`) keep the AttributeArray type and - its connection to Blender - other operation results are new arrays that - should not sync back. - """ + """Return plain numpy arrays from operations that produce new arrays.""" if out_arr is self: return out_arr if return_scalar: return out_arr[()] return np.asarray(out_arr) + def __array_ufunc__(self, ufunc, method, *inputs, out=None, **kwargs): + """Run ufuncs on plain arrays, syncing any AttributeArray written to. + + In-place operators (`pos += 1`) are ufuncs with `out=`, so they are synced + here along with explicit `out=` arguments and `ufunc.at`. + """ + written = tuple(out) if out is not None else () + if method == "at": + written = inputs[:1] + + inputs = tuple( + x.view(np.ndarray) if isinstance(x, AttributeArray) else x for x in inputs + ) + if out is not None: + kwargs["out"] = tuple( + x.view(np.ndarray) if isinstance(x, AttributeArray) else x for x in out + ) + result = getattr(ufunc, method)(*inputs, **kwargs) + + for arr in written: + if isinstance(arr, AttributeArray): + arr._sync_to_blender() + + if out is None or method == "at": + return result + return out[0] if len(out) == 1 else out + + def __array_function__(self, func, types, args, kwargs): + """Sync after numpy functions that write into an AttributeArray.""" + result = super().__array_function__(func, types, args, kwargs) + if func in _WRITING_FUNCTIONS: + target = args[0] if args else next(iter(kwargs.values()), None) + if isinstance(target, AttributeArray): + target._sync_to_blender() + return result + def __setitem__(self, key, value): """Set item and sync changes back to Blender.""" + if self._is_writeback(key, value): + # `pos[:, 2] += 1` assigns the already modified and synced view back to + # itself, so there is nothing to write + return super().__setitem__(key, value) self._sync_to_blender() - def _ensure_correct_shape( - self, data: np.ndarray, attribute: Attribute - ) -> np.ndarray: - """Ensure data has the correct shape for Blender. + def _is_writeback(self, key, value) -> bool: + if ( + not isinstance(value, AttributeArray) + or value._link is None + or value._root is not self._root + ): + return False + try: + target = super().__getitem__(key) + except (IndexError, TypeError, ValueError): + return False + return ( + isinstance(target, np.ndarray) + and target.__array_interface__["data"] == value.__array_interface__["data"] + and target.shape == value.shape + and target.strides == value.strides + ) + + def fill(self, value): + """Fill the array with a scalar value and sync to Blender.""" + super().fill(value) + self._sync_to_blender() - Handles numpy views that may have lost dimension information and - reshapes 1D arrays to match the expected attribute dimensions. - """ - expected_dims = attribute.atype.value.dimensions - # the total number of scalar values per element, e.g. FLOAT_VECTOR (3,) - # gives 3 and FLOAT4X4 (4, 4) gives 16 - expected_components = int(np.prod(expected_dims)) - - # Reshape 1D to correct dimensionality if needed - if data.ndim == 1 and len(data) % expected_components == 0: - n_elements = len(data) // expected_components - if len(expected_dims) == 1: - # 1D attribute (FLOAT, INT, BOOLEAN, etc.) - return data - else: - # Multi-dimensional attribute - return data.reshape(n_elements, *expected_dims) - - # Handle views that lost shape information (e.g., column slices) - if data.ndim != len(attribute.shape): - # Try to get the full array from the root - full_array = np.asarray(self._root).copy() - if full_array.shape == attribute.shape: - return full_array - - return data + def sort(self, *args, **kwargs): + """Sort the array in-place and sync to Blender.""" + super().sort(*args, **kwargs) + self._sync_to_blender() + + def put(self, *args, **kwargs): + """Set values at the given flat indices and sync to Blender.""" + super().put(*args, **kwargs) + self._sync_to_blender() + + def partition(self, *args, **kwargs): + """Partition the array in-place and sync to Blender.""" + super().partition(*args, **kwargs) + self._sync_to_blender() def _sync_to_blender(self): """Sync the current array data back to the Blender object. @@ -210,78 +307,37 @@ def _sync_to_blender(self): API requiring the full array. For large meshes, consider batching multiple modifications before triggering a sync. """ - attribute = self._attribute - attr_name = self._attr_name - if attribute is None or attr_name is None: + link = self._link + if link is None: # a detached copy with no linked attribute; nothing to sync return - if self._blender_object is None: - warnings.warn( - "AttributeArray has lost its Blender object reference. " - "Changes will not be synced back to Blender. This can happen " - "if the array was created from a deleted object or copied incorrectly.", - RuntimeWarning, - stacklevel=3, - ) - return - # Always sync using the root array to ensure full shape - root = getattr(self, "_root", self) - data_to_sync = np.asarray(root) - data_to_sync = self._ensure_correct_shape(data_to_sync, attribute) - - # Use the attribute's actual dtype instead of hardcoding float32 - expected_dtype = attribute.dtype - if data_to_sync.dtype != expected_dtype: - data_to_sync = data_to_sync.astype(expected_dtype) - store_named_attribute( - self._blender_object, - data_to_sync, - name=attr_name, - atype=attribute.atype, - domain=attribute.domain, + link.resolve(), + self._root.view(np.ndarray), + name=link.name, + atype=link.atype, + domain=link.domain, ) - def _inplace_operation_with_sync(self, operation, other): - """Common method for in-place operations.""" - result = operation(other) - self._sync_to_blender() - return result - - def __iadd__(self, other): - """In-place addition with Blender syncing.""" - return self._inplace_operation_with_sync(super().__iadd__, other) - - def __isub__(self, other): - """In-place subtraction with Blender syncing.""" - return self._inplace_operation_with_sync(super().__isub__, other) - - def __imul__(self, other): - """In-place multiplication with Blender syncing.""" - return self._inplace_operation_with_sync(super().__imul__, other) - - def __itruediv__(self, other): - """In-place division with Blender syncing.""" - return self._inplace_operation_with_sync(super().__itruediv__, other) + def _describe(self) -> tuple[str, str, str, str, str]: + """Names of the attribute, domain, type, object and data-block for printing.""" + link = self._link + if link is None: + return ("Unknown", "Unknown", "Unknown", "Unknown", "Unknown") + obj_name = obj_type = "Unknown" + try: + obj = link.resolve() + obj_name, obj_type = obj.name, obj.data.name + except LinkedObjectError: + obj_name = link.object_name + return link.name, link.domain.name, str(link.atype.value), obj_name, obj_type def __str__(self): """String representation showing attribute info and array data.""" - # Get basic info - attr_name = getattr(self, "_attr_name", "Unknown") - domain = getattr(self._attribute, "domain", None) - domain_name = domain.name if domain else "Unknown" - - # Get object info - obj_name = "Unknown" - obj_type = "Unknown" - if self._blender_object: - obj_name = getattr(self._blender_object, "name", "Unknown") - obj_type = getattr(self._blender_object.data, "name", "Unknown") - - # Get array info - array_str = np.array_str(np.asarray(self)) + attr_name, domain_name, _, obj_name, obj_type = self._describe() + array_str = np.array_str(np.asarray(self).view(np.ndarray)) return ( f"AttributeArray '{attr_name}' from {obj_type}('{obj_name}')" @@ -291,23 +347,11 @@ def __str__(self): def __repr__(self): """Detailed representation for debugging.""" - # Get basic info - attr_name = getattr(self, "_attr_name", "Unknown") - domain = getattr(self._attribute, "domain", None) - domain_name = domain.name if domain else "Unknown" - atype = getattr(self._attribute, "atype", None) - type_name = atype.value if atype is not None else "Unknown" - - # Get object info - obj_name = "Unknown" - obj_type = "Unknown" - if self._blender_object: - obj_name = getattr(self._blender_object, "name", "Unknown") - obj_type = getattr(self._blender_object.data, "name", "Unknown") + attr_name, domain_name, type_name, obj_name, obj_type = self._describe() # Get array representation with explicit dtype for cross-platform consistency # np.array_repr() can omit dtype on Windows when it's the platform default - arr = np.asarray(self) + arr = np.asarray(self).view(np.ndarray) # Use np.array_repr() but then ensure dtype is always appended array_repr = np.array_repr(arr) # If dtype isn't already in the repr, add it before the closing parenthesis diff --git a/src/databpy/attribute.py b/src/databpy/attribute.py index 80f8ab5..06eacee 100644 --- a/src/databpy/attribute.py +++ b/src/databpy/attribute.py @@ -1,12 +1,16 @@ +import os import warnings from dataclasses import dataclass from enum import Enum -from typing import Literal +from typing import Literal, cast import bpy import numpy as np +import numpy.typing as npt from bpy.types import Object +from .errors import DatabpyError + COMPATIBLE_TYPES = [bpy.types.Mesh, bpy.types.Curves, bpy.types.PointCloud] AttributeDataBlock = bpy.types.Mesh | bpy.types.Curves | bpy.types.PointCloud PossibleAttributeTypes = ( @@ -45,19 +49,37 @@ ] -class NamedAttributeError(AttributeError): +class NamedAttributeError(DatabpyError, AttributeError): """ Base exception for all attribute-related errors in databpy. This exception is raised when operations on Blender named attributes fail, such as when an attribute doesn't exist, has incorrect dimensions, or cannot be created. + + Notes + ----- + This currently also subclasses `AttributeError`, which will be removed in databpy + 0.12.0 as `hasattr()` and `getattr()` silently swallow `AttributeError`. Catch + `NamedAttributeError` or `DatabpyError` instead. """ def __init__(self, message): self.message = message super().__init__(self.message) + def __str__(self) -> str: + return self.message + + +class AttributeNotFoundError(NamedAttributeError, KeyError): + """ + Exception raised when a named attribute doesn't exist. + + This is also a `KeyError`, so dictionary-style access such as `bob["name"]` can + be handled like any other missing key. + """ + def _attribute_data(obj: Object) -> AttributeDataBlock: """Return the object's data-block, checked to be a type that holds attributes.""" @@ -312,7 +334,9 @@ def guess_atype_from_array(array: np.ndarray) -> AttributeTypes: This function matches arrays broadly to Blender attribute types while ensuring they are categorized correctly based on both shape and dtype. It handles: - - Integer types: distinguishes int8, int32, int16_2d and int32_2d based on dtype and shape + - Integer types: distinguishes int8, int32, int16_2d and int32_2d based on dtype and shape. + Unsigned integers map to the 32-bit types, as their values may not fit the smaller + signed types - Float types: all floating point arrays map to float32-based attributes - 4D data: uint8 maps to BYTE_COLOR, other dtypes map to the generic FLOAT4. FLOAT_COLOR and QUATERNION must be explicitly requested via `atype` @@ -333,6 +357,8 @@ def guess_atype_from_array(array: np.ndarray) -> AttributeTypes: ------ TypeError If input is not a numpy array. + ValueError + If no attribute type matches the array's shape and dtype. Examples -------- @@ -346,89 +372,116 @@ def guess_atype_from_array(array: np.ndarray) -> AttributeTypes: if not isinstance(array, np.ndarray): raise TypeError(f"`array` must be a numpy array, not {type(array)=}") + if array.ndim == 0: + raise ValueError( + "`array` must have at least one dimension, with one row per element" + ) dtype = array.dtype + kind = dtype.kind shape = array.shape n_row = shape[0] + numeric = kind in "biuf" # Handle 1D arrays (single values per element) if shape == (n_row, 1) or shape == (n_row,): - # Boolean arrays - if np.issubdtype(dtype, np.bool_): + if kind == "b": return AttributeTypes.BOOLEAN - # Integer arrays - check for int8 vs int32 - elif np.issubdtype(dtype, np.integer): - # Check if it's int8 or uint8 (but not for colors) - if dtype in (np.int8, np.uint8): - return AttributeTypes.INT8 - else: - # All other integer types default to INT (int32) - return AttributeTypes.INT - # Float arrays - elif np.issubdtype(dtype, np.floating): + elif kind == "i": + return AttributeTypes.INT8 if dtype == np.int8 else AttributeTypes.INT + elif kind == "u": + # unsigned values (e.g. uint8 above 127) don't fit in the signed INT8 + return AttributeTypes.INT + elif kind == "f": return AttributeTypes.FLOAT # String arrays (unicode, bytes or numpy 2.x StringDType) - elif dtype.kind in ("U", "S", "T"): + elif kind in ("U", "S", "T"): return AttributeTypes.STRING # Handle 2D arrays (vectors, colors, matrices) elif shape == (n_row, 2): - # 2D vectors - check dtype to determine int16_2d / int32_2d vs float2 - if np.issubdtype(dtype, np.integer): - if dtype in (np.int16, np.uint16): + if kind == "i": + if dtype == np.int16: return AttributeTypes.INT16_2D return AttributeTypes.INT32_2D - elif np.issubdtype(dtype, np.floating): + elif kind == "u": + return AttributeTypes.INT32_2D + elif kind == "f": return AttributeTypes.FLOAT2 - elif shape == (n_row, 3): - # 3D vectors (FLOAT_VECTOR expects float32) + elif shape == (n_row, 3) and numeric: return AttributeTypes.FLOAT_VECTOR - elif shape == (n_row, 4): + elif shape == (n_row, 4) and numeric: # 4D data - uint8 maps to BYTE_COLOR, everything else to the generic FLOAT4. # The color and quaternion types must be explicitly requested via `atype` if dtype == np.uint8: return AttributeTypes.BYTE_COLOR - else: - return AttributeTypes.FLOAT4 + return AttributeTypes.FLOAT4 # Handle 3D arrays (matrices) - elif shape == (n_row, 4, 4): + elif shape == (n_row, 4, 4) and numeric: return AttributeTypes.FLOAT4X4 - # Default fallback - return AttributeTypes.FLOAT + raise ValueError( + f"Unable to infer an attribute type for an array with shape {shape} and dtype " + f"{dtype}. Pass `atype` explicitly or convert the data to a supported shape." + ) def _trigger_data_update(obj_data) -> None: - # The updating of data doesn't work 100% of the time (see: - # https://projects.blender.org/blender/blender/issues/118507) so this resetting of a - # single vertex is the current fix. Not great as I can see it breaking when we are - # missing a vertex - but for now we shouldn't be dealing with any situations where this - # is the case For now we will set a single vert to it's own position, which triggers a - # proper refresh of the object data. - try: - obj_data.vertices[0].co = obj_data.vertices[0].co - except AttributeError: - # For non-mesh objects (Curves, PointCloud), try update() if it exists - try: - obj_data.attributes["position"].data[0].vector = ( - obj_data.attributes["position"].data[0].vector + # tag the data-block so the depsgraph re-evaluates modifiers and the viewport redraws + obj_data.update_tag() + + +def _as_storage_array(data: np.ndarray, atype: AttributeTypes) -> np.ndarray: + """ + Flatten and cast data to the attribute's storage dtype. + + Casting to the storage dtype lets `foreach_set` use the fast buffer protocol path. + Values that can't be represented in an integer storage type raise instead of + silently wrapping around. + """ + target = np.dtype(atype.value.dtype) + source = data.dtype + + if source.kind == "c": + raise AttributeMismatchError( + f"Complex data cannot be stored in a `{atype.value.type_name}` attribute." + ) + + if ( + target.kind in "iu" + and source.kind in "iuf" + and data.size + and not np.can_cast(source, target) + ): + info = np.iinfo(cast(np.dtype[np.integer], target)) + low, high = np.min(data), np.max(data) + if ( + (source.kind == "f" and not (np.isfinite(low) and np.isfinite(high))) + or low < info.min + or high > info.max + ): + raise AttributeMismatchError( + f"Values in the range [{low}, {high}] can't be stored in the `{atype.value.type_name}` " + f"attribute, which stores {target} values in the range " + f"[{info.min}, {info.max}]." ) - except AttributeError: - if hasattr(obj_data, "update"): - obj_data.update() + + return np.ravel(data).astype(target, copy=False) def _warn_string_support() -> None: # STRING attributes are accessible through the Python API but aren't yet properly - # supported in Geometry Nodes, so treat their use as experimental for now + # supported in Geometry Nodes, so treat their use as experimental for now. The + # warning points at the first frame outside databpy, so Python's default filter + # shows it once per call site warnings.warn( "String attributes can be read and written through the Python API but are not " "yet properly supported within Geometry Nodes. Support may change in future " "Blender / databpy versions.", - stacklevel=4, + skip_file_prefixes=(os.path.dirname(__file__),), ) @@ -635,7 +688,7 @@ def size(self) -> int: """Returns the total number of scalar values in the attribute.""" return int(np.prod(self.shape, dtype=int)) - def from_array(self, array: np.ndarray) -> None: + def from_array(self, array: npt.ArrayLike) -> None: """ Set the attribute data from a numpy array. @@ -644,15 +697,17 @@ def from_array(self, array: np.ndarray) -> None: Parameters ---------- - array : np.ndarray + array : npt.ArrayLike Array containing the data to set. Must have the same total number of elements as the attribute. Raises ------ AttributeMismatchError - If array cannot be reshaped to match attribute shape. + If array cannot be reshaped to match attribute shape, or its values + can't be represented by the attribute's type. """ + array = np.asarray(array) if array.size != self.size: raise AttributeMismatchError( f"Array size {array.size} does not match attribute size {self.size}. " @@ -662,10 +717,8 @@ def from_array(self, array: np.ndarray) -> None: if self.atype == AttributeTypes.STRING: _write_string_values(self.attribute, array) else: - # casting to the storage dtype lets 'foreach_set' use the fast buffer - # protocol path instead of per-item iteration self.attribute.data.foreach_set( - self.value_name, np.ravel(array).astype(self.dtype, copy=False) + self.value_name, _as_storage_array(array, self.atype) ) _trigger_data_update(self.attribute.id_data) @@ -727,10 +780,10 @@ def _match_domain(domain: DomainNames | AttributeDomains) -> DomainNames: def store_named_attribute( obj: bpy.types.Object, - data: np.ndarray, + data: npt.ArrayLike, name: str, atype: AttributeTypeNames | AttributeTypes | None = None, - domain: DomainNames | AttributeDomains = AttributeDomains.POINT, + domain: DomainNames | AttributeDomains | None = None, overwrite: bool = True, ) -> bpy.types.Attribute: """ @@ -741,13 +794,15 @@ def store_named_attribute( obj : bpy.types.Object The Blender object. data : np.ndarray - The attribute data as a numpy array. + The attribute data, as a numpy array or anything convertible to one. name : str The name of the attribute. atype : str or AttributeTypes or None, optional - The attribute type to store the data as. If None, type is inferred from data. - domain : str or AttributeDomains, optional - The domain of the attribute, by default 'POINT'. + The attribute type to store the data as. If None, the type of an existing + attribute is used, otherwise the type is inferred from data. + domain : str or AttributeDomains or None, optional + The domain of the attribute. If None, the domain of an existing attribute is + used, otherwise 'POINT'. overwrite : bool, optional Whether to overwrite existing attribute, by default True. @@ -759,9 +814,12 @@ def store_named_attribute( Raises ------ ValueError - If atype string doesn't match available types. + If atype string doesn't match available types, or no type can be inferred. + NamedAttributeError + If data length doesn't match domain size, or the atype or domain don't match + an existing attribute. AttributeMismatchError - If data length doesn't match domain size. + If the values can't be represented by the attribute's type. Examples -------- @@ -781,9 +839,7 @@ def store_named_attribute( ``` """ - atype = _match_atype(atype, data) - domain = _match_domain(domain) - + data = np.asarray(data) obj_data = obj.data if not isinstance( @@ -796,8 +852,22 @@ def store_named_attribute( if name == "": raise NamedAttributeError("Attribute name cannot be an empty string.") - existing_attribute = obj_data.attributes.get(name) - if existing_attribute is None or not overwrite: + existing_attribute = obj_data.attributes.get(name) if overwrite else None + if existing_attribute is not None: + # writing to an existing attribute keeps its type and domain + attribute = _as_typed_attribute(existing_attribute) + existing_atype = AttributeTypes[attribute.data_type] + atype = existing_atype if atype is None else _match_atype(atype, data) + if domain is not None and _match_domain(domain) != attribute.domain: + raise NamedAttributeError( + f"Attribute `{name}` already exists on the `{attribute.domain}` domain, " + f"not `{_match_domain(domain)}`. Remove it first to store it on a " + "different domain." + ) + domain = attribute.domain + else: + atype = _match_atype(atype, data) + domain = _match_domain(AttributeDomains.POINT if domain is None else domain) current_names = obj_data.attributes.keys() try: new_attribute = obj_data.attributes.new(name, atype.value.type_name, domain) @@ -819,8 +889,6 @@ def store_named_attribute( "Potentially the attribute name is too long or there is no geometry on the object for the given domain." ) attribute = _as_typed_attribute(new_attribute) - else: - attribute = _as_typed_attribute(existing_attribute) target_atype = AttributeTypes[attribute.data_type] @@ -854,10 +922,8 @@ def store_named_attribute( _write_string_values(attribute, data) else: # the 'foreach_set' requires a 1D array, regardless of the shape of the attribute - # so we have to flatten it first. Casting to the attribute's storage dtype lets - # 'foreach_set' use the fast buffer protocol path instead of per-item iteration attribute.data.foreach_set( - atype.value.value_name, np.ravel(data).astype(atype.value.dtype, copy=False) + atype.value.value_name, _as_storage_array(data, atype) ) _trigger_data_update(obj_data) @@ -926,8 +992,8 @@ def named_attribute( Raises ------ - AttributeError - If the named attribute does not exist on the mesh. + AttributeNotFoundError + If the named attribute does not exist on the object. Examples -------- @@ -950,8 +1016,10 @@ def named_attribute( try: attr = Attribute(_attribute_data(obj).attributes[name]) except KeyError: - message = f"The selected attribute '{name}' does not exist on the mesh." - raise NamedAttributeError(message) + raise AttributeNotFoundError( + f"The selected attribute '{name}' does not exist on the object. " + f"Available attributes: {list_attributes(obj, evaluate=evaluate)}" + ) from None return attr.as_array() @@ -969,8 +1037,10 @@ def remove_named_attribute(obj: bpy.types.Object, name: str) -> None: Raises ------ - AttributeError - If the named attribute does not exist on the mesh. + AttributeNotFoundError + If the named attribute does not exist on the object. + NamedAttributeError + If the attribute is required by Blender (e.g. `position`) and can't be removed. Examples -------- @@ -991,8 +1061,13 @@ def remove_named_attribute(obj: bpy.types.Object, name: str) -> None: obj_data = _attribute_data(obj) try: attr = obj_data.attributes[name] - obj_data.attributes.remove(attr) except KeyError: - raise NamedAttributeError( + raise AttributeNotFoundError( f"The selected attribute '{name}' does not exist on the object" - ) + ) from None + try: + obj_data.attributes.remove(attr) + except RuntimeError as e: + raise NamedAttributeError( + f"The attribute '{name}' is required by Blender and can't be removed" + ) from e diff --git a/src/databpy/errors.py b/src/databpy/errors.py new file mode 100644 index 0000000..a341ac3 --- /dev/null +++ b/src/databpy/errors.py @@ -0,0 +1,22 @@ +class DatabpyError(Exception): + """Base class for all errors raised by databpy.""" + + +class LinkedObjectError(DatabpyError): + """ + Error raised when a Python object doesn't have a linked object in the 3D scene. + + Parameters + ---------- + message : str + The error message describing why the linked object is missing or invalid. + + Attributes + ---------- + message : str + The error message that was passed. + """ + + def __init__(self, message: str): + self.message = message + super().__init__(self.message) diff --git a/src/databpy/geometry.py b/src/databpy/geometry.py index e7debcc..cd3eb23 100644 --- a/src/databpy/geometry.py +++ b/src/databpy/geometry.py @@ -4,7 +4,12 @@ import numpy as np from bpy.types import Context, Object -from .attribute import Attribute, AttributeDataBlock, NamedAttributeError +from .attribute import ( + Attribute, + AttributeDataBlock, + AttributeNotFoundError, + NamedAttributeError, +) GeometryComponents = Literal["MESH", "POINTCLOUD", "CURVES", "INSTANCES"] @@ -194,14 +199,16 @@ def named_attribute( Raises ------ - NamedAttributeError + AttributeNotFoundError If the attribute does not exist on the given (or any) component. + NamedAttributeError + If the given component isn't present. """ if component is None: for data in self.components().values(): if name in data.attributes: return Attribute(data.attributes[name]).as_array() - raise NamedAttributeError( + raise AttributeNotFoundError( f"The attribute '{name}' does not exist on any component of the " f"evaluated geometry. Available attributes: {self.list_attributes()}" ) @@ -210,7 +217,7 @@ def named_attribute( try: attribute = data.attributes[name] except KeyError: - raise NamedAttributeError( + raise AttributeNotFoundError( f"The attribute '{name}' does not exist on the {component} component. " f"Available attributes: {sorted(data.attributes.keys())}" ) diff --git a/src/databpy/nodes/__init__.py b/src/databpy/nodes/__init__.py index 5ffe31d..cc5fe69 100644 --- a/src/databpy/nodes/__init__.py +++ b/src/databpy/nodes/__init__.py @@ -1,3 +1,8 @@ +""" +Deprecated: node-related functionality is moving to nodebpy and this module will be +removed in databpy 0.12.0. Each function warns with a `FutureWarning` when used. +""" + from .appending import ( DuplicatePrevention, append_from_blend, diff --git a/src/databpy/nodes/_deprecation.py b/src/databpy/nodes/_deprecation.py new file mode 100644 index 0000000..5fa9263 --- /dev/null +++ b/src/databpy/nodes/_deprecation.py @@ -0,0 +1,48 @@ +import functools +import warnings +from collections.abc import Callable +from typing import overload + +REMOVAL_VERSION = "0.12.0" + +# depth of nested deprecated calls, so a deprecated function that internally uses +# other deprecated functions only warns once for the user's outermost call +_depth = 0 + + +@overload +def deprecated[T: type](obj: T) -> T: ... + + +@overload +def deprecated[**P, R](obj: Callable[P, R]) -> Callable[P, R]: ... + + +def deprecated(obj): + """Mark a `databpy.nodes` function or class as deprecated. + + Classes warn when instantiated. Only the outermost deprecated call warns. + """ + target = obj.__init__ if isinstance(obj, type) else obj + + @functools.wraps(target) + def wrapper(*args, **kwargs): + global _depth + if _depth == 0: + warnings.warn( + f"`databpy.nodes.{obj.__name__}` is deprecated and will be removed in " + f"databpy {REMOVAL_VERSION}. Node-related functionality is moving to " + "nodebpy (https://pypi.org/project/nodebpy/).", + FutureWarning, + stacklevel=2, + ) + _depth += 1 + try: + return target(*args, **kwargs) + finally: + _depth -= 1 + + if isinstance(obj, type): + obj.__init__ = wrapper + return obj + return wrapper diff --git a/src/databpy/nodes/appending.py b/src/databpy/nodes/appending.py index 2dd50f1..7358601 100644 --- a/src/databpy/nodes/appending.py +++ b/src/databpy/nodes/appending.py @@ -5,9 +5,11 @@ import bpy +from ._deprecation import deprecated from .utils import NODE_DUP_SUFFIX +@deprecated def deduplicate_node_trees(node_trees: list[bpy.types.NodeTree]): """Deduplicate node trees by remapping duplicates to their originals. @@ -108,6 +110,7 @@ def deduplicate_node_trees(node_trees: list[bpy.types.NodeTree]): pass +@deprecated def cleanup_duplicates(purge: bool = False): # Collect all node trees from node groups, excluding "NodeGroup" named ones node_trees = [tree for tree in bpy.data.node_groups if "NodeGroup" not in tree.name] @@ -120,6 +123,7 @@ def cleanup_duplicates(purge: bool = False): bpy.ops.outliner.orphans_purge() +@deprecated class DuplicatePrevention: "Context manager to cleanup duplicated node trees when appending node groups" @@ -142,6 +146,7 @@ def __exit__(self, type, value, traceback): print(f"De-duplication time: {end_time - self.start_time:.2f} seconds") +@deprecated def append_from_blend( name: str, filepath: str | Path, link: bool = False ) -> bpy.types.NodeTree: diff --git a/src/databpy/nodes/generating.py b/src/databpy/nodes/generating.py index c579fff..18c5697 100644 --- a/src/databpy/nodes/generating.py +++ b/src/databpy/nodes/generating.py @@ -2,6 +2,7 @@ import bpy +from ._deprecation import deprecated from .utils import ( MaintainConnections, NodeGroupCreationError, @@ -14,6 +15,7 @@ ) +@deprecated def swap_tree(node: bpy.types.Node, tree: bpy.types.GeometryNodeTree) -> None: if not isinstance(node, bpy.types.GeometryNodeGroup): raise TypeError( @@ -24,6 +26,7 @@ def swap_tree(node: bpy.types.Node, tree: bpy.types.GeometryNodeTree) -> None: node.name = tree.name +@deprecated def new_tree( name: str = "Geometry Nodes", geometry: bool = True, @@ -56,6 +59,7 @@ def new_tree( return tree +@deprecated def custom_string_iswitch( name: str, values: Iterable[str], attr_name: str = "attr_id" ) -> bpy.types.GeometryNodeTree: diff --git a/src/databpy/nodes/utils.py b/src/databpy/nodes/utils.py index 78013bd..01b442a 100644 --- a/src/databpy/nodes/utils.py +++ b/src/databpy/nodes/utils.py @@ -2,6 +2,8 @@ import bpy +from ._deprecation import deprecated + NODE_DUP_SUFFIX = r"\.\d{3}$" # only some socket subclasses carry a `default_value`, and each declares its own @@ -15,6 +17,7 @@ def __init__(self, message: str): super().__init__(self.message) +@deprecated def get_output(group: bpy.types.NodeTree) -> bpy.types.Node: return group.nodes[ bpy.app.translations.pgettext_data( @@ -23,6 +26,7 @@ def get_output(group: bpy.types.NodeTree) -> bpy.types.Node: ] +@deprecated def get_input(group: bpy.types.NodeTree) -> bpy.types.Node: return group.nodes[ bpy.app.translations.pgettext_data( @@ -31,6 +35,7 @@ def get_input(group: bpy.types.NodeTree) -> bpy.types.Node: ] +@deprecated def tree_interface(tree: bpy.types.NodeTree) -> bpy.types.NodeTreeInterface: """Return the interface of a node tree, raising an error if it has none. @@ -43,6 +48,7 @@ def tree_interface(tree: bpy.types.NodeTree) -> bpy.types.NodeTreeInterface: return interface +@deprecated def new_socket( tree: bpy.types.NodeTree, name: str, @@ -78,16 +84,19 @@ def new_socket( ) +@deprecated def input_socket(node: bpy.types.Node, key: int | str) -> bpy.types.NodeSocket: """Get an input socket of a node by index or name.""" return node.inputs[key] +@deprecated def output_socket(node: bpy.types.Node, key: int | str) -> bpy.types.NodeSocket: """Get an output socket of a node by index or name.""" return node.outputs[key] +@deprecated def socket_value(socket: bpy.types.NodeSocket) -> object: """Return the default value of a socket. @@ -101,6 +110,7 @@ def socket_value(socket: bpy.types.NodeSocket) -> object: return socket.default_value +@deprecated def set_socket_value(socket: bpy.types.NodeSocket, value: object) -> None: """Set the default value of a socket. @@ -115,6 +125,7 @@ def set_socket_value(socket: bpy.types.NodeSocket, value: object) -> None: setattr(socket, _DEFAULT_VALUE_ATTR, value) +@deprecated class MaintainConnections: # capture input and output links, so we can rebuild the links based on name # and the sockets they were connected to diff --git a/src/databpy/object.py b/src/databpy/object.py index 7a9556e..3013531 100644 --- a/src/databpy/object.py +++ b/src/databpy/object.py @@ -1,3 +1,4 @@ +import itertools import warnings from uuid import uuid1 @@ -7,7 +8,12 @@ from numpy import typing as npt from . import attribute as attr -from .addon import UUID_PROP_NAME, register +from .addon import ( + find_by_session_uid, + get_uuid, + session_token, + set_uuid, +) from .array import AttributeArray from .attribute import ( Attribute, @@ -20,69 +26,10 @@ list_attributes, ) from .collection import create_collection +from .errors import LinkedObjectError from .utils import active_scene -def get_uuid(obj: Object) -> str: - """ - Get the databpy UUID stored on an object. - - The `uuid` property is registered dynamically on `bpy.types.Object` (see - `databpy.addon.register`), so it is accessed via `getattr` rather than as a - static attribute. - - Parameters - ---------- - obj : Object - The Blender object to read the UUID from. - - Returns - ------- - str - The UUID string, or an empty string if none has been set. - """ - value = getattr(obj, UUID_PROP_NAME, "") - return value if isinstance(value, str) else "" - - -def set_uuid(obj: Object, uuid: str) -> None: - """ - Set the databpy UUID on an object, registering the property if needed. - - Parameters - ---------- - obj : Object - The Blender object to store the UUID on. - uuid : str - The UUID string to store. - """ - try: - setattr(obj, UUID_PROP_NAME, uuid) - except AttributeError: - register() - setattr(obj, UUID_PROP_NAME, uuid) - - -class LinkedObjectError(Exception): - """ - Error raised when a Python object doesn't have a linked object in the 3D scene. - - Parameters - ---------- - message : str - The error message describing why the linked object is missing or invalid. - - Attributes - ---------- - message : str - The error message that was passed. - """ - - def __init__(self, message: str): - self.message = message - super().__init__(self.message) - - class ObjectDatabase: def __getitem__(self, key: str) -> Object: """ @@ -129,6 +76,7 @@ def __enter__(self): The instance of the class. """ self.objects = list(active_scene().objects) + self._existing = {obj.session_uid for obj in self.objects} return self def __exit__(self, type, value, traceback): @@ -143,15 +91,16 @@ def new_objects(self): Returns ------- list - A list of new objects. + A list of new objects, ordered from oldest to newest. """ - obj_names = [o.name for o in self.objects] - current_objects = active_scene().objects - new_objects = [] - for obj in current_objects: - if obj.name not in obj_names: - new_objects.append(obj) - return new_objects + # session_uid values increase as data-blocks are created, so they give both + # a rename-proof identity and the creation order + new_objects = [ + obj + for obj in active_scene().objects + if obj.session_uid not in self._existing + ] + return sorted(new_objects, key=lambda obj: obj.session_uid) def latest(self): """ @@ -167,7 +116,7 @@ def latest(self): return self.new_objects()[-1] -def get_from_uuid(uuid: str) -> Object: +def get_from_uuid(uuid: str, name_hint: str | None = None) -> Object: """ Get an object from the bpy.data.objects collection using a UUID. @@ -175,37 +124,45 @@ def get_from_uuid(uuid: str) -> Object: ---------- uuid : str The UUID of the object to get. + name_hint : str | None, optional + Preferred object name if several objects share the UUID, which happens when + an object has been duplicated. Returns ------- Object The object from the bpy.data.objects collection. """ - for obj in bpy.data.objects: - if get_uuid(obj) == uuid: + matches = [obj for obj in bpy.data.objects if get_uuid(obj) == uuid] + if not matches: + raise LinkedObjectError( + "Failed to find an object in the database with given uuid: " + uuid + ) + for obj in matches: + if obj.name == name_hint: return obj - - raise LinkedObjectError( - "Failed to find an object in the database with given uuid: " + uuid - ) + return matches[0] class BlenderObjectBase: """ Minimal base class for Blender objects with name and object access. - This provides a minimal set of functionality to persistently track a an object in - Blender's database, providing access to it's name property and also the object itself. + This provides a minimal set of functionality to persistently track an object in + Blender's database, providing access to its name property and also the object itself. Referencing an object in the database directly can lead to ReferenceErrors as Blender can _without warning_ alter the database and thus the Object's place in memory. - To get around this BlenderObjectBase always looks up via the name attribute and - double checks with the `uuid` attribute to ensure the correct object is being returned. - If there is a mismatch the entite database will be searched for an object with a uuid - that matches and if none is found a LinkedObjectError will be raised. + To get around this the object is tracked in two ways: - Blender _internally_ uses it's own UUID / reference system but this is currently (and - frustratingly) not available to us via the Python API. + - Within a session, by the object's `session_uid`, which stays the same across + renames and internal reallocations. Duplicates of the object get a new + `session_uid`, so they are never mistaken for the original. If the object is + removed a LinkedObjectError is raised. + - Across sessions (after loading a .blend file, or in a new Python process), by a + persistent `uuid` stored on the object as a custom property. On first access in + a new session the object is found by its `uuid` and then tracked by its new + `session_uid`. Attributes ---------- @@ -228,23 +185,22 @@ def __init__(self, obj: Object | str | None = None): """ self._uuid: str = str(uuid1()) self._object_name: str = "" + self._session_uid: int | None = None + self._session_token: str | None = None - if not hasattr(bpy.types.Object, "uuid"): - register() + if isinstance(obj, str): + obj = bpy.data.objects[obj] if isinstance(obj, Object): existing_uuid = get_uuid(obj) - if existing_uuid != "": - self._uuid = existing_uuid - self.object = obj - elif isinstance(obj, str): - obj = bpy.data.objects[obj] - existing_uuid = get_uuid(obj) - if existing_uuid != "": + if existing_uuid: self._uuid = existing_uuid self.object = obj - elif obj is None: - self._object_name = "" + + def _link(self, obj: Object) -> None: + self._object_name = obj.name + self._session_uid = obj.session_uid + self._session_token = session_token() @property def object(self) -> Object: @@ -257,18 +213,30 @@ def object(self) -> Object: The Blender object, or None if not found. """ - # if we can't match a an object by name in the database, we instead try to match - # by the uuid. If we match by name and the uuid doesn't match, we try to find - # another object instead with the same uuid - - try: - obj = bpy.data.objects[self._object_name] + if self._session_token == session_token(): + obj = ( + None + if self._session_uid is None + else find_by_session_uid(self._session_uid, self._object_name) + ) + if obj is None: + raise LinkedObjectError( + f"The object '{self._object_name}' linked to this " + f"{type(self).__name__} has been removed." + ) + # another wrapper has taken over the object by storing its own uuid on it if get_uuid(obj) != self.uuid: - obj = get_from_uuid(self.uuid) - except (KeyError, MemoryError): - obj = get_from_uuid(self.uuid) - self._object_name = obj.name + raise LinkedObjectError( + f"The object '{obj.name}' linked to this {type(self).__name__} is " + "now linked to a different uuid." + ) + else: + # new session (file loaded or new process), session_uid values are no longer + # valid so find the object again by its persistent uuid + obj = get_from_uuid(self.uuid, name_hint=self._object_name) + if obj.name != self._object_name or self._session_token != session_token(): + self._link(obj) return obj @object.setter @@ -286,7 +254,7 @@ def object(self, value: Object) -> None: raise TypeError(f"{value} must be a bpy.types.Object") set_uuid(value, self.uuid) - self._object_name = value.name + self._link(value) @property def uuid(self) -> str: @@ -354,7 +322,7 @@ def store_named_attribute( data: np.ndarray, name: str, atype: AttributeTypeNames | AttributeTypes | None = None, - domain: DomainNames | AttributeDomains = AttributeDomains.POINT, + domain: DomainNames | AttributeDomains | None = None, ) -> bpy.types.Attribute: """ Store a named attribute on the Blender object. @@ -367,10 +335,11 @@ def store_named_attribute( The name for the attribute. Will overwrite an already existing attribute. atype : str or AttributeTypes or None, optional The attribute type to store the data as. Either string or selection from the - AttributeTypes enum. None will attempt to infer the attribute type from the - input array. - domain : str or AttributeDomains, optional - The domain to store the attribute on. Defaults to AttributeDomains.POINT. + AttributeTypes enum. None uses the type of an existing attribute, otherwise + infers the attribute type from the input array. + domain : str or AttributeDomains or None, optional + The domain to store the attribute on. None uses the domain of an existing + attribute, otherwise AttributeDomains.POINT. Returns ------- @@ -558,6 +527,8 @@ def __getitem__(self, name: str) -> AttributeArray: ------ TypeError If name is not a string. + AttributeNotFoundError + If the attribute doesn't exist. This is also a KeyError. """ if not isinstance(name, str): raise TypeError("Attribute name must be a string") @@ -783,9 +754,10 @@ def new_from_pydata( raise TypeError( f"Object must be a mesh to create a new object from pydata, not {type(self.data)}" ) - vertices, edges, faces = [ - [] if x is None else x for x in (vertices, edges, faces) - ] + vertices = [] if vertices is None else np.asarray(vertices) + edges, faces = [[] if x is None else x for x in (edges, faces)] + edges = _check_indices(edges, len(vertices), "edges") + faces = _check_indices(faces, len(vertices), "faces") self.data.clear_geometry() self.data.from_pydata(vertices, edges, faces) return self.object @@ -801,6 +773,7 @@ def centroid(self, weight: str | np.ndarray | None = None) -> np.ndarray: - If str: Name of attribute to use as weights - If np.ndarray with float dtype: Weights for each position - If np.ndarray with int dtype: Indices of positions to include + - If np.ndarray with bool dtype: Mask of positions to include - If None: Use all positions equally weighted Defaults to None. @@ -812,13 +785,19 @@ def centroid(self, weight: str | np.ndarray | None = None) -> np.ndarray: if isinstance(weight, str): weight = self.named_attribute(weight) - if isinstance(weight, np.ndarray): - if weight.dtype.kind == "f": - return np.average(self.position, weights=weight, axis=0) - elif weight.dtype.kind == "i": - return np.average(self.position[weight], axis=0) + if weight is None: + return np.average(self.position, axis=0) - return np.average(self.position, axis=0) + weight = np.asarray(weight) + if weight.dtype.kind == "f": + return np.average(self.position, weights=weight, axis=0) + elif weight.dtype.kind in "iub": + return np.average(self.position[weight], axis=0) + + raise TypeError( + f"`weight` must contain float weights, integer indices or a boolean mask, " + f"not {weight.dtype}" + ) @property def vertices(self): @@ -888,6 +867,21 @@ def edges(self): return self.data.edges +def _check_indices(indices, n_vertices: int, kind: str): + # from_pydata doesn't validate indices, and out of range values leave an invalid mesh + if not isinstance(indices, np.ndarray): + indices = [list(item) for item in indices] + flat = np.fromiter(itertools.chain.from_iterable(indices), dtype=np.int64) + else: + flat = indices.ravel() + if flat.size and (flat.min() < 0 or flat.max() >= n_vertices): + raise ValueError( + f"`{kind}` reference vertex indices outside of the {n_vertices} vertices " + f"(found range [{flat.min()}, {flat.max()}])" + ) + return indices + + def create_mesh_object( vertices: npt.ArrayLike | None = None, edges: npt.ArrayLike | None = None, @@ -917,16 +911,13 @@ def create_mesh_object( The created mesh object. """ - def _array(a): - if a is None: - return [] - else: - return np.asarray(a) + vertices = [] if vertices is None else np.asarray(vertices) + # edges and faces aren't converted to arrays, as faces can have different sizes + edges = _check_indices([] if edges is None else edges, len(vertices), "edges") + faces = _check_indices([] if faces is None else faces, len(vertices), "faces") mesh = bpy.data.meshes.new(name) - mesh.from_pydata( - vertices=_array(vertices), edges=_array(edges), faces=_array(faces) - ) + mesh.from_pydata(vertices=vertices, edges=edges, faces=faces) obj = bpy.data.objects.new(name, mesh) if collection is None: collection = create_collection("Collection") @@ -966,7 +957,8 @@ def create_curves_object( Raises ------ ValueError - If positions and curve_sizes lengths don't match. + If only one of positions and curve_sizes is given, or their lengths don't + match. Examples -------- @@ -979,6 +971,11 @@ def create_curves_object( curves_obj = create_curves_object(positions, [3, 4]) ``` """ + if (positions is None) != (curve_sizes is None): + raise ValueError( + "`positions` and `curve_sizes` must be given together, or both be None" + ) + curves_data = bpy.data.hair_curves.new(name) obj = bpy.data.objects.new(name, curves_data) @@ -1012,10 +1009,6 @@ def create_pointcloud_object( """ Create a new Blender point cloud object. - This function creates a point cloud by first creating a mesh with vertices - at the specified positions, then converting it to a point cloud using - Blender's convert operator. - Parameters ---------- positions : np.ndarray, optional @@ -1042,23 +1035,18 @@ def create_pointcloud_object( pc_obj = create_pointcloud_object(positions, name="MyPC") print(len(pc_obj.data.points)) # 100 ``` - - Notes - ----- - This function works by creating a temporary mesh and converting it to a - point cloud using `bpy.ops.object.convert(target='POINTCLOUD')`. """ + pointcloud = bpy.data.pointclouds.new(name) + obj = bpy.data.objects.new(name, pointcloud) - obj = create_mesh_object( - vertices=positions, edges=None, faces=None, name=name, collection=collection - ) + if collection is None: + collection = create_collection("Collection") + collection.objects.link(obj) - with bpy.context.temp_override( - active_object=obj, - selected_objects=[obj], - selected_editable_objects=[obj], - ): - bpy.ops.object.convert(target="POINTCLOUD") + if positions is not None: + positions = np.asarray(positions) + pointcloud.resize(len(positions)) + attr.store_named_attribute(obj, positions, "position") return obj @@ -1139,13 +1127,9 @@ def create_bob( name=name, collection=collection, ) - bob = BlenderObject(obj) if uuid: - # update the stored uuid on the object first: the `bob.object` lookup - # matches by uuid, so it must be set before changing `bob._uuid` set_uuid(obj, uuid) - bob._uuid = uuid - return bob + return BlenderObject(obj) # Friendly alias for BlenderObject - commonly used variable name diff --git a/src/databpy/py.typed b/src/databpy/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index a6ee8cb..b095683 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,19 @@ -import bpy -import pytest +import os +import tempfile -import databpy +import pytest -databpy.register() +# Isolate the tests from extensions installed in the user's Blender. `import bpy` adds +# their packages to `sys.path`, where another installed copy of databpy would shadow +# this one. This has to be set before bpy is first imported, which happens when the +# test modules are collected after this file. +os.environ["BLENDER_USER_EXTENSIONS"] = tempfile.mkdtemp(prefix="databpy-tests-") @pytest.fixture(autouse=True) def run_around_tests(): + import bpy + # Code that will run before each tests bpy.ops.wm.read_homefile(app_template="") diff --git a/tests/test_array_sync.py b/tests/test_array_sync.py new file mode 100644 index 0000000..567337b --- /dev/null +++ b/tests/test_array_sync.py @@ -0,0 +1,126 @@ +"""Tests that every in-place modification of an AttributeArray syncs to Blender.""" + +import bpy +import numpy as np +import pytest + +import databpy as db +import databpy.array + + +@pytest.fixture +def bob(): + bob = db.create_bob(np.random.rand(10, 3)) + bob.store_named_attribute(np.arange(10, dtype=np.int32), "ids") + return bob + + +def _assert_synced(bob, name, arr): + assert np.array_equal(bob.named_attribute(name), np.asarray(arr)) + + +@pytest.mark.parametrize( + "operation", + [ + lambda a: a.__iadd__(1), + lambda a: a.__isub__(1), + lambda a: a.__imul__(2), + lambda a: a.__ipow__(2), + lambda a: a.__ifloordiv__(2), + lambda a: a.__imod__(3), + lambda a: a.__ior__(1), + lambda a: a.__iand__(6), + lambda a: a.__ixor__(5), + lambda a: a.__ilshift__(1), + lambda a: a.__irshift__(1), + lambda a: np.add(a, 5, out=a), + lambda a: np.clip(a, 2, 5, out=a), + lambda a: np.add.at(a, [0, 0, 1], 1), + lambda a: a.fill(7), + lambda a: a.put([0, 1], [50, 60]), + lambda a: a.__setitem__(slice(None), a[::-1].copy()) or a.sort(), + lambda a: a.partition(3), + lambda a: np.copyto(a, np.full(10, 3)), + lambda a: np.place(a, a > 4, [0]), + lambda a: np.putmask(a, a > 4, 1), + ], +) +def test_inplace_operations_sync(bob, operation): + ids = bob["ids"] + operation(ids) + _assert_synced(bob, "ids", ids) + + +def test_float_inplace_operations_sync(bob): + pos = bob.position + pos /= 2 + _assert_synced(bob, "position", pos) + pos[:, 2] **= 2 + _assert_synced(bob, "position", pos) + np.fill_diagonal(pos, 0) + _assert_synced(bob, "position", pos) + + +def test_inplace_keeps_attribute_array_type(bob): + pos = bob.position + pos += 1 + assert isinstance(pos, db.AttributeArray) + + +def test_new_arrays_do_not_sync(bob): + before = bob.named_attribute("position") + pos = bob.position + result = pos + 1 + assert type(result) is np.ndarray + copy = pos.copy() + copy += 1 + masked = pos[pos[:, 0] > 0.5] + masked += 1 + assert np.array_equal(bob.named_attribute("position"), before) + + +def test_view_augmented_assignment_syncs_once(bob, monkeypatch): + calls = [] + original = databpy.array.store_named_attribute + + def counting(*args, **kwargs): + calls.append(1) + return original(*args, **kwargs) + + monkeypatch.setattr(databpy.array, "store_named_attribute", counting) + pos = bob.position + pos[:, 2] += 1 + assert len(calls) == 1 + _assert_synced(bob, "position", pos) + + calls.clear() + pos[pos[:, 0] > 0.5] += 1 + assert len(calls) == 1 + _assert_synced(bob, "position", pos) + + +def test_survives_object_rename_and_new_attributes(bob): + pos = bob.position + bob.object.name = "Renamed" + for i in range(20): + bob.store_named_attribute(np.random.rand(10), f"attr_{i}") + pos[0] = [9, 9, 9] + assert np.allclose(bob.named_attribute("position")[0], 9) + + +def test_removed_object_raises(bob): + pos = bob.position + bpy.data.objects.remove(bob.object) + with pytest.raises(db.LinkedObjectError, match="has been removed"): + pos[0] = [1, 2, 3] + # printing a disconnected array still works + assert "position" in repr(pos) + + +def test_file_load_raises(bob, tmp_path): + pos = bob.position + path = str(tmp_path / "sync.blend") + bpy.ops.wm.save_as_mainfile(filepath=path) + bpy.ops.wm.open_mainfile(filepath=path) + with pytest.raises(db.LinkedObjectError, match="file was loaded"): + pos[0] = [1, 2, 3] diff --git a/tests/test_arrays.py b/tests/test_arrays.py index 335a392..018052b 100644 --- a/tests/test_arrays.py +++ b/tests/test_arrays.py @@ -332,8 +332,8 @@ def test_float_color_attribute_handling(self): # Verify shape and components assert colors.shape == (5, 4) - assert colors._attribute is not None - assert colors._attribute.atype.value.dimensions == (4,) + assert colors._link is not None + assert colors._link.atype.value.dimensions == (4,) # Modify and verify sync colors[:, 3] = 0.5 # Set alpha to 0.5 diff --git a/tests/test_attribute.py b/tests/test_attribute.py index d913c69..4359f57 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -4,9 +4,9 @@ import bpy import numpy as np import pytest +from nodebpy import geometry as g import databpy as db -from databpy.nodes import input_socket, new_socket, output_socket, set_socket_value def test_attribute_properties(): @@ -188,7 +188,8 @@ def test_guess_atype(): assert db.attribute.AttributeTypes.INT8 == db.attribute.guess_atype_from_array( np.zeros(10, dtype=np.int8) ) - assert db.attribute.AttributeTypes.INT8 == db.attribute.guess_atype_from_array( + # uint8 values above 127 don't fit in the signed INT8 + assert db.attribute.AttributeTypes.INT == db.attribute.guess_atype_from_array( np.zeros(10, dtype=np.uint8) ) assert db.attribute.AttributeTypes.INT32_2D == db.attribute.guess_atype_from_array( @@ -271,22 +272,16 @@ def test_storage_type(): # a constant value stored via geometry nodes uses SINGLE storage on the # evaluated geometry, which still reads transparently as a full array - tree = db.require(bpy.data.node_groups.new("test_gn", "GeometryNodeTree")) - new_socket(tree, "Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") - new_socket(tree, "Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - n_in = tree.nodes.new("NodeGroupInput") - n_out = tree.nodes.new("NodeGroupOutput") - store = tree.nodes.new("GeometryNodeStoreNamedAttribute") - assert isinstance(store, bpy.types.GeometryNodeStoreNamedAttribute) - store.data_type = "FLOAT" - store.domain = "POINT" - set_socket_value(input_socket(store, "Name"), "const_val") - set_socket_value(input_socket(store, "Value"), 3.5) - tree.links.new(n_in.outputs[0], input_socket(store, "Geometry")) - tree.links.new(output_socket(store, 0), n_out.inputs[0]) + with g.tree("test_gn", arrange=None) as tree: + ( + g.StoreNamedAttribute.point.float( + tree.inputs.geometry(), name="const_val", value=3.5 + ) + >> tree.outputs.geometry() + ) modifier = obj.modifiers.new("test_gn", "NODES") assert isinstance(modifier, bpy.types.NodesModifier) - modifier.node_group = tree + modifier.node_group = tree.tree ev = db.evaluate_object(obj) assert ( @@ -377,15 +372,16 @@ def test_list_attributes(evaluate, drop_hidden): # store a named attribute via geometry nodes as this should only show up # when evaluate=True - tree = db.nodes.new_tree() - n = tree.nodes.new("GeometryNodeStoreNamedAttribute") - set_socket_value(input_socket(n, "Name"), "testing") - set_socket_value(input_socket(n, "Value"), 0.5) - tree.links.new(tree.nodes["Group Input"].outputs["Geometry"], n.inputs["Geometry"]) - tree.links.new(n.outputs["Geometry"], tree.nodes["Group Output"].inputs["Geometry"]) + with g.tree("Geometry Nodes", arrange=None) as tree: + ( + g.StoreNamedAttribute.point.float( + tree.inputs.geometry(), name="testing", value=0.5 + ) + >> tree.outputs.geometry() + ) mod = obj.modifiers.new("db_nodes", "NODES") assert isinstance(mod, bpy.types.NodesModifier) - mod.node_group = tree + mod.node_group = tree.tree for name in names: data = np.random.rand(len(db.mesh_data(obj).vertices), 3) diff --git a/tests/test_attribute_validation.py b/tests/test_attribute_validation.py new file mode 100644 index 0000000..505825c --- /dev/null +++ b/tests/test_attribute_validation.py @@ -0,0 +1,129 @@ +"""Tests for input validation and lossless casting when storing attributes.""" + +import bpy +import numpy as np +import pytest + +import databpy as db +from databpy.attribute import AttributeTypes, guess_atype_from_array + + +@pytest.fixture +def cube(): + return bpy.data.objects["Cube"] + + +def test_uint8_values_are_not_wrapped(cube): + data = np.array([0, 100, 200, 255, 1, 2, 3, 4], dtype=np.uint8) + db.store_named_attribute(cube, data, "values") + assert np.array_equal(db.named_attribute(cube, "values"), data) + + +@pytest.mark.parametrize("dtype", [np.uint16, np.uint32]) +def test_unsigned_2d_uses_int32(dtype): + assert ( + guess_atype_from_array(np.zeros((4, 2), dtype=dtype)) == AttributeTypes.INT32_2D + ) + + +def test_integer_overflow_raises(cube): + with pytest.raises(db.AttributeMismatchError, match="can't be stored"): + db.store_named_attribute(cube, np.full(8, 2**33, dtype=np.int64), "big") + + +def test_integer_in_range_is_stored(cube): + data = np.arange(8, dtype=np.int64) * 1000 + db.store_named_attribute(cube, data, "ids") + assert np.array_equal(db.named_attribute(cube, "ids"), data) + + +def test_explicit_narrow_type_overflow_raises(cube): + with pytest.raises(db.AttributeMismatchError): + db.store_named_attribute(cube, np.full(8, 200), "small", atype="INT8") + + +def test_nan_into_int_raises(cube): + data = np.arange(8, dtype=float) + data[0] = np.nan + with pytest.raises(db.AttributeMismatchError): + db.store_named_attribute(cube, data, "ints", atype="INT") + + +def test_complex_raises(cube): + with pytest.raises(ValueError, match="Unable to infer"): + db.store_named_attribute(cube, np.ones(8, dtype=complex), "c") + with pytest.raises(db.AttributeMismatchError, match="Complex"): + db.store_named_attribute(cube, np.ones(8, dtype=complex), "c", atype="FLOAT") + + +def test_attribute_from_array_checks_overflow(cube): + db.store_named_attribute(cube, np.zeros(8, dtype=np.int8), "small") + attribute = db.Attribute(cube.data.attributes["small"]) + with pytest.raises(db.AttributeMismatchError): + attribute.from_array(np.full(8, 1000)) + + +@pytest.mark.parametrize( + "data", + [ + np.array(1.0), + np.zeros((8, 5)), + np.zeros((8, 2), dtype=bool), + np.zeros((8, 3), dtype="U1"), + ], +) +def test_uninferable_shapes_raise_clearly(cube, data): + with pytest.raises(ValueError): + db.store_named_attribute(cube, data, "bad") + + +def test_array_like_input(cube): + db.store_named_attribute(cube, list(range(8)), "from_list") + assert np.array_equal(db.named_attribute(cube, "from_list"), np.arange(8)) + db.store_named_attribute(cube, [0.5] * 8, "from_list_typed", atype="FLOAT") + assert np.allclose(db.named_attribute(cube, "from_list_typed"), 0.5) + + attribute = db.Attribute(cube.data.attributes["from_list"]) + attribute.from_array(list(range(8, 16))) + assert np.array_equal(attribute.as_array(), np.arange(8, 16)) + + +def test_existing_attribute_type_used_when_atype_omitted(cube): + db.store_named_attribute(cube, np.arange(8, dtype=np.int32), "ids") + db.store_named_attribute(cube, np.arange(8, dtype=float) * 2, "ids") + attribute = cube.data.attributes["ids"] + assert attribute.data_type == "INT" + assert np.array_equal(db.named_attribute(cube, "ids"), np.arange(8) * 2) + + +def test_explicit_atype_mismatch_still_raises(cube): + db.store_named_attribute(cube, np.arange(8, dtype=np.int32), "ids") + with pytest.raises(db.NamedAttributeError, match="does not match"): + db.store_named_attribute(cube, np.arange(8.0), "ids", atype="FLOAT") + + +def test_existing_domain_used_when_domain_omitted(cube): + db.store_named_attribute(cube, np.arange(6), "face_ids", domain="FACE") + db.store_named_attribute(cube, np.arange(6) + 10, "face_ids") + assert cube.data.attributes["face_ids"].domain == "FACE" + assert np.array_equal(db.named_attribute(cube, "face_ids"), np.arange(6) + 10) + + +def test_explicit_domain_mismatch_raises(cube): + db.store_named_attribute(cube, np.arange(6), "face_ids", domain="FACE") + with pytest.raises(db.NamedAttributeError, match="already exists on the `FACE`"): + db.store_named_attribute(cube, np.arange(8), "face_ids", domain="POINT") + + +@pytest.mark.parametrize( + "create", + [ + lambda: db.create_object(np.zeros((0, 3))), + lambda: db.create_pointcloud_object(np.zeros((0, 3))), + lambda: db.create_curves_object(), + ], +) +def test_store_on_empty_geometry(create): + obj = create() + db.store_named_attribute(obj, np.zeros(0), "empty") + assert "empty" in db.list_attributes(obj) diff --git a/tests/test_bob.py b/tests/test_bob.py index b44016b..19fb136 100644 --- a/tests/test_bob.py +++ b/tests/test_bob.py @@ -70,17 +70,16 @@ def test_bob_mismatch_uuid(): assert old_uuid == bob.uuid -def test_register(): - db.unregister() - db.BlenderObject(bpy.data.objects["Cube"]) - +def test_register_deprecated(): + with pytest.warns(FutureWarning, match="no longer required"): + db.register() + bob = db.BlenderObject(bpy.data.objects["Cube"]) -def test_set_uuid_registers_property(): - # after unregistering, setting a uuid re-registers the dynamic property - obj = bpy.data.objects["Cube"] - db.unregister() - db.object.set_uuid(obj, "test-uuid-register") - assert db.object.get_uuid(obj) == "test-uuid-register" + # unregister must not break other add-ons sharing databpy + with pytest.warns(FutureWarning, match="no longer does anything"): + db.unregister() + assert hasattr(bpy.types.Object, "uuid") + assert bob.name == "Cube" def test_object_setter_requires_object(): diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..72915ca --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,65 @@ +"""Tests for the exception hierarchy and warnings raised by databpy.""" + +import warnings + +import bpy +import numpy as np +import pytest + +import databpy as db + + +@pytest.fixture +def bob(): + return db.BlenderObject(bpy.data.objects["Cube"]) + + +@pytest.mark.parametrize( + "call", + [ + lambda bob: bob["missing"], + lambda bob: bob.named_attribute("missing"), + lambda bob: bob.remove_named_attribute("missing"), + lambda bob: db.AttributeArray(bob.object, "missing"), + lambda bob: db.GeometrySet(bob.object).named_attribute("missing"), + ], +) +def test_missing_attribute(bob, call): + with pytest.raises(db.AttributeNotFoundError, match="missing") as info: + call(bob) + # compatible with existing handlers for dictionary-style access and older errors + assert isinstance(info.value, KeyError) + assert isinstance(info.value, db.NamedAttributeError) + assert isinstance(info.value, db.DatabpyError) + + +def test_error_message_is_not_quoted(bob): + with pytest.raises(db.AttributeNotFoundError) as info: + bob["missing"] + assert str(info.value).startswith("The attribute 'missing'") + + +def test_mismatch_is_not_a_key_error(bob): + with pytest.raises(db.NamedAttributeError) as info: + bob.store_named_attribute(np.zeros(3), "wrong_size") + assert not isinstance(info.value, KeyError) + + +def test_remove_required_attribute(bob): + with pytest.raises(db.NamedAttributeError, match="required"): + bob.remove_named_attribute("position") + + +def test_linked_object_error_is_databpy_error(bob): + bpy.data.objects.remove(bob.object) + with pytest.raises(db.DatabpyError): + _ = bob.object + + +def test_string_warning_points_at_caller(bob): + with pytest.warns(UserWarning, match="String attributes"): + bob.store_named_attribute(np.array(list("abcdefgh")), "strings") + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + bob.named_attribute("strings") + assert record[0].filename == __file__ diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 5bc72dc..e64ac38 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -34,6 +34,27 @@ def _new_group_node(tree: bpy.types.NodeTree) -> bpy.types.GeometryNodeGroup: return node +# the node API is deprecated, but its behaviour is still tested until removal +pytestmark = pytest.mark.filterwarnings("ignore::FutureWarning") + + +@pytest.mark.filterwarnings("default::FutureWarning") +@pytest.mark.parametrize( + "call", + [ + lambda: db.nodes.new_tree("Deprecated"), + lambda: db.nodes.custom_string_iswitch("DeprecatedSwitch", ["A", "B"]), + lambda: db.nodes.DuplicatePrevention(), + lambda: db.nodes.cleanup_duplicates(), + ], +) +def test_node_api_deprecated(call): + # nested deprecated calls (e.g. custom_string_iswitch -> new_tree) warn only once + with pytest.warns(FutureWarning, match="nodebpy") as record: + call() + assert len(record) == 1 + + def test_custom_string_iswitch_basic(): """Test basic creation of string index switch node group""" diff --git a/tests/test_object_creation.py b/tests/test_object_creation.py new file mode 100644 index 0000000..0760295 --- /dev/null +++ b/tests/test_object_creation.py @@ -0,0 +1,84 @@ +"""Tests for creating objects and related helpers.""" + +import bpy +import numpy as np +import pytest + +import databpy as db + + +def test_mixed_face_sizes(): + obj = db.create_object(np.random.rand(5, 3), faces=[[0, 1, 2], [1, 2, 3, 4]]) + assert [len(face.vertices) for face in db.mesh_data(obj).polygons] == [3, 4] + + +def test_face_array(): + obj = db.create_object(np.random.rand(4, 3), faces=np.array([[0, 1, 2, 3]])) + assert len(db.mesh_data(obj).polygons) == 1 + + +@pytest.mark.parametrize( + "kwargs", + [{"faces": [[0, 1, 7]]}, {"edges": [[0, -1]]}, {"faces": np.array([[0, 1, 3]])}], +) +def test_out_of_range_indices_raise(kwargs): + with pytest.raises(ValueError, match="outside of the 3 vertices"): + db.create_object(np.zeros((3, 3)), **kwargs) + + +def test_new_from_pydata_checks_indices(): + bob = db.create_bob(np.zeros((3, 3))) + with pytest.raises(ValueError, match="outside"): + bob.new_from_pydata(np.zeros((3, 3)), faces=[[0, 1, 5]]) + + +def test_pointcloud_in_unlinked_collection(): + collection = bpy.data.collections.new("Unlinked") + obj = db.create_pointcloud_object(np.random.rand(4, 3), collection=collection) + assert obj.type == "POINTCLOUD" + assert len(db.pointcloud_data(obj).points) == 4 + assert obj.users_collection[0] == collection + + +def test_pointcloud_positions_and_no_orphan_mesh(): + positions = np.random.rand(6, 3) + n_meshes = len(bpy.data.meshes) + obj = db.create_pointcloud_object(positions) + assert len(bpy.data.meshes) == n_meshes + assert np.allclose(db.named_attribute(obj, "position"), positions) + + +def test_empty_pointcloud(): + obj = db.create_pointcloud_object() + assert obj.type == "POINTCLOUD" + assert len(db.pointcloud_data(obj).points) == 0 + + +@pytest.mark.parametrize( + "kwargs", [{"positions": np.zeros((5, 3))}, {"curve_sizes": [2, 3]}] +) +def test_curves_require_positions_and_sizes_together(kwargs): + with pytest.raises(ValueError, match="given together"): + db.create_curves_object(**kwargs) + + +def test_centroid_boolean_mask(): + bob = db.create_bob(np.array([[0, 0, 0], [2, 0, 0], [10, 10, 10]], dtype=float)) + assert np.allclose(bob.centroid(np.array([True, True, False])), [1, 0, 0]) + + +def test_centroid_unsigned_indices(): + bob = db.create_bob(np.array([[0, 0, 0], [2, 0, 0], [10, 10, 10]], dtype=float)) + assert np.allclose(bob.centroid(np.array([0, 1], dtype=np.uint32)), [1, 0, 0]) + + +def test_centroid_boolean_attribute(): + bob = db.create_bob(np.array([[0, 0, 0], [2, 0, 0], [10, 10, 10]], dtype=float)) + bob.store_named_attribute(np.array([True, True, False]), "selected") + assert np.allclose(bob.centroid("selected"), [1, 0, 0]) + + +def test_centroid_invalid_weight(): + bob = db.create_bob(np.zeros((3, 3))) + with pytest.raises(TypeError): + bob.centroid(np.array(["a", "b", "c"])) diff --git a/tests/test_object_identity.py b/tests/test_object_identity.py new file mode 100644 index 0000000..90ffdac --- /dev/null +++ b/tests/test_object_identity.py @@ -0,0 +1,140 @@ +"""Tests for how BlenderObjectBase tracks its object within and across sessions.""" + +import pickle + +import bpy +import numpy as np +import pytest + +import databpy as db +from databpy.addon import UUID_KEY, UUID_PROP_NAME + + +def _duplicate(obj: bpy.types.Object) -> bpy.types.Object: + dup = db.require(obj.copy()) + db.active_scene().collection.objects.link(dup) + return dup + + +def test_uuid_stored_as_custom_property(): + bob = db.BlenderObject(bpy.data.objects["Cube"]) + assert bob.object[UUID_KEY] == bob.uuid + # kept in sync with the deprecated registered property during the window + assert getattr(bob.object, UUID_PROP_NAME) == bob.uuid + + +def test_survives_rename_and_geometry_changes(): + bob = db.BlenderObject(bpy.data.objects["Cube"]) + bpy.data.objects["Cube"].name = "Renamed" + bob.data.vertices.add(4) + for i in range(20): + bob.data.attributes.new(f"attr_{i}", "FLOAT", "POINT") + + assert bob.name == "Renamed" + assert len(bob) == 12 + assert "attr_19" in bob.list_attributes() + + +def test_duplicate_is_not_mistaken_for_original(): + bob = db.BlenderObject(bpy.data.objects["Cube"]) + dup = _duplicate(bob.object) + # the copy shares the persistent uuid but has its own session_uid + assert dup[UUID_KEY] == bob.uuid + + bob.object.name = "Original" + dup.name = "Cube" + assert bob.name == "Original" + + +def test_removed_object_raises_instead_of_retargeting(): + bob = db.BlenderObject(bpy.data.objects["Cube"]) + _duplicate(bob.object) + bpy.data.objects.remove(bob.object) + + with pytest.raises(db.LinkedObjectError, match="has been removed"): + _ = bob.object + + +def test_object_claimed_by_another_wrapper_raises(): + # a new wrapper storing its own uuid on the object takes over from the old one + bob = db.BlenderObject(bpy.data.objects["Cube"]) + db.object.set_uuid(bob.object, "new-owner") + + with pytest.raises(db.LinkedObjectError, match="different uuid"): + _ = bob.object + assert db.create_bob(uuid="other").uuid == "other" + + +def test_reconnects_after_file_reload(tmp_path): + bob = db.BlenderObject(bpy.data.objects["Cube"]) + bob.store_named_attribute(np.arange(8), "ids") + old_session_uid = bob.object.session_uid + + path = str(tmp_path / "identity.blend") + bpy.ops.wm.save_as_mainfile(filepath=path) + bpy.ops.wm.open_mainfile(filepath=path) + + assert bob.object.session_uid != old_session_uid + assert bob.name == "Cube" + assert np.array_equal(bob.named_attribute("ids"), np.arange(8)) + + +def test_reconnects_after_file_reload_and_rename(tmp_path): + bob = db.BlenderObject(bpy.data.objects["Cube"]) + path = str(tmp_path / "identity.blend") + bpy.ops.wm.save_as_mainfile(filepath=path) + bpy.ops.wm.open_mainfile(filepath=path) + bpy.data.objects["Cube"].name = "Renamed" + + assert bob.name == "Renamed" + + +def test_reload_prefers_original_name_over_duplicate(tmp_path): + bob = db.BlenderObject(bpy.data.objects["Cube"]) + dup = _duplicate(bob.object) + dup.name = "Copy" + path = str(tmp_path / "identity.blend") + bpy.ops.wm.save_as_mainfile(filepath=path) + bpy.ops.wm.open_mainfile(filepath=path) + + assert bob.name == "Cube" + + +def test_unpickled_wrapper_reconnects_by_uuid(): + # simulates a wrapper restored in a new session, where cached session_uid + # values must not be trusted + bob = db.BlenderObject(bpy.data.objects["Cube"]) + restored = pickle.loads(pickle.dumps(bob)) + restored._session_token = "previous-session" + restored._session_uid = -1 + + assert restored.name == "Cube" + + +def test_migrates_legacy_uuid(): + obj = bpy.data.objects["Cube"] + db.addon._ensure_legacy_property() + setattr(obj, UUID_PROP_NAME, "legacy-uuid") + assert UUID_KEY not in obj + + bob = db.BlenderObject(obj) + assert bob.uuid == "legacy-uuid" + assert obj[UUID_KEY] == "legacy-uuid" + + +def test_create_bob_with_uuid(): + bob = db.create_bob(np.zeros((3, 3)), uuid="given-uuid") + assert bob.uuid == "given-uuid" + assert bob.object[UUID_KEY] == "given-uuid" + assert len(bob) == 3 + + +def test_object_tracker_orders_by_creation(): + with db.ObjectTracker() as tracker: + first = db.create_object(name="Zeta") + second = db.create_object(name="Alpha") + # renaming an existing object must not make it look new + bpy.data.objects["Cube"].name = "Renamed" + + assert tracker.new_objects() == [first, second] + assert tracker.latest() == second diff --git a/uv.lock b/uv.lock index f4923cc..7646cf8 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ css = [ [[package]] name = "bpy" -version = "5.2.1" +version = "5.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cattrs" }, @@ -189,23 +189,23 @@ dependencies = [ { name = "zstandard" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/00/3f/692445076cc58f7529af0683610401b947a2b50d54c7c76bfcc0baadd0bc/bpy-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b6a7f8df9bd6c46828a3e6b4a2ea247ef3a3afa7f31a6c7547cdea5aaee7316d", size = 245163979, upload-time = "2026-08-25T11:41:50.387Z" }, - { url = "https://files.pythonhosted.org/packages/e2/26/5ac58b6763071602fc097b57884f77e9826da6ee899614354928caa2ed89/bpy-5.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1027956fa65ce9fc0e3dc9b4ca410b8668a78be239202a133c9626b58fe92199", size = 401696706, upload-time = "2026-08-25T11:40:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/50/aa/0156ce4c788a7b616927b2970e81ab341a7d04cdcb7ff88f546c1e4884a8/bpy-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:70fccb611c97d52710b1752ad1aad515cd24f831ddefbf7031ad8360032a7fc1", size = 338626460, upload-time = "2026-08-25T11:33:49.541Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7d/4ca277c269fe1cb2f2ea7b148d7677bea0a7c47b166a04c0768f427564a6/bpy-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:f62f1619768408919e28383caccd6faa056550a1ba68e1af8384bd497f3460a7", size = 210628437, upload-time = "2026-08-25T11:36:07.269Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a9/12e3c22933ab3320767cf1bdc48bab61d6fc106492fd1872cdea7c9f02ab/bpy-5.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e447dba63ea14ac6a3f10ea23fa7828c6af2a3d5b2ba206ad2262a54d5aa7cf7", size = 245172189, upload-time = "2026-09-15T11:57:51.229Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a7/a26dc1a04934445b1f549969dee2ad3313d4ee1df5205c166df0e336a9ae/bpy-5.2.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3dfd1a9e7a5aec0f0fe95a1b01d10cd66e212b16ea38b77d132159dc9a666d45", size = 401697653, upload-time = "2026-09-15T12:01:03.162Z" }, + { url = "https://files.pythonhosted.org/packages/3f/43/07ffaf5f8cc23a02258d03d972784e93e1fdacbd5422fd75fc9dd7c1738b/bpy-5.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:a2d29dca205e094f8f501544c73009ab15b643fb611a27ddc1155dce2fc6995d", size = 338211575, upload-time = "2026-09-15T11:59:28.292Z" }, + { url = "https://files.pythonhosted.org/packages/f1/30/6417c20eec4f4dafe5cb4ea97b398cd777be9ff27e8775e6dfae12d79021/bpy-5.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:cbec727afdade8ddc0e53edb290acc1cc4cca37a20c7cf1d72dd6b044b20f556", size = 210628942, upload-time = "2026-09-15T11:56:45.883Z" }, ] [[package]] name = "cattrs" -version = "26.2.0" +version = "26.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/b2/42f4524e5479b090040b5fd8bb316dd8c65a079bb6492494ce2079dc91be/cattrs-26.2.0.tar.gz", hash = "sha256:3cf49f69df8326bcf17a3cb3d3d3ec4a856858fe3a7473746c9044c317d3ba55", size = 524993, upload-time = "2026-09-06T20:31:58.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/75/e72b839c3dc869c990b4842f3dba730bdcdf5215f68fc7955edf849a1792/cattrs-26.2.1.tar.gz", hash = "sha256:679132bfdc225c5ee40c024fc42519954767c387f950dc6751946c586bccdc6d", size = 525617, upload-time = "2026-09-26T20:53:21.114Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/4d/6b8460462012a52075ba97932197f8c141be71b8e5f7e918af08ee73424b/cattrs-26.2.0-py3-none-any.whl", hash = "sha256:c680f17cd1df3a1c038ad1db8d90a9c595568cc54c4e8098e7ceb8d7b9bd826b", size = 74830, upload-time = "2026-09-06T20:31:56.327Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/22794a399d99480486120e26e879ef008e21f5e85274c2ed591d568bb326/cattrs-26.2.1-py3-none-any.whl", hash = "sha256:a12aaa3453dc8f633a815293179f08b7421ed18d2575c459c3c736f840beac24", size = 74843, upload-time = "2026-09-26T20:53:19.767Z" }, ] [[package]] @@ -317,26 +317,26 @@ wheels = [ [[package]] name = "coverage" -version = "7.16.1" +version = "7.16.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/74/c08c0c4dc9fa6bcd1d90728a63660aa1b17b488a806948598456c48f75d1/coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", size = 223501, upload-time = "2026-09-13T19:09:37.276Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c8/784986d326663285258a8e39835c46fb730cc85284f0dbcd82078586dd22/coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", size = 223876, upload-time = "2026-09-13T19:09:39.385Z" }, - { url = "https://files.pythonhosted.org/packages/15/76/73fb792928872bbb07e553f920ff55c65ee962c469265feb5d1d4ae5f97a/coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", size = 254863, upload-time = "2026-09-13T19:09:41.326Z" }, - { url = "https://files.pythonhosted.org/packages/ca/8d/1fd78899513244b065ccecdd1cfc6aa8b8f1bdee796d8c4f2aa8e8e5397d/coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", size = 257460, upload-time = "2026-09-13T19:09:43.086Z" }, - { url = "https://files.pythonhosted.org/packages/47/1c/b23ddfcb7ef9bd7b3fdb7be9a5dccf9925ae88e556df7aa5b655283c78f2/coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", size = 258697, upload-time = "2026-09-13T19:09:45.161Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c9/19d0c6ca35778a7e9415c30c46a0c64c9d4374219d004a35563132296896/coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", size = 260827, upload-time = "2026-09-13T19:09:47.061Z" }, - { url = "https://files.pythonhosted.org/packages/97/fc/5862f7344382c62b85df43d483e579168cc062e007f54d895dfa51fe3e7d/coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", size = 255038, upload-time = "2026-09-13T19:09:48.945Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9f/5c26583199a68df8d15124b4590520903f8eb7cef17db293fea712bb0783/coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", size = 256827, upload-time = "2026-09-13T19:09:50.738Z" }, - { url = "https://files.pythonhosted.org/packages/95/13/605198bff079b107336710f28a797312ab132587168600d70a290e7ecd2e/coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", size = 254794, upload-time = "2026-09-13T19:09:52.577Z" }, - { url = "https://files.pythonhosted.org/packages/43/b7/0bbb32dc5ccdac766a13763fdfbffd7d73fc80349a69230c46c6b71e7508/coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", size = 258947, upload-time = "2026-09-13T19:09:54.372Z" }, - { url = "https://files.pythonhosted.org/packages/13/bd/65c31ddd43ff4e61b63721b3dad17cca412dba6e2ce89f92abdf75734339/coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", size = 254613, upload-time = "2026-09-13T19:09:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/25/20/d278115f2ba522b3e3128c6852be265f4e0df7202b2effca4db96cf0217d/coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", size = 256388, upload-time = "2026-09-13T19:09:58.095Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ca/d43aa396fb3a2f71c9411b99d926475f5acaf5c124f605c592865c80c8d5/coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", size = 225550, upload-time = "2026-09-13T19:09:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/0e/30/df2f6114f6a17cffe94721bd1d298f77f86aad493717fa5bc03cb291a1a6/coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", size = 226091, upload-time = "2026-09-13T19:10:02.331Z" }, - { url = "https://files.pythonhosted.org/packages/ee/31/6f90d8aab72a112492bd50e3b5485b53b772b1f5fa70da0a620e723caae6/coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", size = 225481, upload-time = "2026-09-13T19:10:04.151Z" }, - { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, ] [[package]] @@ -363,7 +363,7 @@ wheels = [ [[package]] name = "databpy" -version = "0.9.0" +version = "0.10.0" source = { editable = "." } dependencies = [ { name = "numpy" }, @@ -413,15 +413,15 @@ dev = [ [[package]] name = "debugpy" -version = "1.8.21" +version = "1.8.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/9d/3cb6693342acf96802dba89934a5b8da43c201d764ebd1f2c0514a3d42bd/debugpy-1.8.22.tar.gz", hash = "sha256:e489c7268e1c7b41e13b438d9c533d2a7af73fb59bf8cd30fead8286c1c39c4e", size = 1710877, upload-time = "2026-09-15T21:44:02.578Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, - { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, - { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, - { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f4/fee6d40f5865aa84e1100c9df1a0f2cf8991776b61f11f433ff277df4415/debugpy-1.8.22-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:c21dd7e1ec22556bb41dc3bf6b86c603e440ec889c4acb27d7206354b2106c54", size = 2493531, upload-time = "2026-09-15T21:44:25.835Z" }, + { url = "https://files.pythonhosted.org/packages/bc/4f/8df91270afbf61fc5ab202951f28451194a7eb093704b26da11cb0ecd308/debugpy-1.8.22-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:c1ffb9953708b648ecf6acd2e5ad2c002bf68785ed618197dd4463a2a7226f39", size = 3994351, upload-time = "2026-09-15T21:44:27.31Z" }, + { url = "https://files.pythonhosted.org/packages/4f/11/2e34a4f21d6155b0c58003fd6fca9819df7d6fe7d7acd83b146b648a57ee/debugpy-1.8.22-cp313-cp313-win32.whl", hash = "sha256:ba810a66b437e3c43ca0ce0892404f010338286263ca8e5108442d76a9485337", size = 5359071, upload-time = "2026-09-15T21:44:29.064Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f5/c2b3e8260388d7b76bc4e5a3a29648a0ca47dca6a2721d6549ff2d944eee/debugpy-1.8.22-cp313-cp313-win_amd64.whl", hash = "sha256:f49b1d6cecf326b63c06d7f517e4a6642777f758c58cb799e7bc5a6dd74721c2", size = 5400362, upload-time = "2026-09-15T21:44:30.857Z" }, + { url = "https://files.pythonhosted.org/packages/56/3d/c7dc9f35bc9e22cdb53679f844bee40fd5cff871862f60595869a433400d/debugpy-1.8.22-py2.py3-none-any.whl", hash = "sha256:a9e9d3550e15ca479c59333e90845029190531f0cacfedab3b815a57bd913947", size = 5374857, upload-time = "2026-09-15T21:44:54.454Z" }, ] [[package]] @@ -444,11 +444,11 @@ wheels = [ [[package]] name = "fake-bpy-module" -version = "20260913" +version = "20260927" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/d3/46a98881d2c74eb8471cef834e66f8c8561d0d0fa5e5d332836eb9571387/fake_bpy_module-20260913.tar.gz", hash = "sha256:2bc223b8f2065574788799911f94f3ca8b398e26862c6c215afebb270c9279da", size = 965735, upload-time = "2026-09-13T10:47:50.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/8e/60aa614ffc2baa468a49842e703fac8c04bc0f04f04fbd7482e23e414b7c/fake_bpy_module-20260927.tar.gz", hash = "sha256:f606e8824a2882ad0b29880c224840a21922479ebaa0671f5bc0ff9f677de417", size = 968115, upload-time = "2026-09-27T11:01:28.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/22/6dde4ac444c620c9b212a75b45342ce3edc3e459a3f22f9276c088625bd5/fake_bpy_module-20260913-py3-none-any.whl", hash = "sha256:c68c2addd3ac7c05ce486ba2109dd6f574f03f7e43ff89d6be01c14e31293120", size = 1113602, upload-time = "2026-09-13T10:47:48.387Z" }, + { url = "https://files.pythonhosted.org/packages/60/63/7281dd1efd8b960bd910ef36cf7c9f5a323c6025f64f8a908bce2c9fb0d3/fake_bpy_module-20260927-py3-none-any.whl", hash = "sha256:1a4b2233c2501044e60a284871866f4d71e7d22f78817fe4426cb7b22b7d1694", size = 1116188, upload-time = "2026-09-27T11:01:26.42Z" }, ] [[package]] @@ -520,11 +520,11 @@ wheels = [ [[package]] name = "idna" -version = "3.19" +version = "3.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -836,7 +836,7 @@ wheels = [ [[package]] name = "jupyter-server" -version = "2.21.0" +version = "2.21.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -858,9 +858,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/e0/63b481d5b21f81fe23173dfc9eb25629fa1bc174fdc4a2c19d09c1ff8124/jupyter_server-2.21.0.tar.gz", hash = "sha256:70d9a1883f57d3576ea17f4ce061ec1a7aad7ef388d00428cfb7f5e4f0022271", size = 760357, upload-time = "2026-08-27T16:06:34.049Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/3d/3c9f8bce5d5448107bd285cd80185e76c9c871d076b580dfd90997e52904/jupyter_server-2.21.1.tar.gz", hash = "sha256:a8960aa29263f6041283e97d4756b099fb49b767baab6371891ecb1bd40a63df", size = 761651, upload-time = "2026-09-15T16:00:07.853Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/8a/cb97c2b353cb46cced413be8f742fa8fcd3cb1659524f27314494aa94968/jupyter_server-2.21.0-py3-none-any.whl", hash = "sha256:2ae2e5ce5e97268553e25aebe040197455673d925b5fc7995477153318160cf7", size = 393961, upload-time = "2026-08-27T16:06:31.814Z" }, + { url = "https://files.pythonhosted.org/packages/1d/39/91bc08650cc8e3efeb7a83c25e20ebddcc9b70d28b4eb6f51a4fb55da7a4/jupyter_server-2.21.1-py3-none-any.whl", hash = "sha256:2a6467606af7dbae2e7e31640030025969e15db6a649eae334af90415dc71dca", size = 394641, upload-time = "2026-09-15T16:00:05.526Z" }, ] [[package]] @@ -878,7 +878,7 @@ wheels = [ [[package]] name = "jupyterlab" -version = "4.6.3" +version = "4.6.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-lru" }, @@ -895,9 +895,9 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/c0/45934229995d4c6e38192ca118d93185f60808f64157e3df3c5c28f8c5fd/jupyterlab-4.6.3.tar.gz", hash = "sha256:2e3db6e3a12495ebd188276e985bf5ac502fbde3d1e8628819920210008de498", size = 28319771, upload-time = "2026-08-10T18:50:57.947Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/8d/995cc142f6083346b35e7d3eadc5a6717ee89ce64ea53577010ac493bc3c/jupyterlab-4.6.4.tar.gz", hash = "sha256:404f49b081819378524886c9db66dba57a5565981eff885830df1baba3a17df5", size = 28335647, upload-time = "2026-09-21T15:25:18.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl", hash = "sha256:0a1ebc6567186f1eabd99536e94df7ed9e96d1e7c5ddf3e4406ae16e88abacb7", size = 17168312, upload-time = "2026-08-10T18:50:53.044Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/072bc0d3c6d45d414062a06af7ee81c02a2c12d769c358ec9919f9995ffd/jupyterlab-4.6.4-py3-none-any.whl", hash = "sha256:15b13f991d3985129c797eb84d9949eeb8b6615e14b444868e642411f2c418b2", size = 17171596, upload-time = "2026-09-21T15:25:14.123Z" }, ] [[package]] @@ -911,7 +911,7 @@ wheels = [ [[package]] name = "jupyterlab-server" -version = "2.28.0" +version = "2.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -922,9 +922,9 @@ dependencies = [ { name = "packaging" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/c5/08946a10b81bd64408d8b78cb352e17fe52d3ee11c2aef00a0f06e506938/jupyterlab_server-2.28.1.tar.gz", hash = "sha256:0c3c2418d51021ce280916e63dfe4cba8386b4e2787be5c25433c98f560ddb31", size = 78141, upload-time = "2026-09-15T19:22:09.935Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, + { url = "https://files.pythonhosted.org/packages/23/d3/996332fe24f3c2458b9c353b998570dfa2631763e9a8674ba3f94c0877d0/jupyterlab_server-2.28.1-py3-none-any.whl", hash = "sha256:4bd36c7c11d872e15cefa4951e12380a47e6901687165332dd50a007cb367ad9", size = 60197, upload-time = "2026-09-15T19:22:08.201Z" }, ] [[package]] @@ -1083,25 +1083,25 @@ wheels = [ [[package]] name = "nest-asyncio2" -version = "1.7.2" +version = "1.7.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a6/a2775b388a14b5ea0c894bbe513c0d3e04e65e8ae583e60f5357722b8e03/nest_asyncio2-1.7.3.tar.gz", hash = "sha256:2e9a84d5d1efe6d020c72988d21aec569bac42d98af2ff6b9de24640c5d22a34", size = 15642, upload-time = "2026-09-22T17:15:06.709Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, + { url = "https://files.pythonhosted.org/packages/29/dd/a8bebc78975a7466932077a0973cbab4361a32368b1d28d40683ec19333c/nest_asyncio2-1.7.3-py3-none-any.whl", hash = "sha256:2bc87bdca654e719425145f5e84eaeb0080b013fdd47d65729a7d66243f4987c", size = 8364, upload-time = "2026-09-22T17:15:05.492Z" }, ] [[package]] name = "nodebpy" -version = "520.24.0" +version = "520.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/3b/5a8a270ac0fbfb2caee2c7554c55e5e9f5974e66cf62e2c3ca6f4c3b1b76/nodebpy-520.24.0.tar.gz", hash = "sha256:245467f147f5c5e5071b2872452cea2ff3adc53cb83e31210eb1231dbf580068", size = 403820, upload-time = "2026-09-13T12:35:16.663Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/8c/f89446a45a88882afd0edda0e36db40be78f72e8820ef62747bc7d00ef9c/nodebpy-520.29.0.tar.gz", hash = "sha256:a354022dbcb46e509fe143eacac111ac3fb7537e86ca6c51ee842e27ff3078e0", size = 439441, upload-time = "2026-09-20T22:22:06.537Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/65/96ffd7d1b079ec6acb16ddef310c26c2a6f2e9e4b0ff50b2fd6958d4b202/nodebpy-520.24.0-py3-none-any.whl", hash = "sha256:280158c738d50de886ef87dfb05b5f757722ff9ee5b79b1cad9b711d151f9896", size = 448544, upload-time = "2026-09-13T12:35:14.716Z" }, + { url = "https://files.pythonhosted.org/packages/52/1f/c64393282fcd56723f1f0b256415857fae9636331eb744ac5e15cb23b145/nodebpy-520.29.0-py3-none-any.whl", hash = "sha256:9ab80891c7c607be0ac72b26840db6c6695f3186844fc61a5b7b0f2feedd74f4", size = 485476, upload-time = "2026-09-20T22:22:04.883Z" }, ] [[package]] name = "notebook" -version = "7.6.2" +version = "7.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-builder" }, @@ -1111,9 +1111,9 @@ dependencies = [ { name = "notebook-shim" }, { name = "tornado" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/d9/5c76de84e96e1cf8aae3fce930875e0615e14f3557bbcdb634aab52da9d3/notebook-7.6.2.tar.gz", hash = "sha256:cc02b5f0bb972160cccfe44ad8a1a202036206ba3439469c514f03aefa9ae807", size = 5500147, upload-time = "2026-08-11T13:21:16.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/ac/aedb759dc683dcd129f6c75271bab5eb23020b0f7969163c726343718e1e/notebook-7.6.3.tar.gz", hash = "sha256:e2c08e469c0ae20bb0b3214f0ab77e79653317a2f8e5b34c10361c66874a5b50", size = 5501618, upload-time = "2026-09-21T18:07:24.261Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/fd/3e552ff5b24dd305c6e9a10e8645e29c03091c317429f326ae10dbae1ac6/notebook-7.6.2-py3-none-any.whl", hash = "sha256:5fe9e09c335cb4b7de21627b860f77210e70e54b1fb1276ad942a4a7e1d858d3", size = 5547102, upload-time = "2026-08-11T13:21:13.302Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f7/907b98438cf00bdc4e296570058f29a347214bb42338a48e3b7eae58ae2a/notebook-7.6.3-py3-none-any.whl", hash = "sha256:ad7e0eb765fba836cd4a2ab0c7a3a26cde1d91665fbf6f533b6ae7b2de6d88d2", size = 5548452, upload-time = "2026-09-21T18:07:21.563Z" }, ] [[package]] @@ -1197,11 +1197,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.8" +version = "4.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/18/f3bb8ef0d3b930692343da8aa4d3cbcd6749477c053959395ac81965a6e9/platformdirs-4.11.8.tar.gz", hash = "sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc", size = 37182, upload-time = "2026-09-08T22:20:42.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/4d/e78afe1b449720c481884ca0a2f960f85f9ffdaa34b2d127b5427422c564/platformdirs-4.12.0.tar.gz", hash = "sha256:095be5c143382b1bee917c4f3e9987a0d8d6a582261f1d061ad0c403b7695b5b", size = 58964, upload-time = "2026-09-26T15:26:00.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/e1/5b7b8bbb55084d1425bcb9bc823ff519e1b2be05f6ebb0089e2eacc38413/platformdirs-4.11.8-py3-none-any.whl", hash = "sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081", size = 24027, upload-time = "2026-09-08T22:20:41.537Z" }, + { url = "https://files.pythonhosted.org/packages/60/3a/723ad221b7fef91ccd947f9fddbe84d38fa573e2723317aaa67bfde41015/platformdirs-4.12.0-py3-none-any.whl", hash = "sha256:f6fb2960f2f2eb0870f820f7e49e49e6c0f0589c637f34d33b260b07591327c9", size = 32965, upload-time = "2026-09-26T15:25:59.496Z" }, ] [[package]] @@ -1632,27 +1632,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, - { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, - { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, - { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, - { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, - { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, - { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, - { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, - { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, ] [[package]] @@ -1675,11 +1675,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.9.2" +version = "2.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/c3/1b817965ac12dc002d7c9cd7dfffdd7d4fbf9b45763ef2ffe7b86ee94670/soupsieve-2.10.tar.gz", hash = "sha256:49e9380d7d2905463583bafe285e818c7366a9ed7b3aee221c1ac79c905d8bc0", size = 124337, upload-time = "2026-09-24T02:36:35.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, + { url = "https://files.pythonhosted.org/packages/66/87/5ed59e1d0290564e2027ed066c52b059892c4637741337fc0967183c8d4d/soupsieve-2.10-py3-none-any.whl", hash = "sha256:8596eb8967d744174820280fa62b4542a2e955bfaccca73ed8a13c6eb8e9b502", size = 38019, upload-time = "2026-09-24T02:36:33.709Z" }, ] [[package]] @@ -1747,19 +1747,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.8" +version = "6.5.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/61/53d562a57b28c08eda40b258c0f975e360541943ad7c7bef897a40caafda/tornado-6.5.10.tar.gz", hash = "sha256:a6b1ccd08c04b4a06fb5aeb381be99de5ad1e5375c1785e31d78c880feb57687", size = 537910, upload-time = "2026-09-15T13:47:48.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" }, - { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" }, - { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" }, - { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" }, - { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" }, - { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" }, - { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" }, - { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9261783640e23258694a9ff0795df430a5a7b0a651d3dd53dd0969ad6be16da7", size = 465883, upload-time = "2026-09-15T13:47:35.463Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f5/cd7be26c34a3315532f3aef5f092465da8f59c334dd439d3c14aaef16461/tornado-6.5.10-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:83e6cf438b106c6b3852d70960967bb1b70c87438050dca0981e4b9aa751a4c1", size = 464046, upload-time = "2026-09-15T13:47:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/60/33/df6d7d04854a58619f8349a51e3edb138324130a7562b0bb21f115bb940f/tornado-6.5.10-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bdf942448169e5336451d0494d7e3d81cfa726d5aa312affdc4682dd62a62f6d", size = 467096, upload-time = "2026-09-15T13:47:38.559Z" }, + { url = "https://files.pythonhosted.org/packages/29/17/cc35dff68272d685cffd8600ffafbd8067e7d05e7348d9f80caddffbbd5f/tornado-6.5.10-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69acca6501eed74582b76dbbceee2a91613f54728e3e418346000d7103101676", size = 468067, upload-time = "2026-09-15T13:47:40.085Z" }, + { url = "https://files.pythonhosted.org/packages/c3/01/6e5349b4e1a53a4b4972a6716785e1fe7407f312063c3972690af8ff301b/tornado-6.5.10-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:66aaa3f57d30c6e6becee83ff28055d5930ac724214bde99393eefda83d5e015", size = 467901, upload-time = "2026-09-15T13:47:41.576Z" }, + { url = "https://files.pythonhosted.org/packages/28/5e/b4facf94370dba006819c8d304376f8b9fbec6b935b5e51bf45823a9790b/tornado-6.5.10-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4bd192b959f9128fb99b8898148070ba4574c9589b78bce42d1851131fe85828", size = 467308, upload-time = "2026-09-15T13:47:43.145Z" }, + { url = "https://files.pythonhosted.org/packages/56/ae/047938e828cafc8eca4c908fafb6588fee944e3af39a0af9d7b602499ae5/tornado-6.5.10-cp39-abi3-win32.whl", hash = "sha256:302eb1e0e3e159314eb591920529fdea80acca92df5510a2cec5bbd4f099ec72", size = 468387, upload-time = "2026-09-15T13:47:44.556Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d4/5901517f05affd752490f6a654ba31b7474664e8dd80bd045a00c220bd88/tornado-6.5.10-cp39-abi3-win_amd64.whl", hash = "sha256:37ae8f150cecfdbf747fc4e12f5e9a97ecd8cf1d4cdb3f119e2de84b11196918", size = 468828, upload-time = "2026-09-15T13:47:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/fd497f3a7f7b74bb04f4b94536b5c9f80742b5d50501fd27977652ddec16/tornado-6.5.10-cp39-abi3-win_arm64.whl", hash = "sha256:ce045d3c298fddd30e89a2777f97039d1b641eb9518ac7b26a4721903539c694", size = 467847, upload-time = "2026-09-15T13:47:47.283Z" }, ] [[package]] @@ -1773,27 +1773,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.80" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/b0/6d1b10e0d422736a3c439e487c950ae785f401d71ff879d5be68bccb6d90/ty-0.0.80.tar.gz", hash = "sha256:fe86bc91327e45ff5e3593b7e306e7f57a44bc0608f38d647b8d97bd99c96013", size = 7183998, upload-time = "2026-09-09T21:18:47.547Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/c8/3c93195eca282936ebb574d0ba98843e4b147ee324006f88464777012a92/ty-0.0.80-py3-none-linux_armv6l.whl", hash = "sha256:738d1cfca466c577aea24547c348629c00c63548cf7da2c46b497b3840f85dee", size = 13606509, upload-time = "2026-09-09T21:18:10.476Z" }, - { url = "https://files.pythonhosted.org/packages/e7/48/bbb47f7001c97262a5109bcd92a45bcc8a2fbb21e994c214a9897630bfb7/ty-0.0.80-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:56060164bb8ee43770fa367524fbdba2611fc90f8923a02cc91f80eb37d5a1b0", size = 13203812, upload-time = "2026-09-09T21:18:12.796Z" }, - { url = "https://files.pythonhosted.org/packages/db/8d/fd141160567047b742e466be8ed09a4c4ed80fa045d37f946eb4f4532fee/ty-0.0.80-py3-none-macosx_11_0_arm64.whl", hash = "sha256:da4062e0fbf3923d9b71688157c5753394f243348f00732e9edbb27498397169", size = 13021896, upload-time = "2026-09-09T21:18:15.186Z" }, - { url = "https://files.pythonhosted.org/packages/1e/10/b4177faf9e71bc37bc4d08f512042269660a1ce0ec457c48f96e51fc91d7/ty-0.0.80-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9eb94b2659f506a3a07a9ea1415d5c18aaa1e554adc1612614d617a0ed320e1", size = 13083881, upload-time = "2026-09-09T21:18:17.616Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c2/192d48b9a9acbbe030fc9a4bd73af75671a5566e731949f77e912a68ce68/ty-0.0.80-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2f5e39a87da48c1af1a3b3135f02be7a83a93da30b0d6a5f5d27806e7c747ce", size = 13354874, upload-time = "2026-09-09T21:18:19.744Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5b/736dfd31efd98bed9dbe018ff91676e5ea83485b1e24afb635fd0247728f/ty-0.0.80-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e00149e7779c6b98f3ba12e10a631a861bd7ca1a42bbf064e2677ada7d2c0c2", size = 14204805, upload-time = "2026-09-09T21:18:21.999Z" }, - { url = "https://files.pythonhosted.org/packages/fd/6f/557c726cb53401998e3589bb632c963a6887a70240e0a4386024f3731c81/ty-0.0.80-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a377268f359fb6e7a2cc9026a09223c764f58d020dec8e6baeaba9caba6ff829", size = 14630014, upload-time = "2026-09-09T21:18:24Z" }, - { url = "https://files.pythonhosted.org/packages/e9/19/7a4b18fe27f6b6bd4ffa56b67c8b09ecb76bb4312d1c64b73f65417b4dbd/ty-0.0.80-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4511dbf1b266b9ce5b73e9b28d94468096c2b6da00e2fe205168c32d3b352ca7", size = 14326946, upload-time = "2026-09-09T21:18:26.679Z" }, - { url = "https://files.pythonhosted.org/packages/f8/95/16dd90805fc7e53ec54ae9fdb1a8b74753fb159a55a170f60f49b9417824/ty-0.0.80-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe95feffa7156800c6f804195acb9fb5846a39671c7192c30fff23351aaf7c31", size = 13705988, upload-time = "2026-09-09T21:18:28.681Z" }, - { url = "https://files.pythonhosted.org/packages/b1/09/4e87992c23ab8a742c7d4914ac5fe66fb9301c8464a69fd2ecbc0be3fbcd/ty-0.0.80-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba26b39f06bc8c3c2c5acd3a147239482b36620cdbca1b9d02e915294854f2cc", size = 14233729, upload-time = "2026-09-09T21:18:30.961Z" }, - { url = "https://files.pythonhosted.org/packages/36/e9/b8c11fda8e66a1d1cc1a7160ed719a33f4dc0107b1cef6a14e2673965763/ty-0.0.80-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2e6167320888c115a6fbe69893b63fd1c848f9121a454e75e5f612674528e3f3", size = 13173523, upload-time = "2026-09-09T21:18:33.266Z" }, - { url = "https://files.pythonhosted.org/packages/73/a7/512083fa540c5be1ac8642658f03bfb5bfa6fd168ea4f55e3c0aabe04166/ty-0.0.80-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7aecb62b4de70b479eab07d1779ea66da26dd8b068f50a9330867157312f103c", size = 13373014, upload-time = "2026-09-09T21:18:35.339Z" }, - { url = "https://files.pythonhosted.org/packages/5e/63/cd2f0ca81fd9b8cafef93bbcf022bf636bc4de8ee7465c6aafeec1d4d52b/ty-0.0.80-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4fed06adacd16b7e2d722f37449021b1419598d0440769701c0f4827faddde63", size = 13667827, upload-time = "2026-09-09T21:18:37.241Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5d/ebdfdcb2dc099ae74dc4b75511eef0dd398b2fa27006c543543974b4567f/ty-0.0.80-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:09329af6303ce611ec2cfffc995dc1ddf76c9f47194fc1a4d64a984759c25f5b", size = 13963866, upload-time = "2026-09-09T21:18:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/c286d5e1b560cfd16d6e91fdab683718aacf09fc88938ca06dcc9d1c8502/ty-0.0.80-py3-none-win32.whl", hash = "sha256:a81b3b512f7b4c68e42fbd1835633de658809666e9aafd5defa52bbc5197698d", size = 12881806, upload-time = "2026-09-09T21:18:41.507Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c8/3b3a8ac16d47a23ff34bf57c0d99a491860748aea96e0e886b4bfed54f9b/ty-0.0.80-py3-none-win_amd64.whl", hash = "sha256:8043f99878a2ae434781cb881c8a3840dff70c4a5b60bdc5ae84251f35ee063f", size = 13528883, upload-time = "2026-09-09T21:18:43.687Z" }, - { url = "https://files.pythonhosted.org/packages/2e/71/a6c697930fca76596d7d17f8853120f41a298fd9ea632c901cfca8ca869b/ty-0.0.80-py3-none-win_arm64.whl", hash = "sha256:e277ef034331da5319efc839c064968d24f35715b71c70369cf97d36fe4dcbdb", size = 13370135, upload-time = "2026-09-09T21:18:45.758Z" }, +version = "0.0.84" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" }, + { url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" }, + { url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" }, + { url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" }, + { url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" }, + { url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" }, + { url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" }, ] [[package]] @@ -1837,11 +1837,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] @@ -1867,11 +1867,22 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" }, +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/ac/3a943d2792c9bb368aaa8b50121c0f778460ba2d7fbdc0a0366201d9e761/wcwidth-0.9.1.tar.gz", hash = "sha256:5823209b0d43af322ce698c689380d7c15ca31fa8e6e3be8459f27031bef0af5", size = 952410, upload-time = "2026-09-23T20:23:49.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/7c/130de33c0a7f6efecee28fef5711fc1f8d55ac33885a6b85f4f6689004ba/wcwidth-0.9.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:10b00ba23482e352f874d2e8135e7ace9da838646c7dd800566246bbd46125ff", size = 604219, upload-time = "2026-09-23T20:23:22.071Z" }, + { url = "https://files.pythonhosted.org/packages/33/7c/c7bb03da54de7ac5a08753e186b963f91847b04ed8ae5be136a4d5f52b75/wcwidth-0.9.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:40d936d72c9bdc10df43f93a8be502bc5024b487259139f66a328722c07f34a9", size = 609038, upload-time = "2026-09-23T20:23:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/0b/71/f57effdf895c2d6d49333f1d6833a9f5438725c55a04ed3124a1c95bb3aa/wcwidth-0.9.1-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:69bb970cf5652b88cfdb9d3fdd1764fc15e5f8ad531643e7bb3e894cd969740e", size = 761464, upload-time = "2026-09-23T20:23:32.425Z" }, + { url = "https://files.pythonhosted.org/packages/4f/16/15b03d8fb7a747d0d2cf4a33da7b1b3e092493c58780f6589d5b1e9e3b49/wcwidth-0.9.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5da43d6967668982e44a52fb551967d293f86d26cd86036ac95bbdd34394ed9", size = 770483, upload-time = "2026-09-23T20:23:28.128Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a0/834886b30e3b885a5a96e8358f1b3917bc0bac43ae4178d77f0c210d079b/wcwidth-0.9.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:747fb724223f417a17541a95a17c1dac3a8ef9a0cf41684950f0eab191a35f65", size = 768443, upload-time = "2026-09-23T20:23:33.875Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ae/1b7597e0132ebc2c920632f39d8c1c61881345c9b480c0a95eb91e7460fa/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6e1272b7986cefe79783737e38bdb9eaae0682b333c7bd132024441193dd5ce7", size = 781171, upload-time = "2026-09-23T20:23:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/32149e3b481953f4ef35f38f166e2532886d33ae54ccdd71393f821dfad9/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:fe021c4d8de9d36c31a0cb41d0d2546dadd3b0708a301f1a0c66ce200851831f", size = 781576, upload-time = "2026-09-23T20:23:35.504Z" }, + { url = "https://files.pythonhosted.org/packages/d6/72/1ee3dff67697bda93bc56ec9f7b3b89609cd64b9abb328512a2f22eb9e47/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:708158c082364af442f9983de7b6ec9ac0d2e1b825ada25f0911b1f138d55405", size = 783526, upload-time = "2026-09-23T20:23:36.937Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0a/e94f19a60f6127bcf3dfcf223ca05a37650d57b37b5ed623597c842ce7ca/wcwidth-0.9.1-cp310-abi3-win32.whl", hash = "sha256:356376852357b8fca71fe5415808ec421679e04b4a98eb7c9cb6a7984b911a05", size = 592136, upload-time = "2026-09-23T20:23:43.774Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ff/d884d2ec7dcdc86cda742a86c7160bc726dbccae76d7ae1f9ac833c26680/wcwidth-0.9.1-cp310-abi3-win_amd64.whl", hash = "sha256:991d1c8834f548e9c1f16432075ee84638e122312556bbf1ed595ea8fffc4673", size = 596738, upload-time = "2026-09-23T20:23:44.989Z" }, + { url = "https://files.pythonhosted.org/packages/fa/93/16e30d617b937272a2310a6e3c1f9d8f94984e19ad4a96eb935628e3e6b6/wcwidth-0.9.1-cp310-abi3-win_arm64.whl", hash = "sha256:61bd7aef9cafb6cb77a37a169998d7928ce82a51522146d11f60db9e7d1cb43a", size = 594700, upload-time = "2026-09-23T20:23:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/96/a2/f06f2e0be4895e5943d358755b51968ace85774cf0a4cc42b4dafb793832/wcwidth-0.9.1-py3-none-any.whl", hash = "sha256:e0c3a1c45c5b9550c6919a4449e95f5b177f6e165786376981db8f1addae9b21", size = 300656, upload-time = "2026-09-23T20:23:20.674Z" }, ] [[package]]