Repository files navigation

NETProtocols

CIPython VersionLicense: MITTyped

Low-level implementations of common networking protocols, in pure Python with zero dependencies.

Decode raw header bytes into typed, immutable protocol objects — or build those objects from field values and serialize them back to their exact on-wire form. Every header is a frozen dataclass whose fields mirror the wire format, so decoded traffic is introspectable, comparable, and round-trippable.

>>> from netprotocols import Ethernet
>>> eth = Ethernet.decode(frame)
>>> eth
Ethernet(dst='ff:ff:ff:ff:ff:ff', src='00:07:0d:af:f4:54', ethertype=2054)
>>> eth.ethertype_name
'ARP'
>>> eth.next_protocol()
<class 'netprotocols.layer2.arp.ARP'>
>>> bytes(eth) == frame[:eth.header_len]
True

Every header is also a structural pattern — no extra code, because a frozen dataclass auto-generates __match_args__ and every enum field is an IntEnum, so a bare wire value matches a named one:

matchip:
caseIPv4(protocol=IPProtocol.TCP, ttl=t) ift>32:
...
caseIPv4(protocol=IPProtocol.UDP):
...

Installation

pip install netprotocols

Requires Python 3.12+. Fully typed (py.typed, mypy strict).

Why NETProtocols

Measured against dpkt 1.9.8 and scapy 2.7.0 on a 97-frame corpus of real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured 2026-09-04 — see docs/CLAIMS.md for every number below, its reproduction command, and its caveats):

  • Within 15% of dpkt on decode, 5.6× faster than scapyuv run --group bench python scripts/benchmark.py --compare. dpkt is the faster of the two comparators on this corpus; the gap moves both ways as this library and dpkt each change, and this file's job is to say so honestly rather than only when it's flattering.
  • Decodes further into the stack than dpkt on 27 of 97 corpus frames — DNS and DHCP payloads that dpkt leaves as raw bytes, netprotocols continues decoding. A throughput number means little without knowing how much work it bought.
  • 4.5× faster than dpkt at re-encoding (bytes(header), scripts/benchmark_encode.py) — the other half of "codec" that nobody else benchmarks.
  • Imports in 54 ms; scapy.all takes 458+ ms (scripts/benchmark_import.py), and never touches the host doing it — importing scapy populates live interface and routing tables as a side effect of the import statement.
  • An 85.6 KB wheel against scapy's 2.47 MB — about 30× smaller.
  • Regression-gated in CI, which — as far as we could establish by auditing ten comparable Python packet libraries' CI configurations (dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark, nfstream, stackforge, PyTCP-net_proto; full citations in docs/CLAIMS.md §1.7) — none of them do. Two of the ten ship real benchmark code that simply never runs in CI (construct disables it explicitly; stackforge's Criterion benches are never invoked); the rest have no performance benchmark at all.
  • Typed where the alternatives are not.mypy --strict over the whole of src/, enforced in CI. scapy ships py.typed but enables strict checking on 107 of its files, 2 of the 121 under scapy/layers/ — the dissectors most code touches stay Any. dpkt ships no py.typed at all, and no third-party stub package exists for it.
  • The only MIT-licensed, strictly-typed, zero-dependency packet codec still under active maintenance. scapy is GPL-2.0; pypacker is GPLv2; the newer entrants stackforge and PyTCP-net_proto are both GPL-3.0. The other permissive option, dpkt (BSD), last released 2022-08-18 and last committed 2024-05-05, still targets Python 2.7 and 3.5–3.9, and remains marked Beta after twelve years.
  • Runs where scapy cannot — including in the browser. Verified under a real Pyodide runtime in CI: scapy fails to import at all under Pyodide (from fcntl import ioctl is unconditional in scapy/arch/), where netprotocols, dpkt and pypacker all import cleanly.
  • The only one of these libraries a match/case statement dissects out of the box. dpkt builds __slots__ from a metaclass and generates no __match_args__; scapy routes fields through __getattr__; construct returns dicts. None gives you a pattern to match against.

None of this makes scapy less than an extraordinary piece of software — it crafts, sends, sniffs and fuzzes across thousands of protocols, and this library does none of that. The comparison above is scoped to what both are: a codec that turns bytes into typed objects and back.

One mypy --strict run says more than the bullets above:

# scapy 2.7.0 — a field name that does not exist
p.ThisFieldDoesNotExist → Any (no error)
# dpkt 1.9.8 — the whole module
import dpkt.ethernet → error: missing library stubs or py.typed
# netprotocols — a typo in a real field name
ip.proto → error: "IPv4" has no attribute
"proto"; maybe "protocol"?

Protocol coverage

LayerProtocolClassNotes
2Ethernet IIEthernetIEEE 802.3
2IEEE 802.1Q VLAN tag (802.1ad QinQ)VLAN802.1Q-2018 §9.6, PCP/DEI/VID, tag stacking
2ARPARPRFC 826, IPv4-over-Ethernet binding
3IPv4IPv4RFC 791, IHL/options aware
3IPv6IPv6RFC 8200
3IPv6 Hop-by-Hop OptionsIPv6HopByHopOptionsRFC 8200 §4.3
3IPv6 RoutingIPv6RoutingRFC 8200 §4.4
3IPv6 FragmentIPv6FragmentRFC 8200 §4.5, first-fragment chaining
3IPv6 Destination OptionsIPv6DestinationOptionsRFC 8200 §4.6
3ICMPv4ICMPv4RFC 792, 8-byte header
3ICMPv6ICMPv6RFC 4443, 8-byte header
3IGMPIGMPRFC 1112/2236/3376, IPv4 multicast management; IGMPv3 report group records + query fields
3GREGRERFC 2784/2890, IP protocol 47; payload chains onward by EtherType
4TCPTCPRFC 9293, data-offset/options aware
4UDPUDPRFC 768
7DNSDNSRFC 1035, over UDP and TCP (DNSOverTCP length shim); on-demand name decompression + resource-record parsing
7DHCPDHCPRFC 2131/2132, over UDP 67/68; TLV options parsed on demand

Decoding a captured frame

decode_frame() walks the whole chain and hands back a Packet:

fromnetprotocolsimportTCP, decode_framepacket=decode_frame(frame)
print(packet) # Packet(Ethernet(...), IPv4(...), TCP(...))print(packet[1].src) # '192.168.1.96' — position, unchangedprint(packet[TCP].flags_str) # first TCP layer, or KeyError if noneprint(packet.get(TCP)) # same lookup, None instead of raisingprint(packet.consumed) # bytes the headers occupied

It works because every header answers two questions: header_len (how many bytes it consumed) and next_protocol() (which class decodes what follows). Trailing bytes are fine — frame[packet.consumed:] is whatever the chain did not decode.

Start somewhere other than Ethernet when the buffer does — a tunnel payload, a packet quoted inside an ICMP error, a non-Ethernet link type:

decode_frame(buf, start=IPv4)

Malformed input raises exceptions rooted at a single base class:

fromnetprotocolsimportProtocolError, TruncatedHeaderError, InvalidFieldErrortry:
packet=decode_frame(frame)
exceptTruncatedHeaderError: # buffer shorter than the header claims
...
exceptInvalidFieldError: # nonsense field values (IHL < 5, bad address)
...
exceptProtocolError: # catches every library error
...

Every exception carries structured context alongside its message — err.protocol names the class that raised, err.field names the attribute at fault where one field is at fault, and err.offset / err.frame_offset locate the problem in bytes (in the header's own buffer, and rebased to the whole frame when the error came through decode_frame) — so a fuzzer, conformance suite, or validation tool can act on where and what failed without parsing the message:

try:
packet=decode_frame(frame)
exceptProtocolErrorase:
print(f"{e.protocol.__name__} at byte {e.frame_offset}: {e}")
# IPv4 at byte 14: IPv4 IHL must be at least 5, got 0

A capture tool would usually rather keep the layers it got than lose frame 4,000,001 to an exception. lax=True reports instead of raising:

packet=decode_frame(frame, lax=True)
ifpacket.stopped_byisnotNone:
log.warning("stopped after %d layers: %s", len(packet), packet.stopped_by)

Lax mode never invents a layer and never guesses — each header is decoded exactly as strictly as before; the walk just declines to throw away the part that worked. Chain depth is bounded (max_depth, default 32), so a crafted frame cannot make the walker grind.

One case where reaching for lax=True is the right default, not just a convenience: an ICMP error message's embedded packet. RFC 792 only guarantees the invoking IP header plus 8 bytes of what follows — never a full TCP/UDP header — so decoding it with the strict path raises on ordinary, correctly formed traffic. ICMPv4/ICMPv6 expose this pre-wired as embedded_chain:

icmp.embedded_chain# decode_frame(icmp.embedded_packet, lax=True, start=IPv4)# → Packet([IPv4(...)]), stopped_by=TruncatedHeaderError(...)

embedded_packet stays available for the raw bytes; reach for embedded_chain when you want them already decoded and are prepared to read stopped_by. Outside this one RFC-mandated case, a complete frame that fails to decode is still a bug to raise on — lax=True elsewhere is a capture tool's choice, not a default.

Reading captures

read_captures() takes the bytes of a capture file — not a path — and auto-detects classic pcap vs. pcapng from its magic number:

fromnetprotocolsimportdecode_frame, read_capturesdata=open("traffic.pcap", "rb").read() # or however you got the bytesfortimestamp, frameinread_captures(data):
packet=decode_frame(frame, lax=True)
...

Each CapturedFrame is (timestamp, data)timestamp normalized to nanoseconds since the Unix epoch regardless of the source format's own resolution (classic pcap's microseconds or nanoseconds; pcapng's per-interface if_tsresol). read_pcap()/read_pcapng() are the same thing for a caller who already knows the format and wants to skip detection. A malformed or truncated capture raises MalformedCaptureError, the same ProtocolError family every other exception in this library belongs to.

Flow keys

Packet.flow_key() (or the free function, netprotocols.flow_key(), for a header pair that never went through decode_frame) returns a canonical, direction-independent key for a TCP/UDP conversation — both directions of one flow produce the same key:

request=decode_frame(client_to_server_frame)
reply=decode_frame(server_to_client_frame)
assertrequest.flow_key() ==reply.flow_key()

It returns None, not an error, when there is nothing to key on: no enclosing IP layer, no TCP/UDP layer, or a transport layer without ports at all (ICMPv4/ICMPv6 — this library does not invent a port-slot convention for message types that have none).

Structural pattern matching

Every decoded header works with match/case today, with no code written for it: a frozen dataclass auto-generates __match_args__, and every enum field is an IntEnum, so a class pattern can match a named value against the plain int the wire actually carries. See ARCHITECTURE.md for why both of those hold and how to keep them holding.

IPv4, TCP and DHCP are wide enough (11-15 fields) that their full auto-generated positional form is unusable, so those three additionally curate a short __match_args__ by hand — the fields someone matching by position actually reaches for:

matchip:
caseIPv4(src, dst, IPProtocol.TCP):
print(f"TCP: {src} -> {dst}")

Keyword patterns (case IPv4(protocol=IPProtocol.TCP)) work on every field regardless, curated or not, and stay the documented default.

fromnetprotocolsimportIPv4, IPProtocol, TCPmatchpacket.layers:
case [_, IPv4(protocol=IPProtocol.TCP) asip, TCP(flags_str=f), *_] \
if"SYN"infand"ACK"notinf:
print(f"connection attempt from {ip.src}")
case [_, IPv4() asip, TCP(), *_]:
print(f"TCP from {ip.src}")
case _:
print("not a TCP-over-IPv4 frame")

Decoding a protocol we do not ship

The dispatch tables are public, so a protocol this library does not implement can join the walk without forking it:

fromnetprotocolsimportProtocolfromnetprotocols.registryimportregister@register("ethertype", 0x8847)classMPLS(Protocol):
...

The five tables are ethertype, ip.proto, ip.proto.v6, udp.port and tcp.port, each named after the wire field it dispatches on. To change decoding for one call only — DNS on a nonstandard port in one capture — say so per call instead of registering globally:

decode_frame(frame, decode_as={"udp.port": {6969: DNS}})

See ARCHITECTURE.md for how the tables fit together and how ip.proto.v6 inherits ip.proto.

Building and serializing headers

Constructors take friendly values (string MAC/IP addresses, integer fields) and validate them; bytes() emits the exact on-wire form. Packet concatenates a stack of layers:

fromnetprotocolsimportARP, Ethernet, Packet, random_macsha=random_mac()
frame=Packet(
Ethernet(dst="ff:ff:ff:ff:ff:ff", src=sha, ethertype=0x0806),
ARP(htype=1, ptype=0x0800, hlen=6, plen=4, oper=1,
sha=sha, spa="192.168.1.96",
tha="00:00:00:00:00:00", tpa="192.168.1.254"),
)
raw=bytes(frame) # ready for a raw socket

Checksums are computed and verified on request — never silently: compute()/verify() in netprotocols.checksum, and Packet.with_checksums() to fill a whole stack before sending.

Display helpers

Every class exposes human-readable properties next to the raw fields: Ethernet.ethertype_name, IPv4.protocol_name, IPv4.flags_name, TCP.flags_str ("SYN ACK"), ARP.oper_name, ICMPv4.type_name, and hexadecimal renderings such as checksum_hex_str. Unknown values degrade gracefully ("0x88cc", "unknown (47)") instead of raising.

How it works

See ARCHITECTURE.md for a guided tour: how a header byte layout maps onto a dataclass, the decode contract, the next_protocol() chain, and a cookbook for adding a new protocol.

This library is the engine behind RootWire, a network traffic monitor built on it (formerly Packet-Sniffer).

Roadmap

The post-1.3.0 roadmap, #107, is complete — five tiers plus a competitor CI audit (#124) and the comparative-claims embargo lift that closed it out:

VersionTheme
1.3.1Hygiene
1.4.0Decode performance
2.0.0A public protocol registry, a shipped chain walker, flow keys — released
2.1.0Typed accessors and pattern-matching ergonomics
2.2.0Universal round-trip properties, nightly fuzzing, a pcap reader

Everything through 2.2.0 has landed on master; per the roadmap's own release policy, only 2.0.0 was an actual PyPI release, and the comparative claims above draw on the finished tree. New planned work will open fresh issues rather than restating a list here, so this section stays accurate without upkeep. The wave before this one — TCP and IPv4 option parsing, ICMP message bodies, NDP, IPv6 extension-header TLV options, the GRE checksum arm, a DNS-over-TCP corpus fixture and ipaddress accessors — shipped in 1.3.0; see CHANGELOG.md.

Contributing

Development uses uv: uv sync, then uv run pytest, uv run mypy, and uv run ruff check — all three are enforced by CI on Python 3.12–3.14. The test suite is anchored by a 97-frame corpus of real captured traffic across 17 scenarios (tests/fixtures/MANIFEST.md) plus property-based fuzzing of the decode path. See CONTRIBUTING.md for the full workflow and ARCHITECTURE.md for the design and the add-a-protocol cookbook.

License

MIT

About

Low-level implementations of common networking protocols in Python 3

Topics

Resources

Contributing

Stars

11 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

NETProtocols

CIPython VersionLicense: MITTyped

Low-level implementations of common networking protocols, in pure Python with zero dependencies.

Decode raw header bytes into typed, immutable protocol objects — or build those objects from field values and serialize them back to their exact on-wire form. Every header is a frozen dataclass whose fields mirror the wire format, so decoded traffic is introspectable, comparable, and round-trippable.

>>> from netprotocols import Ethernet
>>> eth = Ethernet.decode(frame)
>>> eth
Ethernet(dst='ff:ff:ff:ff:ff:ff', src='00:07:0d:af:f4:54', ethertype=2054)
>>> eth.ethertype_name
'ARP'
>>> eth.next_protocol()
<class 'netprotocols.layer2.arp.ARP'>
>>> bytes(eth) == frame[:eth.header_len]
True

Every header is also a structural pattern — no extra code, because a frozen dataclass auto-generates __match_args__ and every enum field is an IntEnum, so a bare wire value matches a named one:

matchip:
caseIPv4(protocol=IPProtocol.TCP, ttl=t) ift>32:
...
caseIPv4(protocol=IPProtocol.UDP):
...

Installation

pip install netprotocols

Requires Python 3.12+. Fully typed (py.typed, mypy strict).

Why NETProtocols

Measured against dpkt 1.9.8 and scapy 2.7.0 on a 97-frame corpus of real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured 2026-09-04 — see docs/CLAIMS.md for every number below, its reproduction command, and its caveats):

  • Within 15% of dpkt on decode, 5.6× faster than scapyuv run --group bench python scripts/benchmark.py --compare. dpkt is the faster of the two comparators on this corpus; the gap moves both ways as this library and dpkt each change, and this file's job is to say so honestly rather than only when it's flattering.
  • Decodes further into the stack than dpkt on 27 of 97 corpus frames — DNS and DHCP payloads that dpkt leaves as raw bytes, netprotocols continues decoding. A throughput number means little without knowing how much work it bought.
  • 4.5× faster than dpkt at re-encoding (bytes(header), scripts/benchmark_encode.py) — the other half of "codec" that nobody else benchmarks.
  • Imports in 54 ms; scapy.all takes 458+ ms (scripts/benchmark_import.py), and never touches the host doing it — importing scapy populates live interface and routing tables as a side effect of the import statement.
  • An 85.6 KB wheel against scapy's 2.47 MB — about 30× smaller.
  • Regression-gated in CI, which — as far as we could establish by auditing ten comparable Python packet libraries' CI configurations (dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark, nfstream, stackforge, PyTCP-net_proto; full citations in docs/CLAIMS.md §1.7) — none of them do. Two of the ten ship real benchmark code that simply never runs in CI (construct disables it explicitly; stackforge's Criterion benches are never invoked); the rest have no performance benchmark at all.
  • Typed where the alternatives are not.mypy --strict over the whole of src/, enforced in CI. scapy ships py.typed but enables strict checking on 107 of its files, 2 of the 121 under scapy/layers/ — the dissectors most code touches stay Any. dpkt ships no py.typed at all, and no third-party stub package exists for it.
  • The only MIT-licensed, strictly-typed, zero-dependency packet codec still under active maintenance. scapy is GPL-2.0; pypacker is GPLv2; the newer entrants stackforge and PyTCP-net_proto are both GPL-3.0. The other permissive option, dpkt (BSD), last released 2022-08-18 and last committed 2024-05-05, still targets Python 2.7 and 3.5–3.9, and remains marked Beta after twelve years.
  • Runs where scapy cannot — including in the browser. Verified under a real Pyodide runtime in CI: scapy fails to import at all under Pyodide (from fcntl import ioctl is unconditional in scapy/arch/), where netprotocols, dpkt and pypacker all import cleanly.
  • The only one of these libraries a match/case statement dissects out of the box. dpkt builds __slots__ from a metaclass and generates no __match_args__; scapy routes fields through __getattr__; construct returns dicts. None gives you a pattern to match against.

