pktcap
Read any packet capture, dissect it layer by layer, write it back, replay it. Every frame of a pcap or pcapng file comes back, whatever its link type; the built-in dissectors read the link layer, IP, UDP and TCP, and any other protocol is a dissector you register. Built on the standard library and netimps. Documentation: https://jose-pr.github.io/pktcap/.
Features
- Reads any valid pcap or pcapng capture — either byte order, microseconds
or nanoseconds, several interfaces, from a file or from a pipe that cannot
seek (
tcpdump -w -,dumpcap -w -). Every frame is returned with its octets, link type, time and interface; a link type or protocol nothing dissects is never an error and never skipped. - Dissects layer by layer — Ethernet with 802.1Q and QinQ tags, Linux cooked capture v1 and v2, BSD loopback and raw IP; IPv4, and IPv6 with its extension headers; UDP and TCP headers. A dissected frame is a value you walk: its layers in order, each a small immutable record, and what is left.
- Pluggable dissectors — a dissector is a function from one layer's octets to its fields, its payload and what follows. Register yours for a port, an IP protocol, an ethertype or a link type, in the process-wide registry or in one you build and pass.
- A UDP datagram view on top, for a protocol library that only wants addresses, ports and payload, with IP fragments reassembled.
- Puts TCP streams back together — each direction's octets in order, with
the count of octets the capture missed, from
read_tcp_streamsor aTCPReassemblerfed dissected frames; every rule a passive reader has to choose is stated, and connections, held octets, pieces and silence are bounded. - Treats a capture as untrusted input — every length, count and offset a
file or a frame states has a ceiling checked before anything is allocated:
a 48-octet file claiming a 1 GiB record costs under 1 MiB and one
CaptureFormatError. A dissector that fails, built in or registered, costs that frame one layer and is counted; it never stops the reader. - Writes pcap and pcapng that tcpdump and Wireshark read — any frame back with its link type, octet for octet, or a datagram seen at a socket under synthesised headers with valid checksums.
- Writes records in a format chosen by name — a frame or a datagram, or the plain data a protocol library makes of it, as JSON lines, YAML documents, TOML or INI files, into one growing file or one file per record.
- Filters with
key=value and key!=value— addresses, ports, protocol, VLAN and link type built in, and a protocol library's own keys beside them. - Replays a capture — on its recorded timing, to a callable, or its UDP payloads to one destination the caller names; never to the addresses in the file, and never as raw frames.
- Captures live on Linux without a capture tool, given
CAP_NET_RAW. - Protocol plugins — name a protocol library with
--load,PKTCAP_LOADor one configuration file and its dissector and filter keys work incapture,convertandreplay; nothing is loaded because it is installed. - A
pktcapcommand —capture,replay,convertandplugins, behind thecliextra.
Installation
pip install pktcap
Requires Python 3.9 or newer. netimps is the one dependency.
| Extra | Adds | Needed for |
|---|---|---|
yaml |
PyYAML |
writing and reading records as YAML |
toml |
tomli-w, and tomli before Python 3.11 |
writing records as TOML, and reading them before 3.11 |
cli |
duho |
the pktcap command |
Importing pktcap needs none of them; a format whose extra is missing raises
MissingExtraError (an ImportError) naming the extra when it is used.
Quick start
Write a capture, then read every frame of it, layer by layer:
import pktcap
with pktcap.PcapWriter("trace.pcap") as writer:
writer.write(1700000000.00, ("192.0.2.5", 50000), ("192.0.2.1", 69), b"\x00\x01boot.efi\x00octet\x00")
writer.write(1700000000.05, ("192.0.2.1", 40000), ("192.0.2.5", 50000), b"\x00\x03\x00\x01data")
writer.write(1700000000.10, ("192.0.2.5", 50000), ("192.0.2.1", 40000), b"\x00\x04\x00\x01")
for frame in pktcap.read_dissected("trace.pcap"):
ip, udp = frame.layer(pktcap.IPv4Layer), frame.layer(pktcap.UDPLayer)
print(ip.source, udp.source_port, "->", ip.destination, udp.destination_port, frame.payload)
Register a dissector for a protocol of your own:
import struct
from typing import NamedTuple
class TFTPLayer(NamedTuple):
opcode: int
filename: str
def dissect_tftp(data: bytes) -> pktcap.Dissected:
if len(data) < 4:
raise pktcap.DissectError("a TFTP packet is at least 4 octets")
(opcode,) = struct.unpack_from("!H", data)
name, _, rest = data[2:].partition(b"\0")
return pktcap.Dissected(TFTPLayer(opcode, name.decode("ascii", "replace")), rest)
registry = pktcap.DissectorRegistry() # the built-in dissectors, and now yours
registry.register("udp", 69, dissect_tftp)
dissector = pktcap.FrameDissector(registry)
frames = list(pktcap.read_dissected("trace.pcap", dissector=dissector))
print(frames[0].layers[-1]) # TFTPLayer(opcode=1, filename='boot.efi')
print(dissector.stats) # frames, malformed, failed, unsupported, fragments, ...
Take the UDP datagram view when addresses, ports and payload are all you need:
for datagram in pktcap.read_datagrams("trace.pcap"):
print(datagram.time, datagram.source, datagram.destination, len(datagram.payload))
Filter frames, and write them as records or as a capture:
wanted = pktcap.compile_capture_filter("proto=tftp and src=192.0.2.0/24", pktcap.frame_filter)
with pktcap.CaptureWriter("requests.jsonl") as output: # the ending names the format
for frame in frames:
if wanted(frame):
output.write(frame)
with pktcap.PcapngWriter("copy.pcapng") as writer: # any frame, as it was captured
for captured in pktcap.read_frames("trace.pcap"):
writer.write_frame(captured)
text = pktcap.dumps_record(pktcap.datagram_record(frames[0].datagram()), "ini")
print(text)
print(pktcap.loads_record(text, "ini")) # the record, read back
Replay it, at the recorded pace: UDP payloads to a destination you name, or every frame to a callable:
from netimps import bind
listener = bind("127.0.0.1", 0) # a UDP socket on a free loopback port
result = pktcap.replay_to("trace.pcap", "127.0.0.1", listener.getsockname()[1])
print(result) # ReplayResult(sent=3, partial=0)
print(listener.recvfrom(1500)[0])
listener.close()
seen = []
pktcap.replay(pktcap.read_frames("copy.pcapng"), seen.append, speed=None)
print(len(seen), "frames handed to a callable")
Command line
pip install "pktcap[cli]" installs the pktcap command, also
python -m pktcap, with four subcommands. Each is a call into the library
above. Records and capture octets go to --output (default -, standard
output); the one-line summary goes to standard error. Exit status: 0 done, 1
the operation failed, 2 the invocation was wrong.
pktcap convert -i trace.pcap -o trace.jsonl
pktcap convert -i trace.pcap -f "proto=udp and port=69" --datagrams --format json
pktcap convert -i trace.pcap -o by-frame/{index}.toml --per-record
pktcap replay -i trace.pcap --to 127.0.0.1:9 --no-delay
pktcap plugins --load none
convert copies a capture, filtered, into pcap, pcapng or records. replay
sends the UDP payloads to the one destination --to names, never to the
addresses in the file. plugins shows the protocol plugins that are loaded and
every filter key there is. capture records live on Linux with CAP_NET_RAW;
anything else is piped in. These two need Linux and root, or another tool,
so the tests do not run them:
sudo pktcap capture --interface eth0 -f "proto=udp and port=67" -o dhcp.pcapng
tcpdump -i eth0 -U -w - | pktcap convert -i - --datagrams -o requests.jsonl
capture --listen HOST:PORT (also [::1]:PORT, *:PORT, an adapter name or
a MAC; repeat it) records the datagrams that arrive at UDP ports the command
binds: it runs on any platform with no privilege, and sees what reaches this
host only, under an IP header it makes up. --format text writes one readable
line a frame, and capture --hook PROGRAM (or MODULE:FUNCTION) runs a
program for each record written, with a time limit. The command classes
(pktcap.cli.Capture, Convert, Replay and the bases under them) are
built to be subclassed by a library with a protocol of its own, which adds its
dissector, its filter and its defaults and nothing else; the override points
are in the header.
Every option, and the one tool a PKTCAP_MCP=stdio server offers, is in the
header that ships in the package, pktcap/cli/AGENTS.md, also at
https://github.com/jose-pr/pktcap/blob/main/src/pktcap/cli/AGENTS.md.
API overview
Everything is imported from pktcap; the modules below it are private.
| Module | Purpose |
|---|---|
pktcap |
every public name, below |
| Names | Purpose |
|---|---|
read_frames, CapturedFrame, CaptureSource |
read every frame of a pcap or pcapng container |
read_dissected, FrameDissector, DissectedFrame, DissectStats, LINKTYPES |
dissect frames layer by layer, IP fragments reassembled |
EthernetLayer, VLANLayer, LinuxCookedLayer, LoopbackLayer, IPv4Layer, IPv6Layer, IPv6ExtensionLayer, IPv6FragmentLayer, UDPLayer, TCPLayer |
the records the built-in dissectors make |
Dissector, Dissected, Fragment, Selector, DissectorRegistry, default_registry, register_dissector, check_dissector |
write, register and check a dissector |
read_datagrams, CapturedDatagram |
the UDP datagram view |
read_tcp_streams, TCPReassembler, TCPStreamData, TCPStreamStats |
TCP streams: each direction's octets in order, within bounds |
PcapWriter, PcapngWriter |
write frames and datagrams as a capture |
CaptureWriter, dumps_record, loads_record, datagram_record, frame_record, frame_summary, OUTPUT_FORMATS, RECORD_FORMATS, has_output_format |
write frames, datagrams or records in a named format (text is a line a person reads), and read a record back |
copy_frames, CopyResult, command_hook |
copy a source of dissected frames, filtered, into a writer, and run a program for each frame written |
load_plugins, LoadedPlugin |
load the plugins a user names into a registry |
parse_capture_filter, compile_capture_filter, FilterClause, frame_filter, frame_filter_for, frame_filter_keys, FRAME_FILTER_KEYS |
the filter expression, the keys of the built-in layers, and the layers and keys a registry declares |
replay_schedule, replay, replay_to, ReplayResult, ReplaySource |
replay a capture |
LiveCapture, sniff_frames, sniff, has_live_capture |
live capture on Linux |
UDPCapture, sniff_udp, asniff_udp, datagram_frame |
UDP sockets as a capture source, on every platform, and the frame of one datagram |
PktcapError, CaptureFormatError, CaptureFilterError, UnsupportedFormatError, MissingExtraError, RecordFormatError, DissectError, LiveCaptureError, CapturePluginError, CaptureHookError |
the exceptions |
The reference with every signature, bound and gotcha is the API header that
ships inside the package, pktcap/AGENTS.md, also at
https://github.com/jose-pr/pktcap/blob/main/src/pktcap/AGENTS.md. The
dissector contract and each built-in dissector are in the header beside them,
https://github.com/jose-pr/pktcap/blob/main/src/pktcap/_dissectors/AGENTS.md.
Protocol plugins
A protocol library's dissector and filter keys are loaded by naming it, so
pktcap capture, convert and replay understand its protocol with no code
of yours. Three ways to name a plugin, nearest first; the first that names a
list is the list:
pktcap convert -i trace.pcap --load pydhcp.capture.register_dhcp_dissector,tftp.capture.register_tftp_dissector -f "proto=dhcp"
PKTCAP_LOAD=pydhcp.capture.register_dhcp_dissector pktcap convert -i trace.pcap -f "proto=dhcp"
pktcap plugins # the settings file, what was loaded, every filter key there is
The third is the settings file, pktcap/pktcap.ini under $XDG_CONFIG_HOME
(else ~/.config) on Linux, macOS and the BSDs and under %APPDATA% on
Windows, or the file pktcap --config FILE (before the command's name) or
PKTCAP_CONFIG names; an empty path (--config "") reads no file. A top-level load is the list of every command, and a
table named for a command sets that command's options:
[DEFAULT]
load = pydhcp.capture.register_dhcp_dissector, tftp.capture.register_tftp_dissector
[convert]
format = text
An item is a module with a pktcap_plugin(registry) function, or
MODULE.CALLABLE for any callable that takes the registry. A library's own
filter keys, once its plugin registers them, are LAYER.KEY or the bare KEY
(dhcp.msg_type=DHCPDISCOVER); any field of a layer is LAYER.FIELD
(ipv4.ttl=64), and pktcap plugins lists them all. A hook is a function:
from typing import NamedTuple
import pktcap
class DemoLayer(NamedTuple):
opcode: int
def dissect(data: bytes) -> pktcap.Dissected:
return pktcap.Dissected(DemoLayer(data[0]), data[1:])
def pktcap_plugin(registry: pktcap.DissectorRegistry) -> None:
registry.register_layer(DemoLayer, name="demo")
registry.register("udp", 9999, dissect)
Importing a module runs its code, so a list is read from the option, the
variable and the settings file, and never from the working directory or a
capture; a tool call cannot name plugins at all. The file found by default
must be your own and not writable by everyone; a file you name with --config
or PKTCAP_CONFIG is read as it is, so as root name yours:
sudo pktcap --config ~/.config/pktcap/pktcap.ini capture -f "proto=dhcp" -o dhcp.pcapng
Differences from tshark
The reference for what a capture holds is tshark 4.6.8 (Wireshark's
command-line reader). tests/conformance/ records what it says about 53
captures, written by tcpdump 4.99.6, by Wireshark's editcap, by this library's
writers and its live capture, and by hand, and the suite replays those answers
with no tool installed. Fidelity is to results: how many frames a capture
holds; for each frame, which layers it has and the fields of each (addresses,
VLAN tags, protocol numbers, ports, TCP sequence numbers, flags, options and
segment payload); which UDP datagrams it carries; the octets of each
direction of each TCP stream, and where octets are missing; which captures are refused
and after how many frames; and that tshark reads what PcapWriter and
PcapngWriter write, as the frames they were given and with every checksum
good. Message texts and exit statuses are not reproduced.
pktcap differs on purpose in these cases, each a bound on untrusted input or a rule it states for what a passive reader cannot know:
| Case | Difference |
|---|---|
read-built-ipv4-fragments-overlap |
A fragment that overlaps another: tshark reassembles the datagram and flags the overlap; pktcap discards the datagram and counts it in dropped. |
read-built-fragments-a-minute-apart |
Fragments more than reassembly_timeout (30 s) apart in capture time: tshark reassembles them however far apart; pktcap discards the unfinished datagram, since IP identifiers are reused. |
read-built-fragments-without-end |
One datagram in more than 1,024 fragments: tshark reassembles it; pktcap gives it up at the 1,025th. |
read-built-reassemblies-in-flight |
More than max_reassemblies (256) datagrams being reassembled at once: tshark holds them all and completes the first when its last fragment arrives; pktcap discarded the oldest when the 257th started, and the rest stay counted in pending. |
read-built-pcapng-interfaces-without-end |
More than 4,096 interfaces described in one pcapng section: tshark reads the capture; pktcap raises CaptureFormatError. |
read-built-tcp-stream-missing-unacknowledged |
Octets beyond a hole that nothing acknowledges: tshark holds them back for good and follows only what came before the hole; pktcap gives the hole up when the capture ends and delivers them with the count of octets missing before them. |
read-built-tcp-stream-reset |
A segment after a reset in sequence: tshark keeps it in the same stream; pktcap takes the reset at its word, ends the connection there and numbers what follows as a new stream. |
read-built-tcp-vlan |
A reset in sequence, then another segment on the same addresses: tshark follows one stream there; pktcap starts a new stream after the reset, so its streams are numbered 0, 1 and 2 where tshark's are 0 and 1. |
Two more ceilings are lower than tshark's and have no case, because a capture that reaches them is large: a pcapng packet block over 327,680 octets (tshark: 134,348,832) and a pcapng section header or interface description over 1 MiB are refused.
| tshark reads | pktcap |
|---|---|
| pcap and pcapng, either byte order, microseconds and nanoseconds | yes |
| a frame of any link type | yes: read and returned; dissected when the link type has a dissector |
| Ethernet with 802.1Q and QinQ tags, Linux cooked v1 and v2, BSD loopback, raw IP | yes |
| IPv4, IPv6 extension and fragment headers, IP fragments reassembled | yes; IPv4 options and extension-header contents are kept as octets |
| UDP and TCP headers | yes; TCP options are kept as octets |
| TCP streams, reassembled across segments | yes, within bounds: each direction's octets in order; no dissector reads a stream |
| checksums, verified | no: none is checked |
| ARP, ICMP and every protocol above UDP and TCP | no built-in dissector: the frame ends there with the rest as its payload, until one is registered |
| other capture file formats | no |
Limits that are not differences in what is read: a pcapng file written back keeps every frame, link type and time, and none of the options of the original (comments, interface names); replay sends UDP payloads through an ordinary socket and never a raw frame; live capture is Linux only.
Development
py -3.14-arm64 -m venv .venv/3.14-nt-arm64
.venv/3.14-nt-arm64/Scripts/python -m pip install -e ".[dev,docs]"
.venv/3.14-nt-arm64/Scripts/python -m pytest -q -rs
The floor, Python 3.9, has its own venv and is run before a push. The
environments, the checks and the conventions are in
AGENTS.md.
Releasing
This project follows Semantic Versioning and keeps a
CHANGELOG.md.
Pushing a tag matching v* triggers the release workflow: test gate → build
(checking the tag names the version built) → a strict docs build as a gate →
GitHub release → publish. The release workflow never deploys the docs site
itself: for a final release its last job dispatches the docs workflow at the
tag, which owns every Pages deploy.
License
MIT — see LICENSE.
Metadata
Release files for pktcap 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pktcap-0.1.1.tar.gz | 396.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pktcap-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 599.8 kB
Release files / pktcap-0.1.1.tar.gz
| Download URL | pktcap-0.1.1.tar.gz |
|---|---|
| Size | 396.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
875f43657a2c463b51c5785b69a0d15d6978487e31408b962ceedce79aea4535
|
|
BLAKE2b-256 checksum How to use checksums |
4fd484d8265ae1655d52283c0a0cc268e1d2fc823ddae510dc7cb593ef9e39d9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logRelease files / pktcap-0.1.1-py3-none-any.whl
| Download URL | pktcap-0.1.1-py3-none-any.whl |
|---|---|
| Size | 203.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fecc4a4913f730a7d5c46a14c4952524f5414017a2728f06689d41f23a74863f
|
|
BLAKE2b-256 checksum How to use checksums |
4b50eb306b762ae8daa9d79829838653fdfe938dde25cfa32ec6d245d3ef499c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency log