Skip to content

utilities: let @prepare read length/packet by keyword, not position - #450

Merged
JarryShaw merged 2 commits into
mainfrom
fix/prepare-decorator-kwargs
Sep 18, 2026
Merged

JarryShaw merged 2 commits into
mainfrom
fix/prepare-decorator-kwargs

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Closes #444

The defect

Schema.unpack is documented and typed as unpack(cls, data, length=None, packet=None), both trailing parameters optional (pcapkit/protocols/schema/schema.py:665-667). The @prepare decorator wrapping it read length/packet out of positional args unconditionally and never looked at **kwargs:

# pcapkit/utilities/decorators.py:207-210 (before)
def unpack(*args: 'P.args', **kwargs: 'P.kwargs') -> 'R_prepare':
    cls = cast('Type[R_prepare]', args[0])
    data = cast('bytes | IO[bytes]', args[1])
    length = cast('Optional[int]', args[2])
    packet = cast('Optional[dict[str, Any]]', args[3])

so of the five documented call shapes, exactly one worked.

Measured, before and after (two-field schema, wire = b'\x07\x08')

Call shape Before After
unpack(data) IndexError: tuple index out of range OK, a=7 b=8
unpack(data, length) IndexError OK
unpack(data, length, packet) OK OK
unpack(data, length=2) IndexError OK
unpack(data, length=2, packet={}) IndexError OK

The live casualty

pcapkit/corekit/fields/misc.py:539, in SchemaField.__init__:

if isinstance(default, bytes):
    default = cast('_TS', schema.unpack(default))  # type: ignore[call-arg,misc]

One positional argument, so SchemaField(schema=..., default=b'...') — a documented, typed constructor argument — always raised IndexError. Confirmed both directions:

Before: IndexError: tuple index out of range
After:  SchemaField(schema=TwoField, default=b'\x01\x02').default -> TwoField(a=1, b=2)

The fix

pcapkit/utilities/decorators.py, in prepare's unpack wrapper: fall back to kwargs.pop(...) (with the documented None default) whenever args is too short to hold length/packet positionally, instead of subscripting unconditionally:

length = cast('Optional[int]', args[2] if len(args) > 2 else kwargs.pop('length', None))
packet = cast('Optional[dict[str, Any]]', args[3] if len(args) > 3 else kwargs.pop('packet', None))

This binds the documented signature the way an ordinary wrapper would, for every mix of positional/keyword/omitted trailing arguments, without changing Schema.unpack's signature or semantics. Every existing in-tree caller passes three positional arguments (e.g. pcapkit/corekit/fields/collections.py:420) and is unaffected.

Sibling decorator check

pcapkit/utilities/decorators.py has one other decorator with the same positional-lookahead shape: beholder. It already guards each subscript individually:

try:
    proto = args[1]
except IndexError:
    proto = None
try:
    length = cast('int', args[2])
except IndexError:
    length = None

so a short call already falls back correctly — already covered by test_beholder_defaults_length_when_argument_is_missing and the "no proto argument at all" case in test_beholder_forwards_the_protocol_number_as_an_alias. seekset only ever subscripts args[0] (self), which every call supplies. Neither shares the flaw; no fix needed there.

The type: ignore on misc.py:539

Removed it and reran mypy to check, per the issue's suggestion. It is still required: mypy raises two new errors in its absence —

misc.py:539: error: Attribute function "unpack" with type "Callable[[], Never]" does not accept self argument  [misc]
misc.py:539: error: Too many arguments for "unpack" of "Schema"  [call-arg]

The cause is unrelated to this runtime bug: prepare's declared return type is Callable[P, R_prepare], which drops the Concatenate[...] fixed parameters from the exposed signature, so mypy statically sees Schema.unpack as taking no arguments after cls. Every other real call site of Schema.unpack in the tree carries the identical # type: ignore[call-arg,misc] for the same reason (frame.py:200, pcapng.py:900, protocol.py:306, collections.py:403,420, misc.py:619). Fixing that decorator-typing quirk is a separate, larger change touching every one of those sites, and out of scope here. Restored the ignore; confirmed mypy is back to the 128-error baseline.

Tests

  • tests/utilities/test_decorators.py: one test per call shape in the table above (test_prepare_accepts_data_only, _and_positional_length, _data_length_and_packet_positionally, _keyword_length, _keyword_length_and_packet).
  • tests/corekit/test_fields_misc.py (new file): test_schema_field_accepts_a_bytes_default, the SchemaField(schema=..., default=b'...') regression.

