Skip to content

schema: install the generated typed __init__, so construction runs __post_init__ - #430

Merged
JarryShaw merged 3 commits into
mainfrom
fix/schema-typed-init
Sep 17, 2026
Merged

JarryShaw merged 3 commits into
mainfrom
fix/schema-typed-init

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Closes #422.

The guard could never pass

pcapkit/protocols/schema/schema.py:77 read if not hasattr(cls, '__init__'):, and every class inherits __init__ from object, so the typed __init__ built just below it was never installed. Schema.__init__ stayed as __update__, which fills fields but never calls __post_init__.

The archaeology is decisive: schema.py's hasattr guard is 43cf904ff (2023-05-13); infoclass.py's correct '__init__' not in cls.__dict__ and cls is not Info is aeb72729b (2023-05-14) — the next day. The pattern was fixed once and the schema copy never caught up.

I had briefed this as "infoclass.py installs unconditionally, so do not touch it". That was wrong: it already carries exactly the protection, and of 398 Info subclasses none hand-writes an __init__.

What it broke

Measured on origin/main versus this branch:

BEFORE                                          AFTER
ST.__init__.__qualname__ = Schema.__update__    = TCP.__init__
'__init__' in ST.__dict__ = False               = True

UDP(srcport=53, dstport=5353)
  TypeError: unsupported operand type(s)         -> 003514e900000000
             for &: 'UInt16Field' and 'int'
TCP(srcport=80, dstport=443, offset={...})
  TypeError: … 'UInt32Field' and 'int'           -> 005001bb...5000000000000000

So a schema built from a subset of its fields could not be packed at all, and the error named a field class rather than the missing field.

The confusing error had a separate root cause, now fixed properly: Schema.pack used getattr(self, field.name), which finds the class attribute — the FieldBase object — for an unset field. getattr(self, name, None) could therefore never return None, so every data is None branch was unreachable. It now reads from the instance dict.

One correction to my own brief: TCP(srcport=80, dstport=443) still cannot be packed, and should not be — the option-area length is lambda pkt: pkt['offset']['offset'] * 4 - 20, so offset is genuinely required. It now raises KeyError: 'offset', naming the field. Any tolerant default would emit an invalid header, since offset must be ≥ 5.

Why the pack is conditional, not eager

The substantive finding. Packing unconditionally at construction breaks 39 tests across 8 files, because many schemas are not packable from their own fields alone — MPTCPCapable.rkey's condition is lambda pkt: pkt['length'] != 32, and length belongs to the enclosing TCP option. The implicated modules are exactly tcp.py, mh.py, hopopt.py, ipv6_opts.py, pcapng.py, sctp.py.

So __post_init__ packs only when given a packet context; otherwise __updated__ stays set and __bytes__ packs on first use with the enclosing context, which is where the octets came from before. Zero failures.

pack() is idempotent — three consecutive calls give identical octets across six protocols — and nothing double-packs: pack calls per schema instance are identical to baseline.

Verification

  • Read path byte-identical: 15 captures × tree+json with ip/tcp/reassembly, diff -rq clean. Parsing goes through unpack, which never calls __post_init__. make_samples.py regenerates all 13 captures byte-identically.
  • Write path identical: 14 make+re-parse cases — same octets, same parsed info, same warning categories. Two fail identically on both trees (ipv4_opt_rr, ipv4_opt_ts, pre-existing).
  • Timing: read path unchanged (http.pcap 691.9 → 688.3 ms). Bare schema construction +1.2 µs for the defaults pass; full Protocol construction +2–6%. Nothing given back from perf: cut extraction time by ~46% on an HTTP capture, with byte-identical output #420 or perf: stop the flow dumper re-dissecting every frame, and options being parsed twice #427.
  • Suite 858 passed, 17 skipped, 0 failed, re-run independently. Controlled comparison against a pristine copy of the same base: 839/35 → 840/35, the +1 being the new test.
  • mypy and pylint unchanged.

Tests

test_generated_init_is_installed_and_runs_post_init is new and fails on a pristine tree. test_schema_final_generated_init_and_legacy_version_branch was mocking builtins.hasattr to return False purely to reach the dead branch — that scaffolding is gone, and it also fails on a pristine tree.

Three pre-existing defects found, not fixed

  1. ipv4.py:1480 — _make_opt_ts passes data=ts_list where the field is ts_data, so the IPv4 Timestamp option cannot be constructed on either tree; data= was silently dropped with an UnknownFieldWarning.
  2. tcp.py:2604–2996, 11 sites — every _make_mptcp_* passes kind=/length=, which are not MPTCP fields (declared under TYPE_CHECKING only), so a made MPTCP option omits its leading kind/length octets. tcp.py was held by other work.
  3. hip.py:3445 — EncryptedParameter(cipher=...); cipher is not a field.