None of this makes scapy less than an extraordinary piece of software — it crafts, sends, sniffs and fuzzes across thousands of protocols, and this library does none of that. The comparison above is scoped to what both are: a codec that turns bytes into typed objects and back.

One mypy --strict run says more than the bullets above:

# scapy 2.7.0 — a field name that does not exist
p.ThisFieldDoesNotExist → Any (no error)
# dpkt 1.9.8 — the whole module
import dpkt.ethernet → error: missing library stubs or py.typed
# netprotocols — a typo in a real field name
ip.proto → error: "IPv4" has no attribute
"proto"; maybe "protocol"?

Protocol coverage

LayerProtocolClassNotes
2Ethernet IIEthernetIEEE 802.3
2IEEE 802.1Q VLAN tag (802.1ad QinQ)VLAN802.1Q-2018 §9.6, PCP/DEI/VID, tag stacking
2ARPARPRFC 826, IPv4-over-Ethernet binding
3IPv4IPv4RFC 791, IHL/options aware
3IPv6IPv6RFC 8200
3IPv6 Hop-by-Hop OptionsIPv6HopByHopOptionsRFC 8200 §4.3
3IPv6 RoutingIPv6RoutingRFC 8200 §4.4
3IPv6 FragmentIPv6FragmentRFC 8200 §4.5, first-fragment chaining
3IPv6 Destination OptionsIPv6DestinationOptionsRFC 8200 §4.6
3ICMPv4ICMPv4RFC 792, 8-byte header
3ICMPv6ICMPv6RFC 4443, 8-byte header
3IGMPIGMPRFC 1112/2236/3376, IPv4 multicast management; IGMPv3 report group records + query fields
3GREGRERFC 2784/2890, IP protocol 47; payload chains onward by EtherType
4TCPTCPRFC 9293, data-offset/options aware
4UDPUDPRFC 768
7DNSDNSRFC 1035, over UDP and TCP (DNSOverTCP length shim); on-demand name decompression + resource-record parsing
7DHCPDHCPRFC 2131/2132, over UDP 67/68; TLV options parsed on demand

Decoding a captured frame

decode_frame() walks the whole chain and hands back a Packet:

fromnetprotocolsimportTCP, decode_framepacket=decode_frame(frame)
print(packet) # Packet(Ethernet(...), IPv4(...), TCP(...))print(packet[1].src) # '192.168.1.96' — position, unchangedprint(packet[TCP].flags_str) # first TCP layer, or KeyError if noneprint(packet.get(TCP)) # same lookup, None instead of raisingprint(packet.consumed) # bytes the headers occupied

It works because every header answers two questions: header_len (how many bytes it consumed) and next_protocol() (which class decodes what follows). Trailing bytes are fine — frame[packet.consumed:] is whatever the chain did not decode.

Start somewhere other than Ethernet when the buffer does — a tunnel payload, a packet quoted inside an ICMP error, a non-Ethernet link type:

decode_frame(buf, start=IPv4)

Malformed input raises exceptions rooted at a single base class:

fromnetprotocolsimportProtocolError, TruncatedHeaderError, InvalidFieldErrortry:
packet=decode_frame(frame)
exceptTruncatedHeaderError: # buffer shorter than the header claims
...
exceptInvalidFieldError: # nonsense field values (IHL < 5, bad address)
...
exceptProtocolError: # catches every library error
...

Every exception carries structured context alongside its message — err.protocol names the class that raised, err.field names the attribute at fault where one field is at fault, and err.offset / err.frame_offset locate the problem in bytes (in the header's own buffer, and rebased to the whole frame when the error came through decode_frame) — so a fuzzer, conformance suite, or validation tool can act on where and what failed without parsing the message:

try:
packet=decode_frame(frame)
exceptProtocolErrorase:
print(f"{e.protocol.__name__} at byte {e.frame_offset}: {e}")
# IPv4 at byte 14: IPv4 IHL must be at least 5, got 0

A capture tool would usually rather keep the layers it got than lose frame 4,000,001 to an exception. lax=True reports instead of raising:

packet=decode_frame(frame, lax=True)
ifpacket.stopped_byisnotNone:
log.warning("stopped after %d layers: %s", len(packet), packet.stopped_by)

Lax mode never invents a layer and never guesses — each header is decoded exactly as strictly as before; the walk just declines to throw away the part that worked. Chain depth is bounded (max_depth, default 32), so a crafted frame cannot make the walker grind.

One case where reaching for lax=True is the right default, not just a convenience: an ICMP error message's embedded packet. RFC 792 only guarantees the invoking IP header plus 8 bytes of what follows — never a full TCP/UDP header — so decoding it with the strict path raises on ordinary, correctly formed traffic. ICMPv4/ICMPv6 expose this pre-wired as embedded_chain:

icmp.embedded_chain# decode_frame(icmp.embedded_packet, lax=True, start=IPv4)# → Packet([IPv4(...)]), stopped_by=TruncatedHeaderError(...)

embedded_packet stays available for the raw bytes; reach for embedded_chain when you want them already decoded and are prepared to read stopped_by. Outside this one RFC-mandated case, a complete frame that fails to decode is still a bug to raise on — lax=True elsewhere is a capture tool's choice, not a default.

Reading captures

read_captures() takes the bytes of a capture file — not a path — and auto-detects classic pcap vs. pcapng from its magic number:

fromnetprotocolsimportdecode_frame, read_capturesdata=open("traffic.pcap", "rb").read() # or however you got the bytesfortimestamp, frameinread_captures(data):
packet=decode_frame(frame, lax=True)
...

Each CapturedFrame is (timestamp, data)timestamp normalized to nanoseconds since the Unix epoch regardless of the source format's own resolution (classic pcap's microseconds or nanoseconds; pcapng's per-interface if_tsresol). read_pcap()/read_pcapng() are the same thing for a caller who already knows the format and wants to skip detection. A malformed or truncated capture raises MalformedCaptureError, the same ProtocolError family every other exception in this library belongs to.

Flow keys

Packet.flow_key() (or the free function, netprotocols.flow_key(), for a header pair that never went through decode_frame) returns a canonical, direction-independent key for a TCP/UDP conversation — both directions of one flow produce the same key:

request=decode_frame(client_to_server_frame)
reply=decode_frame(server_to_client_frame)
assertrequest.flow_key() ==reply.flow_key()

It returns None, not an error, when there is nothing to key on: no enclosing IP layer, no TCP/UDP layer, or a transport layer without ports at all (ICMPv4/ICMPv6 — this library does not invent a port-slot convention for message types that have none).

Structural pattern matching

Every decoded header works with match/case today, with no code written for it: a frozen dataclass auto-generates __match_args__, and every enum field is an IntEnum, so a class pattern can match a named value against the plain int the wire actually carries. See ARCHITECTURE.md for why both of those hold and how to keep them holding.

IPv4, TCP and DHCP are wide enough (11-15 fields) that their full auto-generated positional form is unusable, so those three additionally curate a short __match_args__ by hand — the fields someone matching by position actually reaches for:

matchip:
caseIPv4(src, dst, IPProtocol.TCP):
print(f"TCP: {src} -> {dst}")

Keyword patterns (case IPv4(protocol=IPProtocol.TCP)) work on every field regardless, curated or not, and stay the documented default.

fromnetprotocolsimportIPv4, IPProtocol, TCPmatchpacket.layers:
case [_, IPv4(protocol=IPProtocol.TCP) asip, TCP(flags_str=f), *_] \
if"SYN"infand"ACK"notinf:
print(f"connection attempt from {ip.src}")
case [_, IPv4() asip, TCP(), *_]:
print(f"TCP from {ip.src}")
case _:
print("not a TCP-over-IPv4 frame")

Decoding a protocol we do not ship

The dispatch tables are public, so a protocol this library does not implement can join the walk without forking it:

fromnetprotocolsimportProtocolfromnetprotocols.registryimportregister@register("ethertype", 0x8847)classMPLS(Protocol):
...

The five tables are ethertype, ip.proto, ip.proto.v6, udp.port and tcp.port, each named after the wire field it dispatches on. To change decoding for one call only — DNS on a nonstandard port in one capture — say so per call instead of registering globally:

decode_frame(frame, decode_as={"udp.port": {6969: DNS}})

See ARCHITECTURE.md for how the tables fit together and how ip.proto.v6 inherits ip.proto.

Building and serializing headers

Constructors take friendly values (string MAC/IP addresses, integer fields) and validate them; bytes() emits the exact on-wire form. Packet concatenates a stack of layers:

fromnetprotocolsimportARP, Ethernet, Packet, random_macsha=random_mac()
frame=Packet(
Ethernet(dst="ff:ff:ff:ff:ff:ff", src=sha, ethertype=0x0806),
ARP(htype=1, ptype=0x0800, hlen=6, plen=4, oper=1,
sha=sha, spa="192.168.1.96",
tha="00:00:00:00:00:00", tpa="192.168.1.254"),
)
raw=bytes(frame) # ready for a raw socket

Checksums are computed and verified on request — never silently: compute()/verify() in netprotocols.checksum, and Packet.with_checksums() to fill a whole stack before sending.

Display helpers

Every class exposes human-readable properties next to the raw fields: Ethernet.ethertype_name, IPv4.protocol_name, IPv4.flags_name, TCP.flags_str ("SYN ACK"), ARP.oper_name, ICMPv4.type_name, and hexadecimal renderings such as checksum_hex_str. Unknown values degrade gracefully ("0x88cc", "unknown (47)") instead of raising.

How it works

See ARCHITECTURE.md for a guided tour: how a header byte layout maps onto a dataclass, the decode contract, the next_protocol() chain, and a cookbook for adding a new protocol.

This library is the engine behind RootWire, a network traffic monitor built on it (formerly Packet-Sniffer).

Roadmap

The post-1.3.0 roadmap, #107, is complete — five tiers plus a competitor CI audit (#124) and the comparative-claims embargo lift that closed it out:

VersionTheme
1.3.1Hygiene
1.4.0Decode performance
2.0.0A public protocol registry, a shipped chain walker, flow keys — released
2.1.0Typed accessors and pattern-matching ergonomics
2.2.0Universal round-trip properties, nightly fuzzing, a pcap reader

Everything through 2.2.0 has landed on master; per the roadmap's own release policy, only 2.0.0 was an actual PyPI release, and the comparative claims above draw on the finished tree. New planned work will open fresh issues rather than restating a list here, so this section stays accurate without upkeep. The wave before this one — TCP and IPv4 option parsing, ICMP message bodies, NDP, IPv6 extension-header TLV options, the GRE checksum arm, a DNS-over-TCP corpus fixture and ipaddress accessors — shipped in 1.3.0; see CHANGELOG.md.

Contributing

Development uses uv: uv sync, then uv run pytest, uv run mypy, and uv run ruff check — all three are enforced by CI on Python 3.12–3.14. The test suite is anchored by a 97-frame corpus of real captured traffic across 17 scenarios (tests/fixtures/MANIFEST.md) plus property-based fuzzing of the decode path. See CONTRIBUTING.md for the full workflow and ARCHITECTURE.md for the design and the add-a-protocol cookbook.

License

MIT

About

Low-level implementations of common networking protocols in Python 3

Topics

Resources

Contributing

Stars

11 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NETProtocols

CIPython VersionLicense: MITTyped

Low-level implementations of common networking protocols, in pure Python with zero dependencies.

Decode raw header bytes into typed, immutable protocol objects — or build those objects from field values and serialize them back to their exact on-wire form. Every header is a frozen dataclass whose fields mirror the wire format, so decoded traffic is introspectable, comparable, and round-trippable.

>>> from netprotocols import Ethernet
>>> eth = Ethernet.decode(frame)
>>> eth
Ethernet(dst='ff:ff:ff:ff:ff:ff', src='00:07:0d:af:f4:54', ethertype=2054)
>>> eth.ethertype_name
'ARP'
>>> eth.next_protocol()
<class 'netprotocols.layer2.arp.ARP'>
>>> bytes(eth) == frame[:eth.header_len]
True

Every header is also a structural pattern — no extra code, because a frozen dataclass auto-generates __match_args__ and every enum field is an IntEnum, so a bare wire value matches a named one:

matchip:
caseIPv4(protocol=IPProtocol.TCP, ttl=t) ift>32:
...
caseIPv4(protocol=IPProtocol.UDP):
...

Installation

pip install netprotocols

Requires Python 3.12+. Fully typed (py.typed, mypy strict).

Why NETProtocols

Measured against dpkt 1.9.8 and scapy 2.7.0 on a 97-frame corpus of real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured 2026-09-04 — see docs/CLAIMS.md for every number below, its reproduction command, and its caveats):

  • Within 15% of dpkt on decode, 5.6× faster than scapyuv run --group bench python scripts/benchmark.py --compare. dpkt is the faster of the two comparators on this corpus; the gap moves both ways as this library and dpkt each change, and this file's job is to say so honestly rather than only when it's flattering.
  • Decodes further into the stack than dpkt on 27 of 97 corpus frames — DNS and DHCP payloads that dpkt leaves as raw bytes, netprotocols continues decoding. A throughput number means little without knowing how much work it bought.
  • 4.5× faster than dpkt at re-encoding (bytes(header), scripts/benchmark_encode.py) — the other half of "codec" that nobody else benchmarks.
  • Imports in 54 ms; scapy.all takes 458+ ms (scripts/benchmark_import.py), and never touches the host doing it — importing scapy populates live interface and routing tables as a side effect of the import statement.
  • An 85.6 KB wheel against scapy's 2.47 MB — about 30× smaller.
  • Regression-gated in CI, which — as far as we could establish by auditing ten comparable Python packet libraries' CI configurations (dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark, nfstream, stackforge, PyTCP-net_proto; full citations in docs/CLAIMS.md §1.7) — none of them do. Two of the ten ship real benchmark code that simply never runs in CI (construct disables it explicitly; stackforge's Criterion benches are never invoked); the rest have no performance benchmark at all.
  • Typed where the alternatives are not.mypy --strict over the whole of src/, enforced in CI. scapy ships py.typed but enables strict checking on 107 of its files, 2 of the 121 under scapy/layers/ — the dissectors most code touches stay Any. dpkt ships no py.typed at all, and no third-party stub package exists for it.
  • The only MIT-licensed, strictly-typed, zero-dependency packet codec still under active maintenance. scapy is GPL-2.0; pypacker is GPLv2; the newer entrants stackforge and PyTCP-net_proto are both GPL-3.0. The other permissive option, dpkt (BSD), last released 2022-08-18 and last committed 2024-05-05, still targets Python 2.7 and 3.5–3.9, and remains marked Beta after twelve years.
  • Runs where scapy cannot — including in the browser. Verified under a real Pyodide runtime in CI: scapy fails to import at all under Pyodide (from fcntl import ioctl is unconditional in scapy/arch/), where netprotocols, dpkt and pypacker all import cleanly.
  • The only one of these libraries a match/case statement dissects out of the box. dpkt builds __slots__ from a metaclass and generates no __match_args__; scapy routes fields through __getattr__; construct returns dicts. None gives you a pattern to match against.

