Skip to content

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:

def on_sym_change(self, updates):
    if 'x' in updates:
        self._z = self.x ** 2

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