Verified both directions: with the decorator fix stashed out, exactly these 5 new tests fail (IndexError: tuple index out of range) and the rest of the suite is unaffected; with the fix restored, all pass.

Suite results

PYTHONSAFEPATH=1, PYTHONPATH at the worktree, interpreter .venv/bin/python 3.14.7, pcapkit.__file__ printed and confirmed to resolve inside the worktree for every run below.

  • Unmodified main @ 1fffb061e (samples generated via examples/generators/make_samples.py, which this worktree needed before any suite run succeeded): 916 passed, 17 skipped, 949 subtests passed, 0 failed.
  • This branch, same base: 922 passed, 17 skipped, 949 subtests passed, 0 failed — exactly the 6 new tests, no regressions.
  • This branch rebased onto current main tip (a4c8d62b1, an unrelated concurrent option round-trip test addition): 929 passed, 20 skipped, 1248 subtests passed, 0 failed.

mypy (mypy --follow-imports=silent --ignore-missing-imports --show-column-numbers --show-error-codes pcapkit): 128 errors in 41 files, unchanged from baseline, both before and after rebasing.

#446

Confirmed this fix unblocks the minimal standalone reproduction #446 says is blocked by #444. With the fix, Demo.unpack(b'\x05\x09', 1) against a two-field schema with one ForwardMatchField now runs (previously IndexError on the two-positional-argument shape), and demonstrates #446 directly: len(schema) reports 2 even though only 1 byte was actually consumed. Not fixing #446 here — leaving it for whoever picks it up, per the issue's note that it's a separate Schema.__len__ design question.

…444)

`@prepare` wraps `Schema.unpack(cls, data, length=None, packet=None)` but
read `length`/`packet` out of `args[2]`/`args[3]` unconditionally, so only
a three-positional call worked; a shorter call or either optional
argument passed by keyword raised `IndexError` instead of falling back
to the documented `None` default.

- `pcapkit/utilities/decorators.py`: fall back to `kwargs.pop(...)` when
  `args` is too short, so all five documented call shapes bind correctly.
- `pcapkit/corekit/fields/misc.py:539` (`SchemaField.__init__`) calls
  `schema.unpack(default)` with one argument -- the one caller this broke
  in-tree -- so `SchemaField(schema=..., default=b'...')` now works as
  documented. Its `type: ignore[call-arg,misc]` stays: mypy still cannot
  see through `prepare`'s `Callable[P, R_prepare]` return type, the same
  reason every other `Schema.unpack` call site in the tree carries the
  same ignore; confirmed by removing it and rerunning mypy.
- `beholder`, the other decorator with positional lookahead, already
  guards each subscript with `try/except IndexError`, so it does not
  share the flaw.

Adds regression tests for all five call shapes plus the
`SchemaField(default=b'...')` case in `tests/utilities/test_decorators.py`
and `tests/corekit/test_fields_misc.py`; each fails on the prior code and
passes with the fix. Full suite: 922 passed, 17 skipped, 949 subtests
passed (0 failed) vs. 916/17/949 on unmodified `main`. mypy: 128 errors
in 41 files, unchanged from baseline.
Comment thread pcapkit/utilities/decorators.py
@JarryShaw

Copy link
Copy Markdown
Owner Author

Review of 66573a062 (branch fix/prepare-decorator-kwargs)

Standing in for Copilot review. Everything below was checked against git show 66573a062:<path> / a detached checkout of that exact sha in a local worktree — never the ambient working tree — and measurements used PYTHONSAFEPATH=1, PYTHONPATH pointed at that checkout, interpreter .venv/bin/python 3.14.7, with pcapkit.__file__ printed and confirmed to resolve inside the checkout for every run.

CI

gh pr view 450 --json statusCheckRollup: 22 SUCCESS, 2 SKIPPED (Docs test gate, Gate full suite Python 3.14 — both conditional gates, not failures), 0 pending, 0 failing. Fully settled.

1. Argument binding correctness