None of this makes scapy less than an extraordinary piece of software — it crafts, sends, sniffs and fuzzes across thousands of protocols, and this library does none of that. The comparison above is scoped to what both are: a codec that turns bytes into typed objects and back.

One mypy --strict run says more than the bullets above:

# scapy 2.7.0 — a field name that does not exist
p.ThisFieldDoesNotExist → Any (no error)
# dpkt 1.9.8 — the whole module
import dpkt.ethernet → error: missing library stubs or py.typed
# netprotocols — a typo in a real field name
ip.proto → error: "IPv4" has no attribute
"proto"; maybe "protocol"?

Protocol coverage

LayerProtocolClassNotes
2Ethernet IIEthernetIEEE 802.3
2IEEE 802.1Q VLAN tag (802.1ad QinQ)VLAN802.1Q-2018 §9.6, PCP/DEI/VID, tag stacking
2ARPARPRFC 826, IPv4-over-Ethernet binding
3IPv4IPv4RFC 791, IHL/options aware
3IPv6IPv6RFC 8200
3IPv6 Hop-by-Hop OptionsIPv6HopByHopOptionsRFC 8200 §4.3
3IPv6 RoutingIPv6RoutingRFC 8200 §4.4
3IPv6 FragmentIPv6FragmentRFC 8200 §4.5, first-fragment chaining
3IPv6 Destination OptionsIPv6DestinationOptionsRFC 8200 §4.6
3ICMPv4ICMPv4RFC 792, 8-byte header
3ICMPv6ICMPv6RFC 4443, 8-byte header
3IGMPIGMPRFC 1112/2236/3376, IPv4 multicast management; IGMPv3 report group records + query fields
3GREGRERFC 2784/2890, IP protocol 47; payload chains onward by EtherType
4TCPTCPRFC 9293, data-offset/options aware
4UDPUDPRFC 768
7DNSDNSRFC 1035, over UDP and TCP (DNSOverTCP length shim); on-demand name decompression + resource-record parsing
7DHCPDHCPRFC 2131/2132, over UDP 67/68; TLV options parsed on demand

Decoding a captured frame

decode_frame() walks the whole chain and hands back a Packet:

fromnetprotocolsimportTCP, decode_framepacket=decode_frame(frame)
print(packet) # Packet(Ethernet(...), IPv4(...), TCP(...))print(packet[1].src) # '192.168.1.96' — position, unchangedprint(packet[TCP].flags_str) # first TCP layer, or KeyError if noneprint(packet.get(TCP)) # same lookup, None instead of raisingprint(packet.consumed) # bytes the headers occupied

It works because every header answers two questions: header_len (how many bytes it consumed) and next_protocol() (which class decodes what follows). Trailing bytes are fine — frame[packet.consumed:] is whatever the chain did not decode.

Start somewhere other than Ethernet when the buffer does — a tunnel payload, a packet quoted inside an ICMP error, a non-Ethernet link type:

decode_frame(buf, start=IPv4)

Malformed input raises exceptions rooted at a single base class:

fromnetprotocolsimportProtocolError, TruncatedHeaderError, InvalidFieldErrortry:
packet=decode_frame(frame)
exceptTruncatedHeaderError: # buffer shorter than the header claims
...
exceptInvalidFieldError: # nonsense field values (IHL < 5, bad address)
...
exceptProtocolError: # catches every library error
...

Every exception carries structured context alongside its message — err.protocol names the class that raised, err.field names the attribute at fault where one field is at fault, and err.offset / err.frame_offset locate the problem in bytes (in the header's own buffer, and rebased to the whole frame when the error came through decode_frame) — so a fuzzer, conformance suite, or validation tool can act on where and what failed without parsing the message:

try:
packet=decode_frame(frame)
exceptProtocolErrorase:
print(f"{e.protocol.__name__} at byte {e.frame_offset}: {e}")
# IPv4 at byte 14: IPv4 IHL must be at least 5, got 0

A capture tool would usually rather keep the layers it got than lose frame 4,000,001 to an exception. lax=True reports instead of raising:

packet=decode_frame(frame, lax=True)
ifpacket.stopped_byisnotNone:
log.warning("stopped after %d layers: %s", len(packet), packet.stopped_by)

Lax mode never invents a layer and never guesses — each header is decoded exactly as strictly as before; the walk just declines to throw away the part that worked. Chain depth is bounded (max_depth, default 32), so a crafted frame cannot make the walker grind.

One case where reaching for lax=True is the right default, not just a convenience: an ICMP error message's embedded packet. RFC 792 only guarantees the invoking IP header plus 8 bytes of what follows — never a full TCP/UDP header — so decoding it with the strict path raises on ordinary, correctly formed traffic. ICMPv4/ICMPv6 expose this pre-wired as embedded_chain:

icmp.embedded_chain# decode_frame(icmp.embedded_packet, lax=True, start=IPv4)# → Packet([IPv4(...)]), stopped_by=TruncatedHeaderError(...)

embedded_packet stays available for the raw bytes; reach for embedded_chain when you want them already decoded and are prepared to read stopped_by. Outside this one RFC-mandated case, a complete frame that fails to decode is still a bug to raise on — lax=True elsewhere is a capture tool's choice, not a default.

Reading captures

read_captures() takes the bytes of a capture file — not a path — and auto-detects classic pcap vs. pcapng from its magic number:

fromnetprotocolsimportdecode_frame, read_capturesdata=open("traffic.pcap", "rb").read() # or however you got the bytesfortimestamp, frameinread_captures(data):
packet=decode_frame(frame, lax=True)
...

Each CapturedFrame is (timestamp, data)timestamp normalized to nanoseconds since the Unix epoch regardless of the source format's own resolution (classic pcap's microseconds or nanoseconds; pcapng's per-interface if_tsresol). read_pcap()/read_pcapng() are the same thing for a caller who already knows the format and wants to skip detection. A malformed or truncated capture raises MalformedCaptureError, the same ProtocolError family every other exception in this library belongs to.

Flow keys

Packet.flow_key() (or the free function, netprotocols.flow_key(), for a header pair that never went through decode_frame) returns a canonical, direction-independent key for a TCP/UDP conversation — both directions of one flow produce the same key:

request=decode_frame(client_to_server_frame)
reply=decode_frame(server_to_client_frame)
assertrequest.flow_key() ==reply.flow_key()

It returns None, not an error, when there is nothing to key on: no enclosing IP layer, no TCP/UDP layer, or a transport layer without ports at all (ICMPv4/ICMPv6 — this library does not invent a port-slot convention for message types that have none).

Structural pattern matching

Every decoded header works with match/case today, with no code written for it: a frozen dataclass auto-generates __match_args__, and every enum field is an IntEnum, so a class pattern can match a named value against the plain int the wire actually carries. See ARCHITECTURE.md for why both of those hold and how to keep them holding.

IPv4, TCP and DHCP are wide enough (11-15 fields) that their full auto-generated positional form is unusable, so those three additionally curate a short __match_args__ by hand — the fields someone matching by position actually reaches for:

matchip:
caseIPv4(src, dst, IPProtocol.TCP):
print(f"TCP: {src} -> {dst}")

Keyword patterns (case IPv4(protocol=IPProtocol.TCP)) work on every field regardless, curated or not, and stay the documented default.

fromnetprotocolsimportIPv4, IPProtocol, TCPmatchpacket.layers:
case [_, IPv4(protocol=IPProtocol.TCP) asip, TCP(flags_str=f), *_] \
if"SYN"infand"ACK"notinf:
print(f"connection attempt from {ip.src}")
case [_, IPv4() asip, TCP(), *_]:
print(f"TCP from {ip.src}")
case _:
print("not a TCP-over-IPv4 frame")

Decoding a protocol we do not ship

The dispatch tables are public, so a protocol this library does not implement can join the walk without forking it:

fromnetprotocolsimportProtocolfromnetprotocols.registryimportregister@register("ethertype", 0x8847)classMPLS(Protocol):
...

The five tables are ethertype, ip.proto, ip.proto.v6, udp.port and tcp.port, each named after the wire field it dispatches on. To change decoding for one call only — DNS on a nonstandard port in one capture — say so per call instead of registering globally:

decode_frame(frame, decode_as={"udp.port": {6969: DNS}})

See ARCHITECTURE.md for how the tables fit together and how ip.proto.v6 inherits ip.proto.

Building and serializing headers

Constructors take friendly values (string MAC/IP addresses, integer fields) and validate them; bytes() emits the exact on-wire form. Packet concatenates a stack of layers:

fromnetprotocolsimportARP, Ethernet, Packet, random_macsha=random_mac()
frame=Packet(
Ethernet(dst="ff:ff:ff:ff:ff:ff", src=sha, ethertype=0x0806),
ARP(htype=1, ptype=0x0800, hlen=6, plen=4, oper=1,
sha=sha, spa="192.168.1.96",
tha="00:00:00:00:00:00", tpa="192.168.1.254"),
)
raw=bytes(frame) # ready for a raw socket

Checksums are computed and verified on request — never silently: compute()/verify() in netprotocols.checksum, and Packet.with_checksums() to fill a whole stack before sending.

Display helpers

Every class exposes human-readable properties next to the raw fields: Ethernet.ethertype_name, IPv4.protocol_name, IPv4.flags_name, TCP.flags_str ("SYN ACK"), ARP.oper_name, ICMPv4.type_name, and hexadecimal renderings such as checksum_hex_str. Unknown values degrade gracefully ("0x88cc", "unknown (47)") instead of raising.

How it works

See ARCHITECTURE.md for a guided tour: how a header byte layout maps onto a dataclass, the decode contract, the next_protocol() chain, and a cookbook for adding a new protocol.

This library is the engine behind RootWire, a network traffic monitor built on it (formerly Packet-Sniffer).

Roadmap

The post-1.3.0 roadmap, #107, is complete — five tiers plus a competitor CI audit (#124) and the comparative-claims embargo lift that closed it out:

VersionTheme
1.3.1Hygiene
1.4.0Decode performance
2.0.0A public protocol registry, a shipped chain walker, flow keys — released
2.1.0Typed accessors and pattern-matching ergonomics
2.2.0Universal round-trip properties, nightly fuzzing, a pcap reader

Everything through 2.2.0 has landed on master; per the roadmap's own release policy, only 2.0.0 was an actual PyPI release, and the comparative claims above draw on the finished tree. New planned work will open fresh issues rather than restating a list here, so this section stays accurate without upkeep. The wave before this one — TCP and IPv4 option parsing, ICMP message bodies, NDP, IPv6 extension-header TLV options, the GRE checksum arm, a DNS-over-TCP corpus fixture and ipaddress accessors — shipped in 1.3.0; see CHANGELOG.md.

Contributing

Development uses uv: uv sync, then uv run pytest, uv run mypy, and uv run ruff check — all three are enforced by CI on Python 3.12–3.14. The test suite is anchored by a 97-frame corpus of real captured traffic across 17 scenarios (tests/fixtures/MANIFEST.md) plus property-based fuzzing of the decode path. See CONTRIBUTING.md for the full workflow and ARCHITECTURE.md for the design and the add-a-protocol cookbook.

License

MIT

About

Low-level implementations of common networking protocols in Python 3

Topics

Resources

Contributing

Stars

11 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NETProtocols

CIPython VersionLicense: MITTyped

Low-level implementations of common networking protocols, in pure Python with zero dependencies.

Decode raw header bytes into typed, immutable protocol objects — or build those objects from field values and serialize them back to their exact on-wire form. Every header is a frozen dataclass whose fields mirror the wire format, so decoded traffic is introspectable, comparable, and round-trippable.

>>> from netprotocols import Ethernet
>>> eth = Ethernet.decode(frame)
>>> eth
Ethernet(dst='ff:ff:ff:ff:ff:ff', src='00:07:0d:af:f4:54', ethertype=2054)
>>> eth.ethertype_name
'ARP'
>>> eth.next_protocol()
<class 'netprotocols.layer2.arp.ARP'>
>>> bytes(eth) == frame[:eth.header_len]
True

Every header is also a structural pattern — no extra code, because a frozen dataclass auto-generates __match_args__ and every enum field is an IntEnum, so a bare wire value matches a named one:

matchip:
caseIPv4(protocol=IPProtocol.TCP, ttl=t) ift>32:
...
caseIPv4(protocol=IPProtocol.UDP):
...

Installation

pip install netprotocols

Requires Python 3.12+. Fully typed (py.typed, mypy strict).

Why NETProtocols

Measured against dpkt 1.9.8 and scapy 2.7.0 on a 97-frame corpus of real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured 2026-09-04 — see docs/CLAIMS.md for every number below, its reproduction command, and its caveats):

  • Within 15% of dpkt on decode, 5.6× faster than scapyuv run --group bench python scripts/benchmark.py --compare. dpkt is the faster of the two comparators on this corpus; the gap moves both ways as this library and dpkt each change, and this file's job is to say so honestly rather than only when it's flattering.
  • Decodes further into the stack than dpkt on 27 of 97 corpus frames — DNS and DHCP payloads that dpkt leaves as raw bytes, netprotocols continues decoding. A throughput number means little without knowing how much work it bought.
  • 4.5× faster than dpkt at re-encoding (bytes(header), scripts/benchmark_encode.py) — the other half of "codec" that nobody else benchmarks.
  • Imports in 54 ms; scapy.all takes 458+ ms (scripts/benchmark_import.py), and never touches the host doing it — importing scapy populates live interface and routing tables as a side effect of the import statement.
  • An 85.6 KB wheel against scapy's 2.47 MB — about 30× smaller.
  • Regression-gated in CI, which — as far as we could establish by auditing ten comparable Python packet libraries' CI configurations (dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark, nfstream, stackforge, PyTCP-net_proto; full citations in docs/CLAIMS.md §1.7) — none of them do. Two of the ten ship real benchmark code that simply never runs in CI (construct disables it explicitly; stackforge's Criterion benches are never invoked); the rest have no performance benchmark at all.
  • Typed where the alternatives are not.mypy --strict over the whole of src/, enforced in CI. scapy ships py.typed but enables strict checking on 107 of its files, 2 of the 121 under scapy/layers/ — the dissectors most code touches stay Any. dpkt ships no py.typed at all, and no third-party stub package exists for it.
  • The only MIT-licensed, strictly-typed, zero-dependency packet codec still under active maintenance. scapy is GPL-2.0; pypacker is GPLv2; the newer entrants stackforge and PyTCP-net_proto are both GPL-3.0. The other permissive option, dpkt (BSD), last released 2022-08-18 and last committed 2024-05-05, still targets Python 2.7 and 3.5–3.9, and remains marked Beta after twelve years.
  • Runs where scapy cannot — including in the browser. Verified under a real Pyodide runtime in CI: scapy fails to import at all under Pyodide (from fcntl import ioctl is unconditional in scapy/arch/), where netprotocols, dpkt and pypacker all import cleanly.
  • The only one of these libraries a match/case statement dissects out of the box. dpkt builds __slots__ from a metaclass and generates no __match_args__; scapy routes fields through __getattr__; construct returns dicts. None gives you a pattern to match against.

