pg.Object design & style¶
Conventions for writing pg.Object subclasses that interact cleanly with
runtime validation and with static type-checkers (pyright/Pylance/mypy).
The patterns here come out of repeated friction with PEP 681's
dataclass_transform model — they're not arbitrary stylistic choices.
What pg.Object offers¶
- Schema-driven, dataclass-like classes. Declare fields with type
annotations; an
__init__is synthesized from them — no boilerplate. - Runtime field validation. Every field carries a
pg.typing.ValueSpecderived from its annotation. Types, defaults, numeric bounds, regex, callable signatures, etc. are enforced at construction and at every assignment. - Static type checking via PEP 681.
pg.Objectis decorated withdataclass_transform, so pyright/Pylance/mypy infer the synthesized__init__signature, field types, and field defaults without a custom plugin. - Symbolic operations out of the box. Value-based
__eq__/__hash__(sym_eq/sym_hash), deep cloning (sym_clone, withoverride=as thedataclasses.replace()analog), JSON serialization (to_json/from_json), formattedrepr(format), and — undertopo=True— in-place mutation viasym_rebind. - Per-class behavioral knobs. Class-statement kwargs flip immutability, freezing, equality semantics, and serialization-registry membership (see Class-level behaviors).
- Per-field flags. Skip
__init__, exclude fromrepr/eq/hash, attach docstrings or metadata, hook a post-validation transform or a custom validator (see Field-level behaviors). - Lifecycle hooks.
on_sym_readyruns after fields are validated and attached, once the object is concrete — the natural place to compute derived state.on_sym_boundis its completeness-agnostic sibling (fires even when partial);on_sym_changefires onsym_rebindfor incremental update. - Model validators.
on_sym_validatechecks cross-field invariants: it fires with all fields committed — at construct (before any lifecycle hook) and on every write — and raising rejects the construct or rolls the write back before observers see it.on_sym_preinit(a classmethod) reshapes the raw init kwargs before resolution — accept legacy aliases, derive fields — returning the mapping to construct from. - Inheritable. Subclasses inherit fields, value specs, and per-class options; each level can overlay overrides.
The semantic spectrum: dataclass → symbolic object¶
pg.Object is not one fixed thing. The same machinery spans a spectrum
of class semantics — from a plain mutable dataclass at one end to a fully
symbolic, immutable, tree-aware value object at the other. You pick where
you sit by setting class-statement kwargs (and override per-field where a
single field needs to differ). There is no separate "dataclass" type to
switch to; you turn knobs on the one you already have.
The top-level axis is topo: it selects symbolic-tree vs. flat
dataclass semantics. Within it, the remaining knobs layer additively.
From the irreducible base upward:
| Layer | Knob(s) | What turning it on adds |
|---|---|---|
| Storage + init (always present) | — | Synthesized keyword-only __init__ from annotations, typed fields, per-instance copying of mutable defaults. This is the dataclass you always get. |
| Symbolic tree (top axis) | topo |
topo=False (the default) is a flat, reference-semantics object: no tree, no single-position rule, raw members. topo=True (opt-in, inherited by subclasses) makes instances nodes in a symbolic object tree — topo_path / topo_parent / topo_root, a single tree position per node, contextual resolution, change notification — and it is what an unauthored container spec follows when deciding storage (see Container storage). |
| Validation | validate |
Type-checking, coercion, and default-filling through each field's ValueSpec.apply. Meaningful in both topo modes. |
| Container storage | the ANNOTATION | Whether a raw dict / list is stored as-is or wrapped into pg.Dict / pg.List so it joins the tree. Declared by the field's type: pg.Dict[str, V] / pg.List[E] is symbolic on both axes, builtin dict[str, V] / list[E] is raw on both, and an unauthored spec follows the topo axis. Holds at every depth. Strictly narrower than validate. |
| Equality | eq |
Value-based __eq__ / __hash__ via sym_eq / sym_hash (vs. identity). |
| Mutability | attr_write, frozen |
attr_write gates direct obj.x = v; frozen seals the whole object against sym_rebind too. |
| Attribute surface | attr_read |
Dotted obj.x access (vs. obj.sym_getattr('x') only). |
The default is the flat, validated end — topo=False, attr_read=True,
frozen=False, eq=True, order=False, validate=True, with attr_write
FOLLOWING the topo axis (True under topo=False, False under
topo=True; an explicit attr_write= pins it for the subtree). A bare
class Foo(pg.Object) is a mutable, validated, value-equal flat value
object — dataclass/pydantic-shaped, with reference semantics and no tree;
obj.x = v is a validated write and sym_rebind(x=..., y=...) is its
batch form — which also accepts nested path keys
(sym_rebind({'model.units': 64})) and rebinder callables, so writes
descend regardless of mode. topo=True is the single opt-in
rung that adds the symbolic tree (and flips the write posture to
rebind-first); frozen=True is the immutability knob in both modes.
Not exactly @dataclass: the deliberate divergences¶
"Flat dataclass semantics" is the mental model, not a byte-for-byte
contract. Where @dataclasses.dataclass and pydantic disagree, flat
pg.Object consistently sides with pydantic — the reference for the
validated-model ergonomics this library targets:
| Aspect | @dataclass |
pydantic v2 | pg.Object (flat and topo=True alike) |
|---|---|---|---|
Synthesized __init__ |
positional + keyword, declaration order | keyword-only | keyword-only (a teaching TypeError names the kwargs spelling; positional construction = an explicit __init__, below) |
z: list = [] (mutable literal default) |
rejected at class creation ("use default_factory") |
accepted; deep-copied per instance | accepted; deep-copied per instance (default_factory also supported) |
c: Client = CLIENT (arbitrary-type default) |
shared (plain class attribute) | shared (template instance reused) | shared (template instance reused; unpicklable defaults legal — default_factory for a per-instance copy) |
| Validation / coercion | none | yes | yes (validate=True default) |
__match_args__ |
derived | absent (positional patterns fail) | derived; a class-body declaration wins |
Two of these deserve their WHY:
- Keyword-only construction. Positional synthesis makes field
declaration order silent public API — reordering fields (or inserting
one in a base class) rebinds every positional call site, with no error
when adjacent fields share a type. Pydantic made the same call. The
sanctioned positional spelling is an explicit
__init__("Adding a positional__init__"), where the contract is visible in source and immune to reordering. - Mutable defaults are copied, not banned.
@dataclassrejects the literal spelling because its default would be a shared class attribute and it has no per-instance hook to copy at.pg.Object(like pydantic) runs a construct funnel per instance anyway, so each construct deep-copies the stored default —Foo().z is Foo().zisFalse, mutations never leak across instances, and neither the schema default nor the literal you wrote is ever aliased into an instance. (Corollary: don't compareid()s of defaults across temporary instances — CPython reuses freed addresses, soid(Foo().z)can repeat across constructs that never coexisted.)
One sharing rule that matches both neighbors: a shallow clone
(sym_clone(), the default) reference-keeps a flat object's raw
container members, exactly as copy.copy shares a dataclass's — use
sym_clone(deep=True) for an isolated copy.
Sliding down the spectrum¶
Each rung below shows the deltas from the default and a minimal example.
Rung 0 — plain mutable dataclass. Flat (the topo=False default) with
validation off; writes are already on by default. Behaves like
@dataclasses.dataclass: synthesized init, free mutation, values stored
verbatim, raw containers, reference semantics (a second holder is just
another reference), no
symbolic tree.
class Point(pg.Object, validate=False):
x: int
y: int = 0
p = Point(x='oops') # no error — value lands verbatim
p.x = 2 # mutable
Rung 1 — validated mutable record (the default). Everything at
defaults. Validated, value-equal, hashable, and mutable: attr_write
follows the topo axis, so a flat object accepts obj.x = v (validated,
like a mutable pydantic model — type errors fire at construction and
assignment), and sym_rebind(x=..., y=...) is the batch form (same
validation, one coalesced on_sym_change) — reaching nested values too,
via path keys or a rebinder callable.
class Money(pg.Object):
amount: int
currency: str = 'USD'
m = Money(amount=5)
Money(amount=5) == Money(amount=5) # True (value equality)
m.amount = 9 # validated in-place write
m.amount = 'nope' # TypeError: expect int, got str
m.sym_rebind(amount=5, currency='EUR') # batch form, one change event
m.sym_rebind({'meta.tag': 'x'}) # SymbolicModeError — path keys need a tree
Since the default is mutable and hashable, don't mutate an object while it serves as a dict/set key (the same hazard any hashable-mutable value has) — take the next rung for construct-once values.
Rung 2 — immutable value object. Pin attr_write=False for a
read-only dotted surface (sym_rebind still works — frozen alone seals
it), or frozen=True to seal the object completely at construction. To
derive a modified value, use sym_clone(override=...) — the
dataclasses.replace() analog, available in both topo modes:
class Snapshot(pg.Object, frozen=True):
amount: int
currency: str = 'USD'
s = Snapshot(amount=5)
s.amount = 9 # WritePermissionError
s2 = s.sym_clone(override=dict(currency='EUR')) # new Snapshot; s unchanged
Rung 3 — full symbolic node. The opt-in rung: add topo=True (which
also makes unauthored container fields wrap, and is inherited by
subclasses). The
object and its nested containers participate in the symbolic tree:
topo_path, sym_rebind, sym_diff, contextual value resolution, and
on_sym_change notifications all apply to the field subtree.
class Money(pg.Object, topo=True):
amount: int
currency: str = 'USD'
class Order(pg.Object, topo=True):
items: list[Money]
o = Order(items=[Money(amount=5)])
o.items[0].topo_path # 'items[0]' — tree-addressable
o.sym_rebind({'items[0].amount': 7}) # path-targeted mutation
Rung 4 — frozen. Add frozen=True to seal at construction. Now even
sym_rebind raises (unless scoped under pg.as_sealed(False)). Use for shared
constants and snapshot safety.
class Frozen(pg.Object, topo=True, frozen=True):
x: int
Frozen(x=1).sym_rebind(x=2) # WritePermissionError
The configuration matrix¶
The knobs compose — the rows below are common combinations, not mutually exclusive modes. "Deltas" are the kwargs you set; everything unlisted stays at its default.
| Semantic | Class-arg deltas | Direct write | Validate | Symbolic tree | Equality | Reach for it when |
|---|---|---|---|---|---|---|
| Plain dataclass | validate=False |
✓ | — | — | value | You genuinely want raw, verbatim values + free mutation (not a speed win — see the note below). |
| Validated record (default) | (none) | ✓ (validated; sym_rebind batches) |
✓ | — | value, hashable | Most subclasses. |
| Immutable value | attr_write=False (or frozen=True to seal rebind too) |
— (use sym_clone(override=...)) |
✓ | — | value, hashable | Construct-once values; safe dict/set keys. |
| Symbolic node | topo=True |
— (use sym_rebind) |
✓ | ✓ | value, hashable | Trees: paths, adoption, path-targeted rebind, patching, search. |
| Frozen node | topo=True, frozen=True |
— (sealed) | ✓ | ✓ | value, hashable | Shared constants; defend against any post-construction change. (frozen=True alone seals a flat object too.) |
| Identity object | eq=False |
per other knobs | per | per | identity | Fields are unhashable, or node identity (not value) is the key. |
| Validated, raw storage | x: dict[str, V] |
per | ✓ | — | value | Type-check the value but keep type(x) is dict for third-party interop. |
The spectrum is a semantic dial, not a performance one. Turning
validateoff, or raw storage (or thetopoaxis on or off), changes meaning (raw vs. validated / wrapped values, flat vs. tree), not speed in any meaningful way. Everypg.Object, at every rung, carries the same symbolic substrate, and the native core runs it at speeds that make the rung a poor lever for performance —topo=Falseandtopo=Truemeasure at parity. Pick the rung whose meaning you want; if performance is the question, see the performance report.
Field-level overrides¶
validate is tunable per field, and storage is per-field by
annotation; a per-field
setting wins over the topo-axis default (resolution is materialized at
class-creation time). So a flat class can wrap one field, or a symbolic
class hold one raw field:
class Mixed(pg.Object, topo=False):
raw: dict # topo=False default → raw
wrapped: pg.Dict[str, Any] # annotation → wrapped
The equality/repr surface is field-tunable too — pg.field(compare=False),
hash=False, and repr=False drop a field from sym_eq / sym_hash /
format, and init=False moves a field out of __init__ and out of
symbolic storage entirely. See Field-level behaviors
for the full set.
For the deeper treatment of the topo axis — the dataclass-to-symbolic
spectrum, the single-tree-position divergence, and the
implementation-vs-semantics analysis — see the
pg.Object semantic spectrum companion doc.
The sections below are the practical authoring guide for each knob and
declaration form.
How to subclass pg.Object¶
A minimal subclass declares fields with type annotations; __init__ is
synthesized as keyword-only from those declarations:
class Message(pg.Object):
text: str
sender: str = 'user'
Message(text='hi', sender='ai') # OK
Message('hi') # TypeError at runtime; reportCallIssue under pyright
Both the runtime constructor and pyright's synthesized init treat schema
fields as keyword-only arguments. This is the intended contract — see
Inheritance for when to override __init__ for positional
ergonomics.
Three forms of field declaration¶
class A(pg.Object):
# 1. Bare annotation. Cleanest for the common case — annotation drives
# the runtime ValueSpec, default (if any) follows `=`.
name: str
count: int = 0
# 2. `typing.Annotated` for attaching a docstring without leaving the
# annotation form. The first slot is the type; subsequent string
# slots become the field's `description`. Use when the field's
# semantics need a one-liner explanation.
tag: Annotated[str, 'Short identifier shown in logs and the tree view.']
# 3. `pg.field(...)` for anything bare / Annotated can't express:
# - mutable defaults via `default_factory`
# - runtime constraints beyond the annotation (e.g. regex, ranges)
# - `init=False` / `repr=False` / `compare=False` etc.
# - explicit `metadata` dict
items: list[int] = pg.field(default_factory=list)
email: str = pg.field(
value_spec=pg.typing.Str(regex=r'.+@.+'),
doc='User email, validated at construction.',
)
When to use which¶
- Bare annotation: the field has no docstring and the annotation already captures the type. Reach for this first.
Annotated[T, 'doc']: the field needs a one-line docstring but nothing else. Cheaper visual cost thanpg.field(doc=...).pg.field(...): anything beyond type + default + docstring — factories, value-spec overrides, init/repr/compare/hash flags, metadata. Also use for derived fields (init=False).
You can combine the forms: Annotated[T, pg.field(...)] puts the
pg.field descriptor inside an Annotated slot, useful when you want
the type to remain visually leading. Most pygx code uses the bare
pg.field form.
Bare annotations and Annotated forms internally desugar to pg.field()
with all flags at defaults — no functional difference for the common case.
What a static default does per shape (default vs default_factory)¶
Static defaults copy selectively, matching pydantic v2 (#664):
- Recognized copyable shapes deep-copy per instance — builtin
containers (
dict/list/set/tuple/bytearray, subclasses included, opaque elements inside copied too), symbolic values (pg.Object/pg.Dict/pg.List, via their own copy protocol — contextual markers likepg.contextual.Placeholderride this arm), dataclass instances, and types that opt in by extendingpg.CopyIfDefault. Mutating one instance's default never leaks into another. -
Arbitrary-type defaults are shared by reference — the template instance is reused across every construct (defining
__deepcopy__does not opt a type into copying — same as pydantic; extendingpg.CopyIfDefaultis the declared per-type opt-in). This makes unpicklable defaults (locks, client handles, connections) legal:
The footgun is mutable state on a shared template: if each instance
must own its object, say so with default_factory — the explicit
per-instance escape hatch:
```python
class Service(pg.Object):
client: HttpClient = pg.field(default_factory=HttpClient)
```
- Factory output is stored as-is (validated, never copied again).
Generic classes (G[int] validates at runtime)¶
A generic pg.Object subclass may declare TypeVar-typed fields; on the
unparameterized class they validate gradually (the TypeVar's bound,
its constraints-union, or Any). Subscripting with concrete args mints
a cached, real subclass whose fields validate against the bound types
— pydantic parity — and class C(G[int]) inherits the substituted
fields by ordinary subclassing:
T = typing.TypeVar('T')
class Box(pg.Object, typing.Generic[T]):
v: T | None = None
xs: list[T] = []
Box[int](v=5) # OK — a cached subclass named 'Box[int]'
Box[int](v='x') # TypeError: Expect int
Box(v='x') # OK — the unparameterized class stays gradual
Parameterized instances serialize under a parameterized _type tag
('mod.Box[int]') and round-trip with class identity preserved; a
partial subscription (Box[S]) stays a typing alias for annotation
positions. Declare optional TypeVar fields as v: T | None = None —
a bare v: T = None fails at mint time when the substituted type
rejects None (defaults validate eagerly).
Stdlib scalar fields (datetime, UUID, Decimal, Path)¶
datetime.datetime / date / time / timedelta, uuid.UUID,
decimal.Decimal, and pathlib.Path annotations map to first-class
specs (pg.typing.Datetime etc.) with pydantic-v2-parity lax coercion —
ISO-8601 strings (and POSIX timestamps for datetime), canonical
UUID/decimal/path strings — while strict=True (field or class level)
accepts only the exact type. On the wire they serialize as tagged
human-readable strings ({"_type": "datetime.datetime", "value":
"2026-01-02T03:04:05"}) instead of pickled blobs, and JSON schemas
carry the standard format markers (date-time, uuid, ...):
class Event(pg.Object):
at: datetime.datetime
uid: uuid.UUID
amount: decimal.Decimal
Event(at='2026-01-02T03:04:05', uid='550e8400-...', amount='1.50')
Structured dict fields via TypedDict¶
A typing.TypedDict annotation declares a closed dict schema in one
stroke: keys become const fields, extra keys are rejected, and
NotRequired / total=False keys become noneable fields defaulting to
None (an absent key reads back as None):
from typing import NotRequired, TypedDict
class Retry(TypedDict):
max_attempts: int
backoff: NotRequired[float]
class HttpClient(pg.Object):
retry: Retry # validated dict; stays raw like `dict[str, V]`
retry2: pg.Dict[Retry] # same schema, field IS the symbolic container
Like dict[str, V], the bare spelling does not force wrapping;
pg.Dict[SomeTypedDict] declares the field is a pg.Dict (forces
symbolic storage in both modes, like pg.Dict[str, V]).
pg.typing.Dict(SomeTypedDict) builds the same value spec explicitly.
Recursive TypedDicts are rejected (a dict schema is a tree, not a graph);
generic TypedDicts substitute their type parameters (Movie[int]), with
unbound parameters erasing to Any.
PEP 728 open TypedDicts are honored: extra_items=T appends a typed
wildcard (extra keys allowed, validated as T, inherited by subclasses),
while closed=True and the pre-PEP default reject extra keys. PEP 695
type aliases (type Retry = ...) are transparent everywhere an
annotation is read — an alias of pg.Dict[str, V] still declares the
symbolic container.
Class-level behaviors¶
A subclass's behavior is tuned via keyword arguments to the class
statement. These flow into _ObjectOptions and are inherited by further
subclasses (each level overlays its own overrides):
class Counter(pg.Object, attr_write=True):
count: int = 0
class FrozenCounter(Counter, frozen=True):
pass
class IdentityBag(pg.Object, eq=False):
"""Compared by identity, not value — usable as dict/set keys."""
items: list[int] = pg.field(default_factory=list)
| Class kwarg | Effect | Default |
|---|---|---|
topo |
Top-level axis, True or False. topo=True makes instances symbolic-tree nodes (one tree position per node, topo_path / topo_parent / topo_root, contextual resolution, change notification; an unauthored container spec stores symbolic — an explicit dict / list annotation stays raw). topo=False is a flat, reference-semantics object: no tree, no single-position rule, raw members; the tree-position members (topo_path / topo_parent / topo_root / topo_ancestor and their setters) and contextual resolution raise SymbolicModeError, while the value-shaped writes all work — sym_rebind accepts plain field names, nested path keys, and rebinder callables, and sym_clone(override=...) is the dataclasses.replace() analog. topo=None (unspecified) inherits the base class's mode. |
None (inherit; root pg.Object is False) |
attr_read |
obj.field_name returns the symbolic field's value. When False, attribute access raises and obj.sym_get(...) is required. |
True |
attr_write |
obj.field_name = v updates symbolic state. When False, raises WritePermissionError. Gates only the dotted surface — sym_rebind is sealed by frozen alone. |
follows not topo (True for flat, False for symbolic); an explicit value pins the subtree |
frozen |
Object is sealed at construction; subsequent sym_rebind / assignment raises unless under pg.as_sealed(False). |
False |
eq |
__eq__ / __ne__ / __hash__ delegate to sym_eq / sym_hash. When False, fall back to identity. |
True |
order |
Synthesize < / <= / > / >= (à la @dataclass(order=True)): lexicographic comparison over the compare=True fields in declaration order — the same fields that feed sym_eq. A different type yields NotImplemented (Python then raises TypeError). Orthogonal to sym. Delegates to sym_lt / sym_gt, so a < b and pg.lt(a, b) agree for same-type operands. |
False |
validate |
Whether values flow through ValueSpec.apply at __init__, reassignment, and rebind. When False, values land verbatim — type check, coercion, and default-filling are all skipped. |
True |
init |
PEP 681 class keyword. init=False opts the subclass out of pyright's dataclass_transform __init__ resynthesis, so the parent's explicit __init__ resolves via MRO instead. Runtime no-op. See §Inheriting a parent's explicit init. |
True |
Per-field dict / list storage is declared by the ANNOTATION (see
§Container storage below),
falling back to the topo axis when nothing declares it. There is no
class-level wrapping kwarg — the former class-level
symbolize= keyword has been removed; use topo=False (or per-field
a builtin dict / list annotation) instead.
The defaults are deliberate: flat (dataclass/pydantic-shaped), validated,
mutable-with-validation, value-equal-by-default — the contract a newcomer
expects from a modeled class. Reach for these knobs (topo=True first
among them, for the symbolic tree; frozen=True for immutability) when
you genuinely want different behavior, not as defensive defaults.
Container storage — the annotation decides¶
Whether a raw dict / list supplied to a field is stored as-is or
wrapped into a pg.Dict / pg.List (so it participates in the symbolic
tree — topo_path, sym_rebind, sym_diff, contextual lookup, change
notification) is decided by the field's annotation:
| Annotation | Storage | Notes |
|---|---|---|
x: pg.Dict[str, V] / pg.List[E] |
symbolic | On BOTH axes — a flat class stores the symbolic container too (held by reference, not adopted). |
x: dict[str, V] / list[E] |
raw | On BOTH axes — an explicit builtin annotation means what it says. |
x: Any, x: dict-less specs, pg.field(value_spec=...) |
the topo axis |
Nothing declared storage, so topo=True wraps and topo=False stays raw. |
class Foo(pg.Object, topo=True):
a: pg.Dict[str, int] # symbolic — declared
b: dict[str, int] # raw — declared
c: typing.Any # unauthored → follows topo (here: wraps)
f = Foo(a={'x': 1}, b={'y': 2}, c={'z': 3})
type(f.a) # pg.Dict
type(f.b) # dict
type(f.c) # pg.Dict
The law holds at every depth, not just at a field's top level:
class Nested(pg.Object, topo=True):
m: pg.Dict[str, dict[str, int]] # outer symbolic, inner RAW
n: pg.Dict[str, typing.Any] # inner unannotated → wraps
o: pg.Dict[str, typing.Any, False] # `auto_wrap=False` → inner verbatim
pg.Dict[K, V, auto_wrap] takes an optional trailing bool: it governs
raw containers landing in positions the annotation does not constrain
to a container type. It defaults to True, so pg.Dict[str, Any] is
pg.Dict[str, Any, True].
Removed in 0.4:
pg.field(wrap=...)andField.enable_wrap. The annotation says everything the flag said, and says it at every depth rather than only at the field boundary. Migration: a field that needs the symbolic container is respelledpg.Dict[...]/pg.List[...]; a field that needs raw storage is spelled with the builtindict/list. A barex: dicton atopo=Trueclass now means RAW — this is a semantic change, not a respelling. See Migration to 0.4.Earlier still, this flag was spelled
symbolize; that name was removed in 0.2.
Where storage has effect: only on fields whose values are dict or
list (or contain nested dict/list). Declaring raw storage does NOT
disable:
- Type-validation against the field's
ValueSpec(usevalidate=Falsefor that). - Type-owned coercion — the
int→floatwidening intrinsic topg.typing.Float, and "into a type you own" coercions declared by a target type's__pg_accept__(value)classmethod (e.g.KeyPath/Htmlacceptingstr). These still fire. - Default-filling for missing keys (that lives in the apply pipeline gated by
validate).
The (validate, storage) combinations. They compose into three real
behaviors (the fourth collapses because validate=False short-circuits
before the wrapping callback can fire):
validate |
storage | Behavior |
|---|---|---|
True |
symbolic | Wrap, validate, fill defaults. |
True |
raw | Validate against the schema, but leave the value raw. Useful when you want type safety + raw access (e.g. for isinstance(x, dict) interop, performance, or aliasing semantics). |
False |
* | Whole apply pipeline skipped. Values land verbatim; storage is moot. |
Container-level on pg.Dict / pg.List. The flag also exists at
the container surface as a persistent property — pg.Dict(...,
wrap=False) builds a symbolic outer container with raw children
that stays consistent across __init__ and subsequent __setitem__
/ update / sym_rebind:
d = pg.Dict({'a': {'k': 1}}, wrap=False)
type(d['a']) # <class 'dict'>
d['b'] = {'k': 2}
type(d['b']) # <class 'dict'> — also raw
Combining wrap=False with value_spec= runs validation against
the schema without wrapping children (same as the field-level (True,
False) row above).
pg.from_json(..., wrap=False). The same flag controls
JSON-side deserialization for schemaless layers. _type-tagged typed
payloads always dispatch through the class's own apply pipeline (so a
nested pg.Object still gets constructed), and embedded
__symbolic__: true markers force-wrap a specific subtree regardless
of the caller's flag. See pg.from_json for details.
When to reach for wrap=False.
- The field stores plain JSON-ish data that you hand off to a library that does
type(x) is dictchecks. - The field is a perf-sensitive hot path and the symbolic tree features (
sym_rebind,sym_diff, change notification, contextual) aren't used on it. - You want
obj.x is original_dictto hold for aliasing reasons (the default wrapping breaks identity by creating a newpg.Dict).
When not to. If you use sym_rebind / topo_path / contextual
lookup / change notification on the field's subtree, leave it on.
wrap=False is a sharp escape hatch, not a general-purpose
performance knob.
Design note: why storage lives on the SPEC, not on Field¶
Container storage was originally a Field-level flag (pg.field(wrap=...),
resolved against the class's topo axis and materialized onto each field at
class creation). It moved onto the value spec in 0.4, because the flag could
not express the law it was being asked to enforce:
- A field-level bit only speaks at the field boundary.
pg.Dict[str, dict[str, Any]]— wrapped-of-raw — needs storage stated at an INNER position. The spec tree has a node there; the field does not. - The spec was mode-blind, so the information was erased. Before 0.4,
pg.Dict[str, V]anddict[str, V]produced byte-identical specs, and symbolic-ness survived only as a boolean on the field. By the time the funnel walked into a nested position, nothing distinguished them. - Two sources of truth disagreed. The annotation and the flag could
contradict each other (
pg.Dict[...]+wrap=Falsewas a class-creation error precisely because of this), and at the field boundary the flag won — which is why a barex: dictkept wrapping even after the annotation said otherwise.
The spec now carries a TRI-STATE symbolic: True (a pg.Dict[...] /
pg.List[...] annotation), False (an explicit builtin dict / list),
or None — UNAUTHORED, meaning no annotation spoke for this spec (a
value_spec= passed directly, an untyped field, a spec built in library
code). Only the two explicit states override the topo axis; None defers
to it. The distinction is load-bearing: collapsing "declared raw" into
"never declared" would stop every directly-built pg.typing.Dict(...) from
wrapping.
Specs are per-declaration objects (subclassing clones rather than shares), so storing the declaration on the spec does not leak across classes — the sharing hazard that motivated the Field-level surface does not apply.
Serialization-related class kwargs¶
Forwarded into pg.Object.__init_subclass__ and consumed by the
serialization registry:
serialization_key— explicit registry key for JSON round-trip (overrides the default<module>.<qualname>).additional_keys— extra registry aliases (typically for renames / migrations).add_to_registry— set False on bases that should not be deserialized directly.user_cls— used by class-wrapper machinery; you almost never set this.
These are orthogonal to the field-level flags; both can appear on the same class statement.
Field-level behaviors¶
pg.field(...) exposes per-field flags that mirror or extend
dataclasses.field. Bare and Annotated declarations get all flags at
default; reach for pg.field only when you need a non-default flag.
| Param | Purpose |
|---|---|
default |
Initial value; required-when-omitted unless default_factory is set. |
default_factory |
Zero-arg callable producing the default per instance (lists, dicts, time stamps). Mutually exclusive with default. |
doc |
One-line docstring. Equivalent to Annotated[T, doc]. |
metadata |
Arbitrary dict carried on the schema field — useful for downstream tools (serialization hints, etc.). |
value_spec |
Explicit pg.typing.ValueSpec overriding the annotation-derived spec. Annotation still drives static types. |
transform |
(value) -> value hook run after type validation. |
alias |
Wire-layer key alias: accepted by from_json, emitted by to_json(by_alias=True) and JSON Schema. Attribute access and __init__ keep the field name. |
validator |
(value) -> None after check, called with the final (coerced/wrapped) value on construct, assignment, and sym_rebind; raise to reject (re-raised path-suffixed as the same exception class). Return value ignored — transformation is transform's job. |
repr / compare / hash |
Mirror dataclasses.field. Exclude the field from format() / sym_eq / sym_hash. |
init=False |
Drop the field from __init__; store on self.__dict__ instead of _sym_attributes. The class manages it (typically via on_sym_ready). |
clone |
Only meaningful with init=False: preserve the value through sym_clone instead of reseeding from default. |
validate |
Force / suppress ValueSpec.apply regardless of init. |
Non-fields: use typing.ClassVar¶
Annotated class attributes that are configuration, not schema fields,
must be marked typing.ClassVar so dataclass_transform skips them:
class MyOperator(pg.Object):
OPERATOR_STR: typing.ClassVar[str] = '+' # config, not a field
OPERATOR_FN: typing.ClassVar[Callable] = ... # config, not a field
x: Any # schema field
y: Any # schema field
Without ClassVar, pyright treats OPERATOR_STR and OPERATOR_FN as
required-but-defaulted schema fields, pollutes the synthesized
__init__ signature, and (worse) triggers the "fields without default
values cannot appear after fields with default values" rule on later
subclasses that add required fields.
pg.Object itself follows this convention for its
__sym_options__, __infer_fields__, __auto_schema__,
_exclude_from_repr, _non_symbolic_fields configs.
Inheritance¶
Three patterns come up in nearly every non-trivial subclass hierarchy.
Overriding a field's default value¶
When a subclass overrides a parent field's default, re-annotate to keep
pyright's synthesized __init__ in sync:
class Message(pg.Object):
text: str
sender: str = pg.MISSING_VALUE
class UserMessage(Message):
sender: str = 'user' # good — re-annotated, pyright picks up the new default
class UserMessage2(Message):
sender = 'user' # bad — bare assignment, pyright still sees `sender` as required
Bare-assignment overrides are silently invisible to dataclass_transform
synthesis. pygx emits a runtime warning when it detects this (see
#101); the fix is always
to re-annotate.
Inheriting a complex parent spec: use pg.typing.Inherit¶
When the parent field is declared with an Enum, regex, numeric bound,
or any other rich value_spec, a re-annotation like model: str = 'a'
creates a fresh Str spec on the subclass that cannot extend the
parent's Enum — class creation fails with TypeError: ... cannot
extend ...: incompatible type. Restating the full parent spec on every
subclass leaks the source-of-truth (e.g. the Enum's value set) into the
override.
Use pg.typing.Inherit to keep the parent's spec verbatim and only
swap the default:
class LM(pg.Object):
model: Annotated[
str, pg.field(value_spec=pg.typing.Enum(SUPPORTED_MODEL_IDS))
]
class Claude46Opus(LM):
model: pg.typing.Inherit = 'claude-opus-4-6' # inherits Enum, default='…'
class Gpt5(LM):
model: pg.typing.Inherit = 'gpt-5' # ditto, no Enum restatement
Static type checkers see Inherit as Any, so pyright honors the new
default (no reportCallIssue) and doesn't fire
reportIncompatibleVariableOverride. The runtime still validates
assignments against the inherited Enum.
Errors at class creation if (a) no parent field with the same key
exists, or (b) no default value is supplied — Inherit without a
default is a no-op and intentionally rejected. For
parent fields with plain types (int, str, …), prefer the simple
re-annotation form (x: int = 1) — Inherit is for cases where the
parent spec is non-trivial to restate.
Inheriting a parent's explicit init¶
A common library pattern declares a **kwargs-forwarding __init__ on
a base class to route flat kwargs into a nested field, then has concrete
subclasses that only override a default:
class LM(pg.Object):
model: str
sampling_options: SamplingOptions = pg.field(default_factory=SamplingOptions)
def __init__(self, **kwargs: Any) -> None:
# Routes flat `thinking=`, `effort=`, … into `sampling_options`.
super().__init__(**kwargs)
class Claude47Opus(LM, init=False):
model: pg.typing.Inherit = 'claude-opus-4-7'
Claude47Opus(api_key='…', thinking=True) # accepted by pyright
Without init=False, pyright would resynthesize Claude47Opus.__init__
from its schema fields only — losing the parent's **kwargs escape hatch
and flagging thinking=True as reportCallIssue: No parameter named
"thinking". PEP 681's init=False class keyword tells pyright to skip
resynthesis for that subclass; __init__ then resolves through the MRO
to the parent's explicit definition, with the parent's full typed
signature still enforced.
At runtime, init=False is a no-op — Python's MRO already resolves
__init__ lookups the same way; the keyword exists purely to align the
static-analysis view with the runtime view.
When to use it. Subclass adds no new fields (or only overrides
defaults via re-annotation / pg.typing.Inherit) and the parent has an
explicit __init__ that pyright must not shadow.
When not to. Subclass adds new schema fields you want in the
synthesized init signature. init=False disables synthesis entirely —
those new fields would only be reachable via the parent's **kwargs.
For new fields with their own kwargs surface, leave init at its
default and let the synthesizer build a fresh init.
Adding a positional __init__ for ergonomic primary fields¶
If a class has a small, semantically obvious "primary" field — the kind
users naturally write as the first positional argument — provide an
explicit __init__ that forwards to super().__init__(**kwargs):
class Label(HtmlControl):
text: str | Html
tooltip: Tooltip | None = None
link: str | None = None
target: str | None = None
def __init__(
self,
text: str | Html,
tooltip: Tooltip | None = None,
link: str | None = None,
target: str | None = None,
**kwargs,
):
super().__init__(
**dict(text=text, tooltip=tooltip, link=link, target=target),
**kwargs,
)
class BinaryOperator(Operator):
x: Any
y: Any
def __init__(self, x: Any, y: Any, **kwargs):
super().__init__(**dict(x=x, y=y), **kwargs)
Rule of thumb: one or two leading positional fields. Beyond that the
positional call site becomes harder to read than the kwargs form, and the
explicit __init__ becomes maintenance burden when the schema evolves.
Classes without a clear primary field should stay kw-only — let the
synthesized __init__ from dataclass_transform do the work.
Calling super().__init__: use the **dict(...) idiom¶
When forwarding schema fields to super().__init__(), use
super().__init__(**dict(field=field, ...), **kwargs) rather than
super().__init__(field=field, ..., **kwargs). Reason:
When super() resolves to a pg.Object subclass that doesn't itself
declare an explicit __init__, pyright synthesizes the parent's init
closed over only that parent's local fields — it doesn't accept
arbitrary subclass field names. So
super().__init__(text=text, tooltip=tooltip) gets flagged
reportCallIssue: No parameter named "text" even though the call is
correct at runtime (Python eventually dispatches to
pg.Object.__init__(**kwargs), which validates against the full schema).
The **dict(text=text, tooltip=tooltip) form passes the same kwargs but
hides their names behind a dict literal. Pyright sees the spread as
**Mapping[str, Any] and does not validate the keys — the runtime call
is identical. One extra layer of source-level indirection buys silent,
ignore-free static checking.
When you need a # pyright: ignore[reportCallIssue] anyway¶
A small set of cases the **dict(...) form doesn't cover:
- Subclass passes positional arguments to
super().__init__()(rare in pygx — almost everything goes by keyword). - Subclass calls a sibling class's constructor where the field names
collide with framework keywords (e.g.
allow_partial). - The forwarded kwargs include a field that fails an unrelated check
(
reportArgumentTypetypically — that's a different rule and a different fix).
In those cases keep the # pyright: ignore[reportCallIssue] (or
# pyright: ignore[<other-rule>]) — but reach for them only after
trying the **dict(...) form first.
Quick reference¶
| Situation | Pattern |
|---|---|
| Type + default, no docstring needed | Bare annotation: name: str = '' |
| Type + one-line docstring | field: Annotated[T, 'doc'] |
| Mutable default, validation, init/repr/compare flags | field: T = pg.field(...) |
| Class-level config attribute (not a schema field) | name: typing.ClassVar[T] = default |
| Read-only dotted surface (rebind still works) | class C(pg.Object, attr_write=False): ... |
| Sealed-on-construction class | class C(pg.Object, frozen=True): ... |
| Identity-equal class | class C(pg.Object, eq=False): ... |
| New class, all fields kw-only | Don't write __init__. Let the synthesizer handle it. |
| New class, primary positional arg (1–2 fields) | Explicit __init__, forward via **dict(field=field, ...), **kwargs |
| Subclass changing a parent default | Re-annotate: field: T = new_default |
| Subclass changing a parent default w/ a complex spec (Enum, regex, ...) | field: pg.typing.Inherit = new_default |
Subclass inheriting parent's explicit __init__(**kwargs) (no new fields) |
class Sub(Parent, init=False): ... |
super().__init__(field=field) flagged reportCallIssue |
Switch to **dict(field=field), **kwargs — don't add # pyright: ignore |
Direct pg.Object(...) construction with unknown kwarg |
Pyright correctly flags it. Fix the call site. |