"""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]})"