None of this makes scapy less than an extraordinary piece of software — it crafts, sends, sniffs and fuzzes across thousands of protocols, and this library does none of that. The comparison above is scoped to what both are: a codec that turns bytes into typed objects and back.

One mypy --strict run says more than the bullets above:

# scapy 2.7.0 — a field name that does not exist
p.ThisFieldDoesNotExist → Any (no error)
# dpkt 1.9.8 — the whole module
import dpkt.ethernet → error: missing library stubs or py.typed
# netprotocols — a typo in a real field name
ip.proto → error: "IPv4" has no attribute
"proto"; maybe "protocol"?

Protocol coverage

LayerProtocolClassNotes
2Ethernet IIEthernetIEEE 802.3
2IEEE 802.1Q VLAN tag (802.1ad QinQ)VLAN802.1Q-2018 §9.6, PCP/DEI/VID, tag stacking
2ARPARPRFC 826, IPv4-over-Ethernet binding
3IPv4IPv4RFC 791, IHL/options aware
3IPv6IPv6RFC 8200
3IPv6 Hop-by-Hop OptionsIPv6HopByHopOptionsRFC 8200 §4.3
3IPv6 RoutingIPv6RoutingRFC 8200 §4.4
3IPv6 FragmentIPv6FragmentRFC 8200 §4.5, first-fragment chaining
3IPv6 Destination OptionsIPv6DestinationOptionsRFC 8200 §4.6
3ICMPv4ICMPv4RFC 792, 8-byte header
3ICMPv6ICMPv6RFC 4443, 8-byte header
3IGMPIGMPRFC 1112/2236/3376, IPv4 multicast management; IGMPv3 report group records + query fields
3GREGRERFC 2784/2890, IP protocol 47; payload chains onward by EtherType
4TCPTCPRFC 9293, data-offset/options aware
4UDPUDPRFC 768
7DNSDNSRFC 1035, over UDP and TCP (DNSOverTCP length shim); on-demand name decompression + resource-record parsing
7DHCPDHCPRFC 2131/2132, over UDP 67/68; TLV options parsed on demand

Decoding a captured frame

decode_frame() walks the whole chain and hands back a Packet:

fromnetprotocolsimportTCP, decode_framepacket=decode_frame(frame)
print(packet) # Packet(Ethernet(...), IPv4(...), TCP(...))print(packet[1].src) # '192.168.1.96' — position, unchangedprint(packet[TCP].flags_str) # first TCP layer, or KeyError if noneprint(packet.get(TCP)) # same lookup, None instead of raisingprint(packet.consumed) # bytes the headers occupied

It works because every header answers two questions: header_len (how many bytes it consumed) and next_protocol() (which class decodes what follows). Trailing bytes are fine — frame[packet.consumed:] is whatever the chain did not decode.

Start somewhere other than Ethernet when the buffer does — a tunnel payload, a packet quoted inside an ICMP error, a non-Ethernet link type:

decode_frame(buf, start=IPv4)

Malformed input raises exceptions rooted at a single base class:

fromnetprotocolsimportProtocolError, TruncatedHeaderError, InvalidFieldErrortry:
packet=decode_frame(frame)
exceptTruncatedHeaderError: # buffer shorter than the header claims
...
exceptInvalidFieldError: # nonsense field values (IHL < 5, bad address)
...
exceptProtocolError: # catches every library error
...

Every exception carries structured context alongside its message — err.protocol names the class that raised, err.field names the attribute at fault where one field is at fault, and err.offset / err.frame_offset locate the problem in bytes (in the header's own buffer, and rebased to the whole frame when the error came through decode_frame) — so a fuzzer, conformance suite, or validation tool can act on where and what failed without parsing the message:

try:
packet=decode_frame(frame)
exceptProtocolErrorase:
print(f"{e.protocol.__name__} at byte {e.frame_offset}: {e}")
# IPv4 at byte 14: IPv4 IHL must be at least 5, got 0

A capture tool would usually rather keep the layers it got than lose frame 4,000,001 to an exception. lax=True reports instead of raising:

packet=decode_frame(frame, lax=True)
ifpacket.stopped_byisnotNone:
log.warning("stopped after %d layers: %s", len(packet), packet.stopped_by)

Lax mode never invents a layer and never guesses — each header is decoded exactly as strictly as before; the walk just declines to throw away the part that worked. Chain depth is bounded (max_depth, default 32), so a crafted frame cannot make the walker grind.

One case where reaching for lax=True is the right default, not just a convenience: an ICMP error message's embedded packet. RFC 792 only guarantees the invoking IP header plus 8 bytes of what follows — never a full TCP/UDP header — so decoding it with the strict path raises on ordinary, correctly formed traffic. ICMPv4/ICMPv6 expose this pre-wired as embedded_chain:

icmp.embedded_chain# decode_frame(icmp.embedded_packet, lax=True, start=IPv4)# → Packet([IPv4(...)]), stopped_by=TruncatedHeaderError(...)

embedded_packet stays available for the raw bytes; reach for embedded_chain when you want them already decoded and are prepared to read stopped_by. Outside this one RFC-mandated case, a complete frame that fails to decode is still a bug to raise on — lax=True elsewhere is a capture tool's choice, not a default.

Reading captures

read_captures() takes the bytes of a capture file — not a path — and auto-detects classic pcap vs. pcapng from its magic number:

fromnetprotocolsimportdecode_frame, read_capturesdata=open("traffic.pcap", "rb").read() # or however you got the bytesfortimestamp, frameinread_captures(data):
packet=decode_frame(frame, lax=True)
...

Each CapturedFrame is (timestamp, data)timestamp normalized to nanoseconds since the Unix epoch regardless of the source format's own resolution (classic pcap's microseconds or nanoseconds; pcapng's per-interface if_tsresol). read_pcap()/read_pcapng() are the same thing for a caller who already knows the format and wants to skip detection. A malformed or truncated capture raises MalformedCaptureError, the same ProtocolError family every other exception in this library belongs to.

Flow keys

Packet.flow_key() (or the free function, netprotocols.flow_key(), for a header pair that never went through decode_frame) returns a canonical, direction-independent key for a TCP/UDP conversation — both directions of one flow produce the same key:

request=decode_frame(client_to_server_frame)
reply=decode_frame(server_to_client_frame)
assertrequest.flow_key() ==reply.flow_key()

It returns None, not an error, when there is nothing to key on: no enclosing IP layer, no TCP/UDP layer, or a transport layer without ports at all (ICMPv4/ICMPv6 — this library does not invent a port-slot convention for message types that have none).

Structural pattern matching

Every decoded header works with match/case today, with no code written for it: a frozen dataclass auto-generates __match_args__, and every enum field is an IntEnum, so a class pattern can match a named value against the plain int the wire actually carries. See ARCHITECTURE.md for why both of those hold and how to keep them holding.

IPv4, TCP and DHCP are wide enough (11-15 fields) that their full auto-generated positional form is unusable, so those three additionally curate a short __match_args__ by hand — the fields someone matching by position actually reaches for:

matchip:
caseIPv4(src, dst, IPProtocol.TCP):
print(f"TCP: {src} -> {dst}")

Keyword patterns (case IPv4(protocol=IPProtocol.TCP)) work on every field regardless, curated or not, and stay the documented default.

fromnetprotocolsimportIPv4, IPProtocol, TCPmatchpacket.layers:
case [_, IPv4(protocol=IPProtocol.TCP) asip, TCP(flags_str=f), *_] \
if"SYN"infand"ACK"notinf:
print(f"connection attempt from {ip.src}")
case [_, IPv4() asip, TCP(), *_]:
print(f"TCP from {ip.src}")
case _:
print("not a TCP-over-IPv4 frame")

Decoding a protocol we do not ship

The dispatch tables are public, so a protocol this library does not implement can join the walk without forking it:

fromnetprotocolsimportProtocolfromnetprotocols.registryimportregister@register("ethertype", 0x8847)classMPLS(Protocol):
...

The five tables are ethertype, ip.proto, ip.proto.v6, udp.port and tcp.port, each named after the wire field it dispatches on. To change decoding for one call only — DNS on a nonstandard port in one capture — say so per call instead of registering globally:

decode_frame(frame, decode_as={"udp.port": {6969: DNS}})

See ARCHITECTURE.md for how the tables fit together and how ip.proto.v6 inherits ip.proto.

Building and serializing headers

Constructors take friendly values (string MAC/IP addresses, integer fields) and validate them; bytes() emits the exact on-wire form. Packet concatenates a stack of layers:

fromnetprotocolsimportARP, Ethernet, Packet, random_macsha=random_mac()
frame=Packet(
Ethernet(dst="ff:ff:ff:ff:ff:ff", src=sha, ethertype=0x0806),
ARP(htype=1, ptype=0x0800, hlen=6, plen=4, oper=1,
sha=sha, spa="192.168.1.96",
tha="00:00:00:00:00:00", tpa="192.168.1.254"),
)
raw=bytes(frame) # ready for a raw socket

Checksums are computed and verified on request — never silently: compute()/verify() in netprotocols.checksum, and Packet.with_checksums() to fill a whole stack before sending.

Display helpers

Every class exposes human-readable properties next to the raw fields: Ethernet.ethertype_name, IPv4.protocol_name, IPv4.flags_name, TCP.flags_str ("SYN ACK"), ARP.oper_name, ICMPv4.type_name, and hexadecimal renderings such as checksum_hex_str. Unknown values degrade gracefully ("0x88cc", "unknown (47)") instead of raising.

How it works

See ARCHITECTURE.md for a guided tour: how a header byte layout maps onto a dataclass, the decode contract, the next_protocol() chain, and a cookbook for adding a new protocol.

This library is the engine behind RootWire, a network traffic monitor built on it (formerly Packet-Sniffer).

Roadmap

The post-1.3.0 roadmap, #107, is complete — five tiers plus a competitor CI audit (#124) and the comparative-claims embargo lift that closed it out:

VersionTheme
1.3.1Hygiene
1.4.0Decode performance
2.0.0A public protocol registry, a shipped chain walker, flow keys — released
2.1.0Typed accessors and pattern-matching ergonomics
2.2.0Universal round-trip properties, nightly fuzzing, a pcap reader

Everything through 2.2.0 has landed on master; per the roadmap's own release policy, only 2.0.0 was an actual PyPI release, and the comparative claims above draw on the finished tree. New planned work will open fresh issues rather than restating a list here, so this section stays accurate without upkeep. The wave before this one — TCP and IPv4 option parsing, ICMP message bodies, NDP, IPv6 extension-header TLV options, the GRE checksum arm, a DNS-over-TCP corpus fixture and ipaddress accessors — shipped in 1.3.0; see CHANGELOG.md.

Contributing

Development uses uv: uv sync, then uv run pytest, uv run mypy, and uv run ruff check — all three are enforced by CI on Python 3.12–3.14. The test suite is anchored by a 97-frame corpus of real captured traffic across 17 scenarios (tests/fixtures/MANIFEST.md) plus property-based fuzzing of the decode path. See CONTRIBUTING.md for the full workflow and ARCHITECTURE.md for the design and the add-a-protocol cookbook.

License

MIT

About

Low-level implementations of common networking protocols in Python 3

Topics

Resources

Contributing

Stars

11 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

NETProtocols

CIPython VersionLicense: MITTyped

Low-level implementations of common networking protocols, in pure Python with zero dependencies.

Decode raw header bytes into typed, immutable protocol objects — or build those objects from field values and serialize them back to their exact on-wire form. Every header is a frozen dataclass whose fields mirror the wire format, so decoded traffic is introspectable, comparable, and round-trippable.

>>> from netprotocols import Ethernet
>>> eth = Ethernet.decode(frame)
>>> eth
Ethernet(dst='ff:ff:ff:ff:ff:ff', src='00:07:0d:af:f4:54', ethertype=2054)
>>> eth.ethertype_name
'ARP'
>>> eth.next_protocol()
<class 'netprotocols.layer2.arp.ARP'>
>>> bytes(eth) == frame[:eth.header_len]
True

Every header is also a structural pattern — no extra code, because a frozen dataclass auto-generates __match_args__ and every enum field is an IntEnum, so a bare wire value matches a named one:

matchip:
caseIPv4(protocol=IPProtocol.TCP, ttl=t) ift>32:
...
caseIPv4(protocol=IPProtocol.UDP):
...

Installation

pip install netprotocols

Requires Python 3.12+. Fully typed (py.typed, mypy strict).

Why NETProtocols

Measured against dpkt 1.9.8 and scapy 2.7.0 on a 97-frame corpus of real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured 2026-09-04 — see docs/CLAIMS.md for every number below, its reproduction command, and its caveats):

  • Within 15% of dpkt on decode, 5.6× faster than scapyuv run --group bench python scripts/benchmark.py --compare. dpkt is the faster of the two comparators on this corpus; the gap moves both ways as this library and dpkt each change, and this file's job is to say so honestly rather than only when it's flattering.
  • Decodes further into the stack than dpkt on 27 of 97 corpus frames — DNS and DHCP payloads that dpkt leaves as raw bytes, netprotocols continues decoding. A throughput number means little without knowing how much work it bought.
  • 4.5× faster than dpkt at re-encoding (bytes(header), scripts/benchmark_encode.py) — the other half of "codec" that nobody else benchmarks.
  • Imports in 54 ms; scapy.all takes 458+ ms (scripts/benchmark_import.py), and never touches the host doing it — importing scapy populates live interface and routing tables as a side effect of the import statement.
  • An 85.6 KB wheel against scapy's 2.47 MB — about 30× smaller.
  • Regression-gated in CI, which — as far as we could establish by auditing ten comparable Python packet libraries' CI configurations (dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark, nfstream, stackforge, PyTCP-net_proto; full citations in docs/CLAIMS.md §1.7) — none of them do. Two of the ten ship real benchmark code that simply never runs in CI (construct disables it explicitly; stackforge's Criterion benches are never invoked); the rest have no performance benchmark at all.
  • Typed where the alternatives are not.mypy --strict over the whole of src/, enforced in CI. scapy ships py.typed but enables strict checking on 107 of its files, 2 of the 121 under scapy/layers/ — the dissectors most code touches stay Any. dpkt ships no py.typed at all, and no third-party stub package exists for it.
  • The only MIT-licensed, strictly-typed, zero-dependency packet codec still under active maintenance. scapy is GPL-2.0; pypacker is GPLv2; the newer entrants stackforge and PyTCP-net_proto are both GPL-3.0. The other permissive option, dpkt (BSD), last released 2022-08-18 and last committed 2024-05-05, still targets Python 2.7 and 3.5–3.9, and remains marked Beta after twelve years.
  • Runs where scapy cannot — including in the browser. Verified under a real Pyodide runtime in CI: scapy fails to import at all under Pyodide (from fcntl import ioctl is unconditional in scapy/arch/), where netprotocols, dpkt and pypacker all import cleanly.
  • The only one of these libraries a match/case statement dissects out of the box. dpkt builds __slots__ from a metaclass and generates no __match_args__; scapy routes fields through __getattr__; construct returns dicts. None gives you a pattern to match against.

