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 — undersym=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 sym: 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) | sym |
sym=False (the default) is a flat, reference-semantics object: no tree, no clone-on-reparent, raw members. sym=True (opt-in, inherited by subclasses) makes instances nodes in a symbolic object tree — sym_path / sym_parent / sym_root, clone-on-reparent, contextual resolution, change notification — and dict / list members wrap by default. |
| Validation | validate |
Type-checking, coercion, and default-filling through each field's ValueSpec.apply. Meaningful in both sym modes. |
| Container wrapping | wrap (per field) |
Raw dict / list wrapped into pg.Dict / pg.List so values join the tree. Defaults to the sym axis (wrap under sym=True, raw under sym=False); override per field with pg.field(wrap=...). 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 — sym=False, attr_read=True,
frozen=False, eq=True, order=False, validate=True, with attr_write
FOLLOWING the sym axis (True under sym=False, False under
sym=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 over the immediate fields. sym=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 sym=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) |
| 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 sym=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 (no clone-on-reparent), 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 sym 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 over the
immediate fields (same validation, one coalesced on_sym_change).
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 sym 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 sym=True (which
also flips per-field wrap on, and is inherited by subclasses). The
object and its nested containers participate in the symbolic tree:
sym_path, sym_rebind, sym_diff, contextual value resolution, and
on_sym_change notifications all apply to the field subtree.
class Money(pg.Object, sym=True):
amount: int
currency: str = 'USD'
class Order(pg.Object, sym=True):
items: list[Money]
o = Order(items=[Money(amount=5)])
o.items[0].sym_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, sym=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 | sym=True |
— (use sym_rebind) |
✓ | ✓ | value, hashable | Trees: paths, adoption, path-targeted rebind, patching, search. |
| Frozen node | sym=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 | pg.field(wrap=False) |
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
validate/wrapoff (or thesymaxis on or off) changes meaning (raw vs. validated / wrapped values, flat vs. tree), not speed in any meaningful way — they move construction by ~10% and attribute reads by 0%. Everypg.Object, at every rung, carries the same symbolic substrate and its cost. If you need dataclass-grade performance, that's an implementation concern — a Rust-core prototype already demonstrates it across the whole spectrum (full symbolic object at near-dataclass speed, see #191) — not a reason to pick the low end.
Field-level overrides¶
validate and wrap are tunable per field, and a per-field
setting wins over the sym-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, sym=False):
raw: dict # sym=False default → raw
wrapped: dict = pg.field(wrap=True) # field override → 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 sym axis — the dataclass-to-symbolic
spectrum, the clone-on-reparent 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.
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 |
|---|---|---|
sym |
Top-level axis, tri-state. sym=True makes instances symbolic-tree nodes (clone-on-reparent, sym_path / sym_parent / sym_root, contextual resolution, change notification; dict / list members wrap by default). sym=False is a flat, reference-semantics object: no tree, no clone-on-reparent, raw members; tree-shaped sym_* ops raise SymbolicModeError, while sym_rebind with plain field-name keys mutates the immediate fields (and sym_clone(override=...) is the dataclasses.replace() analog). sym=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 sym (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 wrapping is controlled by pg.field(wrap=...)
(see §The wrap flag below), defaulting to the sym
axis. There is no class-level wrapping kwarg — the former class-level
symbolize= keyword has been removed; use sym=False (or per-field
pg.field(wrap=False)) 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 (sym=True first
among them, for the symbolic tree; frozen=True for immutability) when
you genuinely want different behavior, not as defensive defaults.
The wrap flag¶
The wrap flag controls whether raw dict / list values
supplied to a field get wrapped into pg.Dict / pg.List so they
participate in the symbolic tree (sym_path, sym_rebind, sym_diff,
contextual lookup, change notification). It defaults to the class's
sym axis — wrap under sym=True, raw under sym=False — and is
overridden per field with pg.field(wrap=...).
Renamed from
symbolize. This flag (field-levelpg.field(...), container-levelpg.Dict/pg.List, andpg.from_json) was previously spelledsymbolize; that name has been removed, along with the class-levelsymbolize=keyword (usesym=Falseinstead). There is no back-compat alias —symbolize=now raises.
class Foo(pg.Object, sym=True):
x: dict
f = Foo(x={'a': 1})
type(f.x) # <class 'pg.symbolic.Dict'> — wrapped (sym=True default)
class Bar(pg.Object, sym=True):
x: dict = pg.field(wrap=False)
b = Bar(x={'a': 1})
type(b.x) # <class 'dict'> — left raw
Where the flag has effect: only on fields whose values are dict
or list (or contain nested dict/list). For other field types it is
accepted but a no-op — there is no wrapping to disable for int,
str, declared pg.Object subclass fields, or anything that doesn't
go through the symbolic transform callback. In particular,
pg.field(wrap=False) 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).
sym-axis default + per-field override. The sym axis sets the
default (wrap under sym=True, raw under sym=False); an explicit
per-field pg.field(wrap=...) wins. Materialized at class-definition
time, so apply sites read a single resolved bit.
class Mixed(pg.Object, sym=False):
raw: dict # sym=False default → raw
wrapped: dict = pg.field(wrap=True) # field explicit → wrapped
m = Mixed(raw={'a': 1}, wrapped={'b': 2})
type(m.raw) # <class 'dict'>
type(m.wrapped) # <class 'pg.symbolic.Dict'>
The (validate, wrap) combinations. The two flags compose into three
real behaviors (the fourth collapses because validate=False
short-circuits before the wrap callback can fire):
validate |
wrap |
Behavior |
|---|---|---|
True |
True |
The sym=True default — wrap, validate, fill defaults. |
True |
False |
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. wrap 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 / sym_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 the flag lives on Field, not on pg.typing.Dict / pg.typing.List¶
The wrap callback only fires for dict / list values, so an argument
could be made for hanging the flag on the value spec itself
(pg.typing.Dict(wrap=False)) rather than on the Field. We chose
the Field-level surface deliberately:
- Consistency with other Field flags.
validate,repr,compare,hash,init,cloneall live onpg.field(...)/Field. Splittingwrapto a separate location would break the pattern users already know. sym-axis propagation needs Field storage. Asym=Falseclass materializeswrap=Falseonto each field's_enable_wrapat class-creation time so every apply site reads a single resolved bit. Materializing onto aValueSpecwould either mutate the shared spec (dangerous — specs are reused across fields and classes) or force a per-class clone of every Dict/List spec.- Ergonomics for annotation-style fields.
x: dict = pg.field(wrap=False)is one line; the spec-level equivalent (x: dict = pg.field(value_spec=pg.typing.Dict(wrap=False))) requires spelling out the inferred spec. - "Accepted but no-op" is already a pattern.
default_factoryis no-op whendefaultis set;cloneis no-op wheninit=True. A Field flag that only meaningfully applies to certain value types is consistent with existing convention — the constraint is documented rather than enforced at the type-spec level.
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. |