Skip to content

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:

PYGX_CORE=python python your_program.py

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 / list members 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') and root.sym_rebind({'f.options.x': 2}) both work. The write is in place and silent, though: no validation below the field boundary, and no on_sym_change. on_sym_change still fires for a flat object's own field writes; changes inside a raw container are invisible.
  • Mutability. obj.attr = value works (validated when validate=True, the default). sym_rebind also 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=False is, 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_schema is illegal once instances exist. Before, you could mutate a class's schema at any time; now it raises TypeError: 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, and pg.get_converter (the global converter registry) are gone. Replacements: for skipping validation, the validate=False class keyword or pg.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-spec transform.
  • 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 generic takes 1 positional argument but 2 were given). If you match on that text, update the match.
  • ValueSpec as a type annotation (x: pg.typing.Int() = 1) is rejected in both versions — use x: 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. Under topo=True this is the default wrap behavior; under topo=False a raw dict / list input converts into a new pg.Dict / pg.List and a pre-built pg.Dict / pg.List input 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 JSON True-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 plain A instance (unbound type args erase to Any) 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) and to_json(type_info=False) — dump controls.
  • __match_args__match statement support on pg.Object subclasses.

5. Migration checklist

  1. pip install pygx (the Python floor is 3.12 as before — now declared, so pip enforces it).
  2. Decide the posture per class. Anything that uses topo_path / topo_parent, contextual resolution (pg.ContextualObject, pg.contextual.Placeholder / pg.contextual.override, or pg.symbolic.ValueFromParentChain), upward change notification, or relies on wrapped pg.Dict / pg.List members → add topo=True (a shared base class is enough — it inherits). Note what is not on this list: path-based sym_rebind, traversal, query, patching, and search all work on flat objects, so they are not a reason to opt in.
  3. 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 class topo=True.
  4. Audit direct attribute writes: on flat classes they now succeed — code that relied on WritePermissionError to catch accidental writes needs topo=True (or attr_write=False / frozen=True on a flat class).
  5. 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.