None of this makes scapy less than an extraordinary piece of software — it crafts, sends, sniffs and fuzzes across thousands of protocols, and this library does none of that. The comparison above is scoped to what both are: a codec that turns bytes into typed objects and back.

One mypy --strict run says more than the bullets above:

# scapy 2.7.0 — a field name that does not exist
p.ThisFieldDoesNotExist → Any (no error)
# dpkt 1.9.8 — the whole module
import dpkt.ethernet → error: missing library stubs or py.typed
# netprotocols — a typo in a real field name
ip.proto → error: "IPv4" has no attribute
"proto"; maybe "protocol"?

Protocol coverage

LayerProtocolClassNotes
2Ethernet IIEthernetIEEE 802.3
2IEEE 802.1Q VLAN tag (802.1ad QinQ)VLAN802.1Q-2018 §9.6, PCP/DEI/VID, tag stacking
2ARPARPRFC 826, IPv4-over-Ethernet binding
3IPv4IPv4RFC 791, IHL/options aware
3IPv6IPv6RFC 8200
3IPv6 Hop-by-Hop OptionsIPv6HopByHopOptionsRFC 8200 §4.3
3IPv6 RoutingIPv6RoutingRFC 8200 §4.4
3IPv6 FragmentIPv6FragmentRFC 8200 §4.5, first-fragment chaining
3IPv6 Destination OptionsIPv6DestinationOptionsRFC 8200 §4.6
3ICMPv4ICMPv4RFC 792, 8-byte header
3ICMPv6ICMPv6RFC 4443, 8-byte header
3IGMPIGMPRFC 1112/2236/3376, IPv4 multicast management; IGMPv3 report group records + query fields
3GREGRERFC 2784/2890, IP protocol 47; payload chains onward by EtherType
4TCPTCPRFC 9293, data-offset/options aware
4UDPUDPRFC 768
7DNSDNSRFC 1035, over UDP and TCP (DNSOverTCP length shim); on-demand name decompression + resource-record parsing
7DHCPDHCPRFC 2131/2132, over UDP 67/68; TLV options parsed on demand

Decoding a captured frame

decode_frame() walks the whole chain and hands back a Packet:

fromnetprotocolsimportTCP, decode_framepacket=decode_frame(frame)
print(packet) # Packet(Ethernet(...), IPv4(...), TCP(...))print(packet[1].src) # '192.168.1.96' — position, unchangedprint(packet[TCP].flags_str) # first TCP layer, or KeyError if noneprint(packet.get(TCP)) # same lookup, None instead of raisingprint(packet.consumed) # bytes the headers occupied

It works because every header answers two questions: header_len (how many bytes it consumed) and next_protocol() (which class decodes what follows). Trailing bytes are fine — frame[packet.consumed:] is whatever the chain did not decode.

Start somewhere other than Ethernet when the buffer does — a tunnel payload, a packet quoted inside an ICMP error, a non-Ethernet link type:

decode_frame(buf, start=IPv4)

Malformed input raises exceptions rooted at a single base class:

fromnetprotocolsimportProtocolError, TruncatedHeaderError, InvalidFieldErrortry:
packet=decode_frame(frame)
exceptTruncatedHeaderError: # buffer shorter than the header claims
...
exceptInvalidFieldError: # nonsense field values (IHL < 5, bad address)
...
exceptProtocolError: # catches every library error
...

Every exception carries structured context alongside its message — err.protocol names the class that raised, err.field names the attribute at fault where one field is at fault, and err.offset / err.frame_offset locate the problem in bytes (in the header's own buffer, and rebased to the whole frame when the error came through decode_frame) — so a fuzzer, conformance suite, or validation tool can act on where and what failed without parsing the message:

try:
packet=decode_frame(frame)
exceptProtocolErrorase:
print(f"{e.protocol.__name__} at byte {e.frame_offset}: {e}")
# IPv4 at byte 14: IPv4 IHL must be at least 5, got 0

A capture tool would usually rather keep the layers it got than lose frame 4,000,001 to an exception. lax=True reports instead of raising:

packet=decode_frame(frame, lax=True)
ifpacket.stopped_byisnotNone:
log.warning("stopped after %d layers: %s", len(packet), packet.stopped_by)

Lax mode never invents a layer and never guesses — each header is decoded exactly as strictly as before; the walk just declines to throw away the part that worked. Chain depth is bounded (max_depth, default 32), so a crafted frame cannot make the walker grind.

One case where reaching for lax=True is the right default, not just a convenience: an ICMP error message's embedded packet. RFC 792 only guarantees the invoking IP header plus 8 bytes of what follows — never a full TCP/UDP header — so decoding it with the strict path raises on ordinary, correctly formed traffic. ICMPv4/ICMPv6 expose this pre-wired as embedded_chain:

icmp.embedded_chain# decode_frame(icmp.embedded_packet, lax=True, start=IPv4)# → Packet([IPv4(...)]), stopped_by=TruncatedHeaderError(...)

embedded_packet stays available for the raw bytes; reach for embedded_chain when you want them already decoded and are prepared to read stopped_by. Outside this one RFC-mandated case, a complete frame that fails to decode is still a bug to raise on — lax=True elsewhere is a capture tool's choice, not a default.

Reading captures

read_captures() takes the bytes of a capture file — not a path — and auto-detects classic pcap vs. pcapng from its magic number:

fromnetprotocolsimportdecode_frame, read_capturesdata=open("traffic.pcap", "rb").read() # or however you got the bytesfortimestamp, frameinread_captures(data):
packet=decode_frame(frame, lax=True)
...

Each CapturedFrame is (timestamp, data)timestamp normalized to nanoseconds since the Unix epoch regardless of the source format's own resolution (classic pcap's microseconds or nanoseconds; pcapng's per-interface if_tsresol). read_pcap()/read_pcapng() are the same thing for a caller who already knows the format and wants to skip detection. A malformed or truncated capture raises MalformedCaptureError, the same ProtocolError family every other exception in this library belongs to.

Flow keys

Packet.flow_key() (or the free function, netprotocols.flow_key(), for a header pair that never went through decode_frame) returns a canonical, direction-independent key for a TCP/UDP conversation — both directions of one flow produce the same key:

request=decode_frame(client_to_server_frame)
reply=decode_frame(server_to_client_frame)
assertrequest.flow_key() ==reply.flow_key()

It returns None, not an error, when there is nothing to key on: no enclosing IP layer, no TCP/UDP layer, or a transport layer without ports at all (ICMPv4/ICMPv6 — this library does not invent a port-slot convention for message types that have none).

Structural pattern matching

Every decoded header works with match/case today, with no code written for it: a frozen dataclass auto-generates __match_args__, and every enum field is an IntEnum, so a class pattern can match a named value against the plain int the wire actually carries. See ARCHITECTURE.md for why both of those hold and how to keep them holding.

IPv4, TCP and DHCP are wide enough (11-15 fields) that their full auto-generated positional form is unusable, so those three additionally curate a short __match_args__ by hand — the fields someone matching by position actually reaches for:

matchip:
caseIPv4(src, dst, IPProtocol.TCP):
print(f"TCP: {src} -> {dst}")

Keyword patterns (case IPv4(protocol=IPProtocol.TCP)) work on every field regardless, curated or not, and stay the documented default.

fromnetprotocolsimportIPv4, IPProtocol, TCPmatchpacket.layers:
case [_, IPv4(protocol=IPProtocol.TCP) asip, TCP(flags_str=f), *_] \
if"SYN"infand"ACK"notinf:
print(f"connection attempt from {ip.src}")
case [_, IPv4() asip, TCP(), *_]:
print(f"TCP from {ip.src}")
case _:
print("not a TCP-over-IPv4 frame")

Decoding a protocol we do not ship

The dispatch tables are public, so a protocol this library does not implement can join the walk without forking it:

fromnetprotocolsimportProtocolfromnetprotocols.registryimportregister@register("ethertype", 0x8847)classMPLS(Protocol):
...

The five tables are ethertype, ip.proto, ip.proto.v6, udp.port and tcp.port, each named after the wire field it dispatches on. To change decoding for one call only — DNS on a nonstandard port in one capture — say so per call instead of registering globally:

decode_frame(frame, decode_as={"udp.port": {6969: DNS}})

See ARCHITECTURE.md for how the tables fit together and how ip.proto.v6 inherits ip.proto.

Building and serializing headers

Constructors take friendly values (string MAC/IP addresses, integer fields) and validate them; bytes() emits the exact on-wire form. Packet concatenates a stack of layers:

fromnetprotocolsimportARP, Ethernet, Packet, random_macsha=random_mac()
frame=Packet(
Ethernet(dst="ff:ff:ff:ff:ff:ff", src=sha, ethertype=0x0806),
ARP(htype=1, ptype=0x0800, hlen=6, plen=4, oper=1,
sha=sha, spa="192.168.1.96",
tha="00:00:00:00:00:00", tpa="192.168.1.254"),
)
raw=bytes(frame) # ready for a raw socket

Checksums are computed and verified on request — never silently: compute()/verify() in netprotocols.checksum, and Packet.with_checksums() to fill a whole stack before sending.

Display helpers

Every class exposes human-readable properties next to the raw fields: Ethernet.ethertype_name, IPv4.protocol_name, IPv4.flags_name, TCP.flags_str ("SYN ACK"), ARP.oper_name, ICMPv4.type_name, and hexadecimal renderings such as checksum_hex_str. Unknown values degrade gracefully ("0x88cc", "unknown (47)") instead of raising.

How it works

See ARCHITECTURE.md for a guided tour: how a header byte layout maps onto a dataclass, the decode contract, the next_protocol() chain, and a cookbook for adding a new protocol.

This library is the engine behind RootWire, a network traffic monitor built on it (formerly Packet-Sniffer).

Roadmap

The post-1.3.0 roadmap, #107, is complete — five tiers plus a competitor CI audit (#124) and the comparative-claims embargo lift that closed it out:

VersionTheme
1.3.1Hygiene
1.4.0Decode performance
2.0.0A public protocol registry, a shipped chain walker, flow keys — released
2.1.0Typed accessors and pattern-matching ergonomics
2.2.0Universal round-trip properties, nightly fuzzing, a pcap reader

Everything through 2.2.0 has landed on master; per the roadmap's own release policy, only 2.0.0 was an actual PyPI release, and the comparative claims above draw on the finished tree. New planned work will open fresh issues rather than restating a list here, so this section stays accurate without upkeep. The wave before this one — TCP and IPv4 option parsing, ICMP message bodies, NDP, IPv6 extension-header TLV options, the GRE checksum arm, a DNS-over-TCP corpus fixture and ipaddress accessors — shipped in 1.3.0; see CHANGELOG.md.

Contributing

Development uses uv: uv sync, then uv run pytest, uv run mypy, and uv run ruff check — all three are enforced by CI on Python 3.12–3.14. The test suite is anchored by a 97-frame corpus of real captured traffic across 17 scenarios (tests/fixtures/MANIFEST.md) plus property-based fuzzing of the decode path. See CONTRIBUTING.md for the full workflow and ARCHITECTURE.md for the design and the add-a-protocol cookbook.

License

MIT

About

Low-level implementations of common networking protocols in Python 3

Topics

Resources

Contributing

Stars

11 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NETProtocols

CIPython VersionLicense: MITTyped

Low-level implementations of common networking protocols, in pure Python with zero dependencies.

Decode raw header bytes into typed, immutable protocol objects — or build those objects from field values and serialize them back to their exact on-wire form. Every header is a frozen dataclass whose fields mirror the wire format, so decoded traffic is introspectable, comparable, and round-trippable.

>>> from netprotocols import Ethernet
>>> eth = Ethernet.decode(frame)
>>> eth
Ethernet(dst='ff:ff:ff:ff:ff:ff', src='00:07:0d:af:f4:54', ethertype=2054)
>>> eth.ethertype_name
'ARP'
>>> eth.next_protocol()
<class 'netprotocols.layer2.arp.ARP'>
>>> bytes(eth) == frame[:eth.header_len]
True

Every header is also a structural pattern — no extra code, because a frozen dataclass auto-generates __match_args__ and every enum field is an IntEnum, so a bare wire value matches a named one:

matchip:
caseIPv4(protocol=IPProtocol.TCP, ttl=t) ift>32:
...
caseIPv4(protocol=IPProtocol.UDP):
...

Installation

pip install netprotocols

Requires Python 3.12+. Fully typed (py.typed, mypy strict).

Why NETProtocols

Measured against dpkt 1.9.8 and scapy 2.7.0 on a 97-frame corpus of real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured 2026-09-04 — see docs/CLAIMS.md for every number below, its reproduction command, and its caveats):

  • Within 15% of dpkt on decode, 5.6× faster than scapyuv run --group bench python scripts/benchmark.py --compare. dpkt is the faster of the two comparators on this corpus; the gap moves both ways as this library and dpkt each change, and this file's job is to say so honestly rather than only when it's flattering.
  • Decodes further into the stack than dpkt on 27 of 97 corpus frames — DNS and DHCP payloads that dpkt leaves as raw bytes, netprotocols continues decoding. A throughput number means little without knowing how much work it bought.
  • 4.5× faster than dpkt at re-encoding (bytes(header), scripts/benchmark_encode.py) — the other half of "codec" that nobody else benchmarks.
  • Imports in 54 ms; scapy.all takes 458+ ms (scripts/benchmark_import.py), and never touches the host doing it — importing scapy populates live interface and routing tables as a side effect of the import statement.
  • An 85.6 KB wheel against scapy's 2.47 MB — about 30× smaller.
  • Regression-gated in CI, which — as far as we could establish by auditing ten comparable Python packet libraries' CI configurations (dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark, nfstream, stackforge, PyTCP-net_proto; full citations in docs/CLAIMS.md §1.7) — none of them do. Two of the ten ship real benchmark code that simply never runs in CI (construct disables it explicitly; stackforge's Criterion benches are never invoked); the rest have no performance benchmark at all.
  • Typed where the alternatives are not.mypy --strict over the whole of src/, enforced in CI. scapy ships py.typed but enables strict checking on 107 of its files, 2 of the 121 under scapy/layers/ — the dissectors most code touches stay Any. dpkt ships no py.typed at all, and no third-party stub package exists for it.
  • The only MIT-licensed, strictly-typed, zero-dependency packet codec still under active maintenance. scapy is GPL-2.0; pypacker is GPLv2; the newer entrants stackforge and PyTCP-net_proto are both GPL-3.0. The other permissive option, dpkt (BSD), last released 2022-08-18 and last committed 2024-05-05, still targets Python 2.7 and 3.5–3.9, and remains marked Beta after twelve years.
  • Runs where scapy cannot — including in the browser. Verified under a real Pyodide runtime in CI: scapy fails to import at all under Pyodide (from fcntl import ioctl is unconditional in scapy/arch/), where netprotocols, dpkt and pypacker all import cleanly.
  • The only one of these libraries a match/case statement dissects out of the box. dpkt builds __slots__ from a metaclass and generates no __match_args__; scapy routes fields through __getattr__; construct returns dicts. None gives you a pattern to match against.

