Skip to content

utilities: reject @prepare's extra arguments, and stop treating a declared zero length as EOF - #461

Merged
JarryShaw merged 4 commits into
mainfrom
fix-454-458-prepare-extras-and-eof
Sep 18, 2026
Merged

JarryShaw merged 4 commits into
mainfrom
fix-454-458-prepare-extras-and-eof

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Closes #454
Closes #458

Both defects live in pcapkit/utilities/decorators.py's prepare decorator, on top of #450 (da2422728), which fixed a third, related defect in the same lines (args[0..3] read unconditionally). Branched from da2422728.

#454 -- extras silently discarded

Mechanism: prepare's docstring told implementors the decorated function receives *args: 'typing.Any', **kwargs: 'Any', but the wrapper called func(cls, data, length, packet) (decorators.py:238 at da2422728) -- neither was ever forwarded. A misspelled or unsupported argument produced a successful parse and no signal. A narrower case: length supplied both positionally and by keyword silently kept the positional and dropped the keyword, because kwargs.pop('length', ...) only runs when the positional slot (args[2]) was not supplied (decorators.py:215-216).

Census (the evidence for the design choice): @prepare decorates exactly one function in the whole tree -- Schema.unpack (pcapkit/protocols/schema/schema.py:664) -- whose real signature has never had more than (cls, data, length=None, packet=None); it does not itself declare *args, **kwargs, despite what the decorator's docstring promised on its behalf. Every .unpack(...) call site across pcapkit/ and tests/ was censused (git grep -n "\.unpack(") and none passes more than the three trailing positionals (data, length, packet) or any keyword beyond length/packet.

Design chosen: given the census, forwarding (func(cls, data, length, packet, *args[4:], **kwargs)) would only ever hand Schema.unpack arguments its real signature can't accept, raising TypeError from inside the call anyway. So I took the issue's second option: dropped *args, **kwargs from the documented signature, and made the wrapper raise TypeError explicitly for any leftover positional (args[4:]) or un-consumed keyword -- which also catches the duplicate-length/packet case for free, since the leftover keyword surfaces the same way. See decorators.py:231-244.

#458 -- zero-length raises a bare EOFError