Because mypy believes those keywords are valid — the TYPE_CHECKING stubs declare them — the generated __init__ forwards **kwargs to __update__ rather than rejecting unknown keywords. Tightening that would turn all 13 sites into TypeErrors, so it wants its own change.

…`__post_init__` (#422)

`schema_final` guarded the typed `__init__` it builds on
`hasattr(cls, '__init__')`, which every class satisfies through `object`, so the
method was never installed and `Schema.__init__` stayed bound to `__update__`.
Constructing a schema therefore never reached `__post_init__`. `info_final`
spells the same test correctly, on `cls.__dict__`, and did so a day later than
this one was written (`43cf904ff`, then `aeb72729b`); this is the fix that never
came back to `schema.py`.

The consequence was not merely dead code. A schema built from a subset of its
fields could not be packed at all:

    >>> from pcapkit.protocols.schema.transport.udp import UDP
    >>> bytes(UDP(srcport=53, dstport=5353))
    TypeError: unsupported operand type(s) for &: 'UInt16Field' and 'int'

which now returns `b'\x00\x35\x14\xe9\x00\x00\x00\x00'`.

* `schema_final`: test `'__init__' not in cls.__dict__`.
  `pcapkit.protocols.schema.misc.null.NoPayload` is what that protects, and says
  so in its own comment. Forward `**kwargs` as well, so a keyword naming
  something other than a field keeps drawing `__update__`'s
  `UnknownFieldWarning` instead of a `TypeError`: the Multipath TCP options are
  constructed with a `kind` and a `length` that the enclosing option owns, and
  `MPTCP` declares them for the type checker only.
* `Schema.pack`: read each field's value from the instance rather than with
  `getattr`, which finds the class attribute when the instance has none -- and a
  schema's class attribute for a field is the `FieldBase` object. That is where
  the error above came from, and why it named a field class rather than the
  field that had been left out. It is also why the `data is None` branches could
  never fire.
* `Schema.__post_init__`: fill a field left unset from its declared default;
  where it declares none, drop the `NoValue` the generated `__init__` seeded,
  since `NoValue` is a field sentinel and not a value a schema may hold. A
  `None` the caller passed is kept, being a chosen value on an optional field.
* `Schema.__post_init__`: pack only when a packet context was given. A schema is
  not in general packable from its own fields alone -- a field callback may read
  a key the enclosing layer owns, as `MPTCPCapable.rkey` does with the TCP
  option's `length` -- and `__updated__` is still set, so one left unpacked here
  is packed by `__bytes__` on first use, which is where its octets came from
  before. Construction therefore performs exactly as many `pack` calls as it did
  before this change, measured per schema instance.
* `FieldBase._default`: declare on the class. A field class may replace
  `__init__` without chaining to `FieldBase`'s, as `ListField` does since a list
  of fields takes no default of its own, and reading `default` off one of those
  raised `AttributeError` for a private attribute.

`Schema.from_dict` is brought into line by the same `__post_init__` change: it
seeds only the keys its argument carries, so a partial dict used to raise
`KeyError` naming the first field left out.

Verified: every capture in `examples/captures` serialised to `tree` and `json`
with `ip=True, tcp=True, reassembly=True` is byte-identical, as it must be --
parsing goes through `unpack`, which never calls `__post_init__`; extraction of
`http.pcap` is unchanged at 688 ms against 692 ms. Twelve protocols `make`d and
re-parsed give identical octets and identical parsed data, and
`examples/generators/make_samples.py` regenerates all thirteen captures
byte-identically. Bare schema construction costs 1.2 us more, 3.0 us to 4.2 us,
for the `__post_init__` pass over the fields; a whole `Protocol` construction,
which makes, packs and re-parses, is 2% to 6% slower on that.

`tests/protocols/schema/test_schema_unit.py`: the new
`test_generated_init_is_installed_and_runs_post_init` pins all of the above, and
fails on a pristine tree at
`assertIsNot(HeaderSchema.__init__, Schema.__update__)`.
`test_schema_final_generated_init_and_legacy_version_branch` was reaching the
dead branch by mocking `builtins.hasattr` to return `False`, which is no longer
needed and would no longer describe anything; it now finalises the two schemas
the ordinary way, and fails on a pristine tree.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Schema.__post_init__ currently overwrites an explicitly provided None with the field default, contradicting the new documented behavior and preventing callers from intentionally preserving None for conditional packing logic.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR fixes schema_final so the generated typed __init__ is actually installed, ensuring Schema.__post_init__ runs on construction, and corrects packing to read values from the instance (not class-level field objects) for unset fields.

Changes:

  • Fix schema_final’s __init__ installation guard and ensure generated __init__ calls __post_init__ (while preserving unknown-keyword warning behavior).
  • Update Schema.__post_init__ and Schema.pack to handle unset fields correctly (instance dict lookup; defaults and missing-value behavior).
  • Add/adjust unit tests to cover the previously-dead generated __init__ path and the corrected packing behavior.
File summaries
File Description
pcapkit/protocols/schema/schema.py Fixes generated __init__ installation and corrects default-filling + packing behavior for unset fields.
pcapkit/corekit/fields/field.py Ensures all fields have a well-defined _default so default access is reliable even when subclasses override __init__.
tests/protocols/schema/test_schema_unit.py Updates legacy test scaffolding and adds a new regression test to ensure generated __init__ is installed and runs __post_init__.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pcapkit/protocols/schema/schema.py
`__post_init__` guarded on `value is not NoValue and value is not None`, so a
field the caller had explicitly set to `None` fell through and was overwritten
with the field's declared default. The comment four lines below claimed the
opposite -- that such a `None` was kept -- which held only for a field declaring
no default, the one branch an explicit `None` could never reach.

The code is what was wrong, not the comment. `unpack` stores `None` for a
`ConditionalField` whose test fails, *including* one that declares a default of
its own, so substituting the default left a constructed schema disagreeing with
a parsed one about the same packet. Measured on the tree as it stood, with a
conditional `maybe` defaulting to `0xCC`:

    parsed  = Probe.unpack(b'\x01zz', 3, None)
    parsed.to_dict()                              # {'kind': 1, 'maybe': None, ...}
    Probe.from_dict(parsed.to_dict()).to_dict()   # {'kind': 1, 'maybe': 204, ...}

`to_dict` did not survive `from_dict`. Telling "unset" from "set to `None`" is
what `NoValue` is for, and the information was already there; the guard now tests
`NoValue` alone, which also collapses the `elif` below into an `else`.

Nothing relied on the substitution. It takes a field with a real default to see
at all, which among the option schemas means only `len` on `CALIPSOOption`,
`HomeAddressOption`, `ILNPOption` and `IPDFFOption`, all defaulting to 0 -- and
every `_make_*` that builds those passes a computed integer (`8 + cmpt_len`,
`math.ceil(...)`, `16`, `2`), never `None`. Nor is there anything in the schema
package for which the substitution could have mattered on the wire: no field
callback anywhere compares a packet value against `None`, and `FieldBase.pack`
resolves a `None` from the field's own default regardless, so the octets are the
same either way. `HomeAddressOption(len=None)` and `IPDFFOption(len=None)` pack
byte-for-byte as before.

Verified: every capture in `examples/captures` serialised to `tree` and `json`
with `ip=True, tcp=True, reassembly=True` is byte-identical both to `origin/main`
and to the previous commit; the fourteen `make`-and-re-parse cases are identical
to the previous commit; `examples/generators/make_samples.py` regenerates all
thirteen captures byte-identically.

`tests/protocols/schema/test_schema_unit.py`: the new
`test_post_init_fills_the_unset_and_keeps_an_explicit_none` pins both halves --
an omitted field takes its default, an explicit `None` does not -- along with the
octets agreeing either way and the `from_dict(parsed.to_dict())` round trip. It
fails on the previous commit at `{'maybe': 204} != {'maybe': None}`.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The fix is targeted, internally consistent, and is backed by new/updated tests that directly pin the previously-dead __init__/__post_init__ path and the corrected packing behavior.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@JarryShaw
JarryShaw merged commit 2cf9f15 into main Sep 17, 2026
24 checks passed
JarryShaw added a commit that referenced this pull request Sep 17, 2026
Brings in #427 (the option-parse shortcut), #428 (dispatch no longer writing to
shared registries) and #430 (the generated typed `__init__`).

One conflict, in `tests/protocols/transport/test_tcp_udp_unit.py`, where both
sides appended test methods to the end of `TCPUDPUnitTests`. Both kept.

`pcapkit/corekit/fields/collections.py` auto-merged, which is the file worth
checking by hand rather than trusting: #427 rewrote the head of the
`OptionField.unpack` loop and this branch rewrote its tail, so the two touch the
same function without overlapping. The merged loop reads the type field through
#427's shortcut, rewinds by its `consumed`, then subtracts `len(data)`, breaks on
the end-of-option-list code, and only then checks that the option moved the
stream. `git diff origin/main -- pcapkit/corekit/fields/collections.py` is
additions only -- none of #427's work is lost, and the guard is still after the
`eool` break, which is where #431's last commit had to move it.
@JarryShaw
JarryShaw deleted the fix/schema-typed-init branch September 18, 2026 20:22
@JarryShaw JarryShaw added the fix Pull requests that fix a defect (fix: subject prefix) label Sep 22, 2026
@JarryShaw JarryShaw added this to the 1.5 milestone Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Pull requests that fix a defect (fix: subject prefix)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

schema_final's generated typed __init__ is dead code, so Schema.__post_init__ never runs

2 participants