None of this makes scapy less than an extraordinary piece of software — it crafts, sends, sniffs and fuzzes across thousands of protocols, and this library does none of that. The comparison above is scoped to what both are: a codec that turns bytes into typed objects and back.

One mypy --strict run says more than the bullets above:

# scapy 2.7.0 — a field name that does not exist
p.ThisFieldDoesNotExist → Any (no error)
# dpkt 1.9.8 — the whole module
import dpkt.ethernet → error: missing library stubs or py.typed
# netprotocols — a typo in a real field name
ip.proto → error: "IPv4" has no attribute
"proto"; maybe "protocol"?

Protocol coverage

LayerProtocolClassNotes
2Ethernet IIEthernetIEEE 802.3
2IEEE 802.1Q VLAN tag (802.1ad QinQ)VLAN802.1Q-2018 §9.6, PCP/DEI/VID, tag stacking
2ARPARPRFC 826, IPv4-over-Ethernet binding
3IPv4IPv4RFC 791, IHL/options aware
3IPv6IPv6RFC 8200
3IPv6 Hop-by-Hop OptionsIPv6HopByHopOptionsRFC 8200 §4.3
3IPv6 RoutingIPv6RoutingRFC 8200 §4.4
3IPv6 FragmentIPv6FragmentRFC 8200 §4.5, first-fragment chaining
3IPv6 Destination OptionsIPv6DestinationOptionsRFC 8200 §4.6
3ICMPv4ICMPv4RFC 792, 8-byte header
3ICMPv6ICMPv6RFC 4443, 8-byte header
3IGMPIGMPRFC 1112/2236/3376, IPv4 multicast management; IGMPv3 report group records + query fields
3GREGRERFC 2784/2890, IP protocol 47; payload chains onward by EtherType
4TCPTCPRFC 9293, data-offset/options aware
4UDPUDPRFC 768
7DNSDNSRFC 1035, over UDP and TCP (DNSOverTCP length shim); on-demand name decompression + resource-record parsing
7DHCPDHCPRFC 2131/2132, over UDP 67/68; TLV options parsed on demand

Decoding a captured frame

decode_frame() walks the whole chain and hands back a Packet:

fromnetprotocolsimportTCP, decode_framepacket=decode_frame(frame)
print(packet) # Packet(Ethernet(...), IPv4(...), TCP(...))print(packet[1].src) # '192.168.1.96' — position, unchangedprint(packet[TCP].flags_str) # first TCP layer, or KeyError if noneprint(packet.get(TCP)) # same lookup, None instead of raisingprint(packet.consumed) # bytes the headers occupied

It works because every header answers two questions: header_len (how many bytes it consumed) and next_protocol() (which class decodes what follows). Trailing bytes are fine — frame[packet.consumed:] is whatever the chain did not decode.

Start somewhere other than Ethernet when the buffer does — a tunnel payload, a packet quoted inside an ICMP error, a non-Ethernet link type:

decode_frame(buf, start=IPv4)

Malformed input raises exceptions rooted at a single base class:

fromnetprotocolsimportProtocolError, TruncatedHeaderError, InvalidFieldErrortry:
packet=decode_frame(frame)
exceptTruncatedHeaderError: # buffer shorter than the header claims
...
exceptInvalidFieldError: # nonsense field values (IHL < 5, bad address)
...
exceptProtocolError: # catches every library error
...

Every exception carries structured context alongside its message — err.protocol names the class that raised, err.field names the attribute at fault where one field is at fault, and err.offset / err.frame_offset locate the problem in bytes (in the header's own buffer, and rebased to the whole frame when the error came through decode_frame) — so a fuzzer, conformance suite, or validation tool can act on where and what failed without parsing the message:

try:
packet=decode_frame(frame)
exceptProtocolErrorase:
print(f"{e.protocol.__name__} at byte {e.frame_offset}: {e}")
# IPv4 at byte 14: IPv4 IHL must be at least 5, got 0

A capture tool would usually rather keep the layers it got than lose frame 4,000,001 to an exception. lax=True reports instead of raising:

packet=decode_frame(frame, lax=True)
ifpacket.stopped_byisnotNone:
log.warning("stopped after %d layers: %s", len(packet), packet.stopped_by)

Lax mode never invents a layer and never guesses — each header is decoded exactly as strictly as before; the walk just declines to throw away the part that worked. Chain depth is bounded (max_depth, default 32), so a crafted frame cannot make the walker grind.

One case where reaching for lax=True is the right default, not just a convenience: an ICMP error message's embedded packet. RFC 792 only guarantees the invoking IP header plus 8 bytes of what follows — never a full TCP/UDP header — so decoding it with the strict path raises on ordinary, correctly formed traffic. ICMPv4/ICMPv6 expose this pre-wired as embedded_chain:

icmp.embedded_chain# decode_frame(icmp.embedded_packet, lax=True, start=IPv4)# → Packet([IPv4(...)]), stopped_by=TruncatedHeaderError(...)

embedded_packet stays available for the raw bytes; reach for embedded_chain when you want them already decoded and are prepared to read stopped_by. Outside this one RFC-mandated case, a complete frame that fails to decode is still a bug to raise on — lax=True elsewhere is a capture tool's choice, not a default.

Reading captures

read_captures() takes the bytes of a capture file — not a path — and auto-detects classic pcap vs. pcapng from its magic number:

fromnetprotocolsimportdecode_frame, read_capturesdata=open("traffic.pcap", "rb").read() # or however you got the bytesfortimestamp, frameinread_captures(data):
packet=decode_frame(frame, lax=True)
...

Each CapturedFrame is (timestamp, data)timestamp normalized to nanoseconds since the Unix epoch regardless of the source format's own resolution (classic pcap's microseconds or nanoseconds; pcapng's per-interface if_tsresol). read_pcap()/read_pcapng() are the same thing for a caller who already knows the format and wants to skip detection. A malformed or truncated capture raises MalformedCaptureError, the same ProtocolError family every other exception in this library belongs to.

Flow keys

Packet.flow_key() (or the free function, netprotocols.flow_key(), for a header pair that never went through decode_frame) returns a canonical, direction-independent key for a TCP/UDP conversation — both directions of one flow produce the same key:

request=decode_frame(client_to_server_frame)
reply=decode_frame(server_to_client_frame)
assertrequest.flow_key() ==reply.flow_key()

It returns None, not an error, when there is nothing to key on: no enclosing IP layer, no TCP/UDP layer, or a transport layer without ports at all (ICMPv4/ICMPv6 — this library does not invent a port-slot convention for message types that have none).

Structural pattern matching

Every decoded header works with match/case today, with no code written for it: a frozen dataclass auto-generates __match_args__, and every enum field is an IntEnum, so a class pattern can match a named value against the plain int the wire actually carries. See ARCHITECTURE.md for why both of those hold and how to keep them holding.

IPv4, TCP and DHCP are wide enough (11-15 fields) that their full auto-generated positional form is unusable, so those three additionally curate a short __match_args__ by hand — the fields someone matching by position actually reaches for:

matchip:
caseIPv4(src, dst, IPProtocol.TCP):
print(f"TCP: {src} -> {dst}")

Keyword patterns (case IPv4(protocol=IPProtocol.TCP)) work on every field regardless, curated or not, and stay the documented default.

fromnetprotocolsimportIPv4, IPProtocol, TCPmatchpacket.layers:
case [_, IPv4(protocol=IPProtocol.TCP) asip, TCP(flags_str=f), *_] \
if"SYN"infand"ACK"notinf:
print(f"connection attempt from {ip.src}")
case [_, IPv4() asip, TCP(), *_]:
print(f"TCP from {ip.src}")
case _:
print("not a TCP-over-IPv4 frame")

Decoding a protocol we do not ship

The dispatch tables are public, so a protocol this library does not implement can join the walk without forking it:

fromnetprotocolsimportProtocolfromnetprotocols.registryimportregister@register("ethertype", 0x8847)classMPLS(Protocol):
...

The five tables are ethertype, ip.proto, ip.proto.v6, udp.port and tcp.port, each named after the wire field it dispatches on. To change decoding for one call only — DNS on a nonstandard port in one capture — say so per call instead of registering globally:

decode_frame(frame, decode_as={"udp.port": {6969: DNS}})

See ARCHITECTURE.md for how the tables fit together and how ip.proto.v6 inherits ip.proto.

Building and serializing headers

Constructors take friendly values (string MAC/IP addresses, integer fields) and validate them; bytes() emits the exact on-wire form. Packet concatenates a stack of layers:

fromnetprotocolsimportARP, Ethernet, Packet, random_macsha=random_mac()
frame=Packet(
Ethernet(dst="ff:ff:ff:ff:ff:ff", src=sha, ethertype=0x0806),
ARP(htype=1, ptype=0x0800, hlen=6, plen=4, oper=1,
sha=sha, spa="192.168.1.96",
tha="00:00:00:00:00:00", tpa="192.168.1.254"),
)
raw=bytes(frame) # ready for a raw socket

Checksums are computed and verified on request — never silently: compute()/verify() in netprotocols.checksum, and Packet.with_checksums() to fill a whole stack before sending.

Display helpers

Every class exposes human-readable properties next to the raw fields: Ethernet.ethertype_name, IPv4.protocol_name, IPv4.flags_name, TCP.flags_str ("SYN ACK"), ARP.oper_name, ICMPv4.type_name, and hexadecimal renderings such as checksum_hex_str. Unknown values degrade gracefully ("0x88cc", "unknown (47)") instead of raising.

How it works

See ARCHITECTURE.md for a guided tour: how a header byte layout maps onto a dataclass, the decode contract, the next_protocol() chain, and a cookbook for adding a new protocol.

This library is the engine behind RootWire, a network traffic monitor built on it (formerly Packet-Sniffer).

Roadmap

The post-1.3.0 roadmap, #107, is complete — five tiers plus a competitor CI audit (#124) and the comparative-claims embargo lift that closed it out:

VersionTheme
1.3.1Hygiene
1.4.0Decode performance
2.0.0A public protocol registry, a shipped chain walker, flow keys — released
2.1.0Typed accessors and pattern-matching ergonomics
2.2.0Universal round-trip properties, nightly fuzzing, a pcap reader

Everything through 2.2.0 has landed on master; per the roadmap's own release policy, only 2.0.0 was an actual PyPI release, and the comparative claims above draw on the finished tree. New planned work will open fresh issues rather than restating a list here, so this section stays accurate without upkeep. The wave before this one — TCP and IPv4 option parsing, ICMP message bodies, NDP, IPv6 extension-header TLV options, the GRE checksum arm, a DNS-over-TCP corpus fixture and ipaddress accessors — shipped in 1.3.0; see CHANGELOG.md.

Contributing

Development uses uv: uv sync, then uv run pytest, uv run mypy, and uv run ruff check — all three are enforced by CI on Python 3.12–3.14. The test suite is anchored by a 97-frame corpus of real captured traffic across 17 scenarios (tests/fixtures/MANIFEST.md) plus property-based fuzzing of the decode path. See CONTRIBUTING.md for the full workflow and ARCHITECTURE.md for the design and the add-a-protocol cookbook.

License

MIT

About

Low-level implementations of common networking protocols in Python 3

Topics

Resources

Contributing

Stars

11 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NETProtocols

CIPython VersionLicense: MITTyped

Low-level implementations of common networking protocols, in pure Python with zero dependencies.

Decode raw header bytes into typed, immutable protocol objects — or build those objects from field values and serialize them back to their exact on-wire form. Every header is a frozen dataclass whose fields mirror the wire format, so decoded traffic is introspectable, comparable, and round-trippable.

>>> from netprotocols import Ethernet
>>> eth = Ethernet.decode(frame)
>>> eth
Ethernet(dst='ff:ff:ff:ff:ff:ff', src='00:07:0d:af:f4:54', ethertype=2054)
>>> eth.ethertype_name
'ARP'
>>> eth.next_protocol()
<class 'netprotocols.layer2.arp.ARP'>
>>> bytes(eth) == frame[:eth.header_len]
True

Every header is also a structural pattern — no extra code, because a frozen dataclass auto-generates __match_args__ and every enum field is an IntEnum, so a bare wire value matches a named one:

matchip:
caseIPv4(protocol=IPProtocol.TCP, ttl=t) ift>32:
...
caseIPv4(protocol=IPProtocol.UDP):
...

Installation

pip install netprotocols

Requires Python 3.12+. Fully typed (py.typed, mypy strict).

Why NETProtocols

Measured against dpkt 1.9.8 and scapy 2.7.0 on a 97-frame corpus of real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured 2026-09-04 — see docs/CLAIMS.md for every number below, its reproduction command, and its caveats):

  • Within 15% of dpkt on decode, 5.6× faster than scapyuv run --group bench python scripts/benchmark.py --compare. dpkt is the faster of the two comparators on this corpus; the gap moves both ways as this library and dpkt each change, and this file's job is to say so honestly rather than only when it's flattering.
  • Decodes further into the stack than dpkt on 27 of 97 corpus frames — DNS and DHCP payloads that dpkt leaves as raw bytes, netprotocols continues decoding. A throughput number means little without knowing how much work it bought.
  • 4.5× faster than dpkt at re-encoding (bytes(header), scripts/benchmark_encode.py) — the other half of "codec" that nobody else benchmarks.
  • Imports in 54 ms; scapy.all takes 458+ ms (scripts/benchmark_import.py), and never touches the host doing it — importing scapy populates live interface and routing tables as a side effect of the import statement.
  • An 85.6 KB wheel against scapy's 2.47 MB — about 30× smaller.
  • Regression-gated in CI, which — as far as we could establish by auditing ten comparable Python packet libraries' CI configurations (dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark, nfstream, stackforge, PyTCP-net_proto; full citations in docs/CLAIMS.md §1.7) — none of them do. Two of the ten ship real benchmark code that simply never runs in CI (construct disables it explicitly; stackforge's Criterion benches are never invoked); the rest have no performance benchmark at all.
  • Typed where the alternatives are not.mypy --strict over the whole of src/, enforced in CI. scapy ships py.typed but enables strict checking on 107 of its files, 2 of the 121 under scapy/layers/ — the dissectors most code touches stay Any. dpkt ships no py.typed at all, and no third-party stub package exists for it.
  • The only MIT-licensed, strictly-typed, zero-dependency packet codec still under active maintenance. scapy is GPL-2.0; pypacker is GPLv2; the newer entrants stackforge and PyTCP-net_proto are both GPL-3.0. The other permissive option, dpkt (BSD), last released 2022-08-18 and last committed 2024-05-05, still targets Python 2.7 and 3.5–3.9, and remains marked Beta after twelve years.
  • Runs where scapy cannot — including in the browser. Verified under a real Pyodide runtime in CI: scapy fails to import at all under Pyodide (from fcntl import ioctl is unconditional in scapy/arch/), where netprotocols, dpkt and pypacker all import cleanly.
  • The only one of these libraries a match/case statement dissects out of the box. dpkt builds __slots__ from a metaclass and generates no __match_args__; scapy routes fields through __getattr__; construct returns dicts. None gives you a pattern to match against.

