netprotocols 2.0.0__tar.gz → 2.2.0__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 (131) hide show
  1. {netprotocols-2.0.0 → netprotocols-2.2.0}/.github/workflows/ci.yml +23 -0
  2. netprotocols-2.2.0/.github/workflows/fuzz.yml +64 -0
  3. {netprotocols-2.0.0 → netprotocols-2.2.0}/.gitignore +5 -0
  4. {netprotocols-2.0.0 → netprotocols-2.2.0}/ARCHITECTURE.md +26 -7
  5. {netprotocols-2.0.0 → netprotocols-2.2.0}/CHANGELOG.md +208 -0
  6. {netprotocols-2.0.0 → netprotocols-2.2.0}/PKG-INFO +127 -11
  7. {netprotocols-2.0.0 → netprotocols-2.2.0}/README.md +126 -10
  8. {netprotocols-2.0.0 → netprotocols-2.2.0}/docs/CLAIMS.md +300 -171
  9. {netprotocols-2.0.0 → netprotocols-2.2.0}/pyproject.toml +1 -1
  10. netprotocols-2.2.0/scripts/benchmark_encode.py +107 -0
  11. netprotocols-2.2.0/scripts/benchmark_import.py +63 -0
  12. netprotocols-2.2.0/scripts/pyodide/check_in_pyodide.py +137 -0
  13. netprotocols-2.2.0/scripts/pyodide/package-lock.json +53 -0
  14. netprotocols-2.2.0/scripts/pyodide/package.json +9 -0
  15. netprotocols-2.2.0/scripts/pyodide/run_in_pyodide.mjs +44 -0
  16. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/__init__.py +26 -3
  17. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/_base.py +79 -4
  18. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer2/arp.py +48 -3
  19. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer2/ethernet.py +11 -0
  20. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer2/vlan.py +14 -1
  21. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer3/gre.py +11 -0
  22. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer3/ip.py +117 -5
  23. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer3/ipv6_ext.py +104 -6
  24. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer4/tcp.py +7 -0
  25. netprotocols-2.2.0/src/netprotocols/layer7/__init__.py +20 -0
  26. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer7/dhcp.py +138 -5
  27. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer7/dns.py +199 -17
  28. netprotocols-2.2.0/src/netprotocols/pcap.py +504 -0
  29. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/utils/exceptions.py +18 -0
  30. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/conftest.py +13 -21
  31. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_arp.py +16 -2
  32. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_corpus.py +8 -8
  33. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_dhcp.py +117 -3
  34. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_dns.py +91 -2
  35. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_ethernet.py +3 -1
  36. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_fuzz.py +115 -4
  37. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_gre.py +9 -6
  38. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_igmp.py +2 -2
  39. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_ip.py +163 -2
  40. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_ipv6_ext.py +135 -3
  41. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_pattern_matching.py +93 -8
  42. netprotocols-2.2.0/tests/test_pcap.py +367 -0
  43. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_vlan.py +3 -0
  44. {netprotocols-2.0.0 → netprotocols-2.2.0}/uv.lock +1 -1
  45. netprotocols-2.0.0/src/netprotocols/layer7/__init__.py +0 -4
  46. {netprotocols-2.0.0 → netprotocols-2.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  47. {netprotocols-2.0.0 → netprotocols-2.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  48. {netprotocols-2.0.0 → netprotocols-2.2.0}/.github/ISSUE_TEMPLATE/protocol_request.yml +0 -0
  49. {netprotocols-2.0.0 → netprotocols-2.2.0}/.github/pull_request_template.md +0 -0
  50. {netprotocols-2.0.0 → netprotocols-2.2.0}/.github/workflows/release.yml +0 -0
  51. {netprotocols-2.0.0 → netprotocols-2.2.0}/CONTRIBUTING.md +0 -0
  52. {netprotocols-2.0.0 → netprotocols-2.2.0}/LICENSE +0 -0
  53. {netprotocols-2.0.0 → netprotocols-2.2.0}/benchmarks/baseline.json +0 -0
  54. {netprotocols-2.0.0 → netprotocols-2.2.0}/scripts/benchmark.py +0 -0
  55. {netprotocols-2.0.0 → netprotocols-2.2.0}/scripts/capture_fixtures.sh +0 -0
  56. {netprotocols-2.0.0 → netprotocols-2.2.0}/scripts/capture_fixtures_dhcp.sh +0 -0
  57. {netprotocols-2.0.0 → netprotocols-2.2.0}/scripts/capture_fixtures_dns_tcp.sh +0 -0
  58. {netprotocols-2.0.0 → netprotocols-2.2.0}/scripts/capture_fixtures_gre.sh +0 -0
  59. {netprotocols-2.0.0 → netprotocols-2.2.0}/scripts/capture_fixtures_igmp.sh +0 -0
  60. {netprotocols-2.0.0 → netprotocols-2.2.0}/scripts/capture_fixtures_supplement.sh +0 -0
  61. {netprotocols-2.0.0 → netprotocols-2.2.0}/scripts/capture_fixtures_vlan.sh +0 -0
  62. {netprotocols-2.0.0 → netprotocols-2.2.0}/scripts/check_fixtures.py +0 -0
  63. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/_defaults.py +0 -0
  64. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/_enums.py +0 -0
  65. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/checksum.py +0 -0
  66. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/flow.py +0 -0
  67. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer2/__init__.py +0 -0
  68. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer3/__init__.py +0 -0
  69. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer3/icmp.py +0 -0
  70. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer3/igmp.py +0 -0
  71. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer4/__init__.py +0 -0
  72. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer4/_ports.py +0 -0
  73. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/layer4/udp.py +0 -0
  74. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/packet.py +0 -0
  75. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/py.typed +0 -0
  76. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/registry.py +0 -0
  77. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/utils/__init__.py +0 -0
  78. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/utils/ipv4.py +0 -0
  79. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/utils/mac.py +0 -0
  80. {netprotocols-2.0.0 → netprotocols-2.2.0}/src/netprotocols/walk.py +0 -0
  81. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/MANIFEST.md +0 -0
  82. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/arp_exchange.pcap +0 -0
  83. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/dhcp.pcap +0 -0
  84. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/dns_tcp.pcap +0 -0
  85. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/gre.pcap +0 -0
  86. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/icmpv4_echo_lo.pcap +0 -0
  87. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/icmpv4_external.pcap +0 -0
  88. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/icmpv4_ttl_exceeded.pcap +0 -0
  89. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/icmpv6_echo_lo.pcap +0 -0
  90. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/igmp.pcap +0 -0
  91. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/ipv4_fragments.pcap +0 -0
  92. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/ipv6_fragments.pcap +0 -0
  93. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/ipv6_mld.pcap +0 -0
  94. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/ipv6_ndp_mld.pcap +0 -0
  95. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/arp_exchange.pcap +0 -0
  96. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/dhcp.pcap +0 -0
  97. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/dns_tcp.pcap +0 -0
  98. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/gre.pcap +0 -0
  99. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/icmpv4_echo_lo.pcap +0 -0
  100. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/icmpv4_external.pcap +0 -0
  101. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/icmpv4_ttl_exceeded.pcap +0 -0
  102. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/icmpv6_echo_lo.pcap +0 -0
  103. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/igmp.pcap +0 -0
  104. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/ipv4_fragments.pcap +0 -0
  105. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/ipv6_fragments.pcap +0 -0
  106. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/ipv6_mld.pcap +0 -0
  107. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/ipv6_ndp_mld.pcap +0 -0
  108. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/tcp_http.pcap +0 -0
  109. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/tcp_https.pcap +0 -0
  110. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/udp_dns.pcap +0 -0
  111. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/staging/vlan_icmp.pcap +0 -0
  112. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/tcp_http.pcap +0 -0
  113. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/tcp_https.pcap +0 -0
  114. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/udp_dns.pcap +0 -0
  115. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/fixtures/vlan_icmp.pcap +0 -0
  116. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/strategies.py +0 -0
  117. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_checksum.py +0 -0
  118. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_contract.py +0 -0
  119. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_diagnostics.py +0 -0
  120. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_docs.py +0 -0
  121. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_exports.py +0 -0
  122. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_flow.py +0 -0
  123. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_icmp.py +0 -0
  124. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_packet.py +0 -0
  125. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_registry.py +0 -0
  126. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_tcp.py +0 -0
  127. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_udp.py +0 -0
  128. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_utils.py +0 -0
  129. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_version.py +0 -0
  130. {netprotocols-2.0.0 → netprotocols-2.2.0}/tests/test_walk.py +0 -0
  131. {netprotocols-2.0.0 → netprotocols-2.2.0}/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
@@ -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,214 @@ 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.0] - 2026-09-04
9
+
10
+ ### Added
11
+ - **Typed accessors for every enum-backed field.** `_enums.py` defines
12
+ `EtherType`, `IPProtocol`, `ARPOperation` and `ARPHardwareType`, but
13
+ until now no decoded field exposed them — only the `*_name` display
14
+ strings, and the enums themselves were used only for dispatch. Every
15
+ field with a fixed wire vocabulary now has an `*_enum` companion
16
+ alongside the existing `int` field and its `*_name` string, mirroring
17
+ the `src`/`src_address` precedent 1.3.0 established for IP addresses:
18
+ `Ethernet.ethertype_enum`, `VLAN.ethertype_enum`, `GRE.protocol_enum`,
19
+ `IPv4.protocol_enum`, `IPv6.next_header_enum` (and the three IPv6
20
+ extension headers that share the field: `IPv6HopByHopOptions`,
21
+ `IPv6DestinationOptions`, `IPv6Routing`, `IPv6Fragment`),
22
+ `ARP.oper_enum`, `ARP.ptype_enum`, `ARP.htype_name`/`ARP.htype_enum`
23
+ (new — `ARPHardwareType` was exported but referenced nowhere), and
24
+ `DHCP.htype_name`/`DHCP.htype_enum`. Each returns `None` — never
25
+ raises — for a wire value this library does not enumerate, so
26
+ `bytes(decode(x)) == x` is unaffected for unrecognized values; the
27
+ raw `int` field stays canonical and round-trips regardless (#95).
28
+ - **IPv4, TCP and DHCP curate a short `__match_args__` by hand.** Their
29
+ full auto-generated positional form runs to 11-15 fields — unusable
30
+ positionally, since nobody writes, or gets right, a fourteen-slot
31
+ pattern. `IPv4.__match_args__ == ('src', 'dst', 'protocol')`,
32
+ `TCP.__match_args__ == ('src_port', 'dst_port', 'flags')`,
33
+ `DHCP.__match_args__ == ('op', 'chaddr', 'yiaddr')` — the two or
34
+ three fields someone drafting `case IPv4(...)` actually reaches for.
35
+ Every other header (including the still-wide `IPv6`, at 8 fields)
36
+ keeps the plain auto-generated tuple, and keyword patterns
37
+ (`case IPv4(protocol=6)`) are unaffected either way and stay the
38
+ documented default. `__match_args__` is part of the public API once
39
+ documented, so the chosen order is pinned by
40
+ `tests/test_pattern_matching.py` rather than left free to drift.
41
+ ARCHITECTURE.md and `docs/CLAIMS.md` are updated to describe this as
42
+ a deliberate, tested exception rather than the "this library never
43
+ hand-declares `__match_args__`" absolute they stated before (#94).
44
+ - **DNS resource records expose typed RDATA, and every question, not
45
+ just the first.** `DNSResourceRecord.rdata_text` decoded A/AAAA/MX/
46
+ SOA/etc. into a display string only; a new `rdata_value` field adds
47
+ the typed decoding alongside it (`rdata_text` is unchanged) —
48
+ `ipaddress.IPv4Address`/`IPv6Address` for A/AAAA, the decompressed
49
+ target `str` for NS/CNAME/PTR, a new `MXRecord(preference, exchange)`
50
+ for MX, a new `SOARecord(mname, rname, serial, refresh, retry,
51
+ expire, minimum)` for SOA, `list[str]` of character-strings for TXT,
52
+ and `None` — never raises — for the types this library does not
53
+ decode. Computed eagerly at parse time, like `rdata_text` always has
54
+ been: a name inside RDATA (a CNAME's target, for instance) can use
55
+ DNS compression against the *whole* message, so decoding it needs
56
+ the same message-wide context `rdata_text` already required, which a
57
+ property computed lazily from `rdata` alone could not resolve.
58
+ Separately, `DNS.questions` adds a `tuple[DNSQuestion, ...]` walking
59
+ every entry of the question section — `question_name`/`question_type`
60
+ /`question_class` exposed only the first and are unchanged (#96).
61
+ - **A typed `DHCPOption`, alongside `DHCP.option_map`.** `option_map`
62
+ was the only accessor in the library returning a bare
63
+ `dict[int, bytes]` rather than typed objects; it is unchanged (still
64
+ returns exactly what it always did). A new `parsed_options` property
65
+ wraps the same, already RFC-3396-concatenated, mapping into a
66
+ `tuple[DHCPOption, ...]`, mirroring `TCPOption`/`IPv4Option`: `code`,
67
+ `data`, `code_name`, and a decoded `.value` for the option codes this
68
+ library understands — a single `ipaddress.IPv4Address` for Subnet
69
+ Mask (1)/Requested IP Address (50)/Server Identifier (54), a tuple of
70
+ one or more for Router (3)/Domain Name Server (6) (RFC 2132 permits
71
+ repeating either), the integer seconds for IP Address Lease Time
72
+ (51), the raw byte for Message Type (53). `None` — never raises —
73
+ for codes this library does not decode and malformed option data
74
+ (#96).
75
+ - **`IPv4Option.value` decodes Record Route, Timestamp and Router
76
+ Alert.** `IPv4Option` had `kind_name` and no `.value` — the three
77
+ common kinds (RFC 791 §3.1, RFC 2113) were named but their contents
78
+ left raw, unlike `TCPOption`, which already decodes its values.
79
+ Record Route (7) decodes to a `tuple[ipaddress.IPv4Address, ...]` of
80
+ the addresses recorded so far — the option's own pointer byte says
81
+ how many of the address slots are actually filled, not the option's
82
+ total length. Timestamp (68) decodes to `tuple[int, ...]` of plain
83
+ millisecond timestamps when its flag selects that shape, or
84
+ `tuple[tuple[ipaddress.IPv4Address, int], ...]` of (address,
85
+ timestamp) pairs when the flag says each entry carries one — the
86
+ overflow counter and pointer are not decoded, read `data` raw for
87
+ those. Router Alert (148) decodes to the 2-byte value as `int`.
88
+ `None` — never raises — for every other kind and for malformed data
89
+ on one of these three (a bad pointer, an unrecognized Timestamp
90
+ flag, a short buffer) (#96).
91
+ - **`IPv6Routing.segments` and `IPv6Option.value` for Router Alert /
92
+ Jumbo Payload.** `IPv6Routing.data` was entirely unparsed — no
93
+ segment-list extraction for any routing type. RH0 (`routing_type`
94
+ ``0``, deprecated by RFC 5095 but still seen — RFC 2460 §4.4) and
95
+ Mobile IPv6 (``2``, RFC 6275 §6.4) both store a 4-byte reserved field
96
+ followed by one IPv6 address per segment; `segments` decodes that
97
+ into a `tuple[ipaddress.IPv6Address, ...]`. RPL Source Routing (``3``,
98
+ RFC 6554) is deliberately **not** decoded: RFC 6554 §3 elides a
99
+ shared prefix from each intermediate address relative to the
100
+ enclosing packet's *destination* address, context this
101
+ one-extension-header accessor does not have — `data` stays available
102
+ raw. `None` for every other routing type and for malformed address
103
+ data. Separately, `IPv6Option.value` (Hop-by-Hop / Destination
104
+ Options TLVs) now decodes Router Alert (5, RFC 2711 §2.1) and Jumbo
105
+ Payload (194, RFC 2675 §2) into typed `int`s, the last of #96's four
106
+ pieces (`IPv6Option` "likewise decode[d] no values"); `None` for
107
+ every other type and malformed data, same contract throughout this
108
+ tier (#96).
109
+ - **CI proves the library runs under a real Pyodide (WebAssembly)
110
+ runtime**, not just that the modules `scapy`/`dpkt` need are
111
+ individually blocked. A new `pyodide` job (`.github/workflows/ci.yml`)
112
+ boots actual Pyodide under Node via `scripts/pyodide/run_in_pyodide.mjs`,
113
+ installs the wheel this job just built, and decodes the entire
114
+ real-capture fixture corpus with it — see
115
+ `scripts/pyodide/check_in_pyodide.py`. That surfaced a genuine bug
116
+ along the way: `IPv6.decode()`/`bytes(IPv6(...))` used
117
+ `socket.inet_ntop`/`inet_pton(AF_INET6, ...)`, and Pyodide's CPython
118
+ build has `AF_INET6` sockets disabled, so every IPv6 frame failed to
119
+ decode there. `_base.py`'s `ipv6_to_bytes`/`bytes_to_ipv6` are now a
120
+ pure-Python implementation (`ipaddress` for parsing; a hand-rolled
121
+ RFC 5952 canonicalizer, differentially verified against glibc's
122
+ `inet_ntop` across 500,000+ random addresses plus both of its
123
+ dotted-quad special cases, for formatting — `str(ipaddress.IPv6Address)`
124
+ was tried first and rejected: it disagrees with glibc on IPv4-mapped
125
+ addresses, and disagrees with *itself* between Python 3.11 and 3.12).
126
+ IPv4 addressing is unaffected; behavior for every existing platform
127
+ is unchanged, exception type included (#99).
128
+ - **Nightly fuzzing with a moving seed.** `tests/test_fuzz.py`'s
129
+ `"netprotocols"` Hypothesis profile is deterministic on purpose (200
130
+ examples, `derandomize=True`) so a pull request is reproducible — but
131
+ that also means every build since it was written has run the *same*
132
+ 200 inputs. A new `"nightly"` profile (10,000 examples, a real random
133
+ seed each run) runs the whole suite on a new schedule,
134
+ `.github/workflows/fuzz.yml` (03:00 UTC daily, plus manual dispatch),
135
+ kept out of `ci.yml` deliberately — `ci.yml` is reused by
136
+ `release.yml` via `workflow_call`, and a `schedule:` trigger there
137
+ would fire the whole PR/release gate on a cron, not just this
138
+ exploratory job. `.hypothesis/`'s example database accumulates
139
+ across nightly runs via `actions/cache` (keyed by run id with a
140
+ prefix restore-key, since a fixed key would restore forever but
141
+ never actually save a new entry) and uploads as a workflow artifact
142
+ on failure, so a counterexample is recoverable without repo write
143
+ access; a failed scheduled run's own red build is the notification
144
+ (GitHub already does this by default), so nothing here files an
145
+ issue on top of it. Reproduce locally:
146
+ `HYPOTHESIS_PROFILE=nightly uv run pytest`. Also adds a targeted
147
+ strategy building well-formed TCP SYN options (MSS, window scale,
148
+ SACK-Permitted, SACK, timestamps) — the real-capture corpus never
149
+ caught a SYN, so unlike NOP/Timestamps these otherwise depend on
150
+ `max_examples` alone stumbling into a well-formed TLV by chance
151
+ (#98).
152
+ - **A pcap/pcapng reader that takes bytes, not filenames.** New
153
+ `netprotocols.pcap`: `read_captures(buffer)` auto-detects classic
154
+ pcap vs. pcapng from its magic number and yields `CapturedFrame`
155
+ (`timestamp` in nanoseconds since the Unix epoch, normalized from
156
+ whatever resolution the source recorded, `data` the frame's raw
157
+ bytes); `read_pcap()`/`read_pcapng()` are the same for a caller who
158
+ already knows the format. pcapng support covers exactly the block
159
+ types frames can come from — Section Header, Interface Description
160
+ (read only for its `if_tsresol` option), Enhanced Packet, and Simple
161
+ Packet (which the format gives no timestamp at all, hence `0`);
162
+ every other block type is skipped wholesale. A malformed or
163
+ truncated capture raises the new `MalformedCaptureError`
164
+ (`ProtocolError` family, no `lax` mode — a corrupt *container* is a
165
+ different failure shape than a malformed header inside one already-
166
+ extracted frame). Format detection is eager; producing frames is
167
+ lazy (a generator), so a bad record downstream doesn't invalidate
168
+ what already iterated cleanly, and a huge capture is never forced
169
+ into a list of frames nobody asked for.
170
+
171
+ `tests/conftest.py` drops the private classic-pcap reader every test
172
+ file reached for — `pcap_frames()` is now a thin adapter over the
173
+ shipped `read_pcap()`, and `~10` test files were migrated onto it (a
174
+ real migration, not a rename: `tests/test_pcap.py` keeps its own
175
+ independent reference reader, deliberately never importing the
176
+ module it is cross-checking, the same "standalone, so a shared bug
177
+ can't cancel itself out" precedent as `scripts/benchmark.py` and
178
+ `scripts/check_fixtures.py`).
179
+
180
+ One design idea was tried and reverted on measurement: slicing each
181
+ frame lazily out of a `memoryview` over the whole buffer, to keep
182
+ large captures zero-copy. Measured across synthetic captures up to
183
+ ~140MB, it was 0.91x-0.98x — never faster, sometimes slower — because
184
+ a real capture is many *small* frames, and a `memoryview` slice's own
185
+ overhead is paid per frame; #88's identical finding for a single
186
+ frame generalizes rather than being contradicted. `docs/CLAIMS.md`
187
+ 5.8 is corrected accordingly — it previously forward-referenced this
188
+ issue with an unverified "1.8x" figure (#100).
189
+
190
+ ### Documentation
191
+ - **The comparative-claims embargo is lifted (#107, #124).** `docs/CLAIMS.md`
192
+ 1.7 gained a per-project table auditing ten comparable Python packet
193
+ libraries' CI configurations (dpkt, scapy, pypacker, construct,
194
+ pcapkit, dnspython, pyshark, nfstream, stackforge, PyTCP-net_proto)
195
+ for an actual performance-regression gate — cited file:line or
196
+ workflow, dated 2026-09-04; none of the ten gate on one, so the
197
+ comparative form of 1.7's claim is now stated with that evidence
198
+ (#124). Every section that previously carried a
199
+ `COMPARATIVE — HELD` banner is re-measured against the current tree
200
+ and published: decode throughput (1.1, 1.2), encoding throughput
201
+ (1.3), import time (1.4), wheel size (1.5), decode depth (1.6), the
202
+ `mypy --strict` comparison (2.1), `match`/`case` exclusivity (2.2),
203
+ the five-property combination (2.3), Pyodide/browser support (3.1),
204
+ and the permissive-licence wedge (4.1). One finding moved against
205
+ this project rather than for it and is reported as such: three tiers
206
+ of added surface since Tier 1 (#87's dispatch rewrite, #88's chain
207
+ walker, #91's structured errors chief among them) reversed decode
208
+ throughput from 1.16× faster than dpkt back to roughly 11% slower —
209
+ still within the 15% band claim 1.2 names, just no longer ahead.
210
+ Two new reproduction scripts back claims that previously had none:
211
+ `scripts/benchmark_encode.py` (1.3) and `scripts/benchmark_import.py`
212
+ (1.4). `README.md` gains a "Why NETProtocols" section drawing the
213
+ now-published claims into the project's front door, in the tone
214
+ `docs/CLAIMS.md` 4.2 sets for writing about competitors.
215
+
8
216
  ## [2.0.0] - 2026-09-04
9
217
 
10
218
  ### Development
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: netprotocols
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: Low-level implementations of common networking protocols
5
5
  Project-URL: Homepage, https://github.com/EONRaider/NETProtocols
6
6
  Project-URL: Repository, https://github.com/EONRaider/NETProtocols
@@ -72,6 +72,80 @@ pip install netprotocols
72
72
 
73
73
  Requires Python 3.12+. Fully typed (`py.typed`, mypy strict).
74
74
 
75
+ ## Why NETProtocols
76
+
77
+ Measured against `dpkt 1.9.8` and `scapy 2.7.0` on a 97-frame corpus of
78
+ real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured
79
+ 2026-09-04 — see [docs/CLAIMS.md](docs/CLAIMS.md) for every number
80
+ below, its reproduction command, and its caveats):
81
+
82
+ - **Within 15% of dpkt on decode, 5.6× faster than scapy** —
83
+ `uv run --group bench python scripts/benchmark.py --compare`. dpkt is
84
+ the faster of the two comparators on this corpus; the gap moves both
85
+ ways as this library and dpkt each change, and this file's job is to
86
+ say so honestly rather than only when it's flattering.
87
+ - **Decodes further into the stack than dpkt on 27 of 97 corpus
88
+ frames** — DNS and DHCP payloads that dpkt leaves as raw bytes,
89
+ netprotocols continues decoding. A throughput number means little
90
+ without knowing how much work it bought.
91
+ - **4.5× faster than dpkt at re-encoding** (`bytes(header)`,
92
+ `scripts/benchmark_encode.py`) — the other half of "codec" that
93
+ nobody else benchmarks.
94
+ - **Imports in 54 ms; `scapy.all` takes 458+ ms** (`scripts/benchmark_import.py`),
95
+ and never touches the host doing it — importing scapy populates live
96
+ interface and routing tables as a side effect of the import
97
+ statement.
98
+ - **An 85.6 KB wheel against scapy's 2.47 MB** — about 30× smaller.
99
+ - **Regression-gated in CI, which — as far as we could establish by
100
+ auditing ten comparable Python packet libraries' CI configurations
101
+ (dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark,
102
+ nfstream, stackforge, PyTCP-net_proto; full citations in
103
+ [docs/CLAIMS.md §1.7](docs/CLAIMS.md)) — none of them do.** Two of
104
+ the ten ship real benchmark code that simply never runs in CI
105
+ (construct disables it explicitly; stackforge's Criterion benches are
106
+ never invoked); the rest have no performance benchmark at all.
107
+ - **Typed where the alternatives are not.** `mypy --strict` over the
108
+ whole of `src/`, enforced in CI. scapy ships `py.typed` but enables
109
+ strict checking on 107 of its files, 2 of the 121 under
110
+ `scapy/layers/` — the dissectors most code touches stay `Any`. dpkt
111
+ ships no `py.typed` at all, and no third-party stub package exists
112
+ for it.
113
+ - **The only MIT-licensed, strictly-typed, zero-dependency packet codec
114
+ still under active maintenance.** scapy is GPL-2.0; pypacker is
115
+ GPLv2; the newer entrants `stackforge` and `PyTCP-net_proto` are both
116
+ GPL-3.0. The other permissive option, dpkt (BSD), last released
117
+ 2022-08-18 and last committed 2024-05-05, still targets Python 2.7
118
+ and 3.5–3.9, and remains marked Beta after twelve years.
119
+ - **Runs where scapy cannot — including in the browser.** Verified
120
+ under a real Pyodide runtime in CI: scapy fails to import at all
121
+ under Pyodide (`from fcntl import ioctl` is unconditional in
122
+ `scapy/arch/`), where netprotocols, dpkt and pypacker all import
123
+ cleanly.
124
+ - **The only one of these libraries a `match`/`case` statement
125
+ dissects out of the box.** dpkt builds `__slots__` from a metaclass
126
+ and generates no `__match_args__`; scapy routes fields through
127
+ `__getattr__`; construct returns dicts. None gives you a pattern to
128
+ match against.
129
+
130
+ None of this makes scapy less than an extraordinary piece of software —
131
+ it crafts, sends, sniffs and fuzzes across thousands of protocols, and
132
+ this library does none of that. The comparison above is scoped to what
133
+ both are: a codec that turns bytes into typed objects and back.
134
+
135
+ One `mypy --strict` run says more than the bullets above:
136
+
137
+ ```
138
+ # scapy 2.7.0 — a field name that does not exist
139
+ p.ThisFieldDoesNotExist → Any (no error)
140
+
141
+ # dpkt 1.9.8 — the whole module
142
+ import dpkt.ethernet → error: missing library stubs or py.typed
143
+
144
+ # netprotocols — a typo in a real field name
145
+ ip.proto → error: "IPv4" has no attribute
146
+ "proto"; maybe "protocol"?
147
+ ```
148
+
75
149
  ## Protocol coverage
76
150
 
77
151
  | Layer | Protocol | Class | Notes |
@@ -185,6 +259,29 @@ to read `stopped_by`. Outside this one RFC-mandated case, a *complete*
185
259
  frame that fails to decode is still a bug to raise on — `lax=True`
186
260
  elsewhere is a capture tool's choice, not a default.
187
261
 
262
+ ## Reading captures
263
+
264
+ `read_captures()` takes the bytes of a capture file — not a path — and
265
+ auto-detects classic pcap vs. pcapng from its magic number:
266
+
267
+ ```python
268
+ from netprotocols import decode_frame, read_captures
269
+
270
+ data = open("traffic.pcap", "rb").read() # or however you got the bytes
271
+ for timestamp, frame in read_captures(data):
272
+ packet = decode_frame(frame, lax=True)
273
+ ...
274
+ ```
275
+
276
+ Each `CapturedFrame` is `(timestamp, data)` — `timestamp` normalized to
277
+ nanoseconds since the Unix epoch regardless of the source format's own
278
+ resolution (classic pcap's microseconds or nanoseconds; pcapng's
279
+ per-interface `if_tsresol`). `read_pcap()`/`read_pcapng()` are the same
280
+ thing for a caller who already knows the format and wants to skip
281
+ detection. A malformed or truncated capture raises
282
+ `MalformedCaptureError`, the same `ProtocolError` family every other
283
+ exception in this library belongs to.
284
+
188
285
  ## Flow keys
189
286
 
190
287
  `Packet.flow_key()` (or the free function, `netprotocols.flow_key()`,
@@ -212,6 +309,20 @@ named value against the plain `int` the wire actually carries. See
212
309
  [ARCHITECTURE.md](ARCHITECTURE.md#structural-pattern-matching) for why
213
310
  both of those hold and how to keep them holding.
214
311
 
312
+ `IPv4`, `TCP` and `DHCP` are wide enough (11-15 fields) that their
313
+ full auto-generated positional form is unusable, so those three
314
+ additionally curate a short `__match_args__` by hand — the fields
315
+ someone matching by position actually reaches for:
316
+
317
+ ```python
318
+ match ip:
319
+ case IPv4(src, dst, IPProtocol.TCP):
320
+ print(f"TCP: {src} -> {dst}")
321
+ ```
322
+
323
+ Keyword patterns (`case IPv4(protocol=IPProtocol.TCP)`) work on every
324
+ field regardless, curated or not, and stay the documented default.
325
+
215
326
  ```python
216
327
  from netprotocols import IPv4, IPProtocol, TCP
217
328
 
@@ -294,24 +405,29 @@ monitor built on it (formerly Packet-Sniffer).
294
405
 
295
406
  ## Roadmap
296
407
 
297
- Planned work lives in the issue tracker rather than being restated
298
- here, so this section cannot drift out of date the way a copied list
299
- does. The current plan is
300
- [#107](https://github.com/EONRaider/NETProtocols/issues/107) — five
301
- tiers, each mapped to a release:
408
+ The post-1.3.0 roadmap,
409
+ [#107](https://github.com/EONRaider/NETProtocols/issues/107), is
410
+ complete — five tiers plus a competitor CI audit
411
+ ([#124](https://github.com/EONRaider/NETProtocols/issues/124)) and the
412
+ comparative-claims embargo lift that closed it out:
302
413
 
303
414
  | Version | Theme |
304
415
  |---|---|
305
416
  | [1.3.1](https://github.com/EONRaider/NETProtocols/issues/102) | Hygiene |
306
417
  | [1.4.0](https://github.com/EONRaider/NETProtocols/issues/103) | Decode performance |
307
- | [2.0.0](https://github.com/EONRaider/NETProtocols/issues/104) | A public protocol registry, a shipped chain walker, flow keys |
418
+ | [2.0.0](https://github.com/EONRaider/NETProtocols/issues/104) | A public protocol registry, a shipped chain walker, flow keys — **released** |
308
419
  | [2.1.0](https://github.com/EONRaider/NETProtocols/issues/105) | Typed accessors and pattern-matching ergonomics |
309
420
  | [2.2.0](https://github.com/EONRaider/NETProtocols/issues/106) | Universal round-trip properties, nightly fuzzing, a pcap reader |
310
421
 
311
- The previous wave — TCP and IPv4 option parsing, ICMP message bodies,
312
- NDP, IPv6 extension-header TLV options, the GRE checksum arm, a
313
- DNS-over-TCP corpus fixture and `ipaddress` accessors — shipped in
314
- 1.3.0; see [CHANGELOG.md](CHANGELOG.md).
422
+ Everything through 2.2.0 has landed on `master`; per the roadmap's own
423
+ release policy, only 2.0.0 was an actual PyPI release, and the
424
+ comparative claims above draw on the finished tree. New planned work
425
+ will open fresh issues rather than restating a list here, so this
426
+ section stays accurate without upkeep. The wave before this one — TCP
427
+ and IPv4 option parsing, ICMP message bodies, NDP, IPv6
428
+ extension-header TLV options, the GRE checksum arm, a DNS-over-TCP
429
+ corpus fixture and `ipaddress` accessors — shipped in 1.3.0; see
430
+ [CHANGELOG.md](CHANGELOG.md).
315
431
 
316
432
  ## Contributing
317
433