netprotocols 2.0.0
Development
-
The round-trip property (
bytes(decode(x)) == x) is now
Hypothesis-generated for all 18 protocols, not 4. It previously
held only forEthernet,UDP,TCPandIPv6Fragment; the other
14 rested on a single example each.tests/strategies.pyadds one
reusable strategy per protocol — reusable because a strategy that
generates valid instances is useful for more than this one
property — andtests/test_fuzz.py::TestGeneralizedRoundTrips
asserts the property in one method, parametrized over all 14.The interdependent-field protocols the issue specifically flagged
are generated as consistent combinations, never independently: IPv4
drawsihlfirst and sizesoptionsto match; the three IPv6
extension headers drawhdr_ext_lenfirst and size their TLV bytes
to match; GRE drawsflagsfirst and computesfields' length from
the same_optional_len()the decoder itself uses, so the two can
never silently disagree.The 14 single-example assertions already living in each protocol's
own test file (test_arp.py::test_round_tripand so on) are kept as
regression anchors — a fixed example pinned to a real captured header
catches a specific regression fast and readably, which a generated
property does not replace.One adjacent gap closed while surveying this:
DHCPandGREwere
missing fromtest_fuzz.py::ALL_PROTOCOLSentirely, so neither had
ever been fuzzed with untrusted bytes for basic decode safety (any input either decodes or raises ProtocolError, never anything else).
Both are now included.
Added
-
match/casedissection is documented. It already worked on
every decoded header, with zero code changes needed — a frozen
dataclass auto-generates__match_args__, and every field with a
fixed wire vocabulary is anIntEnum, so a value pattern binds the
plainintthe decoder stored. None of that appeared anywhere:match packet.layers: case [_, IPv4(protocol=IPProtocol.TCP) as ip, TCP(flags_str=f), *_] \ if "SYN" in f and "ACK" not in f: print(f"connection attempt from {ip.src}")
README gains a first-screen example and a dedicated section;
ARCHITECTURE.md explains the two mechanisms and the two ways a
contributor can quietly break them (reordering a class's fields
changes what a positional pattern binds to; degrading a fixed-
vocabulary field fromIntEnumto a bareintmakes a value
pattern stop matching without raising).tests/test_pattern_matching.py
pins both mechanisms directly and runs the README's own example over
the corpus, so a regression in either is caught rather than
incidental. -
Structured diagnostics on every
ProtocolError. Every exception
used to carry only a formatted string — the full attribute set on a
raisedTruncatedHeaderErrorwasargs,add_note,
with_traceback. A fuzzing harness, conformance suite, or
protocol-validation tool that wanted to know where a parse failed
had to regex the message. Every raise site insrc/now attaches:try: packet = decode_frame(frame) except ProtocolError as e: print(e.protocol, e.field, e.offset, e.frame_offset, e.expected, e.actual) # <class 'netprotocols.layer3.ip.IPv4'> ihl 0 14 >=5 0
protocol— the class that raised. Set on every single raise site.field— the attribute at fault, where one field is at fault.offset— byte offset of the problem, relative to thedecode()
buffer for a fixed-header error, or to thefieldattribute
(options,body,sections) for an on-demand parse — a TCP
option error's offset is relative toheader.options, not the
frame.Nonefor a__post_init__validation error, which sees
field values, never the bytes they came from.frame_offset—offsetrebased to the whole captured frame.
decode_frameis the only code holding the cursor needed to do
this, so it's the only thing that sets it; a bare
SomeClass.decode()call leaves itNone.expected/actual— added wherever there's a concrete pair to
state beyond the message text.
All five default to
None, and no message string changed — every
existingstr(err)andmatch=assertion holds. New public name:
MaxDepthExceededErrorgains the same attributes as every other
ProtocolErrorsubclass (it already existed as of thedecode_frame
work; this is the first release to give it structured context too). -
decode_frame(): the chain walker ships with the library. The
README taught a hand-rolled eight-line loop, ARCHITECTURE.md showed
it again, and the test suite kept its own copy — so the library's
most-used function was the one function it did not provide. It does
now, with the parts a copy-pasted loop never has:from netprotocols import decode_frame packet = decode_frame(frame) # Packet(Ethernet(...), IPv4(...), TCP(...)) frame[packet.consumed:] # whatever the chain did not decode
- An explicit starting layer.
decode_frame(buf, start=IPv4)
walks a buffer that begins mid-stack — a tunnel payload, a packet
quoted inside an ICMP error, a non-Ethernet link type. There was
previously no way to ask for this. - Bounded depth.
max_depth(default 32) caps the chain and
raises the newMaxDepthExceededError, rooted atProtocolError
like everything else. Every header already validated its own
length, so a chain always terminated; the bound turns a crafted
frame's very long walk into an immediate named error. The deepest
chain in the 97-frame corpus is 5. - A lax mode that reports instead of raising.
lax=Trueends the
walk on aProtocolErrorand returns the layers decoded so far,
with the reason onpacket.stopped_by— what a capture tool needs
when frame 4,000,001 is malformed. It relaxes the walk, never a
decoder: every layer returned was decoded under the ordinary strict
rules. - Per-call decoder overrides.
decode_as={"udp.port": {6969: DNS}}
reads DNS on a nonstandard port for one call, without touching
global state;registry=takes a prepared registry for bulk work.
Packetgainsstopped_by(why a walk ended early,Nonefor a
packet you built) andconsumed(the bytes its headers occupy).
Both default to the constructed-packet values, soPacket(eth, ip)
is unchanged.On
memoryview: the walker slices what it is given and does not
convert. Measured on the corpus, wrapping each frame in a
memoryviewruns at 0.95x the plain-byteswalk — for one small
frame the view costs more to build than the copy it saves — while a
memoryviewover a large capture buffer keeps slices zero-copy.
Converting internally would have been worse than either. - An explicit starting layer.
-
A public protocol registry: third parties can now extend the decode
walk without editing library source. Dispatch was four hardcoded
functions with dict literals inside them and no hook of any kind — a
protocol this library does not implement (MPLS, VXLAN, a proprietary
telemetry header) could only be added by forking. It is now five named
tables owned bynetprotocols.registry, and registering against one
is all it takes:from netprotocols.registry import register @register("ethertype", 0x8847) class MPLS(Protocol): ... register("ip.proto", 132, SCTP) # for a class you did not write
The tables are
ethertype,ip.proto,ip.proto.v6,udp.portand
tcp.port, each named after the wire field it dispatches on.
ip.proto.v6inheritsip.proto, which is how the IPv6-only
gating generalises: the four extension headers are registered in the
v6 table alone, so an IPv4 packet withprotocol=0still cannot
conjure a Hop-by-Hop layer — and because inheritance is resolved at
registration rather than at lookup, the gating costs nothing on the
hot path. Dispatch behaviour is unchanged entry for entry.Registrations land in the process-wide
DEFAULTregistry;
Registry.from_defaults()gives an isolated one for embedding or test
isolation, andRegistry.derive()makes a copy-on-write child.
Registering over an existing key raisesRegistryConflictErrorunless
override=Trueis passed, so two packages claiming the same port
cannot silently resolve by import order; re-registering the same
class to the same key is a no-op, since a decorator re-runs whenever
its module does.New public names:
Registry,register,register_all,DEFAULT,
RegistryConflictError,UnknownTableError.
Changed
next_protocol()accepts an optional registry. Threading a
per-call registry through the walk needs the dispatch to be
redirectable, andnext_protocol()read the process-wide tables
directly. It now takesregistry=None, passed to the same dispatch
helpers as before, so no table knowledge is duplicated and the
default path stays a singledict.get. Measured in one process, the
optional parameter costs +2.1 ns per dispatch — roughly 0.1% of a
frame decode. Every existing zero-argument call is unaffected.- Port-based dispatch is a table lookup rather than a rebuild.
udp_app_classandtcp_app_classre-ran their deferred imports and
rebuilt adictliteral on every call — they were outside the scope
of the earlier dispatch-hoisting work, which named only the EtherType
and IP-protocol functions. Both now read the registry's flat tables:
udp.port303.1 → 46.0 ns (6.6x),tcp.port186.8 → 45.1 ns (4.1x),
measured old and new shapes in one process.ethertypeloses its
lazy-build guard (51.8 → 47.7 ns) andip.protois unchanged, having
already been a table. Corpus throughput moves about 1% — only 28 of
the 97 corpus frames reach a transport header and therefore do a port
dispatch at all — which is below this machine's run-to-run noise; the
per-call figures above are the measurement that resolves. - The layer modules no longer import each other's classes. The
built-in decoder map moved into_defaults.py, which registers it
once from__init__.pyafter every protocol class exists, so the
function-body imports that kept the module graph acyclic are gone
fromvlan.py,gre.py,tcp.py,udp.pyandipv6_ext.py; those
dispatch helpers are now ordinary module-level imports. The tables
are built at package import instead of on first dispatch, so the
lazy-build branch is off the hot path entirely. - DNS section parsing happens once, and no longer re-serializes the
message to read it. Six helpers calledbytes(self)— a full
re-serialization of the whole message — so a single.answersaccess
rebuilt the message 14 times; andanswers,authoritiesand
additionalseach re-parsed all three sections, so reading all
three parsed the message three times. The parsers are now module-level
functions working directly from thesectionsbytes the instance
already holds, in section-relative offsets (a compression pointer,
which counts from the start of the message, is translated once), and
the three accessors share a single parse cached on those immutable
bytes. Measured here on a corpus response:.answers16.84 → 0.20 us,
all three sections 52.08 → 0.54 us, and one uncached parse 17.03 →
12.17 us from dropping the re-serializations alone. Nothing is stored
on the frozen instance — the cache is a bounded module-level memo
keyed by value, so equal messages share a parse and records (frozen
dataclasses) are shared rather than copied. The byte-exact round-trip
is unchanged. A compression pointer that addresses the fixed header
now raisesInvalidFieldErrorinstead of decoding header bytes as
labels. No API change. - The decoder no longer re-validates the addresses it just
generated. Every header's__post_init__ran the address
validators, including for instances built bydecode()— so a MAC
mechanically rendered from six bytes bybytes_to_mac()was
immediately matched againstmac_regexto confirm what the
conversion had already guaranteed.Ethernet,ARPandIPv4now
build their decoded instance directly (object.__new__plus
object.__setattr__per field), skipping__init__and
__post_init__on that path only. Measured here:Ethernet.decode
2330 → 830 ns (2.8x),IPv4.decode4936 → 2596 ns (1.9x), a corpus
walk 76,500 → 113,200 frames/sec (1.48x). Strictness on
construction is unchanged — every public constructor still
validates and still raises, which is now asserted alongside a test
that the decode path runs no regex at all. The__post_init__checks
bypassed this way are onesdecode()establishes itself (documented
at each site and in_base.py). No API change. - Protocol dispatch is a table lookup, not a table construction.
_ethertype_class()and_ip_protocol_class()re-ran their deferred
imports and rebuilt adictliteral on every call — once per layer
per frame, the hottest path in the library. Both now populate a
module-level table on first use (the imports stay deferred, so the
layer modules remain acyclic) and reduce the call to a lookup.
Measured here:_ip_protocol_class1439 → 104 ns (13.9x),
_ethertype_class712 → 86 ns (8.3x), and a corpus walk 58,300 →
76,500 frames/sec (1.31x). The IPv6-only gating is unchanged: the
IPv4 table simply omits those numbers, so an IPv4 packet with
protocol=0still cannot decode a Hop-by-Hop layer, now asserted at
the table level as well as through the public API. No API change. bytes_to_macrenders addresses withbytes.hex(":")instead of a
generator overformat(). The old form ran seven generator steps and
sixformat()calls per address, twice per Ethernet frame, and showed
up in a corpus profile as 88,200 generator calls. Measured 16–29×
faster on the call depending on run. Output is byte-for-byte
identical, andmemoryview.hextakes a separator too, so a
decode-time view still works. No API change.
Removed
- BREAKING:
Packet.payloadis gone. It was always exactly
bytes(self)— a redundant duplicate of__bytes__/bytes(packet),
which already existed and is the unambiguous spelling — so it is
deleted outright rather than deprecated. This is the breaking change
behind the 2.0.0 major bump. Replacepacket.payloadwith
bytes(packet).
Added
-
Packetindexes by protocol type, not just position, and is
hashable.packet[TCP]returns the firstTCPlayer in wire order
(KeyErrorif there is none);packet.get(TCP)returnsNone
instead of raising.packet[0]keeps indexing positionally —
__getitem__branches on whether the key is anintor a type.
Packet.__hash__mirrors exactly what__eq__already compares
(layers, andstopped_by's type andstr(), not its identity —
ProtocolErrorhas no custom__eq__), so aPacketis now usable
as a dict key or set member. -
Canonical, direction-independent flow keys.
netprotocols.flow
is a new small module (FlowKey,flow_key()) that folds a TCP/UDP
segment and its enclosing IPv4/IPv6 header into a key that is
identical for both directions of one conversation:from netprotocols import flow_key flow_key(tcp, ip=ipv4) == flow_key(reply_tcp, ip=reply_ipv4) # True
FlowKeyis aNamedTuple— canonicalizing the two directions is
comparing the two(address, port)endpoint tuples and always
emitting the lexicographically smaller one first, which a plain
tuple already orders natively; every other frozen dataclass in this
codebase models a wire format (decode()/__bytes__/_struct), and
a derived key isn't one.Packet.flow_key()is the convenience form:
it walksself.layersfor the first IPv4/IPv6 and TCP/UDP layers and
delegates to the free function,Noneif either is missing. Reads
whichever ofIPv4.protocol/IPv6.next_headerthe enclosing
header actually has — the same semantic field, different attribute
name. A transport layer with no ports (ICMP) returnsNonefrom
both forms, rather than raising or inventing a port-slot convention
for a message type that has none. -
ICMPv4/ICMPv6gainembedded_chain: an error message's
embedded packet, already decoded.decode_frame(lax=True, start=...)already handled this — an RFC-792 error message quotes
only the invoking IP header plus 8 bytes of what follows, never a
full TCP/UDP header, so decoding it needs the lax path and no
try/except, verified with zero new code before this landed.
embedded_chainis the pre-wired convenience next to the existing
rawembedded_packet, same shape as an accessor-plus-typed-view pair
elsewhere in this codebase:icmp.embedded_chain # decode_frame(icmp.embedded_packet, lax=True, start=IPv4) # → Packet([IPv4(...)]), stopped_by=TruncatedHeaderError(...)
Nonefor the same casesembedded_packetdegrades to (non-error
message types, an empty body); starts atIPv4for anICMPv4
message,IPv6forICMPv6. The README documents the distinction
the issue asked for:embedded_chain'slax=Trueis the right
default here because the truncation is RFC-mandated, expected input
— not a license to reach forlax=Trueon a complete frame that
fails to decode, which is still a bug to raise on.
Fixed
- The release workflow can no longer publish untested code. Pushing
av*tag ranuv buildanduv publishwith no dependency on the
lint, typecheck or test jobs — and because the CI workflow triggers
only on pushes and pull requests targetingmaster, a tag push ran
no checks at all. A tag against broken code published to PyPI
unchallenged, and a published version can only be yanked, never
replaced.ci.ymlis now also a reusable workflow (workflow_call),
andrelease.ymlinvokes it against the tagged commit with the
publish job gated behind it, so the release gate and the pull-request
gate are one definition and cannot drift apart.
Fixed
- A mistagged release can no longer publish the wrong version.
release.ymlbuilt frompyproject.tomland never consulted the tag,
so pushingv1.4.0while the project still read1.3.0would either
fail at upload against an existing version or, if that version was
never released, silently publish the wrong one. The QA ladder cannot
catch this — every check passes, because the code is fine and only the
tag is wrong. The release now reads the version back out of the built
artifact and refuses to publish unless it matches the tag. - The README no longer advertises shipped work as upcoming. Its
Roadmap section listed the eight decoder-depth items tracked by #67 —
all of which shipped in 1.3.0, with #67 itself closed — so the front
page presented finished work as planned and pointed readers at a
closed issue. It now links the current roadmap (#107) and its five
release-mapped epics instead of restating individual items, which is
what went stale. - ARCHITECTURE.md no longer misstates the corpus or the source
layout. It described "93 frames across 16 scenarios" long after the
corpus reached 97 across 17, and its layout map omittedvlan.py,
gre.pyanddhcp.py. The counts now live in
tests/fixtures/MANIFEST.mdand the README alone — ARCHITECTURE.md
points at the MANIFEST rather than keeping a third copy, which is
what drifted. - Layer sub-packages now export everything their layer defines.
from netprotocols.layer7 import DHCPfailed while
from netprotocols import DHCPworked, because each layer's own
__init__carried a subset of what the top-level package re-exports.
layer3was missingGRE,IPv4Option,IPv6Option,NDPOption
andIGMPv3GroupRecord;layer4was missingTCPOption;layer7
exported onlyDNS, omittingDHCP,DNSOverTCPand
DNSResourceRecord. Purely additive — no name changed meaning.
Development
- Comparative claims are under embargo until the roadmap closes.
docs/CLAIMS.mdgains a standing rule and a per-claim
COMPARATIVE — HELDmarker: nothing mentioning scapy, dpkt or any
other library reaches the README, release notes or package metadata
until #107 is finished. The measurements keep being recorded — the
register exists to hold evidence, not to publish it — and ship once,
together, rather than in pieces that each need defending. The
rationale is in the numbers: claim 1.2 went from "2.9x slower than
dpkt" to "1.16x faster" inside a single tier. - Three differentiators found during Tier 1 are now tracked as claims
rather than living in pull-request descriptions: decode depth (1.6 —
the corpus walk reaches a deeper layer than dpkt on 27 of 97 frames,
reproducible withscripts/benchmark.py --depth, which this change
adds), the CI throughput gate (1.7), and strict construction on a
decode path that pays nothing for it (5.4). The stale "we are ~86% of
dpkt" line in "Claims we must not make" is corrected, and a rule
added that a superiority claim must name its axis — scapy crafts,
sends and covers thousands of protocols, so the codec axes are where
the evidence is. - A reproducible decode benchmark, and a CI job that fails on a
regression.scripts/benchmark.pywalks the 97-frame corpus and
reports frames/sec;--comparetimesdpktandscapyon the same
frames (both dev-only extras in a newbenchdependency group — the
package still has zero runtime dependencies). Because absolute
throughput is a property of the machine, every run also times a fixed
calibration workload and reports a normalized figure, which is what
--checkcompares againstbenchmarks/baseline.json. The CI job
blocks at 15% below the baseline: measured, that is about four
times the noise it must tolerate — the job's first run landed 3.7%
from a baseline recorded on entirely different hardware, and
run-to-run spread on one machine is ~2%, while the Tier 1 changes
moved the number by 30-90% each. A failing check re-measures once
before failing, so a runner that loses CPU to a neighbour does not
block an unrelated pull request. Comparison output carries its own
caveats — notably that the libraries are not asked for identical
work, since dpkt leaves the DNS-over-TCP payload as raw bytes where
this library decodes it — because a benchmark nobody can check is the
problem #86 set out to fix, not the goal. tests/test_version.pyholdsnetprotocols.__version__against the
version inpyproject.toml. The two were kept in step by hand and
nothing checked them, so a bump that missed one would ship a package
whose metadata and__version__disagree.tests/test_docs.pyholds documented facts against the fixtures:
the MANIFEST's and README's frame/scenario counts must match the real
corpus, ARCHITECTURE.md must not reintroduce a third copy of them,
and the layout map must mention every shipped module. It also
guards the README's Roadmap section against pointing back at the
superseded #67 or re-listing work that has already shipped.tests/test_exports.pykeeps the layer and top-level export sets in
agreement, deriving the expectation from each object's__module__
rather than a hand-written list, so a protocol added to the top level
but forgotten in its layer fails the suite.- Coverage is now enforced, not merely reported:
fail_under = 98in
[tool.coverage.report]. The suite covers 99% of 1441 statements —
the only misses arePacket.__repr__and its__eq__
NotImplementedbranch — so the gate is set below the current figure
to leave room for a legitimately unreachable branch, and well above
the level at which the bar would stop meaning anything. tests/test_workflows.pyasserts the release gate stays wired:
thatci.ymlremains callable, that some release job invokes it,
thatpublishdepends on that job, and that every localuses: ./…
reference resolves to a workflow declaringworkflow_call. The
wiring is otherwise easy to remove silently — releases keep working
while the gate quietly stops existing — and it cannot be verified by
running it, since publishing is irreversible.docs/CLAIMS.mdrecords the positioning claims produced by the
post-1.3.0 competitive analysis, each with its evidence, a
reproduction path and a publication status —VERIFIED,
GATED ON #nn, orNEEDS RE-MEASUREMENT. Several of the strongest
claims are not true yet (decode throughput against dpkt is gated on
the Tier 1 performance work; the browser claim is untested until a
Pyodide CI job exists), so the register also lists what must not be
said and why. Feeds the README as each roadmap tier lands; see #101
and the roadmap in #107.