Skip to content

corekit: narrow interface fields' post_process return type via isinstance - #479

Merged
JarryShaw merged 2 commits into
mainfrom
fix-473-interface-post-process-types
Sep 18, 2026
Merged

JarryShaw merged 2 commits into
mainfrom
fix-473-interface-post-process-types

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Summary

  • IPv4InterfaceField.post_process and IPv6InterfaceField.post_process each built their return value with the polymorphic ipaddress.ip_interface(...) -- typed IPv4Interface | IPv6Interface -- against a declared return type that admits only one of the two. mypy flagged this as two standing [return-value] errors.
  • The matching pre_process methods had the identical defect wearing a # type: ignore[assignment] suppression instead -- confirmed by removing the suppressions and re-running mypy, which reported the same union-vs-concrete mismatch at those lines.
  • Fixed all four sites the same way: replace the val.version != self.version comparison with not isinstance(val, IPv4Interface) / not isinstance(val, IPv6Interface), a form mypy accepts as a genuine type-narrowing guard.

Why isinstance-narrowing instead of a cast, and instead of constructing the concrete class directly

A cast/# type: ignore was ruled out per the issue -- it would silence the checker while leaving the genuinely polymorphic call in place.

Constructing the concrete class directly (ipaddress.IPv4Interface(...) / IPv6Interface(...)) instead of going through ip_interface() looked like the natural alternative, but it changes runtime behaviour in pre_process: passing an already-constructed interface object of the other concrete type (e.g. an IPv4Interface into IPv6InterfaceField.pre_process) makes the concrete constructor raise immediately with an AddressValueError-derived message, before the existing version check ever runs. That flips the raised message from "IP version mismatch: 4 != 6" to "invalid IP interface: ...", which breaks the existing test test_wrong_version_message_is_not_relabelled_as_a_malformed_value (verified locally by making the swap and running it: it fails as predicted).

The isinstance-narrowing approach keeps ip_interface()'s polymorphic parsing (so all input forms it accepts still work identically) and only changes how the already-present version check is expressed, in a form mypy can follow through to the return statement. It also unifies pre_process and post_process on one idiom instead of two.

Verification

  • mypy pcapkit: 125 errors in 40 files -> 123 errors in 39 files, diffed error-list-to-error-list. The only two errors removed are exactly the ones named in Interface fields declare a narrower post_process return type than ip_interface() gives, as two standing mypy [return-value] errors #473 (ipaddress.py:286 and ipaddress.py:377 on the pre-fix line numbers); nothing else changed anywhere in the package.
  • Behavioural parity: loaded the pre-fix and post-fix versions of the module side by side and ran 16 representative inputs through both -- round trips at extreme prefix lengths (0, 1, 24/64, 31/127, 32/128) for both IPv4 and IPv6, wrong-version strings, wrong-version pre-built Interface objects (the case that would have broken under the concrete-constructor approach), malformed values, a non-contiguous IPv4 netmask, and an out-of-range IPv6 prefix length. 0 mismatches in return value or exception type/message.
  • pytest tests/ (excluding test_mh_unit.py, test_hip_unit.py, test_fields_misc_packet_context.py, test_option_roundtrip_unit.py, owned by other in-flight PRs): 963 passed, 17 skipped, 873 subtests passed, 0 failures.
  • tests/corekit/test_fields_ipaddress.py specifically: 12 passed, 17 subtests passed, including the version-mismatch-message test that would have caught the rejected alternative approach.

Deliberately not fixed here

Closes #473

