Skip to content

Migrating to PyGX 0.4

0.4 makes one change, and it is a semantic one: the annotation on a container field now decides how that field is stored.

The one-sentence summary: a bare x: dict or x: list means a RAW dict / list, even on a topo=True class — spell x: pg.Dict[str, V] / x: pg.List[E] where you want the symbolic container. pg.field(wrap=...) and Field.enable_wrap are removed.

This is not a respelling you can apply mechanically everywhere: on a reactive (topo=True) class, a bare : dict field that used to wrap now stores raw, so anything reading topo_path / topo_parent through that member, or rebinding into it by path, changes behavior.


1. What decides storage now

Annotation Storage On which axis
x: pg.Dict[str, V] / pg.List[E] symbolic pg.Dict / pg.List both — a flat class stores it too (held by reference, not adopted)
x: dict[str, V] / list[E] raw dict / list both — an explicit builtin annotation means what it says
x: Any, pg.field(value_spec=...), untyped, library-built specs follows the class's topo unauthored: topo=True wraps, topo=False stays raw

The third row is the compatibility escape hatch: nothing about specs you build yourself changed. Only annotated fields changed meaning.

The law holds at every depth, which is what the old field-level flag could not express:

from typing import Literal

class Cfg(pg.Object, topo=True):
    a: pg.Dict[str, dict[str, int]]           # outer symbolic, inner RAW
    b: pg.Dict[str, Any]                      # inner unannotated → wraps
    c: pg.Dict[str, Any, Literal[False]]      # auto_wrap off → verbatim

pg.Dict[K, V, auto_wrap] / pg.List[E, auto_wrap] take an optional trailing flag governing raw containers in positions the annotation does not constrain to a container type. It defaults to True.

The flag answers the same way whenever the member arrives — at construction, on __setitem__ / append, or through sym_rebind:

spec = pg.typing.Dict([(pg.typing.StrKey(), pg.typing.Any())],
                      auto_wrap=False)
d = pg.Dict({'k': {'x': 1}}, value_spec=spec)   # supplied at construction
d['z'] = {'x': 1}                               # written afterwards
type(d['k']), type(d['z'])                      # (dict, dict) — both raw

Before 0.4 a value passed to a bare container's constructor alongside a value_spec= wrapped anyway (the spec bound after the members were populated, so the flag could not see itself), while the same value written a moment later stayed raw. Annotated fields were never affected.

Spell the flag as Literal[False], not a bare False. Both work at runtime, but only the Literal form type-checks: a bare False is a value, and a type checker rejects a value in a type-argument slot (Expected class but received "Literal[False]") no matter how the class is declared. pg.Dict / pg.List ship stubs that give the flag a real type parameter, so the Literal spelling checks cleanly.

2. Migrating your code

Find the fields that need to stay symbolic. These are the ones whose members you traverse or rely on for change notification:

# before: wrapped because the class is topo=True
class Node(pg.Object, topo=True):
    children: list['Node'] = []
    metadata: dict[str, Any] = {}

# after (0.4): state it in the type
class Node(pg.Object, topo=True):
    children: pg.List['Node'] = []
    metadata: pg.Dict[str, Any] = {}

Leave the rest alone. A field you only ever read as a plain container is now honestly annotated — x: dict[str, int] and type(x) is dict finally agree.

Raw members still take path reads and writes. Leaving a member raw does not cost you sym_get / pg.query / sym_rebind by path — the funnel descends into raw dict / list members on both sides:

class F(pg.Object, topo=True):
    d: dict[str, int] = {}

f = F(d={'k': 1})
f.sym_get('d.k')             # 1
f.sym_rebind({'d.k': 2})     # writes through, in place

The write is in place and silent — no validation below the field boundary, no on_sym_change notification, no rollback participation: exactly what f.d['k'] = 2 does, since a raw subtree carries no symbolic machinery. Permission is still gated by the nearest symbolic ancestor, so a raw member under a sealed owner stays unwritable. Raw-dict hops accept new keys and delete on pg.MISSING_VALUE; raw-list hops are in-range positional assignment only (deletion, insertion and append-by-index are symbolic-List semantics with no raw counterpart).

What raw storage does cost is change notification and the symbolic identity of the members themselves — those are the reasons to reach for pg.Dict[...] / pg.List[...].

A quick census. Enumerate the fields whose storage actually changed rather than grepping annotations (most dict[...] / list[...] hits in a codebase are method signatures, not fields):

for name, field in MyClass.__schema__.fields.items():
    if getattr(field.value, 'symbolic', None) is False:
        print(name, '→ now RAW')

3. Removed APIs

Removed Replacement
pg.field(wrap=True) x: pg.Dict[str, V] / pg.List[E]
pg.field(wrap=False) x: dict[str, V] / list[E]
Field.enable_wrap (property) field.value.symbolic (tri-state: True / False / None)
Field(enable_wrap=...) (ctor arg) — (the spec carries it)
class C(pg.Object, varkw=...) class C(pg.Object, extra=...)
class C(pg.Object, sym_mode=...) class C(pg.Object, topo=...)
__kwargs__: Any (annotation, 0.4.6) class C(pg.Object, extra='allow')

