Skip to content

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.ValueSpec derived 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.Object is decorated with dataclass_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, with override= as the dataclasses.replace() analog), JSON serialization (to_json / from_json), formatted repr (format), and — under sym=True — in-place mutation via sym_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 from repr / eq / hash, attach docstrings or metadata, hook a post-validation transform or a custom validator (see Field-level behaviors).
  • Lifecycle hooks. on_sym_ready runs after fields are validated and attached, once the object is concrete — the natural place to compute derived state. on_sym_bound is its completeness-agnostic sibling (fires even when partial); on_sym_change fires on sym_rebind for incremental update.
  • Model validators. on_sym_validate checks 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 endsym=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. @dataclass rejects 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().z is False, mutations never leak across instances, and neither the schema default nor the literal you wrote is ever aliased into an instance. (Corollary: don't compare id()s of defaults across temporary instances — CPython reuses freed addresses, so id(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 / wrap off (or the sym axis 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%. Every pg.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 than pg.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-level pg.field(...), container-level pg.Dict / pg.List, and pg.from_json) was previously spelled symbolize; that name has been removed, along with the class-level symbolize= keyword (use sym=False instead). 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 (use validate=False for that).
  • Type-owned coercion — the intfloat widening intrinsic to pg.typing.Float, and "into a type you own" coercions declared by a target type's __pg_accept__(value) classmethod (e.g. KeyPath/Html accepting str). 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 dict checks.
  • 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_dict to hold for aliasing reasons (the default wrapping breaks identity by creating a new pg.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, clone all live on pg.field(...) / Field. Splitting wrap to a separate location would break the pattern users already know.
  • sym-axis propagation needs Field storage. A sym=False class materializes wrap=False onto each field's _enable_wrap at class-creation time so every apply site reads a single resolved bit. Materializing onto a ValueSpec would 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_factory is no-op when default is set; clone is no-op when init=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.

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 (reportArgumentType typically — 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.