Skip to content

fix(foundation): make register_mh_extension take schema keyword-only (#516) - #526

Merged
JarryShaw merged 2 commits into
mainfrom
fix/516-mh-extension-schema-keyword-only
Sep 20, 2026
Merged

JarryShaw merged 2 commits into
mainfrom
fix/516-mh-extension-schema-keyword-only

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Closes #516.

Why

register_mh_extension was the only one of the fifteen schema-taking register_*
helpers in pcapkit/foundation/registry/protocols.py that declared schema as
POSITIONAL_OR_KEYWORD. The other fourteen all carry a * before it. The
asymmetry is clearly unintended — nothing about MH CGA extensions distinguishes
them — so this adds the missing * and makes all fifteen share one call shape.

This is technically breaking for anyone passing schema positionally, which is
why it belongs in the prerelease window rather than after it.

The fix

One character, protocols.py:537:

-def register_mh_extension(code: ..., meth: ...,
+def register_mh_extension(code: ..., meth: ..., *,
                           schema: 'Optional[Type[Schema_MH_CGAExtension]]' = None) -> 'None':

Test

test_schema_registrars_share_one_signature_contract pins the consistency
rather than the single function that broke it, because the defect was an
inconsistency. It discovers every register_* helper in __all__ that takes a
schema parameter and, for each, asserts code/meth are positional with no
default and schema is keyword-only defaulting to None. The loop runs over
what it discovers, so a sibling added later is covered without touching the test;
a SCHEMA_REGISTRARS floor list keeps it from passing vacuously if discovery
ever stops finding them. That is 15 subtests, one per helper.

Two complementary layers of assertion:

  • inspect.Signature.bind for the forms that must be accepted
    ((code, meth) and (code, meth, schema=...)). Binding rather than calling
    keeps this free of registry side effects.
  • a real call with a third positional argument for the form that must be
    rejected, so the contract is proven against the functions themselves and not
    only against inspect's model of them. This is side-effect-free too: a
    keyword-only parameter is rejected during argument binding, before the body
    runs.

That second assertion needs a guard, and it is the one non-obvious thing here.
RegistryError is declared class RegistryError(BaseError, TypeError), so a
bare assertRaises(TypeError) around the call would be satisfied by a body that
accepted the third positional and then rejected its value — the exact #516
behaviour the test exists to forbid. Measured against the unfixed code, using
the natural string meth that the sibling test at test_protocols.py:214
already uses:

register_mh_extension(CGAExtension.Multi_Prefix, 'missing', object)
  raised    = RegistryError
  isTypeErr = True      <-- a bare assertRaises(TypeError) would PASS here

So the test additionally asserts the exception is not a pcapkit BaseError
and that its message matches positional argument, pinning the interpreter's
arity error specifically.

Fails without the fix

Reverting just the * and re-running, on this PR's exact base (c8fd97bcd):

register_mh_extension signature summary exit
without * (unfixed) 1 failed, 4 passed, 69 subtests passed 1
with * (this PR) 4 passed, 70 subtests passed 0
SUBFAILED(func='register_mh_extension') ...::test_schema_registrars_share_one_signature_contract
E  AssertionError: <_ParameterKind.POSITIONAL_OR_KEYWORD: 1> is not <_ParameterKind.KEYWORD_ONLY: 3>

Exactly one of the 15 subtests flips (69 vs 70), which also confirms the other
fourteen siblings were already correct.

Note the exit codes are quoted deliberately: a failing subtest still reports its
parent test as PASSED, so the summary line alone is misleading and the exit
code is the signal.

In the full run the schema.kind assertion fires first and masks the added
real-call assertion, so that one was also checked in isolation against the
unfixed signature — it escapes AttributeError: 'object' object has no attribute 'name' rather than the TypeError it requires, i.e. it catches #516 on its own.

Wider check, tests/foundation/registry/ post-rebase: 7 passed, 80 subtests passed, exit 0.

Notes

  • Python 3.14.7. Runs were made with PYTHONSAFEPATH=1,
    PYTHONDONTWRITEBYTECODE=1 and PYTHONPATH set to this worktree, asserting
    pcapkit.__file__ resolved here rather than to the editable install, since the
    venv has pcapkit installed editable against a different checkout.
  • Out of scope: the equivalent audit of the register_extractor_* and
    register_dumper_* families, and of the non-schema register_* helpers
    (register_apptype, register_linktype, …). Only the fifteen schema-taking
    helpers named in register_mh_extension takes schema positionally; the other 14 register_* are keyword-only #516 are covered here.

…516)

- register_mh_extension declared `schema` as POSITIONAL_OR_KEYWORD while its
  fourteen schema-taking siblings all declare it keyword-only; add the missing
  `*` so the fifteen share one call shape.
- tests: add test_schema_registrars_share_one_signature_contract, which
  discovers every schema-taking register_* helper from `__all__` and pins
  `code`/`meth` positional and `schema` keyword-only with a None default across
  all fifteen, rather than only the one that had regressed.
- the new test also calls each helper with a third positional argument and
  requires the interpreter's own arity TypeError, asserting that error is not a
  pcapkit BaseError: RegistryError derives from TypeError, so a bare
  assertRaises(TypeError) there would pass against the unfixed code.

Verified against the unfixed signature: tests/foundation/registry/test_protocols.py
reports 1 failed / 69 subtests passed (exit 1) without the `*`, and 4 passed /
70 subtests passed (exit 0) with it.
@JarryShaw

Copy link
Copy Markdown
Owner Author

✅ GOOD TO MERGE — the * fix is proven by a real positional call whose exception (RegistryError) is itself a TypeError subclass, so a naive assertRaises(TypeError) would pass on unfixed code too; the shipped test closes that hole with assertNotIsInstance(..., BaseError) + a message-regex check, and all 15 sibling register_* schema helpers were independently confirmed already keyword-only, so no second victim of #516 was left unfixed.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Detailed review (independent verification, falsify-not-bless)

Reviewed at head 1f05f64020283a60b9766b072f704f7858eaf235 in an isolated worktree. All numbers below were reproduced directly, not taken from the PR description.

1. The actual diff. Exactly one production-code line: pcapkit/foundation/registry/protocols.py:537, adding * before schema in register_mh_extension's signature. The rest of the +86/-1 is test code: a new SCHEMA_REGISTRARS floor tuple plus one new test method in tests/foundation/registry/test_protocols.py.

2. The RegistryError/TypeError trap — checked, and the test closes it. RegistryError is declared class RegistryError(BaseError, TypeError) (pcapkit/utilities/exceptions.py:284). That matters because on the unfixed signature, calling register_mh_extension(CGAExtension.Multi_Prefix, 'missing', object) raises RegistryError — which is a TypeError (confirmed by direct call: raised=RegistryError, isTypeErr=True, isBaseErr=True). A bare assertRaises(TypeError) would therefore pass on unfixed code, for the wrong reason (the third positional is accepted, then its value is rejected). The shipped test avoids this: it has both self.assertNotIsInstance(caught.exception, BaseError) (line 292) and self.assertRegex(str(caught.exception), 'positional argument') (line 293) in addition to the assertRaises(TypeError). Verified both lines are present verbatim in the committed file. No plausible implementation was found that accepts the 3rd positional argument and raises a non-BaseError TypeError matching that message — once schema.kind == KEYWORD_ONLY, CPython itself rejects extra positionals at argument-binding, before any body code (including RegistryError-raising code) runs.

3. Test rewrite (static signature check → real call) — verified safe for all 15 siblings, not just one. The inherited test only checked inspect.Signature.bind, never calling the function. The new version adds a real call with 3 sentinel positional args. Because keyword-only rejection happens at binding time, before the body executes, this is side-effect-free — and this was checked empirically, not just reasoned about: the underlying registration calls (MH.register_extension, MH.register_option, TCP.register_option) were mocked, and call_count == 0 after each rejected call, for all 15 helpers.

4. Fails-without-fix, reproduced with correct exit-code handling. Full file tests/foundation/registry/test_protocols.py:

  • Unfixed (* removed): 1 failed, 4 passed, 69 subtests passed, exit 1 — failure is AssertionError: <_ParameterKind.POSITIONAL_OR_KEYWORD: 1> is not <_ParameterKind.KEYWORD_ONLY: 3>.
  • Fixed (* restored): 4 passed, 70 subtests passed, exit 0.

Exactly one of fifteen subtests flips — simultaneously the proof of the fix and the proof the other 14 siblings were already correct. Exit codes were captured directly (> file.log 2>&1; echo $?), not through a pipe — an initial attempt piped through tail and got a misleading EXIT=0 on an actual failure, caught and corrected before being reported here.

5. Falsification attempt. Tried a **kwargs-based "fix" (def register_mh_extension(code, meth, **kwargs): schema = kwargs.get('schema')...) instead of *. Result: caught — exit 1, failing at the SCHEMA_REGISTRARS floor-list membership assertion, because 'schema' is no longer a named parameter the discovery logic can find. No wrong implementation was found that slips past all of the test's layers (floor-list membership, schema.kind, schema.default, signature-bind check, real-call + both exception guards).

6. All 15 siblings enumerated — no second victim of #516.

Function Line Keyword-only schema?
register_ipv4_option 362 yes
register_hip_parameter 387 yes
register_hopopt_option 412 yes
register_ipv6_opts_option 437 yes
register_ipv6_route_routing 462 yes
register_mh_message 487 yes
register_mh_option 512 yes
register_mh_extension 537 yes (this PR's fix)
register_tcp_option 682 yes
register_tcp_mp_option 707 yes
register_http_frame 820 yes
register_pcapng_block 850 yes
register_pcapng_option 875 yes
register_pcapng_record 900 yes
register_pcapng_secrets 926 yes

register_extractor_* / register_dumper_* (in foundation.py) have no schema parameter at all, so they're correctly out of scope — confirmed by reading their signatures, not just by the PR's own claim.

7. Full targeted run. tests/foundation/registry/ (whole directory, still not the full suite): 7 passed, 80 subtests passed, exit 0.

What could not be fully ruled out

A __signature__-spoofing implementation that lies to inspect while internally using *args/**kwargs is not something I can rule out with absolute certainty as unable to slip past every guard, but it is not a plausible output of any real fix attempt, so it isn't treated as a live gap.

Verdict

No discrepancies between claimed and reproduced numbers. Recommend merge.

@JarryShaw
JarryShaw merged commit 5ce9be5 into main Sep 20, 2026
23 checks passed
@JarryShaw
JarryShaw deleted the fix/516-mh-extension-schema-keyword-only branch September 20, 2026 05:18
@JarryShaw JarryShaw added fix Pull requests that fix a defect (fix: subject prefix) breaking Breaks public-facing behaviour or API (apply alongside the type label) labels 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

breaking Breaks public-facing behaviour or API (apply alongside the type label) fix Pull requests that fix a defect (fix: subject prefix)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

register_mh_extension takes schema positionally; the other 14 register_* are keyword-only

1 participant