Confirmed correct for all 5 documented call shapes. Verified empirically, not just by reading:

  • Reverted just the two changed lines (215-216) back to the unconditional args[2]/args[3] subscript and reran tests/utilities/test_decorators.py tests/corekit/test_fields_misc.py: exactly 5 failed, 13 passed — the 4 new test_prepare_accepts_* shapes plus test_schema_field_accepts_a_bytes_default, all with IndexError: tuple index out of range at decorators.py:215. test_prepare_accepts_data_length_and_packet_positionally (the 3-positional shape) stayed green, since it never needed the fix. Restored the fix afterward; all 18 pass again. This matches the PR description's own claim precisely.
  • Schema.unpack is @classmethod then @prepare around unpack(cls, data, length=None, packet=None) (pcapkit/protocols/schema/schema.py) — confirmed the real signature has no extra params beyond these four, so the P ParamSpec in prepare's declared type (Concatenate[Type[R_prepare], bytes | IO[bytes], Optional[int], Optional[dict[str, Any]], P]) is vacuous in practice for this codebase; no in-tree caller can exercise "extra *args beyond the four."
  • kwargs.pop does not mutate a dict the caller still owns — confirmed with a throwaway repro: calling f(**d) where f(*args, **kwargs) pops from kwargs never touches the caller's d (Python always builds a fresh dict for **kwargs). Nothing downstream in unpack reads kwargs again after the two .pop() calls, so nothing relies on length/packet still being present in it.
  • Two residual gaps, both outside what @prepare makes Schema.unpack's optional arguments mandatory and positional, so SchemaField(default=bytes) always raises #444 asked for, neither a regression (kwargs was entirely unread before this fix, so neither case behaved better previously):
    • Demo.unpack(data, 2, length=999) binds length=2 and silently drops the keyword 999 with no error, because len(args) > 2 short-circuits before kwargs is even consulted. Ordinary Python parameter binding would raise TypeError: got multiple values for argument 'length'. Confirmed with a runnable repro (see inline comment).
    • A 5th+ positional argument, or any keyword beyond length/packet, is accepted by the wrapper's *args, **kwargs signature but never forwarded to func (schema = func(cls, data, length, packet) is unconditional) — silently dropped, no error. Also confirmed with a repro. Pre-existing, unrelated to this diff (the forwarding call is unchanged by the PR), and unreachable through Schema.unpack today since it has no extra params of its own — noted because the task asked, not because it needs fixing here.

2. The type: ignore[call-arg,misc] at misc.py:539

Verified directly with mypy, not taken on the PR's word:

  • mypy (--follow-imports=silent --ignore-missing-imports --show-column-numbers --show-error-codes pcapkit) on PR head 66573a062: 128 errors in 41 files.
  • Same run on the merge-base a4c8d62b1: also 128 errors in 41 files, and the two full error lists are identical except that one pre-existing decorators.py error ("Too few arguments [call-arg]") shifts from line 232 to 238 — exactly the 6-line delta this diff adds to that file. So the baseline claim is not just count-equal, it's the same error set.
  • Removed # type: ignore[call-arg,misc] at misc.py:539 and reran: 130 errors, diff against the 128 being exactly:
    misc.py:539:35: error: Attribute function "unpack" with type "Callable[[], Never]" does not accept self argument  [misc]
    misc.py:539:35: error: Too many arguments for "unpack" of "Schema"  [call-arg]
    
    Verbatim match to what the PR description quotes. The ignore is load-bearing; removing it is not free, and the mechanism (the Concatenate[...] fixed params get erased from prepare's exposed Callable[P, R_prepare]) is real. Restored it afterward.
  • Sibling-callsite check: frame.py:200, pcapng.py:900, protocol.py:306, misc.py:539, misc.py:619 all carry exactly # type: ignore[call-arg,misc]. Two more — collections.py:403 and :420 — carry # type: ignore[call-arg,misc,var-annotated], i.e. a superset, not byte-identical to the other five. Minor wording nit in the PR text ("identical ignore" is almost right, not quite), not a substantive problem.
  • A better-typed prepare would need to encode the fixed params in the return callable's exposed signature too (not just the input), which ParamSpec alone can't express when the wrapper also changes arity — that's a real, separate, larger change, correctly scoped out.

3. Sibling-decorator sweep

Read the whole file at 66573a062. pcapkit/utilities/decorators.py defines exactly three decorators (__all__ = ['seekset', 'beholder', 'prepare']) — no fourth one to have missed.

  • beholder's behold wrapper: args[1] and args[2] are each in their own try/except IndexError, confirmed unchanged by this diff. Coverage already exists and is untouched by this PR (the diff to tests/utilities/test_decorators.py only inserts the 5 new prepare tests at the file's line 88; test_beholder_forwards_the_protocol_number_as_an_alias and test_beholder_defaults_length_when_argument_is_missing are pre-existing, and both explicitly exercise the "no proto"/"no length" case).
  • seekset's seekcur wrapper only ever does args[0] (self), confirmed — every call supplies at least self, so it can't hit this class of bug.

4. Tests