varkw= and sym_mode= are class-keyword renames: an unknown class keyword raises at class-creation time, so a stale spelling fails immediately and loudly rather than silently doing nothing.

__kwargs__ is different, and needs a careful sweep: it was an ordinary annotation, so a stale one does not raise — it now resolves to a plain const field literally named __kwargs__, and the class stays closed. The failure surfaces later, as a TypeError on the first unexpected keyword argument. Grep for __kwargs__ and replace each with extra= on the class statement; the two are mutually exclusive, so it is always a replacement. For a typed wildcard, __kwargs__: Annotated[Any, pg.field(...)] becomes extra=pg.field(...). One behavioral difference: the residue wildcard now always sorts last in __schema__.fields, whereas an annotation placed it wherever it was declared.

extra= also takes the Pydantic-aligned strings ('allow' / 'forbid' / 'ignore') and a pg.field(...) for a typed wildcard.

pg.Dict(..., wrap=False) — the container constructor argument — is unchanged. It is a different parameter and remains supported.

Two consequences worth knowing:

  • pg.Dict[...] + pg.field(wrap=False) used to be a class-creation error ("conflicts with"). There is no longer anything to conflict with, so the error is gone.
  • topo=False subclassing a topo=True base does not downcast an annotated field's storage. Narrowing the axis this way is semantic broadening that breaks the base's contract — base code is written against symbolic children — and the core warns that it "breaks substitutability".

4. set_accessor_writable() removed

Attribute writability is now only a class option. pg.Object no longer carries a per-instance writability bit — it reads its class's attr_write directly — and the runtime mutator is gone from every symbolic type:

Removed Replacement
obj.set_accessor_writable(True) declare attr_write=True on the class
container.set_accessor_writable(v) pg.Dict(..., accessor_writable=v) at construction
a temporary flip, anywhere with pg.allow_writable_accessors(True): ...

pg.Dict / pg.List keep their accessor_writable= constructor argument and the read-only .accessor_writable property — a standalone container has no class to carry the flag. What they lose is the runtime toggle.

The scoped override is the better tool for the cases the mutator served: it is bounded, it nests, and it cannot leave an object in a state its class does not describe.

# before
obj = Cfg(x=1)
obj.set_accessor_writable(True)
obj.x = 2

# after — declare it
class Cfg(pg.Object, attr_write=True):
    x: int = 0

# ...or scope it
with pg.allow_writable_accessors(True):
    obj.x = 2

5. The position API is now topo_*

The tree-position family is renamed to match the topo= class option that gates it. This is a mechanical rename — same semantics, same signatures:

Before After
value.sym_path value.topo_path
value.sym_parent value.topo_parent
value.sym_root value.topo_root
value.sym_field value.topo_field
value.sym_ancestor(...) value.topo_ancestor(...)
value.sym_setparent(...) value.topo_setparent(...)
value.sym_setpath(...) value.topo_setpath(...)
def on_sym_parent_change(...) def on_topo_parent_change(...)
def on_sym_path_change(...) def on_topo_path_change(...)

TopologyAware's abstract members move with them, so a custom implementer renames its overrides too.

Only the gated family moves. These are exactly the members that raise SymbolicModeError under topo=False, which is what makes the shared word worth having — the error now names the option that caused it:

`topo_path` requires a symbolic tree, but Cfg was declared with `topo=False`.

Everything else keeps sym_. The distinction is whether a member asks about the value's position in a tree (renamed) or about the value itself (unchanged):

  • Node-local, unchanged: sym_keys / sym_values / sym_items / sym_hasattr / sym_getattr / sym_rebind / sym_clone / sym_eq / sym_contains / sym_descendants / sym_fields — none of these depend on having a parent.
  • The two POSITION hooks move too (0.4.7): on_sym_parent_change / on_sym_path_change become on_topo_parent_change / on_topo_path_change. They fire only when a value has a position — a topo=False object never receives either — so they belong to the gated family, and the pairing now reads straight: topo_parent changing fires on_topo_parent_change. The on_ prefix still marks them "handler, don't call."
  • The other lifecycle hooks keep on_sym_*: on_sym_preinit / on_sym_post_init / on_sym_bound / on_sym_ready / on_sym_change / on_sym_validate — these fire in both modes.

Overrides of the two renamed hooks fail silently: the base implementation is a no-op, so a stale def on_sym_parent_change(...) simply never runs. Grep for both names when you upgrade.

Watch the near-misses when you sweep: sym_fields and sym_field_names are schema surface and stay put, while the internal _sym_parent_for_children seam moves to _topo_parent_for_children — a blind sym_fieldtopo_field substitution gets both wrong.

6. Why this changed

The old model had two sources of truth. A pg.Dict[str, V] annotation and a pg.field(wrap=...) flag could contradict each other, and at a field boundary the flag won — which is why a bare x: dict kept wrapping even though the annotation said dict. Worse, the flag could only speak at the field's top level: pg.Dict[str, dict[str, Any]] had no way to say "wrapped-of-raw", because the spec tree was mode-blind (pg.Dict[str, V] and dict[str, V] produced byte-identical specs).

Moving the declaration onto the spec gives one source of truth, restores annotation honesty, and makes the law expressible at any depth. See §Container storage for the full reference.