Mechanism: decorators.py:227-228 at da2422728 was unconditional -- if length == 0: raise EOFError -- regardless of why length was zero. Traced where that zero comes from:

  • The frame reader (pcapkit/foundation/engines/pcap.py, pcapng.py) always constructs the top-level Frame/block with no explicit length, so it's None going in and gets derived by measuring what's left in the underlying file (data.seek(0, SEEK_END) - current). A derived zero here means the capture is genuinely exhausted.
  • SchemaField.unpack (pcapkit/corekit/fields/misc.py:619) calls self._schema.unpack(file, self.length, {...}) with an explicit, declared length -- which can legitimately be 0 (e.g. an HTTP/2 frame schema sized by a length field that evaluates to zero, per corekit: let a nested schema's field callbacks reach the enclosing schema #457/A nested schema cannot reach the enclosing packet's fields by name, so CGA Parameters raises KeyError: 'length' #445). This is the path the issue's reproduction and provenance point at.
  • ListField/OptionField (pcapkit/corekit/fields/collections.py:169,403,420) only call .unpack(file, length, packet) inside while length > 0: loops, so they never reach this with length == 0.
  • NoPayload (pcapkit/protocols/misc/null.py) overrides __post_init__ entirely and never calls Schema.unpack, so the very common "next layer has zero bytes" case (verified empirically with a zero-payload UDP packet) never went through @prepare at all and was unaffected either way.

Design chosen: distinguish "declared" (caller passed an explicit length, even 0) from "derived" (caller passed None, and the fallback computed 0 from what's actually left in the stream) -- the issue's first option. Only a derived zero raises now (decorators.py:253,264). The still-fatal case raises a new pcapkit.utilities.exceptions.StreamEOFError(BaseError, EOFError) (added in exceptions.py, quiet=True per the StructError(..., quiet=True, eof=True) precedent at protocol.py:983) instead of a bare EOFError, so a caller can now catch it specifically while every existing except (EOFError, StopIteration) (pcapkit/foundation/extraction.py:687,1006,1030) keeps working unchanged, since StreamEOFError is still an EOFError.

EOFError catchers censused (git grep -n "EOFError" pcapkit/): only pcapkit/foundation/extraction.py (three except (EOFError, StopIteration) sites) and the decorator's own raise site. None of them inspect the exception's type beyond that tuple check, so subclassing is transparent to all of them.

Tests

Both halves get coverage in tests/utilities/test_decorators.py, each demonstrated failing before and passing after (captured by reverting the source files to da2422728 and rerunning just these five tests):

  • test_prepare_raises_typeerror_for_extra_positional_and_keyword_arguments -- before: AssertionError: TypeError not raised; after: passes.
  • test_prepare_raises_typeerror_for_length_given_both_positionally_and_by_keyword -- before: AssertionError: TypeError not raised; after: passes.
  • test_prepare_raises_stream_eof_error_not_a_bare_eof_error -- before: AttributeError: module 'pcapkit.utilities.exceptions' has no attribute 'StreamEOFError'; after: passes.
  • test_prepare_still_raises_eof_for_a_truncated_stream -- before: same AttributeError (module has no StreamEOFError); after: passes, confirms the frame-exhausted case is unchanged.
  • test_prepare_accepts_a_declared_zero_length_schema -- before: EOFError raised at decorators.py:228 (the exact Empty.unpack(b'', 0, {}) repro from @prepare raises a bare EOFError for any zero-length schema, so an empty nested area cannot unpack #458); after: passes, returns the unpacked (empty) instance.

Also independently re-verified against the real Schema/schema_final classes (not just the test doubles above), reproducing the issue's own Probe/Empty examples directly, and against a real zero-payload UDP packet to confirm NoPayload is unaffected.

No EXPECTED_FAILURES entries in tests/protocols/test_option_roundtrip_unit.py were touched or need to be -- that file's own internal consistency check would fail loudly if a recorded case's status had flipped, and the full-suite failure count is identical (0) before and after, so nothing there changed status.

Suite counts

Baseline at da2422728 (source and tests both reverted to that commit, fixtures generated once via examples/generators/make_samples.py and reused for both runs): 994 collected, 977 passed, 17 skipped, 0 failed (655.58s).

After this change: 999 collected, 982 passed, 17 skipped, 0 failed (619.50s). The delta is exactly the 5 new tests above, added and passing -- no regressions.

Run with PYTHONSAFEPATH=1, PYTHONPATH set to the worktree, interpreter .venv/bin/python (3.14.7); pcapkit.__file__ printed and confirmed to resolve into this worktree for every run.

Coordination: this PR touches only pcapkit/utilities/decorators.py, pcapkit/utilities/exceptions.py, and tests/utilities/test_decorators.py -- it does not touch pcapkit/corekit/fields/misc.py (#457), pcapkit/protocols/schema/schema.py (#456), pcapkit/protocols/internet/mh.py/examples/generators/options.py (#437), or tests/protocols/test_option_roundtrip_unit.py.

…lared zero length as EOF

- prepare() called func(cls, data, length, packet) unconditionally, so the
  *args/**kwargs its own docstring promised implementors were never
  forwarded: a misspelled or unsupported argument (or `length` supplied
  both positionally and by keyword) was silently discarded, no error, no
  warning. Census: @prepare decorates exactly one function in the tree,
  Schema.unpack, whose real signature never had more than these four
  params, and no call site anywhere passes extras. Chosen fix: drop the
  promise and raise TypeError on any leftover positional or keyword
  argument, the same as an ordinary over-called function would. (#454)

- prepare() raised a bare EOFError whenever length == 0, conflating "the
  frame reader's stream is genuinely exhausted" with "this schema (often
  nested) was declared to have nothing to read". Fix: only raise when
  length was *derived* by measuring what's left in data (length was None
  going in); a *declared* zero -- explicit, even if zero -- unpacks to an
  empty instance instead. The still-fatal case now raises StreamEOFError
  (pcapkit.utilities.exceptions), a subclass of EOFError so existing
  `except (EOFError, StopIteration)` handlers are unaffected. (#458)

Build/test: brazil-build n/a (upstream OSS repo); full suite 982 passed,
17 skipped, 0 failed (999 collected) vs baseline 977/17/0 (994 collected)
at da24227 -- the delta is exactly the 5 new tests added.
Comment thread pcapkit/utilities/decorators.py
@JarryShaw

Copy link
Copy Markdown
Owner Author

Standing in for Copilot review (out of tokens). Reviewed d855df900 (branch fix-454-458-prepare-extras-and-eof, based on da2422728).

CI

gh pr view 461 --json statusCheckRollup at settlement: 21 SUCCESS, 2 SKIPPED (Docs test gate, Gate (full suite, Python 3.14) -- both skip by design on this workflow), 0 pending, 0 failing. No red checks.

Base drift

git rev-list --count d855df900..origin/main = 3 (my docs-only pep.rst commit, #460, #437). git diff --stat da2422728..d855df900 touches exactly pcapkit/utilities/decorators.py, pcapkit/utilities/exceptions.py, tests/utilities/test_decorators.py -- no overlap with #460 (schema/internet/*) or #437 (mh.py). Being 3 behind does not matter here; confirmed by diffing, not assumed.

#454 -- extras silently discarded

Census claim verified independently: git grep -n "@prepare" across d855df900 and origin/main both return exactly one hit outside the test file, pcapkit/protocols/schema/schema.py:664 (Schema.unpack). Grepped every .unpack( call site tree-wide that could reach it (collections.py:403,420, misc.py:539,619, frame.py:200, pcapng.py:900, protocol.py:306) -- none pass more than 3 positional arguments or an extra keyword, so "nothing calls it with more" holds and the fix cannot break an existing caller. Full suite run below confirms no regression.

The new TypeError fires and reads clearly. Reproduced by hand against the PR-head code:

>>> Probe.unpack(b'\x07\x08', 2, {}, 'EXTRA_POSITIONAL', extra_kw='EXTRA_KW')
TypeError: Schema.unpack() got unexpected argument(s): 'EXTRA_POSITIONAL', extra_kw='EXTRA_KW'
>>> Probe.unpack(b'\x07\x08', 2, {}, length=5)   # length given twice
TypeError: Schema.unpack() got unexpected argument(s): length=5

func.__qualname__ resolves to Schema.unpack (not the wrapper's own name), so the message names the right thing.

#458 -- bare EOFError on zero length

StreamEOFError(BaseError, EOFError) (pcapkit/utilities/exceptions.py) follows the StructError(BaseError, struct.error) precedent at line 406 exactly, and BaseError.__init__ accepts the quiet kwarg it's raised with. Confirmed exactly 3 except (EOFError, StopIteration) sites tree-wide, all in pcapkit/foundation/extraction.py (lines 687, 1006, 1030), and that StreamEOFError is an EOFError subclass, so they keep working unchanged.

Independently confirmed NoPayload never reaches @prepare: pcapkit/protocols/misc/null.py's __post_init__ is fully overridden, sets self._info = Data_NoPayload() directly and never calls self.unpack(...).

Traced every route into Schema.unpack for the derived-vs-declared exhaustiveness question (detail in the inline comment on decorators.py:253):

  • Top-level frame/global-header reads (frame.py:200, header.py:272 via engines/pcap.py:113, PCAP-NG equivalent) always construct with no length at all -- genuinely derived.
  • _import_next_layer (protocol.py:1357-1386) special-cases length == 0 before constructing any protocol and redirects to NoPayload, so no ordinary protocol's Schema.unpack is ever reached with a zero length, declared or derived, via that path.
  • OptionField/ListField (collections.py:403,420) only call Schema.unpack inside while length > 0: loops -- length is never 0 there.
  • SchemaField.unpack (misc.py:619) passes self.length explicitly, so a declared 0 is correctly recognized and does not raise. Verified by hand with a zero-length declared schema (unpacks to an empty instance, packet['data'] == b'') versus an exhausted stream with no declared length (raises StreamEOFError, subclass of EOFError).
  • One gap, currently unreachable: SchemaField.__init__'s schema.unpack(default) for a bytes default (misc.py:539) passes neither length nor packet, so it's indistinguishable from the frame-reader case even though the value is author-declared, not stream state. No in-tree SchemaField(...) call (checked all 19 non-test call sites) passes a bytes default, so this is dead code today and behaves identically to main (bare EOFError there instead) -- not a regression, flagged inline as a residual gap rather than a blocker.

Test counts

Interpreter: /local/home/jarryx/GitHub/PyPCAPKit/.venv/bin/python 3.14.7. All runs used PYTHONSAFEPATH=1, PYTHONPATH set to this worktree, and printed pcapkit.__file__ first to confirm the tree under test (.../agent-a58a244c29bf6923e/pcapkit/__init__.py throughout). Fixtures regenerated once via examples/generators/make_samples.py at the da2422728 checkout and reused unmodified for the d855df900 run (this PR touches no protocol schema).

collected passed skipped
da2422728 (baseline) 994 977 17
d855df900 (PR head) 999 982 17

Delta is exactly the 5 new tests in tests/utilities/test_decorators.py. mypy pcapkit: 124 errors in 40 files at both da2422728-equivalent baseline (the task's stated calibration) and d855df900 (ran directly) -- identical, no new errors, and none of the 124 are in the 2 files this PR touches.

Verified each of the 5 new tests independently fails against da2422728's decorators.py/exceptions.py (grafted the PR's test file onto the baseline source, then restored): test_prepare_raises_typeerror_for_extra_positional_and_keyword_arguments and test_prepare_raises_typeerror_for_length_given_both_positionally_and_by_keyword -> AssertionError: TypeError not raised; test_prepare_raises_stream_eof_error_not_a_bare_eof_error and test_prepare_still_raises_eof_for_a_truncated_stream -> one fails with a live EOFError at decorators.py:228, the other with AttributeError: module 'pcapkit.utilities.exceptions' has no attribute 'StreamEOFError'; test_prepare_accepts_a_declared_zero_length_schema -> EOFError raised at decorators.py:228, quoted in full:

E           EOFError
pcapkit/utilities/decorators.py:228: EOFError

All match the PR description's quoted text exactly.

Verdict

GOOD TO MERGE at d855df900. Both fixes are correct and match their issues; CI is green; the census and no-existing-caller claims for #454 check out; the derived/declared split for #458 is exhaustive across every reachable call site, with one currently-unreachable rough edge (misc.py:539, bytes-default SchemaField.__init__) noted inline for awareness rather than as a blocker.

Comment thread pcapkit/utilities/exceptions.py
`StreamEOFError(BaseError, EOFError)` was added at exceptions.py:420 without a
matching autoexception entry, so it was the only exception in that module absent
from the reference. `EOFError` had no category section, so this adds one after
`struct.error`, mirroring StructError's directive and keeping the document's
section order aligned with the module's class order.
@JarryShaw
JarryShaw merged commit fa12895 into main Sep 18, 2026
23 checks passed
JarryShaw added a commit that referenced this pull request Sep 18, 2026
main gained #461 (closing #458: prepare's @prepare decorator now
distinguishes a declared zero length from a derived one, raising
StreamEOFError only for the latter) since this branch's previous
merge, on top of #462 (closing #459, already handled). Between the
two, all three remaining httpv2-frame entries this PR's own fix had
exposed -- DATA, HEADERS, CONTINUATION -- now round-trip cleanly too.
Verified directly against the round-trip harness: all three return
'OK'. Rewrote the HTTP/2 section's comment block to summarise all six
frames' history (PUSH_PROMISE/PING via #445 itself, DATA/HEADERS/
CONTINUATION via #461, SETTINGS via #462) now that none of them need
an entry.

Also merged origin/main (fa12895, #461) -- clean auto-merge, no
conflicts, confirmed against #464's changes to
tests/protocols/internet/test_mh_unit.py (different hunks, and its own
test run clean: 62 passed, 272 subtests, 0 failed).

Verified: mypy pcapkit -> 124 errors/40 files (main at fa12895: 125,
one more than its previous 124 -- unrelated to this branch, and this
branch stays one fewer than whatever main's own count is, from the
same pre-existing type: ignore cleanup as before). Round-trip harness:
7 passed, 363 subtests passed, 0 failed. mh-extension/* re-verified
'OK' again on this merge (all four).
@JarryShaw
JarryShaw deleted the fix-454-458-prepare-extras-and-eof branch September 18, 2026 20:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant