Symbolic Events¶
Symbolic events let users subscribe to updates of symbolic objects:
| Method | Event |
|---|---|
on_sym_post_init |
The object has been created. |
on_sym_change |
The object has changed. |
on_sym_bound |
The object has been created or changed (possibly still partial). |
on_sym_ready |
The object has been created or changed and is now concrete. |
on_sym_parent_change |
The parent of the object has changed. |
on_sym_path_change |
The location of the object has changed. |
Tip
For all events, always call super().<event_method> first.
Which events need sym=True
pg.Object subclasses are flat by default. The creation-time events
(on_sym_post_init, on_sym_bound, on_sym_ready) and change
notification on attribute assignment fire in both sym modes — but
sym_rebind (the mutation API used in the examples below), upward
change propagation through parents, and the parent/path change events
(which fire on tree adoption) require a class declared with
class Foo(pg.Object, sym=True) (inherited by subclasses).
on_sym_post_init¶
For native symbolic classes, since __init__ is generated by the pg.Object
base class, on_sym_post_init is a good place for
performing additional argument validation and setting up internal states for
the class.
For example:
@pg.members([
('x', pg.typing.Int(max_value=1)),
('y', pg.typing.Int(max_value=1)),
])
class Foo(pg.Object):
def on_sym_post_init(self):
super().on_sym_post_init()
if self.x + self.y > 1:
raise ValueError()
self.z = self.x ** 2
foo = Foo(-2, 1)
assert foo.z == 4
Tip
Always consider on_sym_ready first, as it covers both state
initialization and reset and guarantees all fields are present.
on_sym_change¶
Since symbolic arguments can be manipulated at runtime, a symbolic class needs
to respond to updates to ensure the consistency of its internal states. For
example, foo.z needs to be updated automatically when foo.x has changed.
This can be done by subscribing to the
on_sym_change event, which is triggered when
any of the symbolic arguments are updated via
sym_rebind:
@pg.members([
('x', pg.typing.Int(max_value=1)),
('y', pg.typing.Int(max_value=1)),
])
class Foo(pg.Object, sym=True):
def on_sym_post_init(self):
super().on_sym_post_init()
self._validate_and_reset()
def on_sym_change(self, updates):
super().on_sym_change(updates)
self._validate_and_reset()
def _validate_and_reset(self):
if self.x + self.y > 1:
raise ValueError()
self._z = self.x ** 2
The on_sym_change event takes an updates argument, which is a dict of
pg.KeyPath to
pg.FieldUpdate objects, in case the user wants
to cherry-pick which internal states to recompute based on the updates. For
example:
Notification rules¶
The chain of notification. When a symbolic object is changed, its
containing object is considered changed. This chain of change propagates upward
until there is no further containing object. Correspondingly, a chain of
notifications is triggered for each of the impacted objects. PyGX invokes the
on_sym_change method in a bottom-up way — the immediately updated object first,
then its parent, and so on, up to the root of the tree.
Fire just once. If multiple symbolic arguments are updated together, the containing object is guaranteed to receive the notification just once.
Do I really need on_sym_change?¶
In most circumstances, we can simply recompute all the internal states when any
of the arguments changes. In such cases, on_sym_change usually has the same
implementation as on_sym_post_init, which can be replaced by
on_sym_ready.
on_sym_bound¶
on_sym_bound is triggered on every (re)bind and
at the end of __init__, even when the object is still partial or
pure-symbolic (some fields may be pg.MISSING_VALUE). Use it only for logic
that must run regardless of completeness:
@pg.members([
('x', pg.typing.Int()),
('y', pg.typing.Float()),
])
class Foo(pg.Object):
def on_sym_bound(self):
super().on_sym_bound()
# Runs even on `Foo.partial()`, where `self.x` may be MISSING_VALUE.
self._cache = {}
For the common case — computing derived members from fields — prefer
on_sym_ready, which fires right after on_sym_bound but only once the
object is concrete, so every field is guaranteed present.
Note
on_sym_post_init and on_sym_change take precedence
over on_sym_bound if both are subscribed.
on_sym_ready¶
on_sym_ready is the event needed for most
symbolic classes — it handles both internal state setup and reset, and unlike
on_sym_bound it fires only when the object is concrete (not
abstract — neither partial nor
pure-symbolic). Every schema field is therefore guaranteed to be present:
@pg.members([
('x', pg.typing.Int()),
('y', pg.typing.Float()),
])
class Foo(pg.Object, sym=True):
def on_sym_ready(self):
super().on_sym_ready()
self.z = self.x ** 2 # `self.x` is always present here.
on_sym_ready is level-triggered: it fires at the end of __init__ and on
every (re)bind that leaves the object concrete — not only on the
abstract → concrete transition. For one-shot setup, guard with your own flag.
foo = Foo.partial() # `on_sym_bound` fires; `on_sym_ready` does NOT (still partial).
foo.sym_rebind(x=3, y=1.) # now concrete → `on_sym_ready` fires and derives `z`.
on_sym_parent_change¶
Sometimes a symbolic object needs to respond to parent changes — e.g., when an
object is first added to a symbolic tree or when it is removed. This can be
done by subscribing to the
on_sym_parent_change event:
@pg.members([
('x', pg.typing.Int()),
('y', pg.typing.Float()),
])
class Foo(pg.Object, sym=True):
def on_sym_parent_change(self, old_parent, new_parent):
super().on_sym_parent_change(old_parent, new_parent)
print(f'Parent changes from {old_parent!r} to {new_parent!r}')
foo = Foo(1, 2.0)
# Assigning `foo` to a key of a symbolic dict will trigger the
# `on_sym_parent_change` event with a None `old_parent` and the symbolic dict
# as the `new_parent`.
a = pg.Dict(x=foo)
# This will again trigger the `on_sym_parent_change` event on `foo`,
# with `old_parent` set to the symbolic dict and `new_parent` set to None.
a.x = 1
on_sym_path_change¶
Similar to on_sym_parent_change, when users need to subscribe to a symbolic
object's path change, they can override the
on_sym_path_change method.
The on_sym_path_change event is triggered when a symbolic object's location has
changed; the location is represented by a path from the root of its containing
symbolic tree to the object itself. For example:
@pg.members([
('x', pg.typing.Int()),
('y', pg.typing.Float()),
])
class Foo(pg.Object, sym=True):
def on_sym_path_change(self, old_path, new_path):
super().on_sym_path_change(old_path, new_path)
print(f'Symbolic location has changed from {old_path!r} to {new_path!r}')
foo = Foo(1, 2.0)
# `foo`'s path will change from '' to 'x', triggering `on_sym_path_change`.
a = pg.Dict(x=foo)
# `foo`'s path will change from 'x' to '[0].x', triggering the event again.
b = pg.List([a])
# `foo`'s path will change from '[0].x' to '', triggering the event a third time.
a.x = 1