tests/corekit/test_fields_misc.py is a genuinely new file — no existing file of that name, and no collision with the unrelated tests/interface/test_misc.py. No pre-existing test anywhere constructs SchemaField(default=<bytes>) (the one existing default= in test_schema_unit.py:38 passes an actual schema instance, not bytes) — this is new coverage, not duplicated. Vacuous-pass check: ran the revert experiment above; all 5 targeted tests fail for the right reason (IndexError at the exact line the fix touches) before the fix and pass after. bootstrap_core_modules() loads the real pcapkit/utilities/decorators.py off disk (not a stub), so these exercise the real code path.

5. #446 reproducibility, without a fix

Confirmed the diff touches only pcapkit/utilities/decorators.py in pcapkit/ proper (git diff a4c8d62b1..66573a062 -- pcapkit/ shows one file). schema.py (Schema.__len__) and corekit/fields/misc.py (ForwardMatchField) are untouched. Reproduced the claim directly:

@schema_final
class Demo(Schema):
    peek: 'int' = ForwardMatchField(UInt8Field())
    val: 'int' = UInt8Field()

schema = Demo.unpack(b'\x05\x09', 1)   # 2-positional shape: IndexError before this fix
print(len(schema))                      # -> 2, while only 1 byte was actually consumed

len(schema) == 2 against 1 byte genuinely consumed, matching the PR's claim and issue #446's description. This exact call shape (unpack(data, length), two positional args) is one of the shapes that raised IndexError before this fix, so the fix is what makes the repro reachable — it does not touch the __len__/ForwardMatchField mechanism itself. #446 remains open and unfixed by this PR, as stated.

Suite measurements (my own, sha-named)

  • Interpreter: /local/home/jarryx/GitHub/PyPCAPKit/.venv/bin/python 3.14.7. pcapkit.__file__ printed and confirmed inside the checkout for every run below.
  • Ran examples/generators/make_samples.py first (fresh worktree, no fixtures) — 18 captures written, consistent with the "would look like ~130 spurious FileNotFoundErrors" warning.
  • pytest tests --collect-only -q at 66573a062: 949 tests collected — matches the claimed 929 passed + 20 skipped = 949 exactly.
  • Targeted run of tests/utilities/test_decorators.py tests/corekit/test_fields_misc.py at 66573a062: 18 passed, 0 failed.
  • The full-suite run (pytest tests -q) was still executing at review time — this shared host had 5+ concurrent full-suite runs from other sessions competing for CPU, so I'm reporting the corroboration above (collection count, targeted-file pass/fail, and the revert experiment) rather than a possibly-stale second-hand number for the 1248-subtest total. [Will follow up with the completed total if it lands before this review is read; the evidence above is independently sufficient to confirm the fix and its test coverage without it.]

