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.
- {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/workflows/ci.yml +23 -0
- netprotocols-2.2.1/.github/workflows/fuzz.yml +64 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/workflows/release.yml +33 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/.gitignore +5 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/ARCHITECTURE.md +26 -7
- {netprotocols-2.0.0 → netprotocols-2.2.1}/CHANGELOG.md +354 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/PKG-INFO +144 -14
- {netprotocols-2.0.0 → netprotocols-2.2.1}/README.md +143 -13
- {netprotocols-2.0.0 → netprotocols-2.2.1}/benchmarks/baseline.json +4 -4
- netprotocols-2.2.1/docs/CLAIMS.md +972 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/pyproject.toml +5 -5
- netprotocols-2.2.1/scripts/_pcap.py +42 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/benchmark.py +8 -51
- netprotocols-2.2.1/scripts/benchmark_encode.py +107 -0
- netprotocols-2.2.1/scripts/benchmark_import.py +63 -0
- netprotocols-2.2.1/scripts/check_fixtures.py +332 -0
- netprotocols-2.2.1/scripts/pyodide/check_in_pyodide.py +111 -0
- netprotocols-2.2.1/scripts/pyodide/package-lock.json +53 -0
- netprotocols-2.2.1/scripts/pyodide/package.json +9 -0
- netprotocols-2.2.1/scripts/pyodide/run_in_pyodide.mjs +44 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/__init__.py +26 -3
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/_base.py +97 -4
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer2/arp.py +48 -3
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer2/ethernet.py +11 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer2/vlan.py +14 -1
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/gre.py +11 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/ip.py +117 -5
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/ipv6_ext.py +104 -6
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer4/tcp.py +7 -0
- netprotocols-2.2.1/src/netprotocols/layer7/__init__.py +20 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer7/dhcp.py +138 -5
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer7/dns.py +199 -17
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/packet.py +24 -0
- netprotocols-2.2.1/src/netprotocols/pcap.py +504 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/utils/exceptions.py +18 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/walk.py +1 -1
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/conftest.py +13 -21
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_arp.py +16 -2
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_corpus.py +8 -8
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_dhcp.py +117 -3
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_dns.py +91 -2
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_ethernet.py +3 -1
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_fuzz.py +115 -4
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_gre.py +9 -6
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_igmp.py +2 -2
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_ip.py +163 -2
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_ipv6_ext.py +135 -3
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_pattern_matching.py +93 -8
- netprotocols-2.2.1/tests/test_pcap.py +367 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_vlan.py +3 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/uv.lock +1 -1
- netprotocols-2.0.0/docs/CLAIMS.md +0 -680
- netprotocols-2.0.0/scripts/check_fixtures.py +0 -273
- netprotocols-2.0.0/src/netprotocols/layer7/__init__.py +0 -4
- {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/ISSUE_TEMPLATE/protocol_request.yml +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/.github/pull_request_template.md +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/CONTRIBUTING.md +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/LICENSE +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures.sh +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_dhcp.sh +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_dns_tcp.sh +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_gre.sh +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_igmp.sh +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_supplement.sh +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/scripts/capture_fixtures_vlan.sh +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/_defaults.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/_enums.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/checksum.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/flow.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer2/__init__.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/__init__.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/icmp.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer3/igmp.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer4/__init__.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer4/_ports.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/layer4/udp.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/py.typed +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/registry.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/utils/__init__.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/utils/ipv4.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/src/netprotocols/utils/mac.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/MANIFEST.md +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/arp_exchange.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/dhcp.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/dns_tcp.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/gre.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/icmpv4_external.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/igmp.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/ipv4_fragments.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/ipv6_fragments.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/ipv6_mld.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/arp_exchange.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/dhcp.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/dns_tcp.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/gre.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/icmpv4_external.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/igmp.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/ipv6_mld.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/tcp_http.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/tcp_https.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/udp_dns.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/staging/vlan_icmp.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/tcp_http.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/tcp_https.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/udp_dns.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/fixtures/vlan_icmp.pcap +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/strategies.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_checksum.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_contract.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_diagnostics.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_docs.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_exports.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_flow.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_icmp.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_packet.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_registry.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_tcp.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_udp.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_utils.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_version.py +0 -0
- {netprotocols-2.0.0 → netprotocols-2.2.1}/tests/test_walk.py +0 -0
- {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
|
-
|
|
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.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|