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=Falsesubclassing atopo=Truebase 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:
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_changebecomeon_topo_parent_change/on_topo_path_change. They fire only when a value has a position — atopo=Falseobject never receives either — so they belong to the gated family, and the pairing now reads straight:topo_parentchanging fireson_topo_parent_change. Theon_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_field → topo_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.