Source code for simvx.core.descriptors

"""Descriptors, enums, and type aliases used throughout the engine.

``Signal`` and ``Connection`` live in ``simvx.core.signals``.
"""

import logging
import numbers
from collections.abc import Callable, Generator
from enum import Enum, IntEnum, auto
from typing import Any

from .math.types import Vec2, Vec3


def _has_shape(v: Any) -> bool:
    """Return True for array-like values (numpy ndarray, Vec2/Vec3) whose
    equality comparison would return another array rather than a scalar bool.
    """
    return hasattr(v, "shape")


log = logging.getLogger(__name__)

Coroutine = Generator[None]

# Sentinel used by Property to distinguish "no default supplied" from a default of None.
_UNSET: Any = object()


[docs] class CoroutineHandle: """Cancellable handle returned by Node.start_coroutine(). The runtime drives coroutines with ``gen.send(dt)`` after the initial prime, so coroutines may receive the per-tick delta time via ``dt = yield``. Coroutines that yield without binding the value still work: the sent dt is simply discarded. """ __slots__ = ("_gen", "_cancelled", "_primed") def __init__(self, gen: Coroutine): self._gen = gen self._cancelled = False self._primed = False
[docs] def cancel(self): """Cancel this coroutine. It will be removed on the next tick.""" self._cancelled = True
[docs] @property def is_cancelled(self) -> bool: return self._cancelled
# ============================================================================ # UpdateMode: Controls node processing when tree is paused # ============================================================================
[docs] class UpdateMode(IntEnum): """Controls whether a node processes when the SceneTree is paused. Set via ``node.update_mode = UpdateMode.ALWAYS`` (and similar). The effective mode is resolved by walking up the tree until a non-INHERIT ancestor is found; the resolved value is cached and invalidated on reparent. Pause the tree with ``tree.paused = True``. When paused, only ``ALWAYS`` and ``PAUSED_ONLY`` nodes run their ``process`` / ``physics_process``. Because INHERIT walks ancestors, an ``ALWAYS`` mode set high in the tree propagates to every INHERIT descendant: defeating the pause for the whole subtree. Set ALWAYS only on leaf nodes or CanvasLayers, never on a game root with gameplay children. """ INHERIT = 0 # Use parent's mode (default) PAUSABLE = 1 # Stops when paused (normal game nodes) PAUSED_ONLY = 2 # Only runs when paused (pause menus) ALWAYS = 3 # Always runs regardless of pause state DISABLED = 4 # Never runs
[docs] class Notification(IntEnum): """Notifications dispatched to nodes during lifecycle and property changes.""" TRANSFORM_CHANGED = auto() VISIBILITY_CHANGED = auto() ENTER_TREE = auto() EXIT_TREE = auto() READY = auto() PARENTED = auto() UNPARENTED = auto() PROCESS = auto() PHYSICS_PROCESS = auto()
# ============================================================================ # Property: Editor-visible property descriptor # ============================================================================ # Coercing variants of Property (and of its subclasses), keyed by base class. # Built on demand at class-definition time, never in a hot path. _COERCING_CLASSES: dict[type, type] = {} def _callable_name(fn: Any) -> str: return getattr(fn, "__name__", None) or repr(fn) def _coerce_failed(prop: Property, obj: Any, value: Any, exc: Exception) -> str: """Build the message raised when a Property's ``coerce`` callable rejects a value.""" convert = prop.coerce owner = type(obj).__name__ if isinstance(convert, type) and issubclass(convert, Enum): allowed = ", ".join(repr(member.value) for member in convert) return ( f"{owner}.{prop.name}: {value!r} is not a valid {convert.__name__}. " f"Expected one of {allowed}, or a {convert.__name__} member." ) return f"{owner}.{prop.name}: cannot convert {value!r} with {_callable_name(convert)}: {exc}" def _resolve_coerce(coerce: Callable[[Any], Any] | None, default: Any) -> Callable[[Any], Any] | None: """The conversion callable a Property will actually use. An explicit ``coerce=`` always wins. Otherwise an ``Enum`` member used as the default implies its own type: such a property converts whatever it is given into a member, so identity comparisons hold wherever it is read and a value restored from a saved scene (where the member arrives as the plain ``str`` / ``int`` it wraps) comes back as the member it was written from. """ if coerce is not None: return coerce if isinstance(default, Enum): return type(default) return None def _coercing_class(base: type[Property]) -> type[Property]: """Return (and cache) the subclass of ``base`` that applies ``coerce`` in ``__set__``. Coercion lives in a separate class rather than in ``Property.__set__`` so a Property declared without ``coerce`` keeps the exact setter it had before the feature existed: no extra attribute read, no extra branch. """ variant = _COERCING_CLASSES.get(base) if variant is None: def __set__(self, obj, value): try: value = self.coerce(value) except (TypeError, ValueError) as exc: raise ValueError(_coerce_failed(self, obj, value, exc)) from None base.__set__(self, obj, value) variant = type( f"Coercing{base.__name__}", (base,), {"__slots__": ("coerce",), "__set__": __set__, "__module__": base.__module__}, ) _COERCING_CLASSES[base] = variant return variant
[docs] class Property: """Descriptor for editor-visible, serializable node properties. Declares a typed, validated property that the editor inspector can display and that the scene serializer persists automatically. Args: default: Default value. Type is inferred from this (float, str, bool, Vec2, ...). range: ``(lo, hi)`` clamp bounds for numeric values. A ranged property only accepts numbers (plus ``None`` when its default is ``None``); anything else, ``bool`` included, raises ``TypeError`` at assignment. A non-builtin scalar such as ``np.float32`` or a 0-d numeric array is converted to the builtin it stands for, so the stored type does not depend on where the value came from. enum: Allowed values list: the editor renders a dropdown. coerce: Callable applied to an incoming value before validation, so the property can accept a friendly spelling at the boundary and store the canonical one. Implied for an ``Enum`` default. hint: Tooltip / description shown in the inspector. link: When ``True``, the resolved value is the *sum* of this node's stored value and the parent's value (numeric / vector types), or the parent's value for other types. Useful for cumulative offsets that propagate down the tree. propagate: When True, bool/enum Properties inherit disabling values from parents. persist: When True, the value is included in ``SaveManager`` snapshots. save_version: Optional integer schema version recorded alongside the persisted value. Value contract: **A value with a unique correct interpretation is converted silently at the boundary; anything ambiguous or lossy is rejected there.** This is the rule every typed subclass follows. ``"idle"`` for an ``Enum`` property, an ``int`` for a ``float`` one, a two-item sequence for a ``Vec2``: each has exactly one right reading, so it is accepted and stored in canonical form. A string that names no member, or a three-item sequence for a ``Vec2``, has none, so it raises at the assignment that wrote it rather than surfacing later as a wrong value. Numeric ``range`` is the one place a wrong *number* is neither converted nor rejected: with the default ``clamp=True`` an out-of-range number is folded to the nearest bound, and ``clamp=False`` lets it through untouched (see the ``range`` and ``clamp`` arguments). A non-number is still rejected: a range is a promise that the value is numeric. NaN is rejected under both settings, being no magnitude at all. Serialization: ``simvx.core.scene_io`` walks ``node.get_properties()`` and emits any value that differs from *default* into the ``.py`` scene file. Loading passes those stored values as kwargs to the node constructor, which feeds them through ``__set__`` for validation. Storage: ``attr`` is the instance slot the value lives at, and it is **subclass-facing**: a Property subclass may read and write it, a consumer must not. A consumer that wants the value a snapshot should carry calls :meth:`get_raw`, and one that puts a value back calls :meth:`set_raw`. Those two honour :attr:`storage_backed`, which a derived Property (one that computes its value from other state and owns no slot) sets ``False`` on its own class. Reading ``prop.attr`` directly asks a question a derived Property has no answer to, and gets the declared default back instead of the live value. Usage:: class Player(Node2D): speed = Property(5.0, range=(0, 20), hint="Movement speed") mode = Property("walk", enum=["walk", "run", "fly"]) state = Property(State.IDLE) # accepts "idle", stores State.IDLE angle = Property(0.0, coerce=math.radians) # accepts degrees, stores radians Subclassing: Two shapes, and they are not variations of one another. A **validating** subclass keeps the storage and narrows what may go into it, by overriding ``__set__``, checking, and delegating to ``super().__set__``. :class:`~simvx.core.properties.NodePath` and :class:`~simvx.core.properties.Bitmask` are the shipped examples. Every feature above still applies, because the base setter still runs. A **derived** subclass owns no storage: its value is computed from other state, and reading and writing it are the only way that state is expressed. ``DirectionalLight3D.direction`` is the shipped example, over the node's own rotation. The rules, which the base class cannot enforce: 1. **Override both accessors, and delegate to neither.** ``Property.__set__`` writes ``self.attr`` and ``Property.__get__`` reads it, so a half-delegating derived Property writes to a slot nothing reads. 2. **``range``, ``enum``, ``coerce``, ``link`` and ``propagate`` do not apply**, because all five live inside the two accessors that were replaced. Validation goes in the overriding ``__set__``. 3. **Set ``storage_backed = False`` on the subclass.** That is what sends :meth:`get_raw` and :meth:`set_raw` through the accessors instead of at a slot that does not exist. Hot reload captures with ``get_raw``; without the declaration it reads the declared default off a missing slot and the restore writes that default over the live value. 4. **``default`` is still worth declaring**, because the scene emitter compares against it to decide whether the value is worth writing. ``__set_name__`` runs for a derived Property like any other and sets both ``name`` and ``attr``. ``name`` is live: it appears in error messages, in the inspector and in the emitted kwarg. ``attr`` is vestigial, and ``storage_backed = False`` is the statement that nothing should read it. Before that flag existed, the one derived Property in the engine overrode ``__set_name__`` to point ``attr`` at the PUBLIC name, so a raw read resolved through the descriptor by accident. That worked, and it was a lie about what ``attr`` means; the flag replaced it. """ __slots__ = ( "default", "default_factory", "range", "enum", "hint", "name", "attr", "link", "_propagate", "group", "on_change", "coalesce", "persist", "save_version", "scalar", "clamp", ) #: Conversion callable, present ONLY on coercing Properties (those declared #: with ``coerce=`` or with an ``Enum`` default), which are instances of the #: coercing subclass that owns the slot. #: Annotation only: it must not become a class attribute here, or the #: subclass could not declare the slot. coerce: Callable[[Any], Any] #: Whether this Property's value lives at ``self.attr`` on the instance. #: A derived Property (one that overrides both accessors and computes its #: value from other state) sets this False on its own class, and raw-storage #: consumers then go through ``__get__`` / ``__set__`` instead of the slot. #: NOT in ``__slots__``: it is a property of the descriptor class, not of an #: individual declaration, and leaving it off the slots list is what lets a #: subclass override it with a plain class attribute. storage_backed: bool = True
[docs] def __new__(cls, *args, coerce: Callable[[Any], Any] | None = None, **kwargs): # A coercing Property is built from a coercing subclass (see # :func:`_coercing_class`) so the uncoerced case keeps the plain # ``__set__`` and pays nothing for the feature. default = kwargs.get("default", args[0] if args else None) if _resolve_coerce(coerce, default) is not None: cls = _coercing_class(cls) return object.__new__(cls)
def __init__( self, default: Any = _UNSET, *, default_factory: Callable[[], Any] | None = None, range=None, enum=None, coerce: Callable[[Any], Any] | None = None, hint="", link=False, propagate=False, group="", on_change: str | None = None, coalesce: bool = False, persist: bool = False, save_version: int | None = None, scalar: bool = False, clamp: bool = True, ): """Create an editor-visible property descriptor. Args: default: Default value for the property. Mutually exclusive with ``default_factory``. Mutable containers (``list``, ``dict``, ``set``, ``bytearray``) are rejected here because a single shared instance would alias across every owning object: pass ``default_factory`` instead. default_factory: Zero-arg callable invoked the first time each instance reads the property. The result is cached on the instance, matching ``functools.cached_property`` and ``dataclasses.field(default_factory=...)`` semantics. range: Optional (min, max) tuple for numeric values. By default (``clamp=True``) assignments are hard-clamped into ``[min, max]``, which doubles as input validation (audio safety, save-load sanitisation, [0,1] enforcement rely on this). With ``clamp=False`` the range is a *soft* hint: it sets the editor inspector field's default bounds (which stretch to cover a value outside them), and assigned values pass through unclamped. Use ``clamp=False`` for physical magnitudes whose range is a suggestion, not a limit (e.g. particle ``speed`` in pixels/sec). enum: Optional list of allowed values. coerce: Optional single-argument callable that converts an incoming value into the form the property stores. It runs FIRST, before every other check: coerce, then ``scalar`` rejection, then ``range`` clamping, then ``enum`` membership, then the change-detection and ``on_change`` machinery. Everything downstream therefore sees the converted value. The declared ``default`` is converted once at class-definition time so reads of an unwritten property return the canonical form too (``default_factory`` results are used verbatim). Passing an ``Enum`` type is the common case: ``coerce=BodyMode`` lets callers write ``mode="static"`` while the node stores ``BodyMode.STATIC``, so identity comparisons downstream hold. A value the callable rejects raises ``ValueError`` naming the owning class, the property and the accepted values. A property whose ``default`` is an ``Enum`` member coerces to that member's type without being asked, so every enum-valued property stores members rather than the bare strings or ints they wrap. Pass ``coerce`` explicitly to override that. hint: Description shown in the editor inspector. link: When True, child values are offset from the parent's value. propagate: When True, bool/enum Settings inherit disabling values from parents. group: Inspector section name for grouping. Empty string = default "Properties" section. on_change: Name of a bound method to invoke on the owning instance after a successful value change (i.e. when the new value differs from the old). Hooks fired during ``__init__`` are deferred until ``__init__`` returns, then dispatched once each (deduplicated by ``(property, hook)`` pair) so the hook always sees a fully constructed object. After construction, hooks fire synchronously inside ``__set__`` (unless ``coalesce=True``: see below). coalesce: When ``True`` *and* ``on_change`` is set, post-init hook calls fire at most once per scene-tree frame. Multiple writes within one tick collapse to a single deferred call drained at the end of :meth:`SceneTree.tick`: useful for expensive handlers like HUD rasterisation that don't care about intermediate values (Tower Defence: ``coins -= 1`` repeated five times in one frame rasterised the HUD text five times). The owning object must be attached to a SceneTree (``obj._tree`` set); for tree-less owners the flag is ignored and hooks fire synchronously. persist: When True, the value is included in ``SaveManager`` snapshots. save_version: Optional integer schema version recorded alongside the persisted value. clamp: When True (default) a numeric ``range`` hard-clamps every assignment into bounds (validation). When False the ``range`` is an editor-field hint only and out-of-range values are accepted verbatim, both on assignment and in the inspector. Has no effect when ``range`` is None. It does not relax the type: a ranged property converts its value to a builtin number and refuses NaN under either setting, because NaN is not a magnitude that a looser bound could accommodate. scalar: When True, reject array-like values (numpy ndarray with ndim >= 1, Vec2/Vec3, list, tuple, dict, set) at assignment with a clear ``TypeError``. Plain Python numbers, numpy scalar types (``np.float32``, ``np.int64`` etc.), and 0-d numpy arrays are accepted. Use for properties that semantically represent a single number (e.g. ``Camera2D.zoom``) so a stray ``Vec2`` doesn't leak into downstream math and produce visual glitches. """ if default is not _UNSET and default_factory is not None: raise TypeError("Property: pass either `default` or `default_factory`, not both") if default is _UNSET and default_factory is None: raise TypeError("Property: must supply `default` or `default_factory`") if default_factory is None and isinstance(default, list | dict | set | bytearray): raise ValueError( f"Property has mutable default {default!r}. " f"Mutable defaults alias across instances. " f"Use Property(default_factory={type(default).__name__}) instead." ) coerce = _resolve_coerce(coerce, default) if coerce is not None: self.coerce = coerce if default is not _UNSET: try: default = coerce(default) except (TypeError, ValueError) as exc: raise ValueError( f"Property default {default!r} is rejected by coerce={_callable_name(coerce)}: {exc}" ) from None self.default = default # Stays as _UNSET when default_factory is supplied. self.default_factory = default_factory self.range = range self.enum = enum self.hint = hint self.link = link or propagate # Enable parent-child linking self._propagate = propagate # Enhanced propagation for bool/enum self.group = group self.on_change = on_change self.coalesce = coalesce self.persist = persist self.save_version = save_version self.scalar = scalar self.clamp = clamp
[docs] def __set_name__(self, owner, name): self.name = name self.attr = f"_{name}" if "__properties__" not in owner.__dict__: # Walk every base in MRO (most-derived-last) so mixin classes # that each declare Properties merge correctly. The previous # "first-base-wins" rule silently dropped Properties from # secondary mixins, which broke kwargs like `position=` on # diamond classes such as ``CollisionShape2D``. inherited: dict[str, Property] = {} for base in reversed(owner.__mro__[1:]): base_props = base.__dict__.get("__properties__") if base_props: inherited.update(base_props) owner.__properties__ = inherited owner.__properties__[name] = self # Class-level fast-path flag: classes with no on_change Properties # skip the __init__-scope queue entirely. if self.on_change is not None: owner._has_on_change_hooks = True
[docs] def __get__(self, obj, objtype=None): if obj is None: return self value = getattr(obj, self.attr, _UNSET) if value is _UNSET: if self.default_factory is not None: # Lazily materialise the per-instance default. Bypass __set__ so # validation/clamping/on_change don't fire: semantics match # ``functools.cached_property``. value = self.default_factory() setattr(obj, self.attr, value) else: value = self.default # Apply parent linking if enabled if self.link and obj.parent and hasattr(obj.parent, self.name): parent_value = getattr(obj.parent, self.name) return self._apply_link(parent_value, value) return value
def _as_number(self, obj, value): """``value`` as a builtin ``int`` / ``float``, for a Property with a ``range``. A ``range=`` is a declaration that the value is numeric, so the conversion belongs at the assignment: everything after it (the NaN refusal, the clamp, the change compare, the scene emitter) then works on a known type instead of on whichever numeric-ish object the caller happened to hold. A value that is not a number raises ``TypeError`` here, at the assignment that wrote it, rather than wherever it is first used in arithmetic. ``None`` stays legal on a property whose default is ``None``, where it means "unset, use the backend default", and is returned untouched. """ if isinstance(value, bool): # bool subclasses int, so without this it passes every branch below # and then survives the clamp unchanged, because min/max hand back # the original object. A magnitude is never True. raise TypeError( f"{type(obj).__name__}.{self.name} has a range of {self.range} " f"and must be a number, got bool {value!r}" ) if isinstance(value, int | float): return value if value is None and self.default is None: return None if isinstance(value, numbers.Integral): return int(value) if isinstance(value, numbers.Real): return float(value) if getattr(value, "shape", None) == (): # A 0-d array is numerically a scalar, but only when its dtype is # one: ``np.array("loud")`` has shape ``()`` as well. kind = getattr(getattr(value, "dtype", None), "kind", None) if kind in ("i", "u"): return int(value) if kind == "f": return float(value) raise TypeError( f"{type(obj).__name__}.{self.name} has a range of {self.range} and " f"must be a number, got {type(value).__name__} {value!r}" )
[docs] def __set__(self, obj, value): if self.scalar: # 0-d arrays and numpy scalar types pass; ndim >= 1 (including # Vec2/Vec3 which are ndarray subclasses) is rejected. shape = getattr(value, "shape", None) if shape is not None and shape != (): raise TypeError( f"{type(obj).__name__}.{self.name} must be a scalar " f"(got {type(value).__name__} with shape {shape}). " f"For per-axis non-uniform values, a separate API is needed." ) if isinstance(value, list | tuple | dict | set): raise TypeError(f"{type(obj).__name__}.{self.name} must be a scalar " f"(got {type(value).__name__}).") if self.range is not None: # Three ordered steps, and they stay separate. Normalise first, so # every check below runs on a builtin number rather than on whatever # numeric-ish object the caller happened to hold. Then refuse NaN, # whatever ``clamp`` says: a soft range is a hint about magnitude and # NaN is not a magnitude, so it is wrong under both settings. Only # then clamp, and only when asked -- folding the refusal into the # clamp branch would start bounding every soft-range property in the # engine, which is precisely what ``clamp=False`` declines. value = self._as_number(obj, value) if value is not None: if value != value: # NaN compares false against everything, so ``max(lo, min(hi, # nan))`` yields ``hi``: a lost calculation would arrive as the # loudest volume or the largest size rather than as an error. raise ValueError( f"{type(obj).__name__}.{self.name} was given NaN. It has a range " f"of {self.range}, and NaN is not a value within it -- check the " f"calculation that produced it." ) if self.clamp: lo, hi = self.range # Infinities are ordered and clamp to the bound they exceed, # which is what was asked for. value = max(lo, min(hi, value)) if self.enum is not None and value not in self.enum: log.warning("Property %r rejected invalid value %r (allowed: %s)", self.name, value, self.enum) raise ValueError(f"{self.name} must be one of {self.enum}, got {value!r}") old = getattr(obj, self.attr, self.default) setattr(obj, self.attr, value) if old is value: changed = False elif _has_shape(old) or _has_shape(value): # Arrays (numpy, Vec2/Vec3, …) don't have a scalar != so a distinct # instance is treated as changed without an elementwise compare. changed = True else: try: changed = bool(old != value) except (ValueError, TypeError): changed = True # numpy arrays, etc. # Auto-redraw 2D drawables when a property changes (the blanket hook). # Gated on ``_render_auto_dirty`` (True only on ``Drawable2D``), NOT on # ``hasattr(queue_redraw)``: ``queue_redraw`` now lives on the base ``Node`` # (so plain-Node HUDs can dirty by hand), but a Property write must only # auto-dirty an actual 2D drawable -- never a 3D / audio / camera node, whose # presence in the 2D dirty scan would otherwise force spurious re-collects. if changed and getattr(obj, "_render_auto_dirty", False): obj.queue_redraw() # Fire on_change hook. Hooks fired while ``__init__`` is running are # queued and dispatched after init returns (deduplicated), so user # code always sees a fully constructed object. Post-init, if # ``coalesce=True`` and the owner is attached to a SceneTree, the # hook is enqueued on the tree's ``_pending_coalesced_hooks`` set # and drained once at the end of :meth:`SceneTree.tick`: multiple # writes within one frame collapse to a single call. Tree-less # owners fall back to the synchronous path so pure-logic unit # tests keep working. if changed and self.on_change is not None: if getattr(obj, "_on_change_init", False): obj._on_change_pending.append((self.attr, self.on_change)) else: if self.coalesce: tree = getattr(obj, "_tree", None) if tree is not None: tree._pending_coalesced_hooks.add((obj, self.on_change)) return method = getattr(obj, self.on_change, None) if method is not None: method()
def _apply_link(self, parent_value, child_value): """Apply parent-child linking / propagation based on value type.""" # For bool/enum with propagate: disabling parent overrides child, otherwise child value stands if self._propagate: if isinstance(parent_value, bool): if not parent_value: return parent_value return child_value if isinstance(parent_value, IntEnum): try: disabled = type(parent_value)["DISABLED"] if parent_value == disabled: return parent_value except (KeyError, TypeError): pass return child_value # For numeric types: child is offset from parent if isinstance(child_value, int | float) and isinstance(parent_value, int | float): return parent_value + child_value # For vectors: child is offset from parent if isinstance(child_value, Vec2 | Vec3) and isinstance(parent_value, Vec2 | Vec3): return parent_value + child_value # For other types: inherit parent value return parent_value
[docs] def get_raw(self, obj): """This Property's stored value on ``obj``, with no parent-link resolution. The read a state snapshot wants: the node's own value, not the value a ``link=True`` parent contributes to. Reading through ``__get__`` instead would capture the resolved sum, and the matching restore would write it back as the child's own, double-counting the parent on every reload. A derived Property has no slot to read, so the only expression of its value is ``__get__``, and that is what it gets. Its parent-link and propagate flags do not apply in the first place: both live in ``Property.__get__``, which such a subclass replaces. """ if self.storage_backed: return getattr(obj, self.attr, declared_default(self)) return self.__get__(obj)
[docs] def set_raw(self, obj, value) -> None: """Put ``value`` back, bypassing validation and ``on_change`` where that is possible. A storage-backed Property is written straight at its slot: nothing is coerced, clamped or validated and no hook fires. A derived Property has no slot, so the write goes through ``__set__``, which is the only way its value can be expressed at all, and that path DOES run validation and ``on_change``. A caller that must not re-enter ``on_change`` (:func:`restore_property`) therefore refuses a derived Property rather than calling this and hoping. """ if self.storage_backed: setattr(obj, self.attr, value) else: self.__set__(obj, value)
[docs] def try_decrement(self, obj, amount: float | int) -> bool: """Atomically decrement the property if it would not go negative. Returns ``True`` and assigns ``current - amount`` when the result is non-negative; returns ``False`` and leaves the value untouched otherwise. Resolves the "currency / mana / HP gate" pattern that every game-with-currency reinvents: Before:: if player.coins >= cost: player.coins -= cost purchase() After:: if Player.coins.try_decrement(player, cost): purchase() Reads-via-``__get__`` so parent-link offsets are respected; writes the raw stored value (no parent contribution) via ``__set__`` so any ``on_change`` hook and ``range``/``enum`` validation still fire. ``amount`` must be numeric and non-negative. """ if not isinstance(amount, int | float): raise TypeError(f"Property.try_decrement amount must be numeric, got {type(amount).__name__}") if amount < 0: raise ValueError(f"Property.try_decrement amount must be >= 0, got {amount}") current = self.__get__(obj) if not isinstance(current, int | float): raise TypeError( f"Property.try_decrement requires a numeric Property; " f"{self.name!r} is {type(current).__name__}" ) if current < amount: return False self.__set__(obj, current - amount) return True
[docs] def __repr__(self): parts = [f"default={self.default!r}"] if self.range: parts.append(f"range={self.range}") if self.enum: parts.append(f"enum={self.enum}") return f"Property({', '.join(parts)})"
[docs] def declared_default(prop: Property) -> Any: """The value ``prop`` reads back on an instance that has never written it. Ask this rather than reading ``prop.default``. A Property declared with ``default_factory`` leaves ``default`` at an unset sentinel, so a comparison against it calls every value of such a property non-default, and a snapshot that falls back to it stores the sentinel itself. Calling the factory answers the question the caller is actually asking, and matches what :meth:`Property.__get__` materialises on first read. On the ``default_factory`` branch the answer is a FRESH object per call, like the instance default it stands in for: callers there compare it and discard it, and must not hand it out as a value to keep. A plain default is the declared object itself, shared by every caller, exactly as ``prop.default`` would have handed it over. """ if prop.default_factory is not None: return prop.default_factory() return prop.default
[docs] def restore_property(obj: Any, name: str, value: Any) -> None: """Put ``obj``'s Property ``name`` back to ``value`` without running ``__set__``. For the narrow case of undoing a write whose ``on_change`` hook could not carry it through -- a physics knob the simulation refused, say -- where the node must go back to reporting what it had. Assigning through the descriptor would fire the hook again, which would push the restored value straight back into whatever just rejected the new one, so the write goes to the Property's own storage instead. It is not a general setter: nothing is coerced, clamped or validated, so pass a value that came out of the property in the first place. Raises: TypeError: when ``name`` is a derived Property, one that owns no storage and can only be written through ``__set__``. There is no way to put such a value back without re-entering the hook this function exists to avoid, so the caller is told rather than silently given the thing it asked not to happen. """ prop = type(obj).get_properties()[name] if not prop.storage_backed: raise TypeError( f"restore_property cannot restore {type(obj).__name__}.{name}: it is a derived " f"Property ({type(prop).__name__}) with no storage of its own, so the write would " f"have to go through __set__ and would re-enter the on_change hook." ) prop.set_raw(obj, value)
# ============================================================================ # Children: Smart child container # ============================================================================
[docs] class NodeNotFound(KeyError): """Raised when a node lookup by name/path finds no match. Subclasses :class:`KeyError` so existing ``except KeyError`` handlers keep working, while giving a domain-specific type to catch (and a clearer name in tracebacks) for ``node.node_at(...)`` / ``node["name"]`` misses. """
[docs] class Children: """List-like container with named child access. node.children[0] # by index node.children['Camera'] # by name string for c in node.children: # iteration len(node.children) # count """ __slots__ = ("_list", "_names", "_snapshot", "_dirty") def __init__(self): self._list: list = [] self._names: dict[str, Any] = {} self._snapshot: list = [] # cached copy for safe iteration self._dirty: bool = False def _add(self, node): self._list.append(node) if node.name: self._names[node.name] = node self._dirty = True def _remove(self, node): self._list.remove(node) if node.name and self._names.get(node.name) is node: del self._names[node.name] self._dirty = True
[docs] def safe_iter(self) -> list: """Return a snapshot safe for iteration during mutation. Avoids per-frame copy when children are unchanged.""" if self._dirty: self._snapshot = list(self._list) self._dirty = False return self._snapshot
[docs] def move_first(self, node) -> None: """Move ``node`` to index 0 (drawn first, hit-tested last). No-op if absent.""" if node not in self._list: return self._list.remove(node) self._list.insert(0, node) self._dirty = True
[docs] def move_last(self, node) -> None: """Move ``node`` to the end (drawn last, hit-tested first). No-op if absent.""" if node not in self._list: return self._list.remove(node) self._list.append(node) self._dirty = True
[docs] def __getitem__(self, key): if isinstance(key, int): return self._list[key] if isinstance(key, str): if key in self._names: return self._names[key] raise NodeNotFound(f"No child named '{key}'") raise TypeError(f"Invalid key type: {type(key)}")
[docs] def __iter__(self): return iter(self._list)
[docs] def __len__(self): return len(self._list)
[docs] def __contains__(self, item): return item in self._list
[docs] def __bool__(self): return bool(self._list)
[docs] def __repr__(self): return f"Children({[c.name for c in self._list]})"