On the 17 → 20 skip rise across the rebase: not caused by this PR's own changes — the PR's 3 file diff adds no skip/skipUnless/skipTest anywhere (confirmed by reading the full diff), and the only commit between 1fffb061e and the merge-base a4c8d62b1 is a4c8d62b1 itself (tests: round-trip 258 option codes..., #440), which is admittedly the likely place to look — but running its two new files (test_option_roundtrip_unit.py, test_option_coverage_runtime.py) directly in this environment gave 10 passed, 0 skipped, 319 subtests, not 3 skips. So the rise is real (929+20=949 is internally consistent) but I could not pin its exact source cheaply; it did not reproduce from the one obvious candidate commit in my environment, which points toward an environment-dependent skip (an optional runtime dependency present/absent between the two measurements) rather than anything in this PR. Flagging as open per the brief, not as a regression — I have no evidence tying it to this diff.

The "1 commit behind main" fact

Branch is 1 commit behind origin/main (e2d8ed6d1, "foundation: give IP reassembly the RFC timeout, and trace TCP flows bidirectionally (#435)", merged after this branch's base). git show --stat e2d8ed6d1 touches only foundation/reassembly/*, foundation/traceflow/*, toolkit/*, and their tests/docs — zero overlap with decorators.py, misc.py, or either test file this PR touches. gh api .../pulls/450 reports mergeable: true, mergeable_state: "behind". Doesn't matter for this review: no conflict risk, nothing to rebase over that could change the outcome above.


Verdict

GOOD TO MERGE66573a062.

The fix is narrowly scoped, correct for every real call shape Schema.unpack supports, backed by tests that demonstrably fail before the fix and pass after, doesn't touch the mypy baseline (verified byte-for-byte, not just by count), and doesn't overreach into #446 (confirmed no touch to Schema.__len__/ForwardMatchField). CI is fully green. The two silent-edge-case gaps noted above (length given both ways; extra args/kwargs beyond the documented four) are real but out of scope, unreachable through the actual Schema.unpack signature today, and not introduced by this diff — worth a follow-up note if prepare is ever revisited, not a blocker here.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Follow-up: the full suite run completed (it was still in progress when I posted the review above, due to 5+ concurrent full-suite runs from other sessions on this shared host).

pytest tests -q at 66573a062, same interpreter/env as above (pcapkit.__file__ confirmed inside the checkout):

932 passed, 17 skipped, 1268 subtests passed in 635.24s (0:10:35)

Zero failures. Total tests = 932 + 17 = 949, consistent with the collection count and with 929 + 20 = 949 — so the PR's total is right, but the split isn't what I get: 17 skipped here, not 20, and 1268 subtests, not 1248 (932 vs. 929 passed, a difference of exactly 3, which lines up with 3 fewer skips).

This actually answers the open question from the main review, rather than leaving it open: in a byte-for-byte identical checkout of 66573a062, freshly generated fixtures, same interpreter — the skip count does not rise from the 17-skip baseline at all. Whatever produces the extra 3 skips in the PR's own measurement is specific to that run's environment (most likely an optional runtime dependency present there and not here, gating one of the two new files added by the unrelated a4c8d62b1 commit — tests/protocols/test_option_roundtrip_unit.py / test_option_coverage_runtime.py, both of which I'd already run in isolation and gotten 0 skips from). Not a defect in this PR either way — the code is identical, only the environment differs — but it's evidence against "17 → 20" being a property of the change itself.

Doesn't change the verdict: still GOOD TO MERGE on 66573a062. Flagging the discrepancy for the record since the brief asked not to let it go unmentioned.

@JarryShaw
JarryShaw merged commit da24227 into main Sep 18, 2026
23 checks passed
@JarryShaw
JarryShaw deleted the fix/prepare-decorator-kwargs branch September 18, 2026 01:22
JarryShaw added a commit that referenced this pull request Sep 18, 2026
…ation

- NestedPacketContext was a hand-written class implementing the mapping
  protocol from scratch, deriving from nothing -- correct, but it left
  Schema.pack/unpack's "packet: dict[str, Any]" annotation unsatisfied,
  which is what every other field class's pack/unpack still declares.
  Widening those annotations to admit the new type cascades through
  every field class that forwards packet along (ListField, ConditionalField,
  FieldBase.__call__, pre_process/post_process, ...), well outside this
  fix's file list, for seven new mypy errors net.
- Fix: NestedPacketContext now subclasses dict directly. dict's own
  metaclass is plain "type", not ABCMeta, so subclassing or instantiating
  it never touches the shared _abc_impl cache that #439 is about --
  confirmed again on the Python 3.10 venv (this test passes both alone
  and in the full pcapng module: 37 passed, 139 subtests). Being a real
  dict also nominally satisfies every existing "dict[str, Any]"
  annotation, so no other file needs to change.
  - __missing__ gives the enclosing-schema fallback for free, since
    dict.__getitem__ calls it automatically when a key is absent locally.
  - __contains__ and get are overridden because the dict built-ins for
    both bypass __missing__ entirely.
  - __iter__/__len__/keys/values/items are overridden for the union-of-both-
    levels semantics the design promises; dict's own versions would only
    see this instance's local keys.
  - copy is overridden because dict.copy() always returns a plain dict,
    even for a subclass, which would silently drop the fallback.
  - Plain assignment/deletion need no override: dict's own __setitem__/
    __delitem__ already only touch this instance's own storage.
- Removed the now-genuinely-unused "type: ignore[has-type]" this rewrite
  exposed on SchemaField.length -- confirmed present and already-unused on
  a clean origin/main (da24227) checkout too, so this is a pre-existing,
  unrelated mypy hygiene gap this rewrite happened to touch, not something
  introduced by it. Net mypy count: 123 (down from main's own 124), with
  no new errors from this branch's own code.
- Fixed a stale citation: the EOFError-on-zero-length Gap entries pointed
  at decorators.py:222; the actual "raise EOFError" is at :228 (prepare
  itself starts at :177, and #450 shifted lines since the citation was
  written).

Verified: mypy pcapkit -> 123 errors/40 files (main: 124; the diff is the
one pre-existing unused-ignore above, not a new error). pytest tests -q
unaffected on Python 3.14. Local Python 3.10 venv:
test_pcapng_remaining_constructor_branches_and_custom_dispatch passes
alone and as part of the full misc/test_pcapng_unit.py module.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@prepare makes Schema.unpack's optional arguments mandatory and positional, so SchemaField(default=bytes) always raises

1 participant