netprotocols 2.0.0__tar.gz → 2.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/workflows/ci.yml +23 -0
  2. netprotocols-2.2.1/.github/workflows/fuzz.yml +64 -0
  3. {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/workflows/release.yml +33 -0
  4. {netprotocols-2.0.0 → netprotocols-2.2.1}/.gitignore +5 -0
  5. {netprotocols-2.0.0 → netprotocols-2.2.1}/ARCHITECTURE.md +26 -7
  6. {netprotocols-2.0.0 → netprotocols-2.2.1}/CHANGELOG.md +354 -0
  7. {netprotocols-2.0.0 → netprotocols-2.2.1}/PKG-INFO +144 -14
  8. {netprotocols-2.0.0 → netprotocols-2.2.1}/README.md +143 -13
  9. {netprotocols-2.0.0 → netprotocols-2.2.1}/benchmarks/baseline.json +4 -4
  10. netprotocols-2.2.1/docs/CLAIMS.md +972 -0
  11. {netprotocols-2.0.0 → netprotocols-2.2.1}/pyproject.toml +5 -5
  12. netprotocols-2.2.1/scripts/_pcap.py +42 -0
  13. {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/benchmark.py +8 -51
  14. netprotocols-2.2.1/scripts/benchmark_encode.py +107 -0
  15. netprotocols-2.2.1/scripts/benchmark_import.py +63 -0
  16. netprotocols-2.2.1/scripts/check_fixtures.py +332 -0
  17. netprotocols-2.2.1/scripts/pyodide/check_in_pyodide.py +111 -0
  18. netprotocols-2.2.1/scripts/pyodide/package-lock.json +53 -0
  19. netprotocols-2.2.1/scripts/pyodide/package.json +9 -0
  20. netprotocols-2.2.1/scripts/pyodide/run_in_pyodide.mjs +44 -0
  21. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/__init__.py +26 -3
  22. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/_base.py +97 -4
  23. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer2/arp.py +48 -3
  24. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer2/ethernet.py +11 -0
  25. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer2/vlan.py +14 -1
  26. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/gre.py +11 -0
  27. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/ip.py +117 -5
  28. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/ipv6_ext.py +104 -6
  29. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer4/tcp.py +7 -0
  30. netprotocols-2.2.1/src/netprotocols/layer7/__init__.py +20 -0
  31. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer7/dhcp.py +138 -5
  32. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer7/dns.py +199 -17
  33. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/packet.py +24 -0
  34. netprotocols-2.2.1/src/netprotocols/pcap.py +504 -0
  35. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/utils/exceptions.py +18 -0
  36. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/walk.py +1 -1
  37. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/conftest.py +13 -21
  38. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_arp.py +16 -2
  39. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_corpus.py +8 -8
  40. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_dhcp.py +117 -3
  41. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_dns.py +91 -2
  42. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_ethernet.py +3 -1
  43. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_fuzz.py +115 -4
  44. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_gre.py +9 -6
  45. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_igmp.py +2 -2
  46. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_ip.py +163 -2
  47. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_ipv6_ext.py +135 -3
  48. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_pattern_matching.py +93 -8
  49. netprotocols-2.2.1/tests/test_pcap.py +367 -0
  50. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_vlan.py +3 -0
  51. {netprotocols-2.0.0 → netprotocols-2.2.1}/uv.lock +1 -1
  52. netprotocols-2.0.0/docs/CLAIMS.md +0 -680
  53. netprotocols-2.0.0/scripts/check_fixtures.py +0 -273
  54. netprotocols-2.0.0/src/netprotocols/layer7/__init__.py +0 -4
  55. {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  56. {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  57. {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/ISSUE_TEMPLATE/protocol_request.yml +0 -0
  58. {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/pull_request_template.md +0 -0
  59. {netprotocols-2.0.0 → netprotocols-2.2.1}/CONTRIBUTING.md +0 -0
  60. {netprotocols-2.0.0 → netprotocols-2.2.1}/LICENSE +0 -0
  61. {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures.sh +0 -0
  62. {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_dhcp.sh +0 -0
  63. {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_dns_tcp.sh +0 -0
  64. {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_gre.sh +0 -0
  65. {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_igmp.sh +0 -0
  66. {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_supplement.sh +0 -0
  67. {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_vlan.sh +0 -0
  68. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/_defaults.py +0 -0
  69. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/_enums.py +0 -0
  70. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/checksum.py +0 -0
  71. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/flow.py +0 -0
  72. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer2/__init__.py +0 -0
  73. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/__init__.py +0 -0
  74. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/icmp.py +0 -0
  75. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/igmp.py +0 -0
  76. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer4/__init__.py +0 -0
  77. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer4/_ports.py +0 -0
  78. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer4/udp.py +0 -0
  79. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/py.typed +0 -0
  80. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/registry.py +0 -0
  81. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/utils/__init__.py +0 -0
  82. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/utils/ipv4.py +0 -0
  83. {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/utils/mac.py +0 -0
  84. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/MANIFEST.md +0 -0
  85. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/arp_exchange.pcap +0 -0
  86. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/dhcp.pcap +0 -0
  87. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/dns_tcp.pcap +0 -0
  88. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/gre.pcap +0 -0
  89. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
  90. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/icmpv4_external.pcap +0 -0
  91. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
  92. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
  93. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/igmp.pcap +0 -0
  94. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/ipv4_fragments.pcap +0 -0
  95. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/ipv6_fragments.pcap +0 -0
  96. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/ipv6_mld.pcap +0 -0
  97. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
  98. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/arp_exchange.pcap +0 -0
  99. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/dhcp.pcap +0 -0
  100. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/dns_tcp.pcap +0 -0
  101. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/gre.pcap +0 -0
  102. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
  103. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/icmpv4_external.pcap +0 -0
  104. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
  105. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
  106. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/igmp.pcap +0 -0
  107. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
  108. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
  109. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/ipv6_mld.pcap +0 -0
  110. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
  111. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/tcp_http.pcap +0 -0
  112. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/tcp_https.pcap +0 -0
  113. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/udp_dns.pcap +0 -0
  114. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/vlan_icmp.pcap +0 -0
  115. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/tcp_http.pcap +0 -0
  116. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/tcp_https.pcap +0 -0
  117. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/udp_dns.pcap +0 -0
  118. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/vlan_icmp.pcap +0 -0
  119. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/strategies.py +0 -0
  120. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_checksum.py +0 -0
  121. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_contract.py +0 -0
  122. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_diagnostics.py +0 -0
  123. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_docs.py +0 -0
  124. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_exports.py +0 -0
  125. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_flow.py +0 -0
  126. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_icmp.py +0 -0
  127. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_packet.py +0 -0
  128. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_registry.py +0 -0
  129. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_tcp.py +0 -0
  130. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_udp.py +0 -0
  131. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_utils.py +0 -0
  132. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_version.py +0 -0
  133. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_walk.py +0 -0
  134. {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_workflows.py +0 -0
@@ -50,6 +50,29 @@ jobs:
50
50
  - run: uv build
51
51
  - run: uv run --isolated --no-project --with dist/*.whl python -c "import netprotocols; print(netprotocols.__version__)"
52
52
 
53
+ # Proves the README's browser/WASM claim (#99) against the real
54
+ # thing, not a simulation of it: boots an actual Pyodide runtime
55
+ # (scripts/pyodide/run_in_pyodide.mjs, via Node — no browser needed,
56
+ # same WebAssembly build either way) and decodes the whole
57
+ # real-capture corpus with the wheel this job just built. See
58
+ # scripts/pyodide/check_in_pyodide.py for what it checks and why.
59
+ pyodide:
60
+ runs-on: ubuntu-latest
61
+ steps:
62
+ - uses: actions/checkout@v5
63
+ - uses: astral-sh/setup-uv@v6
64
+ with:
65
+ python-version: "3.12"
66
+ - run: uv build
67
+ - uses: actions/setup-node@v4
68
+ with:
69
+ node-version: "22"
70
+ - run: npm ci
71
+ working-directory: scripts/pyodide
72
+ - run: >-
73
+ node scripts/pyodide/run_in_pyodide.mjs
74
+ "$GITHUB_WORKSPACE" scripts/pyodide/check_in_pyodide.py
75
+
53
76
  # Blocking, at 15% below the committed baseline. The threshold has
54
77
  # runner data behind it: this job's first run measured 3.7% away from
55
78
  # a baseline recorded on entirely different hardware, and run-to-run
@@ -0,0 +1,64 @@
1
+ name: Nightly fuzz
2
+
3
+ # Separate from ci.yml on purpose: ci.yml is reused via workflow_call
4
+ # by release.yml specifically so the release gate and the PR gate never
5
+ # drift apart (see the comment at the top of ci.yml). Folding a
6
+ # schedule trigger into it would fire that whole ladder on a cron too,
7
+ # not just this exploratory job.
8
+ on:
9
+ schedule:
10
+ # Arbitrary; 03:00 UTC lands after most contributors' working day
11
+ # across common timezones, clear of any push/PR traffic.
12
+ - cron: "0 3 * * *"
13
+ workflow_dispatch:
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ # tests/test_fuzz.py's own "netprotocols" profile is deliberately
20
+ # derandomize=True at 200 examples, so CI has run the *same* 200
21
+ # inputs on every push since it was written. This job runs the whole
22
+ # suite under the "nightly" profile instead (10,000 examples, a real
23
+ # random seed each time — see tests/test_fuzz.py's module docstring),
24
+ # so it explores new ground every run rather than replaying the same
25
+ # counterexample search forever. Pinned to Python 3.12 only, unlike
26
+ # the "test" job's full matrix: this is an exploratory job, not a
27
+ # compatibility gate, and 3x the interpreters would 3x its cost for
28
+ # no extra coverage of what it is actually looking for.
29
+ fuzz:
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@v5
33
+ - uses: astral-sh/setup-uv@v6
34
+ with:
35
+ python-version: "3.12"
36
+ # Keyed by run id with a prefix restore-key: a fixed key would
37
+ # restore forever but (actions/cache never overwrites an existing
38
+ # key) never actually save a new one, so nothing would
39
+ # accumulate. This restores the most recent run's cache and saves
40
+ # today's as a new entry, so Hypothesis's example database grows
41
+ # night over night instead of starting cold every time.
42
+ - name: Restore accumulated Hypothesis examples
43
+ uses: actions/cache@v4
44
+ with:
45
+ path: .hypothesis
46
+ key: hypothesis-nightly-${{ github.run_id }}
47
+ restore-keys: |
48
+ hypothesis-nightly-
49
+ - name: Run the full suite under the nightly fuzz profile
50
+ env:
51
+ HYPOTHESIS_PROFILE: nightly
52
+ run: uv run --frozen pytest
53
+ # A failed scheduled run already notifies repo watchers by
54
+ # default (GitHub's standard behavior) — that red run *is* the
55
+ # report; nothing here files an issue on top of it. This just
56
+ # makes the minimal counterexample recoverable without needing
57
+ # repo write access to re-run the job locally.
58
+ - name: Upload the failing example database
59
+ if: failure()
60
+ uses: actions/upload-artifact@v4
61
+ with:
62
+ name: hypothesis-nightly-failure-${{ github.run_id }}
63
+ path: .hypothesis
64
+ retention-days: 30
@@ -59,3 +59,36 @@ jobs:
59
59
  fi
60
60
  echo "Tag ${GITHUB_REF_NAME} matches packaged version ${built}."
61
61
  - run: uv publish --trusted-publishing always
62
+
63
+ github-release:
64
+ needs: publish
65
+ runs-on: ubuntu-latest
66
+ permissions:
67
+ contents: write
68
+ steps:
69
+ - uses: actions/checkout@v5
70
+ # CHANGELOG.md is the single source of truth for release notes, so
71
+ # extract this tag's own section rather than maintaining a second
72
+ # copy anywhere. A missing section fails loudly instead of
73
+ # publishing an empty-body release.
74
+ - name: Extract this version's CHANGELOG section
75
+ run: |
76
+ set -euo pipefail
77
+ version="${GITHUB_REF_NAME#v}"
78
+ awk -v ver="$version" '
79
+ found && /^## \[/ { exit }
80
+ found { print }
81
+ $0 ~ "^## \\[" ver "\\]" { found=1 }
82
+ ' CHANGELOG.md > release-notes.md
83
+ if [ ! -s release-notes.md ]; then
84
+ echo "::error::No '## [${version}]' section found in CHANGELOG.md."
85
+ exit 1
86
+ fi
87
+ - name: Create GitHub release
88
+ env:
89
+ GITHUB_TOKEN: ${{ github.token }}
90
+ run: |
91
+ gh release create "${GITHUB_REF_NAME}" \
92
+ --title "netprotocols ${GITHUB_REF_NAME#v}" \
93
+ --notes-file release-notes.md \
94
+ --latest
@@ -150,3 +150,8 @@ amass.log
150
150
 
151
151
  # Written by the benchmark CI step (scripts/benchmark.py output)
152
152
  benchmark.out
153
+
154
+ # npm dependency (the pyodide package itself) for scripts/pyodide/ —
155
+ # CI installs it fresh from package-lock.json, same as any other
156
+ # lockfile-pinned dependency.
157
+ scripts/pyodide/node_modules/
@@ -81,8 +81,20 @@ match ip:
81
81
  declaration order — `Ethernet.__match_args__ ==
82
82
  ('dst', 'src', 'ethertype')` — so a *positional* class pattern
83
83
  (`case Ethernet(dst, src, ethertype)`) works with no extra code. An
84
- ordinary class has to declare `__match_args__` by hand to get that;
85
- this library never does.
84
+ ordinary class has to declare `__match_args__` by hand to get that.
85
+ Most headers here never do either — but a few of the widest ones
86
+ are a deliberate exception: `IPv4`, `TCP` and `DHCP` run to 14, 11
87
+ and 15 fields respectively, so their full auto-generated tuple is
88
+ unusable positionally (nobody writes, or gets right, a
89
+ fourteen-slot pattern). Those three hand-declare a short
90
+ `__match_args__` instead — `IPv4.__match_args__ ==
91
+ ('src', 'dst', 'protocol')` — the two or three fields someone
92
+ drafting a positional pattern actually reaches for. The override is
93
+ documented at each class and pinned by
94
+ `tests/test_pattern_matching.py`, so it cannot drift the way an
95
+ *accidental* override would; keyword patterns
96
+ (`case IPv4(protocol=6)`) are unaffected by any of this and stay
97
+ the documented default (#94).
86
98
  2. **Every field with a fixed vocabulary of wire values is an
87
99
  `IntEnum`**, defined in [`_enums.py`](src/netprotocols/_enums.py),
88
100
  and `IntEnum` subclasses `int`. A *value* pattern therefore matches
@@ -107,11 +119,16 @@ there.)
107
119
  **Keeping this working is a contributor obligation, not a given.**
108
120
  Field declaration order is part of a class's public API — reordering
109
121
  fields changes what a positional pattern binds to, silently, for
110
- anyone matching by position. And a field carrying a closed set of wire
111
- values should stay an `IntEnum` sourced from `_enums.py` rather than
112
- degrade to a bare `int`; the moment it does, `case Header(field=SomeEnum.X)`
113
- stops matching and fails silently (the pattern falls through to the
114
- next `case`, it does not raise) rather than loudly.
122
+ anyone matching by position. (The curated three are the exception:
123
+ `IPv4`/`TCP`/`DHCP`'s hand-declared `__match_args__` is independent of
124
+ field order, so reordering *their* fields cannot break positional
125
+ matching — but adding or renaming one of the fields the curated tuple
126
+ names does need the tuple, and the test pinning it, updated by hand.)
127
+ And a field carrying a closed set of wire values should stay an
128
+ `IntEnum` sourced from `_enums.py` rather than degrade to a bare
129
+ `int`; the moment it does, `case Header(field=SomeEnum.X)` stops
130
+ matching and fails silently (the pattern falls through to the next
131
+ `case`, it does not raise) rather than loudly.
115
132
 
116
133
  ## The decode contract
117
134
 
@@ -387,6 +404,8 @@ src/netprotocols/
387
404
  ├── packet.py Packet composition, with_checksums(), flow_key()
388
405
  ├── checksum.py RFC 1071: internet_checksum, compute, verify
389
406
  ├── flow.py FlowKey, flow_key(): canonical bidirectional keys
407
+ ├── pcap.py read_captures()/read_pcap()/read_pcapng(): classic
408
+ │ pcap and pcapng readers, from bytes not filenames
390
409
  ├── layer2/ ethernet.py, arp.py, vlan.py (802.1Q / 802.1ad)
391
410
  ├── layer3/ ip.py (IPv4 + IPv6), icmp.py (ICMPv4 + ICMPv6),
392
411
  │ igmp.py, gre.py,
@@ -5,6 +5,360 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.2.1] - 2026-09-07
9
+
10
+ ### Fixed
11
+ - **Closed a decode-throughput regression against dpkt that nobody had
12
+ been watching tier over tier (#147).** Re-measuring every held claim
13
+ after #107/#124 closed showed netprotocols had gone from 1.16× faster
14
+ than dpkt right after Tier 1 to ~11% slower after Tiers 2-4 — a
15
+ reversal none of the five tiers' own re-measurements had caught,
16
+ because none of them profiled, only re-measured before/after.
17
+ `cProfile` against the corpus decode loop found two causes, neither
18
+ of them the tiers first suspected: `bytes_to_ipv6` (added by #99 for
19
+ Pyodide portability, not one of the three tiers originally named)
20
+ formatted its eight address words one f-string at a time through a
21
+ generator, ~18% of total corpus decode time; `decode_frame()`'s
22
+ `Packet` construction paid a redundant `isinstance` check, through
23
+ `Protocol`'s ABC machinery, on a list already guaranteed to hold only
24
+ `Protocol` instances, ~7-8% of `decode_frame()`'s own time. Both
25
+ fixed without changing observable behavior: `bytes_to_ipv6` now
26
+ formats all eight words in one `%`-format call (verified
27
+ byte-identical against glibc's `inet_ntop` via the existing
28
+ hypothesis test, 2.1× faster in isolation); `Packet` gained an
29
+ internal `_from_decoded()` fast-construction path, used only by
30
+ `decode_frame()` — the public `Packet(...)` constructor still
31
+ validates arbitrary arguments exactly as before.
32
+ - **`scripts/benchmark.py` now measures `decode_frame()`, the
33
+ documented public chain-walking API, instead of a hand-rolled loop
34
+ that predated it (#147).** The benchmark's `decode_netprotocols()`
35
+ kept its own copy of the pre-#88 walk loop, never updated when
36
+ `decode_frame()` shipped, so every decode-throughput figure this
37
+ project has published — including every number in `docs/CLAIMS.md`
38
+ section 1 before this release — described code the documented API's
39
+ callers never actually ran. `benchmarks/baseline.json`, five tiers
40
+ and this fix stale at a v1.3.0-era figure, was refreshed to match.
41
+
42
+ See `docs/CLAIMS.md`'s "Re-measured after closing the
43
+ dpkt-throughput regression" section for the full profiling writeup,
44
+ before/after numbers under both the old and new benchmark
45
+ methodology, and updated 1.1/1.2/1.6 figures.
46
+
47
+ ### Development
48
+ - **Addressed CodeFactor's static-analysis findings on the tooling
49
+ scripts (#156).** `scripts/pyodide/check_in_pyodide.py` extracted
50
+ the built wheel into a hardcoded, predictable `/tmp/netprotocols-
51
+ wheel` path; it now uses `tempfile.mkdtemp()`, verified against a
52
+ real Pyodide runtime (`node scripts/pyodide/run_in_pyodide.mjs`)
53
+ decoding the full corpus unchanged. `scripts/check_fixtures.py`'s
54
+ `_check_l3` — one 137-line function walking IPv4, IPv6, GRE
55
+ tunneling, VLAN and every upper-layer checksum — is split into one
56
+ small function per protocol; output is byte-identical on the full
57
+ 97-frame fixture corpus before and after. `scripts/benchmark.py`'s
58
+ three broad `except Exception: continue` blocks are unchanged but
59
+ now documented: `dpkt` and `scapy` have no shared "malformed frame"
60
+ exception the way this project's own `ProtocolError` does, so a
61
+ bare `except` is the correct interop boundary, not a swallowed bug.
62
+ The flagged duplication between `benchmark.py` and
63
+ `check_in_pyodide.py`'s minimal pcap readers — nearly byte-identical
64
+ `read_pcap`/`corpus_frames` functions — is now shared via a new
65
+ `scripts/_pcap.py`. An initial pass left the duplication in place,
66
+ reasoning both scripts' docstrings required each verification path
67
+ to stand fully independent of the others; re-reading them shows that
68
+ claim is narrower than stated — "standalone" there means not
69
+ depending on netprotocols (the thing each script verifies), the same
70
+ usage `CONTRIBUTING.md` and `check_fixtures.py`'s own docstring use,
71
+ never independence from sibling scripts. `_pcap.py` is itself
72
+ stdlib-only and does not import netprotocols, so it preserves the
73
+ property the docstrings actually document. Verified against a real
74
+ Pyodide runtime after the extraction: the corpus still decodes
75
+ unchanged, and `check_fixtures.py`'s own, materially different
76
+ `read_pcap` (it additionally validates linktype and length) is
77
+ untouched, since it was never part of this duplication.
78
+
79
+ ### Documentation
80
+ - **Corrected six README.md claims an independent audit against the
81
+ live repo found stale or overstated, none of them accidental
82
+ regressions — each had simply drifted from what it once described.**
83
+ Wheel size (85.6 KB / ~30× smaller than scapy) was measured against
84
+ the `2.0.0` wheel, two releases behind; rebuilt from `HEAD`, it's
85
+ 88.3 KB / ~29× (`docs/CLAIMS.md` §1.5 corrected to match). The typing
86
+ bullet's "107 of [scapy's] files" enable strict mypy checking was a
87
+ raw `wc -l` over scapy's enabled-files list including blank lines
88
+ and comments; filtered the way scapy's own tooling does, it's 89
89
+ (`docs/CLAIMS.md` §2.1 corrected, with the mismeasurement documented
90
+ so it doesn't recur). The Pyodide/browser bullet credited "dpkt and
91
+ pypacker import cleanly" to the real-Pyodide CI job; that job only
92
+ ever imports netprotocols under Pyodide (confirmed by reading
93
+ `scripts/pyodide/check_in_pyodide.py`) — the dpkt/pypacker rows come
94
+ from a separate `sys.meta_path`-blocklist simulation under ordinary
95
+ CPython, not from CI (`docs/CLAIMS.md` §3.1 reworded to say so
96
+ explicitly). The CI-gating bullet's closing clause, "the rest have no
97
+ performance benchmark at all," was contradicted by the very citation
98
+ it points to — `docs/CLAIMS.md` §1.7 documents that scapy and
99
+ PyTCP-net_proto do ship benchmark scripts, just not CI-wired; reworded
100
+ to the claim the table actually supports (none of the ten *gates* on
101
+ a regression). The Contributing section's "`pytest`, `mypy`, and
102
+ `ruff check` — all three are enforced by CI on Python 3.12-3.14"
103
+ overstated the matrix: only `pytest` runs on 3.12-3.14; `mypy` and
104
+ `ruff check` each run on 3.12 only. The Roadmap section's "everything
105
+ through 2.2.0 has landed on `master`" didn't mention that a further,
106
+ unreleased fix (#147) has since landed on top of 2.2.0 — added a
107
+ clause naming it. A seventh finding — the Roadmap's claim that "only
108
+ 2.0.0 was an actual PyPI release" — is contradicted by PyPI itself
109
+ (2.2.0, and in fact five earlier minor/patch versions, are also
110
+ published there); left unfixed pending a maintainer decision on
111
+ whether 2.2.0's publication was intentional, since fixing the prose
112
+ first would paper over a real process question.
113
+ - **Resolved the seventh finding above: 2.2.0's PyPI publication was
114
+ confirmed intentional.** The Roadmap section's "per the roadmap's own
115
+ release policy, only 2.0.0 was an actual PyPI release" is now
116
+ "the roadmap's original 'only at a major version bump' release policy
117
+ wasn't actually held to: both 2.0.0 and 2.2.0 — a minor release — are
118
+ on PyPI," and the roadmap table's 2.2.0 row gained the same
119
+ **released** marker 2.0.0 already carried. Issue #107, the source
120
+ both docs draw this from, updated its own stated policy line with a
121
+ dated correction rather than silently rewriting history.
122
+ - **A fresh independent audit for #101 found four more genuine drifts
123
+ and fixed them, none of them regressions — just the register falling
124
+ behind a codebase and a README that both kept moving.** `docs/CLAIMS.md`
125
+ §5.3's statement count (1,935) had drifted from the live count of
126
+ 2,091 as the codebase grew — the coverage percentage held steady only
127
+ because the miss count never moved, masking it; `pyproject.toml`'s own
128
+ inline comment was still quoting an even older 1,441 and is now
129
+ corrected too. §5.7's "79 tests" for `tests/test_walk.py` undercounted
130
+ the live 85: one test parametrized over the corpus's first 40 frames
131
+ now collects its full 40 instances because the corpus has since grown
132
+ past that slice, not because tests were added. §1.6 overstated its own
133
+ claim about README.md, saying the decode-depth figure travels with the
134
+ throughput figure "in the same sentence" there; they're adjacent
135
+ bullets, not one sentence — corrected. README's Pyodide/browser bullet
136
+ said outright that "pypacker was not tested", contradicted by
137
+ `docs/CLAIMS.md` §3.1's own simulation table two paragraphs away, which
138
+ shows pypacker importing cleanly under the identical CPython-side
139
+ check used for dpkt; reworded to say so, and to stop implying the
140
+ scapy-import failure was observed directly inside the real Pyodide CI
141
+ run rather than established by source citation plus that simulation.
142
+ README's CI-gating bullet lumped PyTCP-net_proto in with three
143
+ libraries whose benchmark code targets the library being compared;
144
+ `docs/CLAIMS.md` §1.7's own citation notes PyTCP-net_proto's benchmark
145
+ exercises its RX daemon instead — reworded to keep that distinction
146
+ rather than flattening it.
147
+ - **Restored this file's own missing `## [2.2.0]` version header.** A
148
+ prior documentation commit deleted it while inserting an adjacent
149
+ bullet, which silently re-merged the entire tagged, PyPI-published
150
+ 2.2.0 release back under `## [Unreleased]` — the section below is
151
+ what a `git show` of the `v2.2.0` tag's own CHANGELOG.md actually
152
+ contained at release time.
153
+
154
+ ## [2.2.0] - 2026-09-04
155
+
156
+ ### Added
157
+ - **Typed accessors for every enum-backed field.** `_enums.py` defines
158
+ `EtherType`, `IPProtocol`, `ARPOperation` and `ARPHardwareType`, but
159
+ until now no decoded field exposed them — only the `*_name` display
160
+ strings, and the enums themselves were used only for dispatch. Every
161
+ field with a fixed wire vocabulary now has an `*_enum` companion
162
+ alongside the existing `int` field and its `*_name` string, mirroring
163
+ the `src`/`src_address` precedent 1.3.0 established for IP addresses:
164
+ `Ethernet.ethertype_enum`, `VLAN.ethertype_enum`, `GRE.protocol_enum`,
165
+ `IPv4.protocol_enum`, `IPv6.next_header_enum` (and the three IPv6
166
+ extension headers that share the field: `IPv6HopByHopOptions`,
167
+ `IPv6DestinationOptions`, `IPv6Routing`, `IPv6Fragment`),
168
+ `ARP.oper_enum`, `ARP.ptype_enum`, `ARP.htype_name`/`ARP.htype_enum`
169
+ (new — `ARPHardwareType` was exported but referenced nowhere), and
170
+ `DHCP.htype_name`/`DHCP.htype_enum`. Each returns `None` — never
171
+ raises — for a wire value this library does not enumerate, so
172
+ `bytes(decode(x)) == x` is unaffected for unrecognized values; the
173
+ raw `int` field stays canonical and round-trips regardless (#95).
174
+ - **IPv4, TCP and DHCP curate a short `__match_args__` by hand.** Their
175
+ full auto-generated positional form runs to 11-15 fields — unusable
176
+ positionally, since nobody writes, or gets right, a fourteen-slot
177
+ pattern. `IPv4.__match_args__ == ('src', 'dst', 'protocol')`,
178
+ `TCP.__match_args__ == ('src_port', 'dst_port', 'flags')`,
179
+ `DHCP.__match_args__ == ('op', 'chaddr', 'yiaddr')` — the two or
180
+ three fields someone drafting `case IPv4(...)` actually reaches for.
181
+ Every other header (including the still-wide `IPv6`, at 8 fields)
182
+ keeps the plain auto-generated tuple, and keyword patterns
183
+ (`case IPv4(protocol=6)`) are unaffected either way and stay the
184
+ documented default. `__match_args__` is part of the public API once
185
+ documented, so the chosen order is pinned by
186
+ `tests/test_pattern_matching.py` rather than left free to drift.
187
+ ARCHITECTURE.md and `docs/CLAIMS.md` are updated to describe this as
188
+ a deliberate, tested exception rather than the "this library never
189
+ hand-declares `__match_args__`" absolute they stated before (#94).
190
+ - **DNS resource records expose typed RDATA, and every question, not
191
+ just the first.** `DNSResourceRecord.rdata_text` decoded A/AAAA/MX/
192
+ SOA/etc. into a display string only; a new `rdata_value` field adds
193
+ the typed decoding alongside it (`rdata_text` is unchanged) —
194
+ `ipaddress.IPv4Address`/`IPv6Address` for A/AAAA, the decompressed
195
+ target `str` for NS/CNAME/PTR, a new `MXRecord(preference, exchange)`
196
+ for MX, a new `SOARecord(mname, rname, serial, refresh, retry,
197
+ expire, minimum)` for SOA, `list[str]` of character-strings for TXT,
198
+ and `None` — never raises — for the types this library does not
199
+ decode. Computed eagerly at parse time, like `rdata_text` always has
200
+ been: a name inside RDATA (a CNAME's target, for instance) can use
201
+ DNS compression against the *whole* message, so decoding it needs
202
+ the same message-wide context `rdata_text` already required, which a
203
+ property computed lazily from `rdata` alone could not resolve.
204
+ Separately, `DNS.questions` adds a `tuple[DNSQuestion, ...]` walking
205
+ every entry of the question section — `question_name`/`question_type`
206
+ /`question_class` exposed only the first and are unchanged (#96).
207
+ - **A typed `DHCPOption`, alongside `DHCP.option_map`.** `option_map`
208
+ was the only accessor in the library returning a bare
209
+ `dict[int, bytes]` rather than typed objects; it is unchanged (still
210
+ returns exactly what it always did). A new `parsed_options` property
211
+ wraps the same, already RFC-3396-concatenated, mapping into a
212
+ `tuple[DHCPOption, ...]`, mirroring `TCPOption`/`IPv4Option`: `code`,
213
+ `data`, `code_name`, and a decoded `.value` for the option codes this
214
+ library understands — a single `ipaddress.IPv4Address` for Subnet
215
+ Mask (1)/Requested IP Address (50)/Server Identifier (54), a tuple of
216
+ one or more for Router (3)/Domain Name Server (6) (RFC 2132 permits
217
+ repeating either), the integer seconds for IP Address Lease Time
218
+ (51), the raw byte for Message Type (53). `None` — never raises —
219
+ for codes this library does not decode and malformed option data
220
+ (#96).
221
+ - **`IPv4Option.value` decodes Record Route, Timestamp and Router
222
+ Alert.** `IPv4Option` had `kind_name` and no `.value` — the three
223
+ common kinds (RFC 791 §3.1, RFC 2113) were named but their contents
224
+ left raw, unlike `TCPOption`, which already decodes its values.
225
+ Record Route (7) decodes to a `tuple[ipaddress.IPv4Address, ...]` of
226
+ the addresses recorded so far — the option's own pointer byte says
227
+ how many of the address slots are actually filled, not the option's
228
+ total length. Timestamp (68) decodes to `tuple[int, ...]` of plain
229
+ millisecond timestamps when its flag selects that shape, or
230
+ `tuple[tuple[ipaddress.IPv4Address, int], ...]` of (address,
231
+ timestamp) pairs when the flag says each entry carries one — the
232
+ overflow counter and pointer are not decoded, read `data` raw for
233
+ those. Router Alert (148) decodes to the 2-byte value as `int`.
234
+ `None` — never raises — for every other kind and for malformed data
235
+ on one of these three (a bad pointer, an unrecognized Timestamp
236
+ flag, a short buffer) (#96).
237
+ - **`IPv6Routing.segments` and `IPv6Option.value` for Router Alert /
238
+ Jumbo Payload.** `IPv6Routing.data` was entirely unparsed — no
239
+ segment-list extraction for any routing type. RH0 (`routing_type`
240
+ ``0``, deprecated by RFC 5095 but still seen — RFC 2460 §4.4) and
241
+ Mobile IPv6 (``2``, RFC 6275 §6.4) both store a 4-byte reserved field
242
+ followed by one IPv6 address per segment; `segments` decodes that
243
+ into a `tuple[ipaddress.IPv6Address, ...]`. RPL Source Routing (``3``,
244
+ RFC 6554) is deliberately **not** decoded: RFC 6554 §3 elides a
245
+ shared prefix from each intermediate address relative to the
246
+ enclosing packet's *destination* address, context this
247
+ one-extension-header accessor does not have — `data` stays available
248
+ raw. `None` for every other routing type and for malformed address
249
+ data. Separately, `IPv6Option.value` (Hop-by-Hop / Destination
250
+ Options TLVs) now decodes Router Alert (5, RFC 2711 §2.1) and Jumbo
251
+ Payload (194, RFC 2675 §2) into typed `int`s, the last of #96's four
252
+ pieces (`IPv6Option` "likewise decode[d] no values"); `None` for
253
+ every other type and malformed data, same contract throughout this
254
+ tier (#96).
255
+ - **CI proves the library runs under a real Pyodide (WebAssembly)
256
+ runtime**, not just that the modules `scapy`/`dpkt` need are
257
+ individually blocked. A new `pyodide` job (`.github/workflows/ci.yml`)
258
+ boots actual Pyodide under Node via `scripts/pyodide/run_in_pyodide.mjs`,
259
+ installs the wheel this job just built, and decodes the entire
260
+ real-capture fixture corpus with it — see
261
+ `scripts/pyodide/check_in_pyodide.py`. That surfaced a genuine bug
262
+ along the way: `IPv6.decode()`/`bytes(IPv6(...))` used
263
+ `socket.inet_ntop`/`inet_pton(AF_INET6, ...)`, and Pyodide's CPython
264
+ build has `AF_INET6` sockets disabled, so every IPv6 frame failed to
265
+ decode there. `_base.py`'s `ipv6_to_bytes`/`bytes_to_ipv6` are now a
266
+ pure-Python implementation (`ipaddress` for parsing; a hand-rolled
267
+ RFC 5952 canonicalizer, differentially verified against glibc's
268
+ `inet_ntop` across 500,000+ random addresses plus both of its
269
+ dotted-quad special cases, for formatting — `str(ipaddress.IPv6Address)`
270
+ was tried first and rejected: it disagrees with glibc on IPv4-mapped
271
+ addresses, and disagrees with *itself* between Python 3.11 and 3.12).
272
+ IPv4 addressing is unaffected; behavior for every existing platform
273
+ is unchanged, exception type included (#99).
274
+ - **Nightly fuzzing with a moving seed.** `tests/test_fuzz.py`'s
275
+ `"netprotocols"` Hypothesis profile is deterministic on purpose (200
276
+ examples, `derandomize=True`) so a pull request is reproducible — but
277
+ that also means every build since it was written has run the *same*
278
+ 200 inputs. A new `"nightly"` profile (10,000 examples, a real random
279
+ seed each run) runs the whole suite on a new schedule,
280
+ `.github/workflows/fuzz.yml` (03:00 UTC daily, plus manual dispatch),
281
+ kept out of `ci.yml` deliberately — `ci.yml` is reused by
282
+ `release.yml` via `workflow_call`, and a `schedule:` trigger there
283
+ would fire the whole PR/release gate on a cron, not just this
284
+ exploratory job. `.hypothesis/`'s example database accumulates
285
+ across nightly runs via `actions/cache` (keyed by run id with a
286
+ prefix restore-key, since a fixed key would restore forever but
287
+ never actually save a new entry) and uploads as a workflow artifact
288
+ on failure, so a counterexample is recoverable without repo write
289
+ access; a failed scheduled run's own red build is the notification
290
+ (GitHub already does this by default), so nothing here files an
291
+ issue on top of it. Reproduce locally:
292
+ `HYPOTHESIS_PROFILE=nightly uv run pytest`. Also adds a targeted
293
+ strategy building well-formed TCP SYN options (MSS, window scale,
294
+ SACK-Permitted, SACK, timestamps) — the real-capture corpus never
295
+ caught a SYN, so unlike NOP/Timestamps these otherwise depend on
296
+ `max_examples` alone stumbling into a well-formed TLV by chance
297
+ (#98).
298
+ - **A pcap/pcapng reader that takes bytes, not filenames.** New
299
+ `netprotocols.pcap`: `read_captures(buffer)` auto-detects classic
300
+ pcap vs. pcapng from its magic number and yields `CapturedFrame`
301
+ (`timestamp` in nanoseconds since the Unix epoch, normalized from
302
+ whatever resolution the source recorded, `data` the frame's raw
303
+ bytes); `read_pcap()`/`read_pcapng()` are the same for a caller who
304
+ already knows the format. pcapng support covers exactly the block
305
+ types frames can come from — Section Header, Interface Description
306
+ (read only for its `if_tsresol` option), Enhanced Packet, and Simple
307
+ Packet (which the format gives no timestamp at all, hence `0`);
308
+ every other block type is skipped wholesale. A malformed or
309
+ truncated capture raises the new `MalformedCaptureError`
310
+ (`ProtocolError` family, no `lax` mode — a corrupt *container* is a
311
+ different failure shape than a malformed header inside one already-
312
+ extracted frame). Format detection is eager; producing frames is
313
+ lazy (a generator), so a bad record downstream doesn't invalidate
314
+ what already iterated cleanly, and a huge capture is never forced
315
+ into a list of frames nobody asked for.
316
+
317
+ `tests/conftest.py` drops the private classic-pcap reader every test
318
+ file reached for — `pcap_frames()` is now a thin adapter over the
319
+ shipped `read_pcap()`, and `~10` test files were migrated onto it (a
320
+ real migration, not a rename: `tests/test_pcap.py` keeps its own
321
+ independent reference reader, deliberately never importing the
322
+ module it is cross-checking, the same "standalone, so a shared bug
323
+ can't cancel itself out" precedent as `scripts/benchmark.py` and
324
+ `scripts/check_fixtures.py`).
325
+
326
+ One design idea was tried and reverted on measurement: slicing each
327
+ frame lazily out of a `memoryview` over the whole buffer, to keep
328
+ large captures zero-copy. Measured across synthetic captures up to
329
+ ~140MB, it was 0.91x-0.98x — never faster, sometimes slower — because
330
+ a real capture is many *small* frames, and a `memoryview` slice's own
331
+ overhead is paid per frame; #88's identical finding for a single
332
+ frame generalizes rather than being contradicted. `docs/CLAIMS.md`
333
+ 5.8 is corrected accordingly — it previously forward-referenced this
334
+ issue with an unverified "1.8x" figure (#100).
335
+
336
+ ### Documentation
337
+ - **The comparative-claims embargo is lifted (#107, #124).** `docs/CLAIMS.md`
338
+ 1.7 gained a per-project table auditing ten comparable Python packet
339
+ libraries' CI configurations (dpkt, scapy, pypacker, construct,
340
+ pcapkit, dnspython, pyshark, nfstream, stackforge, PyTCP-net_proto)
341
+ for an actual performance-regression gate — cited file:line or
342
+ workflow, dated 2026-09-04; none of the ten gate on one, so the
343
+ comparative form of 1.7's claim is now stated with that evidence
344
+ (#124). Every section that previously carried a
345
+ `COMPARATIVE — HELD` banner is re-measured against the current tree
346
+ and published: decode throughput (1.1, 1.2), encoding throughput
347
+ (1.3), import time (1.4), wheel size (1.5), decode depth (1.6), the
348
+ `mypy --strict` comparison (2.1), `match`/`case` exclusivity (2.2),
349
+ the five-property combination (2.3), Pyodide/browser support (3.1),
350
+ and the permissive-licence wedge (4.1). One finding moved against
351
+ this project rather than for it and is reported as such: three tiers
352
+ of added surface since Tier 1 (#87's dispatch rewrite, #88's chain
353
+ walker, #91's structured errors chief among them) reversed decode
354
+ throughput from 1.16× faster than dpkt back to roughly 11% slower —
355
+ still within the 15% band claim 1.2 names, just no longer ahead.
356
+ Two new reproduction scripts back claims that previously had none:
357
+ `scripts/benchmark_encode.py` (1.3) and `scripts/benchmark_import.py`
358
+ (1.4). `README.md` gains a "Why NETProtocols" section drawing the
359
+ now-published claims into the project's front door, in the tone
360
+ `docs/CLAIMS.md` 4.2 sets for writing about competitors.
361
+
8
362
  ## [2.0.0] - 2026-09-04
9
363
 
10
364
  ### Development