None of this makes scapy less than an extraordinary piece of software — it crafts, sends, sniffs and fuzzes across thousands of protocols, and this library does none of that. The comparison above is scoped to what both are: a codec that turns bytes into typed objects and back.

One mypy --strict run says more than the bullets above:

# scapy 2.7.0 — a field name that does not exist
p.ThisFieldDoesNotExist → Any (no error)
# dpkt 1.9.8 — the whole module
import dpkt.ethernet → error: missing library stubs or py.typed
# netprotocols — a typo in a real field name
ip.proto → error: "IPv4" has no attribute
"proto"; maybe "protocol"?

Protocol coverage

LayerProtocolClassNotes
2Ethernet IIEthernetIEEE 802.3
2IEEE 802.1Q VLAN tag (802.1ad QinQ)VLAN802.1Q-2018 §9.6, PCP/DEI/VID, tag stacking
2ARPARPRFC 826, IPv4-over-Ethernet binding
3IPv4IPv4RFC 791, IHL/options aware
3IPv6IPv6RFC 8200
3IPv6 Hop-by-Hop OptionsIPv6HopByHopOptionsRFC 8200 §4.3
3IPv6 RoutingIPv6RoutingRFC 8200 §4.4
3IPv6 FragmentIPv6FragmentRFC 8200 §4.5, first-fragment chaining
3IPv6 Destination OptionsIPv6DestinationOptionsRFC 8200 §4.6
3ICMPv4ICMPv4RFC 792, 8-byte header
3ICMPv6ICMPv6RFC 4443, 8-byte header
3IGMPIGMPRFC 1112/2236/3376, IPv4 multicast management; IGMPv3 report group records + query fields
3GREGRERFC 2784/2890, IP protocol 47; payload chains onward by EtherType
4TCPTCPRFC 9293, data-offset/options aware
4UDPUDPRFC 768
7DNSDNSRFC 1035, over UDP and TCP (DNSOverTCP length shim); on-demand name decompression + resource-record parsing
7DHCPDHCPRFC 2131/2132, over UDP 67/68; TLV options parsed on demand

Decoding a captured frame

decode_frame() walks the whole chain and hands back a Packet:

fromnetprotocolsimportTCP, decode_framepacket=decode_frame(frame)
print(packet) # Packet(Ethernet(...), IPv4(...), TCP(...))print(packet[1].src) # '192.168.1.96' — position, unchangedprint(packet[TCP].flags_str) # first TCP layer, or KeyError if noneprint(packet.get(TCP)) # same lookup, None instead of raisingprint(packet.consumed) # bytes the headers occupied

It works because every header answers two questions: header_len (how many bytes it consumed) and next_protocol() (which class decodes what follows). Trailing bytes are fine — frame[packet.consumed:] is whatever the chain did not decode.

Start somewhere other than Ethernet when the buffer does — a tunnel payload, a packet quoted inside an ICMP error, a non-Ethernet link type:

decode_frame(buf, start=IPv4)

Malformed input raises exceptions rooted at a single base class:

fromnetprotocolsimportProtocolError, TruncatedHeaderError, InvalidFieldErrortry:
packet=decode_frame(frame)
exceptTruncatedHeaderError: # buffer shorter than the header claims
...
exceptInvalidFieldError: # nonsense field values (IHL < 5, bad address)
...
exceptProtocolError: # catches every library error
...

Every exception carries structured context alongside its message — err.protocol names the class that raised, err.field names the attribute at fault where one field is at fault, and err.offset / err.frame_offset locate the problem in bytes (in the header's own buffer, and rebased to the whole frame when the error came through decode_frame) — so a fuzzer, conformance suite, or validation tool can act on where and what failed without parsing the message:

try:
packet=decode_frame(frame)
exceptProtocolErrorase:
print(f"{e.protocol.__name__} at byte {e.frame_offset}: {e}")
# IPv4 at byte 14: IPv4 IHL must be at least 5, got 0

A capture tool would usually rather keep the layers it got than lose frame 4,000,001 to an exception. lax=True reports instead of raising:

packet=decode_frame(frame, lax=True)
ifpacket.stopped_byisnotNone:
log.warning("stopped after %d layers: %s", len(packet), packet.stopped_by)

Lax mode never invents a layer and never guesses — each header is decoded exactly as strictly as before; the walk just declines to throw away the part that worked. Chain depth is bounded (max_depth, default 32), so a crafted frame cannot make the walker grind.

One case where reaching for lax=True is the right default, not just a convenience: an ICMP error message's embedded packet. RFC 792 only guarantees the invoking IP header plus 8 bytes of what follows — never a full TCP/UDP header — so decoding it with the strict path raises on ordinary, correctly formed traffic. ICMPv4/ICMPv6 expose this pre-wired as embedded_chain:

icmp.embedded_chain# decode_frame(icmp.embedded_packet, lax=True, start=IPv4)# → Packet([IPv4(...)]), stopped_by=TruncatedHeaderError(...)

embedded_packet stays available for the raw bytes; reach for embedded_chain when you want them already decoded and are prepared to read stopped_by. Outside this one RFC-mandated case, a complete frame that fails to decode is still a bug to raise on — lax=True elsewhere is a capture tool's choice, not a default.

Reading captures

read_captures() takes the bytes of a capture file — not a path — and auto-detects classic pcap vs. pcapng from its magic number:

fromnetprotocolsimportdecode_frame, read_capturesdata=open("traffic.pcap", "rb").read() # or however you got the bytesfortimestamp, frameinread_captures(data):
packet=decode_frame(frame, lax=True)
...

Each CapturedFrame is (timestamp, data)timestamp normalized to nanoseconds since the Unix epoch regardless of the source format's own resolution (classic pcap's microseconds or nanoseconds; pcapng's per-interface if_tsresol). read_pcap()/read_pcapng() are the same thing for a caller who already knows the format and wants to skip detection. A malformed or truncated capture raises MalformedCaptureError, the same ProtocolError family every other exception in this library belongs to.

Flow keys

Packet.flow_key() (or the free function, netprotocols.flow_key(), for a header pair that never went through decode_frame) returns a canonical, direction-independent key for a TCP/UDP conversation — both directions of one flow produce the same key:

request=decode_frame(client_to_server_frame)
reply=decode_frame(server_to_client_frame)
assertrequest.flow_key() ==reply.flow_key()

It returns None, not an error, when there is nothing to key on: no enclosing IP layer, no TCP/UDP layer, or a transport layer without ports at all (ICMPv4/ICMPv6 — this library does not invent a port-slot convention for message types that have none).

Structural pattern matching

Every decoded header works with match/case today, with no code written for it: a frozen dataclass auto-generates __match_args__, and every enum field is an IntEnum, so a class pattern can match a named value against the plain int the wire actually carries. See ARCHITECTURE.md for why both of those hold and how to keep them holding.

IPv4, TCP and DHCP are wide enough (11-15 fields) that their full auto-generated positional form is unusable, so those three additionally curate a short __match_args__ by hand — the fields someone matching by position actually reaches for:

matchip:
caseIPv4(src, dst, IPProtocol.TCP):
print(f"TCP: {src} -> {dst}")

Keyword patterns (case IPv4(protocol=IPProtocol.TCP)) work on every field regardless, curated or not, and stay the documented default.

fromnetprotocolsimportIPv4, IPProtocol, TCPmatchpacket.layers:
case [_, IPv4(protocol=IPProtocol.TCP) asip, TCP(flags_str=f), *_] \
if"SYN"infand"ACK"notinf:
print(f"connection attempt from {ip.src}")
case [_, IPv4() asip, TCP(), *_]:
print(f"TCP from {ip.src}")
case _:
print("not a TCP-over-IPv4 frame")

Decoding a protocol we do not ship

The dispatch tables are public, so a protocol this library does not implement can join the walk without forking it:

fromnetprotocolsimportProtocolfromnetprotocols.registryimportregister@register("ethertype", 0x8847)classMPLS(Protocol):
...

The five tables are ethertype, ip.proto, ip.proto.v6, udp.port and tcp.port, each named after the wire field it dispatches on. To change decoding for one call only — DNS on a nonstandard port in one capture — say so per call instead of registering globally:

decode_frame(frame, decode_as={"udp.port": {6969: DNS}})

See ARCHITECTURE.md for how the tables fit together and how ip.proto.v6 inherits ip.proto.

Building and serializing headers

Constructors take friendly values (string MAC/IP addresses, integer fields) and validate them; bytes() emits the exact on-wire form. Packet concatenates a stack of layers:

fromnetprotocolsimportARP, Ethernet, Packet, random_macsha=random_mac()
frame=Packet(
Ethernet(dst="ff:ff:ff:ff:ff:ff", src=sha, ethertype=0x0806),
ARP(htype=1, ptype=0x0800, hlen=6, plen=4, oper=1,
sha=sha, spa="192.168.1.96",
tha="00:00:00:00:00:00", tpa="192.168.1.254"),
)
raw=bytes(frame) # ready for a raw socket

Checksums are computed and verified on request — never silently: compute()/verify() in netprotocols.checksum, and Packet.with_checksums() to fill a whole stack before sending.

Display helpers

Every class exposes human-readable properties next to the raw fields: Ethernet.ethertype_name, IPv4.protocol_name, IPv4.flags_name, TCP.flags_str ("SYN ACK"), ARP.oper_name, ICMPv4.type_name, and hexadecimal renderings such as checksum_hex_str. Unknown values degrade gracefully ("0x88cc", "unknown (47)") instead of raising.

How it works

See ARCHITECTURE.md for a guided tour: how a header byte layout maps onto a dataclass, the decode contract, the next_protocol() chain, and a cookbook for adding a new protocol.

This library is the engine behind RootWire, a network traffic monitor built on it (formerly Packet-Sniffer).

Roadmap

The post-1.3.0 roadmap, #107, is complete — five tiers plus a competitor CI audit (#124) and the comparative-claims embargo lift that closed it out:

VersionTheme
1.3.1Hygiene
1.4.0Decode performance
2.0.0A public protocol registry, a shipped chain walker, flow keys — released
2.1.0Typed accessors and pattern-matching ergonomics
2.2.0Universal round-trip properties, nightly fuzzing, a pcap reader

Everything through 2.2.0 has landed on master; per the roadmap's own release policy, only 2.0.0 was an actual PyPI release, and the comparative claims above draw on the finished tree. New planned work will open fresh issues rather than restating a list here, so this section stays accurate without upkeep. The wave before this one — TCP and IPv4 option parsing, ICMP message bodies, NDP, IPv6 extension-header TLV options, the GRE checksum arm, a DNS-over-TCP corpus fixture and ipaddress accessors — shipped in 1.3.0; see CHANGELOG.md.

Contributing

Development uses uv: uv sync, then uv run pytest, uv run mypy, and uv run ruff check — all three are enforced by CI on Python 3.12–3.14. The test suite is anchored by a 97-frame corpus of real captured traffic across 17 scenarios (tests/fixtures/MANIFEST.md) plus property-based fuzzing of the decode path. See CONTRIBUTING.md for the full workflow and ARCHITECTURE.md for the design and the add-a-protocol cookbook.

License

MIT

About

Low-level implementations of common networking protocols in Python 3

Topics

Resources

Contributing

Stars

11 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

NETProtocols

CIPython VersionLicense: MITTyped

Low-level implementations of common networking protocols, in pure Python with zero dependencies.

Decode raw header bytes into typed, immutable protocol objects — or build those objects from field values and serialize them back to their exact on-wire form. Every header is a frozen dataclass whose fields mirror the wire format, so decoded traffic is introspectable, comparable, and round-trippable.

>>> from netprotocols import Ethernet
>>> eth = Ethernet.decode(frame)
>>> eth
Ethernet(dst='ff:ff:ff:ff:ff:ff', src='00:07:0d:af:f4:54', ethertype=2054)
>>> eth.ethertype_name
'ARP'
>>> eth.next_protocol()
<class 'netprotocols.layer2.arp.ARP'>
>>> bytes(eth) == frame[:eth.header_len]
True

Every header is also a structural pattern — no extra code, because a frozen dataclass auto-generates __match_args__ and every enum field is an IntEnum, so a bare wire value matches a named one:

matchip:
caseIPv4(protocol=IPProtocol.TCP, ttl=t) ift>32:
...
caseIPv4(protocol=IPProtocol.UDP):
...

Installation

pip install netprotocols

Requires Python 3.12+. Fully typed (py.typed, mypy strict).

Why NETProtocols

Measured against dpkt 1.9.8 and scapy 2.7.0 on a 97-frame corpus of real captured traffic (CPython 3.12.3, x86-64 Linux; re-measured 2026-09-04 — see docs/CLAIMS.md for every number below, its reproduction command, and its caveats):

  • Within 15% of dpkt on decode, 5.6× faster than scapyuv run --group bench python scripts/benchmark.py --compare. dpkt is the faster of the two comparators on this corpus; the gap moves both ways as this library and dpkt each change, and this file's job is to say so honestly rather than only when it's flattering.
  • Decodes further into the stack than dpkt on 27 of 97 corpus frames — DNS and DHCP payloads that dpkt leaves as raw bytes, netprotocols continues decoding. A throughput number means little without knowing how much work it bought.
  • 4.5× faster than dpkt at re-encoding (bytes(header), scripts/benchmark_encode.py) — the other half of "codec" that nobody else benchmarks.
  • Imports in 54 ms; scapy.all takes 458+ ms (scripts/benchmark_import.py), and never touches the host doing it — importing scapy populates live interface and routing tables as a side effect of the import statement.
  • An 85.6 KB wheel against scapy's 2.47 MB — about 30× smaller.
  • Regression-gated in CI, which — as far as we could establish by auditing ten comparable Python packet libraries' CI configurations (dpkt, scapy, pypacker, construct, pcapkit, dnspython, pyshark, nfstream, stackforge, PyTCP-net_proto; full citations in docs/CLAIMS.md §1.7) — none of them do. Two of the ten ship real benchmark code that simply never runs in CI (construct disables it explicitly; stackforge's Criterion benches are never invoked); the rest have no performance benchmark at all.
  • Typed where the alternatives are not.mypy --strict over the whole of src/, enforced in CI. scapy ships py.typed but enables strict checking on 107 of its files, 2 of the 121 under scapy/layers/ — the dissectors most code touches stay Any. dpkt ships no py.typed at all, and no third-party stub package exists for it.
  • The only MIT-licensed, strictly-typed, zero-dependency packet codec still under active maintenance. scapy is GPL-2.0; pypacker is GPLv2; the newer entrants stackforge and PyTCP-net_proto are both GPL-3.0. The other permissive option, dpkt (BSD), last released 2022-08-18 and last committed 2024-05-05, still targets Python 2.7 and 3.5–3.9, and remains marked Beta after twelve years.
  • Runs where scapy cannot — including in the browser. Verified under a real Pyodide runtime in CI: scapy fails to import at all under Pyodide (from fcntl import ioctl is unconditional in scapy/arch/), where netprotocols, dpkt and pypacker all import cleanly.
  • The only one of these libraries a match/case statement dissects out of the box. dpkt builds __slots__ from a metaclass and generates no __match_args__; scapy routes fields through __getattr__; construct returns dicts. None gives you a pattern to match against.