…instance, not cast (#473)

- IPv4InterfaceField.post_process and IPv6InterfaceField.post_process each
  built their return value with the polymorphic ipaddress.ip_interface(),
  typed IPv4Interface | IPv6Interface, against a declared return type that
  admits only one of the two -- two standing mypy [return-value] errors.
- The two ip_interface() calls in the matching pre_process methods already
  wore a # type: ignore[assignment] for the identical reason, confirmed by
  removing the suppressions and re-running mypy (same errors, different
  code).
- Fixed both pairs the same way: replace the `val.version != self.version`
  comparison with `not isinstance(val, IPv4Interface/IPv6Interface)`, which
  mypy accepts as a narrowing type guard. Rejected constructing the concrete
  class directly (ipaddress.IPv4Interface(...)/IPv6Interface(...)) instead,
  because it changes behaviour: passing an already-constructed interface of
  the *other* concrete type to pre_process would then raise before reaching
  the version check, changing the message from "IP version mismatch: ..."
  to "invalid IP interface: ...", breaking
  test_wrong_version_message_is_not_relabelled_as_a_malformed_value.
- No behaviour change: probed both versions side by side on 16 representative
  inputs (round trips at every extreme prefix length, wrong-version strings,
  wrong-version pre-built Interface objects, malformed values, a non-
  contiguous IPv4 netmask, an out-of-range IPv6 prefix length) -- 0
  mismatches in output or exception message.

mypy pcapkit: 125 errors/40 files -> 123 errors/39 files, diffed line by
line; the only errors removed are the two named in #473, none added.
pytest tests/ (excluding files owned by other in-flight PRs): 963 passed,
17 skipped, 873 subtests passed, 0 failures.
@JarryShaw

Copy link
Copy Markdown
Owner Author

Review of PR #479 at fde0337d3

Worktree confirmation. My review worktree started at main (e80c42217), not the PR head — same trap the task warned about. I fetched and checked out the PR explicitly:

$ git fetch origin pull/479/head:pr-479 && git rev-parse pr-479
fde0337d32843cc47573dd57d4554d2aa40ba428
$ git checkout fde0337d3
$ git rev-parse HEAD
fde0337d32843cc47573dd57d4554d2aa40ba428
$ git merge-base main fde0337d3
f7b5cc5cd7fcafe609cac25da822f292e8420db6
$ git log --oneline f7b5cc5cd..main | wc -l
16

merge-base(main, PR head) = f7b5cc5cd, main is 16 commits ahead — matches the brief exactly. All diffs below are main...fde0337d3 (three-dot) or explicit f7b5cc5cd reverts, never two-dot main..branch.

1. Is not isinstance(val, IPv4Interface) equivalent to val.version != self.version?

Yes, in every reachable path. ipaddress.ip_interface()'s own source is exactly:

def ip_interface(address):
    try:
        return IPv4Interface(address)
    except (AddressValueError, NetmaskValueError):
        pass
    try:
        return IPv6Interface(address)
    except (AddressValueError, NetmaskValueError):
        pass
    raise ValueError(...)

It can only return IPv4Interface, IPv6Interface, or raise. issubclass(IPv4Interface, IPv6Interface) and the reverse are both False — they're unrelated sibling classes — and grepping the repo (grep -rn "class.*IPv4Interface\|class.*IPv6Interface") turns up no subclasses anywhere in pcapkit. So isinstance(parsed, IPv4Interface) and parsed.version == 4 can never disagree for any value ip_interface() can produce. Same argument for the two post_process sites, which build their val the same way.

2. Is the declined direct-construction approach (IPv4Interface(...) instead of ip_interface(...)) actually broken the way the agent claims?

Verified independently, not taking the agent's word for it:

>>> v6 = ipaddress.IPv6Interface('2001:db8::1/64')
>>> ipaddress.IPv4Interface(v6)
AddressValueError: Expected 4 octets in '2001:db8::1'
>>> ipaddress.ip_interface(v6)          # what the PR still uses
IPv6Interface('2001:db8::1/64')          # succeeds, version=6, hits the isinstance check

Constructing IPv4Interface directly from an already-built IPv6Interface fails with AddressValueError inside _split_addr_prefix/_split_optional_netmask (it stringifies the interface, splits on /, then feeds the address half to IPv4Address.__init__, which rejects an IPv6-shaped string) — before the field's own version check ever runs. That changes the observable message from "IP version mismatch: 6 != 4" to something like "invalid IP interface: ...". This is exactly the scenario tests/corekit/test_fields_ipaddress.py::test_wrong_version_message_is_not_relabelled_as_a_malformed_value exercises at line 202 (IPv6InterfaceField().pre_process(ipaddress.IPv4Interface('1.2.3.4/24'), {})). The agent's claim is correct and the narrower fix (isinstance-narrow after ip_interface(), per issue #473's own second suggested direction) was the right call — the concrete-constructor route would have broken that test.

3. mypy — plain mypy pcapkit, grepping ^pcapkit.*error:

$ env PYTHONSAFEPATH=1 .venv/bin/python -m mypy pcapkit   # at fde0337d3
Found 123 errors in 39 files (checked 496 source files)
$ git checkout f7b5cc5cd -- pcapkit/corekit/fields/ipaddress.py   # baseline, same command
Found 125 errors in 39 files (checked 496 source files)
$ git checkout fde0337d3 -- pcapkit/corekit/fields/ipaddress.py   # restored, verified clean

Diffing the two full error sets (comm -23/comm -13 on sorted grep '^pcapkit.*error:' output): exactly two lines differ, both fixed, zero added:

pcapkit/corekit/fields/ipaddress.py:286:16: error: Incompatible return value type (got "IPv4Interface | IPv6Interface", expected "IPv4Interface")  [return-value]
pcapkit/corekit/fields/ipaddress.py:377:16: error: Incompatible return value type (got "IPv4Interface | IPv6Interface", expected "IPv6Interface")  [return-value]

This matches the task's own figures exactly and is what #473 named. warn_unused_ignores = True is already set in mypy.ini, so the plain run already covers --warn-unused-ignores; there are zero complaints of any kind on ipaddress.py in the branch run, so both removed # type: ignore[assignment] suppressions were genuinely unnecessary after the refactor (not merely unreported), and no new suppression was added anywhere in the file (confirmed by grep -n "type: ignore" pcapkit/corekit/fields/ipaddress.py, which shows only the two pre-existing ones, at line 145 and line 336, both unrelated to this diff).

4. Behaviour parity — 21 inputs, self-run script comparing the branch against the f7b5cc5cd baseline

I wrote my own probe (not the agent's) covering both interface fields' pre_process/post_process: normal strings, /0 and /32//128 edge prefixes, wrong-version strings, wrong-version pre-built interface objects, malformed strings, an int input, a non-contiguous IPv4 netmask (0.255.0.255), an out-of-range IPv6 prefix length (200), and the max valid prefix length (128). Ran it as a fresh subprocess against the branch file, then against f7b5cc5cd's file (swapped in with git checkout f7b5cc5cd -- ..., then restored), diffing the JSON of (ok, repr/type) or (exc_type, message) for each case:

$ diff /tmp/parity_baseline.json /tmp/parity_branch.json; echo "exit=$?"
exit=0

Byte-identical across all 21 cases, including exception messages such as "IP version mismatch: 6 != 4" and "invalid IPv4 interface: '192.0.2.1/0.255.0.255' does not appear to be an IPv4 or IPv6 interface". This independently corroborates the agent's own larger 16-input probe — I did not just take its word for it.

5. _IPAddressField.post_process (line 145) — should it get an issue?

Agree it's a different shape and not a defect worth filing, and confirmed the suppression is still load-bearing rather than vestigial: temporarily removing # type: ignore[return-value] from line 145 and rerunning mypy:

pcapkit/corekit/fields/ipaddress.py:145:16: error: Incompatible return value type (got "IPv4Address | IPv6Address", expected "IPv4Address")  [return-value]
pcapkit/corekit/fields/ipaddress.py:145:16: error: Incompatible return value type (got "IPv4Address | IPv6Address", expected "IPv6Address")  [return-value]

(immediately reverted with git checkout fde0337d3 -- pcapkit/corekit/fields/ipaddress.py, confirmed clean via git status --short / git diff --stat). Unlike the interface fields, this method is written once in the generic base class _IPAddressField[_AT] and shared by both IPv4AddressField/IPv6AddressField — isinstance(val, IPv4Address) narrows val to a concrete class, but mypy can't conclude a concrete-class narrowing satisfies the bound TypeVar _AT (which is IPv4Address in one instantiation and IPv6Address in the other). Fixing it would need per-subclass overrides the way the interface fields already have, which is a real but separate refactor, correctly left out of a PR scoped to #473.

6. Tests

$ env PYTHONSAFEPATH=1 PYTHONPATH=<worktree> .venv/bin/python -m pytest tests/corekit/test_fields_ipaddress.py -v
============== 12 passed, 1 warning, 17 subtests passed in 10.10s ==============

Selection: the whole tests/corekit/test_fields_ipaddress.py file (12 test methods, 17 subTest assertions total), including test_wrong_version_message_is_not_relabelled_as_a_malformed_value. Interpreter confirmed to be loading this worktree's pcapkit first (pcapkit.__file__ printed and asserted to start with the worktree root) before any of the above.

7. CI

Initially all 20 CheckRuns were QUEUED at 0s elapsed — waited them out rather than judging on a snapshot:

$ gh pr view 479 --json statusCheckRollup --jq '[.statusCheckRollup[]|select(.__typename=="CheckRun")|.conclusion//.status]|group_by(.)|map("\(.[0]):\(length)")|join(", ")'
SKIPPED:2, SUCCESS:21

Docs test gate and Gate (full suite, Python 3.14) are the 2 SKIPPED (skip-by-design on pull_request, per the brief). All 21 Python 3.10–3.15, Compat Python 3.10–3.15, Integration Python 3.10–3.15, Analyze, CodeQL, deploy-pages runs are SUCCESS. The StatusContext (pyup.io/safety-ci) is pass: "No dependencies with known security vulnerabilities." Fully green, zero failures anywhere.

Verdict

Ten insertions, eight deletions, one file. The isinstance narrowing is behaviourally identical to the .version comparison it replaces in every reachable path (verified from ip_interface()'s own source, not just by inspection), the declined direct-constructor alternative is confirmed broken exactly as the agent described (independently reproduced), no error message changed, both removed # type: ignore[assignment] suppressions are confirmed genuinely dead rather than merely unreported, no new suppression was introduced, mypy's error count drops by exactly the two [return-value] errors #473 names with nothing added elsewhere in the 496-file package, the full test file passes including the version-mismatch-message pinning test, and CI is fully green (21 SUCCESS + 2 by-design SKIPPED). The one declined follow-up (_IPAddressField.post_process's generic-TypeVar suppression) is correctly out of scope and still necessary.

GOOD TO MERGE at fde0337d3

@JarryShaw
JarryShaw merged commit 4c8ee63 into main Sep 18, 2026
22 checks passed
@JarryShaw
JarryShaw deleted the fix-473-interface-post-process-types 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.

Interface fields declare a narrower post_process return type than ip_interface() gives, as two standing mypy [return-value] errors

1 participant