Migrating to PyGX 0.2¶
This guide is for users coming from the pre-0.2 pure-Python PyGX (any
checkout at or before the python-baseline tag, including the pyglove-era
v0.4.x tags) to PyGX 0.2+ — the first PyPI release, backed by the
dual-core symbolic runtime.
The one-sentence summary: pg.Object is no longer symbolic by default —
add topo=True to any class that relied on the old behavior, and everything
you knew still works exactly as before. The rest of this page is the detail:
what changed, why your old code may behave differently, and what new
machinery is worth adopting.
1. Install and runtime¶
before (python-baseline) |
after (0.2+) | |
|---|---|---|
| Install | from source / git checkout | pip install pygx (PyPI) |
| Runtime | pure Python | dual-core: a native Rust engine (pygx-core, the default) + the pure-Python reference core |
| Python floor | 3.12 in practice (PEP 695 syntax), but undeclared | 3.12+, declared (python_requires) |
| Free threading | — | native wheels for free-threaded CPython (3.14t) |
The two cores are behavior-identical (a differential test gate runs the full
suite under both). If you ever need the pure-Python core — e.g. on a platform
without a pygx-core wheel — select it with the environment variable:
No code changes are required either way; pg.active_core() reports which
core is live.
Versioning note: 0.2 restarts the version line. The pre-restart tags ran
v0.1.1 through v0.4.x (the tree itself last self-identified as
0.5.0-dev), and PyGX releases tag as pygx-v0.2.x — so beware in
particular the relic v0.2.0 / v0.2.1 tags, which are not PyGX
0.2.0/0.2.1. Treat python-baseline (the last pure-Python commit) as the
"before" reference.
2. The headline change: pg.Object defaults to a flat dataclass¶
Before, a pg.Object subclass was symbolic by default: members joined a
symbolic tree, raw dict / list values were wrapped into pg.Dict /
pg.List, direct attribute writes were blocked, and placing one object under
two parents cloned it. (topo=False already existed as an opt-out with raw
members — but even those classes blocked attribute writes.)
After, the default is a flat, validated dataclass (topo=False): raw
members, reference semantics, mutable attributes. The symbolic tree is an
explicit opt-in per class — topo=True — and is inherited by subclasses.
Note for existing topo=False users: your classes gain mutability —
obj.attr = value now succeeds where it raised WritePermissionError
(restore the old posture with attr_write=False if you relied on it).
class Config(pg.Object): # AFTER: flat by default
name: str = ''
options: dict = {}
c = Config(name='a', options={'x': 1})
type(c.options) # before: pg.Dict after: dict
c.name = 'b' # before: WritePermissionError
# after: allowed (flat objects are mutable)
Migration rule: if a class (or your whole codebase) relied on the symbolic behavior, declare it:
class Config(pg.Object, topo=True): # byte-for-byte the old behavior
name: str = ''
options: dict = {}
Under topo=True, attribute writes raise WritePermissionError (use
sym_rebind), topo_path / topo_parent / contextual resolution behave as
before, and subclasses inherit the mode. Two things did change since 0.2
independently of this axis: container storage is now decided by the
annotation rather than by the mode (see
Migration to 0.4), and holding an already-held node a
second time now raises SharedHoldError instead of silently cloning — use
pg.maybe_ref / pg.maybe_clone to say which you meant. The JSON wire
format is unchanged in both modes (to_json / from_json round-trip across
versions).
Behavior deltas if you keep the new default¶
If you migrate a class to the flat default (or leave new classes flat), expect these semantic differences from your old symbolic classes:
- Reference, not clone. Assigning the same object under two parents used to clone the second placement; a flat parent now holds it by reference — mutations are visible through every holder, like a normal dataclass:
child = Child()
a, b = Holder(c=child), Holder(c=child)
# before: b.c was a CLONE of child (a.c is child, b.c is not)
# after: a.c is child and b.c is child
- Raw containers.
dict/listmembers stay raw (type(x) is dict) — good for third-party interop, but the raw subtree carries no symbolic machinery of its own. Path reads and writes descend through flat objects and into their raw members —root.sym_get('f.options.x')androot.sym_rebind({'f.options.x': 2})both work. The write is in place and silent, though: no validation below the field boundary, and noon_sym_change.on_sym_changestill fires for a flat object's own field writes; changes inside a raw container are invisible. - Mutability.
obj.attr = valueworks (validated whenvalidate=True, the default).sym_rebindalso works on a flat object for batch updates of its own fields. - Validation is unchanged. The flat default still type-checks and fills
defaults through each field's spec — flat is not "raw dataclass"
(
validate=Falseis, if you want that).
The full knob-by-knob reference (the sym / validate / wrap / eq /
attr_write / frozen axes and which rung of the ladder each combination
gives you) is in the
pg.Object Style Guide.
3. Other breaking changes¶
update_schemais illegal once instances exist. Before, you could mutate a class's schema at any time; now it raisesTypeError: Cannot update the schema of '...': instances have already been created.Schema mutation must happen before the first instantiation — restructure dynamic-schema code to build the class (or a subclass) up front.- Removed APIs:
pg.enable_type_check(the scoped type-check-disable context manager),pg.register_converter, andpg.get_converter(the global converter registry) are gone. Replacements: for skipping validation, thevalidate=Falseclass keyword orpg.field(validate=False)(per-class/per-field, declared rather than scoped); for type conversion, define__pg_accept__(value)as a classmethod on the target type (the per-type coercion protocol — it runs inside validation wherever the type appears in a spec) or a per-spectransform. - Positional construction was and is rejected (the synthesized
__init__is keyword-only), but the error is now a teaching one:A.__init__() accepts keyword arguments only, but 1 positional argument was given(before: the generictakes 1 positional argument but 2 were given). If you match on that text, update the match. ValueSpecas a type annotation (x: pg.typing.Int() = 1) is rejected in both versions — usex: int = pg.field(value_spec=...). (No change; listed here because pyglove-era code sometimes still carries it.)
4. New capabilities worth adopting¶
None of these are required for migration; all are additive.
Typing / validation
pg.Dict[str, V]/pg.List[E]field annotations — declare the field is the symbolic container. Undertopo=Truethis is the default wrap behavior; undertopo=Falsea rawdict/listinput converts into a newpg.Dict/pg.Listand a pre-builtpg.Dict/pg.Listinput is held as-is — in both cases unadopted (no parent, no path — the flat law):
class Registry(pg.Object): # flat class
entries: pg.Dict[str, int] = {} # raw dict input -> pg.Dict, unadopted
strict=True(class keyword or per-spec) — reject implicit conversions (e.g. the JSONTrue-into-an-int-field footgun).pg.validate(value, annotation)— one-shot validation against any annotation, no class needed.pg.field(validator=...)— a field-level after-check;pg.field(alias=...)— wire-layer key aliases.- Parameterized-generic annotations got gradual acceptance:
x: A[int]now accepts a plainAinstance (unbound type args erase toAny) where it used to reject everything but statically-bound subclasses; bound subclasses still discriminate (class AStr(A[str])stays rejected).
Lifecycle / ingest
on_sym_validate/on_sym_preinit— model-level hooks (the pydantic-style whole-object validation points).extra='allow' | 'ignore'— open-schema ingest postures ('ignore'tolerates and drops unknown keys).
Output
to_json(exclude_none=True)andto_json(type_info=False)— dump controls.__match_args__—matchstatement support onpg.Objectsubclasses.
5. Migration checklist¶
pip install pygx(the Python floor is 3.12 as before — now declared, so pip enforces it).- Decide the posture per class. Anything that uses
topo_path/topo_parent, contextual resolution (pg.ContextualObject,pg.contextual.Placeholder/pg.contextual.override, orpg.symbolic.ValueFromParentChain), upward change notification, or relies on wrappedpg.Dict/pg.Listmembers → addtopo=True(a shared base class is enough — it inherits). Note what is not on this list: path-basedsym_rebind, traversal, query, patching, and search all work on flat objects, so they are not a reason to opt in. - Audit shared-object assignments on classes you leave flat: where you
relied on adopt-time cloning for isolation, clone explicitly
(
pg.clone(x, deep=True)) or mark the classtopo=True. - Audit direct attribute writes: on flat classes they now succeed —
code that relied on
WritePermissionErrorto catch accidental writes needstopo=True(orattr_write=False/frozen=Trueon a flat class). - Re-run your suite under both cores once
(
PYGX_CORE=python/PYGX_CORE=rust) if you patch or subclass PyGX internals; for ordinary use the default core is the only one you need.