None of this makes scapy less than an extraordinary piece of software — it crafts, sends, sniffs and fuzzes across thousands of protocols, and this library does none of that. The comparison above is scoped to what both are: a codec that turns bytes into typed objects and back.

One mypy --strict run says more than the bullets above:

# scapy 2.7.0 — a field name that does not exist
p.ThisFieldDoesNotExist → Any (no error)
# dpkt 1.9.8 — the whole module
import dpkt.ethernet → error: missing library stubs or py.typed
# netprotocols — a typo in a real field name
ip.proto → error: "IPv4" has no attribute
"proto"; maybe "protocol"?

Protocol coverage

LayerProtocolClassNotes
2Ethernet IIEthernetIEEE 802.3
2IEEE 802.1Q VLAN tag (802.1ad QinQ)VLAN802.1Q-2018 §9.6, PCP/DEI/VID, tag stacking
2ARPARPRFC 826, IPv4-over-Ethernet binding
3IPv4IPv4RFC 791, IHL/options aware
3IPv6IPv6RFC 8200
3IPv6 Hop-by-Hop OptionsIPv6HopByHopOptionsRFC 8200 §4.3
3IPv6 RoutingIPv6RoutingRFC 8200 §4.4
3IPv6 FragmentIPv6FragmentRFC 8200 §4.5, first-fragment chaining
3IPv6 Destination OptionsIPv6DestinationOptionsRFC 8200 §4.6
3ICMPv4ICMPv4RFC 792, 8-byte header
3ICMPv6ICMPv6RFC 4443, 8-byte header
3IGMPIGMPRFC 1112/2236/3376, IPv4 multicast management; IGMPv3 report group records + query fields
3GREGRERFC 2784/2890, IP protocol 47; payload chains onward by EtherType
4TCPTCPRFC 9293, data-offset/options aware
4UDPUDPRFC 768
7DNSDNSRFC 1035, over UDP and TCP (DNSOverTCP length shim); on-demand name decompression + resource-record parsing
7DHCPDHCPRFC 2131/2132, over UDP 67/68; TLV options parsed on demand

Decoding a captured frame

decode_frame() walks the whole chain and hands back a Packet:

fromnetprotocolsimportTCP, decode_framepacket=decode_frame(frame)
print(packet) # Packet(Ethernet(...), IPv4(...), TCP(...))print(packet[1].src) # '192.168.1.96' — position, unchangedprint(packet[TCP].flags_str) # first TCP layer, or KeyError if noneprint(packet.get(TCP)) # same lookup, None instead of raisingprint(packet.consumed) # bytes the headers occupied

It works because every header answers two questions: header_len (how many bytes it consumed) and next_protocol() (which class decodes what follows). Trailing bytes are fine — frame[packet.consumed:] is whatever the chain did not decode.

Start somewhere other than Ethernet when the buffer does — a tunnel payload, a packet quoted inside an ICMP error, a non-Ethernet link type:

decode_frame(buf, start=IPv4)

Malformed input raises exceptions rooted at a single base class:

fromnetprotocolsimportProtocolError, TruncatedHeaderError, InvalidFieldErrortry:
packet=decode_frame(frame)
exceptTruncatedHeaderError: # buffer shorter than the header claims
...
exceptInvalidFieldError: # nonsense field values (IHL < 5, bad address)
...
exceptProtocolError: # catches every library error
...

Every exception carries structured context alongside its message — err.protocol names the class that raised, err.field names the attribute at fault where one field is at fault, and err.offset / err.frame_offset locate the problem in bytes (in the header's own buffer, and rebased to the whole frame when the error came through decode_frame) — so a fuzzer, conformance suite, or validation tool can act on where and what failed without parsing the message:

try:
packet=decode_frame(frame)
exceptProtocolErrorase:
print(f"{e.protocol.__name__} at byte {e.frame_offset}: {e}")
# IPv4 at byte 14: IPv4 IHL must be at least 5, got 0

A capture tool would usually rather keep the layers it got than lose frame 4,000,001 to an exception. lax=True reports instead of raising:

packet=decode_frame(frame, lax=True)
ifpacket.stopped_byisnotNone:
log.warning("stopped after %d layers: %s", len(packet), packet.stopped_by)

Lax mode never invents a layer and never guesses — each header is decoded exactly as strictly as before; the walk just declines to throw away the part that worked. Chain depth is bounded (max_depth, default 32), so a crafted frame cannot make the walker grind.

One case where reaching for lax=True is the right default, not just a convenience: an ICMP error message's embedded packet. RFC 792 only guarantees the invoking IP header plus 8 bytes of what follows — never a full TCP/UDP header — so decoding it with the strict path raises on ordinary, correctly formed traffic. ICMPv4/ICMPv6 expose this pre-wired as embedded_chain:

icmp.embedded_chain# decode_frame(icmp.embedded_packet, lax=True, start=IPv4)# → Packet([IPv4(...)]), stopped_by=TruncatedHeaderError(...)

embedded_packet stays available for the raw bytes; reach for embedded_chain when you want them already decoded and are prepared to read stopped_by. Outside this one RFC-mandated case, a complete frame that fails to decode is still a bug to raise on — lax=True elsewhere is a capture tool's choice, not a default.

Reading captures

read_captures() takes the bytes of a capture file — not a path — and auto-detects classic pcap vs. pcapng from its magic number:

fromnetprotocolsimportdecode_frame, read_capturesdata=open("traffic.pcap", "rb").read() # or however you got the bytesfortimestamp, frameinread_captures(data):
packet=decode_frame(frame, lax=True)
...

Each CapturedFrame is (timestamp, data)timestamp normalized to nanoseconds since the Unix epoch regardless of the source format's own resolution (classic pcap's microseconds or nanoseconds; pcapng's per-interface if_tsresol). read_pcap()/read_pcapng() are the same thing for a caller who already knows the format and wants to skip detection. A malformed or truncated capture raises MalformedCaptureError, the same ProtocolError family every other exception in this library belongs to.

Flow keys

Packet.flow_key() (or the free function, netprotocols.flow_key(), for a header pair that never went through decode_frame) returns a canonical, direction-independent key for a TCP/UDP conversation — both directions of one flow produce the same key:

request=decode_frame(client_to_server_frame)
reply=decode_frame(server_to_client_frame)
assertrequest.flow_key() ==reply.flow_key()

It returns None, not an error, when there is nothing to key on: no enclosing IP layer, no TCP/UDP layer, or a transport layer without ports at all (ICMPv4/ICMPv6 — this library does not invent a port-slot convention for message types that have none).

Structural pattern matching

Every decoded header works with match/case today, with no code written for it: a frozen dataclass auto-generates __match_args__, and every enum field is an IntEnum, so a class pattern can match a named value against the plain int the wire actually carries. See ARCHITECTURE.md for why both of those hold and how to keep them holding.

IPv4, TCP and DHCP are wide enough (11-15 fields) that their full auto-generated positional form is unusable, so those three additionally curate a short __match_args__ by hand — the fields someone matching by position actually reaches for:

matchip:
caseIPv4(src, dst, IPProtocol.TCP):
print(f"TCP: {src} -> {dst}")

Keyword patterns (case IPv4(protocol=IPProtocol.TCP)) work on every field regardless, curated or not, and stay the documented default.

fromnetprotocolsimportIPv4, IPProtocol, TCPmatchpacket.layers:
case [_, IPv4(protocol=IPProtocol.TCP) asip, TCP(flags_str=f), *_] \
if"SYN"infand"ACK"notinf:
print(f"connection attempt from {ip.src}")
case [_, IPv4() asip, TCP(), *_]:
print(f"TCP from {ip.src}")
case _:
print("not a TCP-over-IPv4 frame")

Decoding a protocol we do not ship

The dispatch tables are public, so a protocol this library does not implement can join the walk without forking it:

fromnetprotocolsimportProtocolfromnetprotocols.registryimportregister@register("ethertype", 0x8847)classMPLS(Protocol):
...

The five tables are ethertype, ip.proto, ip.proto.v6, udp.port and tcp.port, each named after the wire field it dispatches on. To change decoding for one call only — DNS on a nonstandard port in one capture — say so per call instead of registering globally:

decode_frame(frame, decode_as={"udp.port": {6969: DNS}})

See ARCHITECTURE.md for how the tables fit together and how ip.proto.v6 inherits ip.proto.

Building and serializing headers

Constructors take friendly values (string MAC/IP addresses, integer fields) and validate them; bytes() emits the exact on-wire form. Packet concatenates a stack of layers:

fromnetprotocolsimportARP, Ethernet, Packet, random_macsha=random_mac()
frame=Packet(
Ethernet(dst="ff:ff:ff:ff:ff:ff", src=sha, ethertype=0x0806),
ARP(htype=1, ptype=0x0800, hlen=6, plen=4, oper=1,
sha=sha, spa="192.168.1.96",
tha="00:00:00:00:00:00", tpa="192.168.1.254"),
)
raw=bytes(frame) # ready for a raw socket

Checksums are computed and verified on request — never silently: compute()/verify() in netprotocols.checksum, and Packet.with_checksums() to fill a whole stack before sending.

Display helpers

Every class exposes human-readable properties next to the raw fields: Ethernet.ethertype_name, IPv4.protocol_name, IPv4.flags_name, TCP.flags_str ("SYN ACK"), ARP.oper_name, ICMPv4.type_name, and hexadecimal renderings such as checksum_hex_str. Unknown values degrade gracefully ("0x88cc", "unknown (47)") instead of raising.

How it works

See ARCHITECTURE.md for a guided tour: how a header byte layout maps onto a dataclass, the decode contract, the next_protocol() chain, and a cookbook for adding a new protocol.

This library is the engine behind RootWire, a network traffic monitor built on it (formerly Packet-Sniffer).

Roadmap

The post-1.3.0 roadmap, #107, is complete — five tiers plus a competitor CI audit (#124) and the comparative-claims embargo lift that closed it out:

VersionTheme
1.3.1Hygiene
1.4.0Decode performance
2.0.0A public protocol registry, a shipped chain walker, flow keys — released
2.1.0Typed accessors and pattern-matching ergonomics
2.2.0Universal round-trip properties, nightly fuzzing, a pcap reader

Everything through 2.2.0 has landed on master; per the roadmap's own release policy, only 2.0.0 was an actual PyPI release, and the comparative claims above draw on the finished tree. New planned work will open fresh issues rather than restating a list here, so this section stays accurate without upkeep. The wave before this one — TCP and IPv4 option parsing, ICMP message bodies, NDP, IPv6 extension-header TLV options, the GRE checksum arm, a DNS-over-TCP corpus fixture and ipaddress accessors — shipped in 1.3.0; see CHANGELOG.md.

Contributing

Development uses uv: uv sync, then uv run pytest, uv run mypy, and uv run ruff check — all three are enforced by CI on Python 3.12–3.14. The test suite is anchored by a 97-frame corpus of real captured traffic across 17 scenarios (tests/fixtures/MANIFEST.md) plus property-based fuzzing of the decode path. See CONTRIBUTING.md for the full workflow and ARCHITECTURE.md for the design and the add-a-protocol cookbook.

License

MIT

About

Low-level implementations of common networking protocols in Python 3

Topics

Resources

Contributing

Stars

11 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages