pktcap — public API header
Header-file-style reference for the pktcap package: every public export with
its signature, arguments, contract and gotchas, so the package can be used
without reading its source. It ships inside the package and is
self-contained. Development documentation lives with the source at
https://github.com/jose-pr/pktcap.
Everything is imported from pktcap directly. Every module under pktcap
(each name starts with _) is implementation detail: do not import them.
Install with pip install pktcap, which brings netimps. The yaml extra
(pip install "pktcap[yaml]") adds PyYAML and the toml extra adds
tomli-w (and tomli before Python 3.11), each for the record format of that
name, and the cli extra (pip install "pktcap[cli]") adds duho for the
pktcap command. Importing pktcap needs none of them. Python 3.9 or newer.
pktcap.__version__ — the package version string, the same value the
installed distribution's metadata carries.
Topics with much detail keep it in a header beside their code, inside the
installed package (importlib.resources.files("pktcap")):
| Header | Covers |
|---|---|
pktcap/AGENTS.md |
this file: reading, dissecting, the datagram view, filtering, replaying, live capture, the exceptions |
pktcap/_dissectors/AGENTS.md |
the dissector contract, the registry, writing and checking a dissector, each built-in dissector and each layer record |
pktcap/_plugins/AGENTS.md |
layers and the filter keys a registry holds for them, how a filter reads a layer's fields, and loading plugins by name |
pktcap/cli/AGENTS.md |
the pktcap command: capture, replay, convert and plugins, every option, what each prints, its statuses |
pktcap/_streams/AGENTS.md |
TCPReassembler, TCPStreamData, TCPStreamStats: the rules for putting TCP streams back together, and the bounds |
pktcap/_sources/AGENTS.md |
capturing live from an interface, and from UDP sockets: LiveCapture, sniff_frames, sniff, UDPCapture, sniff_udp, asniff_udp, datagram_frame |
pktcap/_copy/AGENTS.md |
copy_frames and CopyResult, command_hook and CaptureHookError: frames into a writer, and a program run for each one |
pktcap/_formats/AGENTS.md |
PcapWriter and PcapngWriter, CaptureWriter in full, what a record is, and exactly what each record format writes |
Any valid capture is read. Every frame of a pcap or pcapng file comes back whatever its link type and whatever it carries: a link type, an ethertype or a protocol nothing here dissects is never an error and never skipped, it is the frame's undissected payload. Only a damaged container raises.
A capture is untrusted input. Every length, count and offset a file or a frame states is compared with a ceiling before anything is allocated or looped over; each section below gives its ceilings and what happens at them.
Options are keyword-only. A function takes the thing it acts on (and one
more operand where the signature shows it) positionally; the * in a
signature below marks where the keyword-only options begin. The named tuples
are positional by nature. Durations are seconds as float.
Reading a capture
read_frames(source, *, max_frame_size=262144) -> Iterator[CapturedFrame]
— every packet of a pcap or pcapng capture, in file order, undissected.
source(CaptureSource) is a path (stroros.PathLike) or a binary stream. The stream need not be seekable, sosys.stdin.bufferfed bytcpdump -w -ordumpcap -w -works. A stream stays the caller's to close.- Both formats, either byte order, microsecond and nanosecond pcap, and in
pcapng several sections and interfaces, the
if_tsresolandif_tsoffsetoptions, and the enhanced, simple and obsolete packet blocks. - Arguments are checked at the call; nothing is read until the first frame is asked for. Frames are yielded as they are read: a damaged capture gives every frame before the damage and then raises.
- An empty input is an empty capture and yields nothing.
- Raises
CaptureFormatErrorfor anything that is not a capture or is a damaged one,OSErrorwhen the path cannot be opened or the stream read,TypeErrorfor a text stream or asourcethat is neither, andValueErrorfor amax_frame_sizethat is not positive.
CapturedFrame(time, linktype, data, interface=None) — a named tuple: one
packet as the capture recorded it. time is seconds since the epoch as a
float, linktype the LINKTYPE_ number saying what data starts with,
data the captured octets (a snap length may have cut them short),
interface the number of the pcapng interface it was captured on, counted
within its section, and None in a pcap file, which has none.
- The file controls
time. It is0.0for a pcapng simple packet block, which has no timestamp, and may be far outside any calendar: guard a call todatetime.fromtimestamp. A nanosecond stamp loses precision below about 0.2 µs in afloat.
Ceilings, each checked before the octets it covers are read:
| What the file states | Ceiling | At the ceiling |
|---|---|---|
| the length of a pcap record or of a packet in a pcapng block | max_frame_size (262,144, the most tcpdump and dumpcap record) |
CaptureFormatError |
| the length of a pcapng packet block | max_frame_size + 65,536 |
CaptureFormatError |
| the length of a pcapng section header or interface description | 1,048,576 | CaptureFormatError |
| the length of any other pcapng block | none: it is read 65,536 octets at a time and discarded | CaptureFormatError if the input ends inside it |
| interfaces described in one pcapng section | 4,096 | CaptureFormatError |
A pcapng block is also refused when its length is not a multiple of four or is too small for its kind, when its two length fields disagree, when a packet is longer than the block holding it, and when a packet names an interface its section did not describe.
Dissecting frames
A frame is dissected layer by layer. The frame's link type selects a
dissector; each dissector reads one layer and names what may follow it;
the walk ends when no dissector is registered for what follows. The built-in
dissectors read the link layer (Ethernet, 802.1Q and QinQ tags, Linux cooked
capture v1 and v2, BSD loopback, raw IP), IPv4 and IPv6 with their extension
and fragment headers, and UDP and TCP headers. Every other protocol is a
dissector someone registers: the contract, the registry and each built-in are
in pktcap/_dissectors/AGENTS.md.
read_dissected(source, *, dissector=None, max_frame_size=262144) -> Iterator[DissectedFrame]
— read_frames and FrameDissector.dissect in one call: every frame of a
capture, dissected, in capture order. It raises CaptureFormatError for a
damaged container and nothing for a frame. Pass a FrameDissector to choose
its registry and options and to read its stats afterwards.
FrameDissector(registry=None, *, reassemble=True, max_reassemblies=256, reassembly_timeout=30.0)
— dissects frames, keeping IP fragment state between them. Feed it every frame
of a capture, in order. Not safe to share between threads. ValueError for a
limit that is not positive.
registryis aDissectorRegistry;Noneisdefault_registry(), the oneregister_dissectoradds to. It is read at each frame, so a dissector registered later is used from then on.FrameDissector.registryis it.FrameDissector.dissect(frame) -> DissectedFrame—frame(aCapturedFrame) with every layer a registered dissector could read. It never raises for a frame, whatever the frame holds and whatever a registered dissector does.FrameDissector.stats— aDissectStatssnapshot of the counters;FrameDissector.unsupported_linktypessays which link types it counted.- IP fragments are reassembled, of any protocol; the frame that completes
a datagram has
reassembled=True, andreassemble=Falsekeeps no state. An overlap discards the datagram. The rules are inpktcap/_dissectors/AGENTS.md.
DissectedFrame(frame, layers, payloads, error=None, reassembled=False) —
a named tuple: a captured frame and what was read from it.
frameis theCapturedFrame;DissectedFrame.timeis its time.layersis the layer records, outermost first:EthernetLayer,VLANLayer,LinuxCookedLayer,LoopbackLayer,IPv4Layer,IPv6Layer,IPv6ExtensionLayer,IPv6FragmentLayer,UDPLayer,TCPLayer, and whatever a registered dissector returned. Each built-in one is a named tuple of plain values.payloads[i]is the octets that followlayers[i].DissectedFrame.payloadis the last of them: what no dissector read. For a frame with no layer it is the whole frame.DissectedFrame.layer(kind) -> Optional[kind]— the outermost layer that is an instance of the classkind, orNone:frame.layer(TCPLayer).DissectedFrame.payload_of(kind) -> Optional[bytes]— the octets after that layer:frame.payload_of(TCPLayer)is the TCP segment's payload.DissectedFrame.datagram() -> Optional[CapturedDatagram]— the frame as a UDP datagram, orNonewhen it carries no UDP over IP.errorisNone, or why dissection stopped early, as"<kind> <number>: <reason>"naming the selector whose dissector could not read its layer ("ip 17: a UDP header is 8 octets"). The layers before it are kept and its octets are the payload.
DissectStats(frames, malformed, failed, unsupported, fragments, dropped, pending)
— a named tuple of counts, one per FrameDissector.stats: frames given to
dissect, those a dissector could not read or broke on, and those of a link
type nothing dissects. Each counter is in pktcap/_dissectors/AGENTS.md.
LINKTYPES — a read-only mapping from the LINKTYPE_ numbers with a
built-in dissector to a name (the list is in pktcap/_dissectors/AGENTS.md).
A frame of any other link type is read and returned undissected; register a
dissector under ("linktype", number) to dissect it.
Registering a protocol is one call, register_dissector("udp", 69, dissect)
for the process or DissectorRegistry.register for one registry, with a
function dissect(data: bytes) -> Dissected. The names the other header
documents: Dissector and Selector (type aliases), Dissected, Fragment,
DissectorRegistry, default_registry, register_dissector,
check_dissector, DissectError.
Ceilings:
| What a frame or a capture states | Ceiling | At the ceiling |
|---|---|---|
| dissectors run on one frame (a tag stack, a chain of extension headers) | 32 | the walk stops, the frame is malformed, the rest is its payload |
| datagrams being reassembled at once | max_reassemblies (256) |
the oldest is discarded, dropped |
| octets in one reassembled datagram | 65,535 | the datagram is discarded, dropped |
| fragments of one datagram | 1,024 | the datagram is discarded, dropped |
| capture time between a datagram's first fragment and a later one | reassembly_timeout (30 s) |
the datagram is discarded, dropped, and a new one starts |
A fragment costs a binary search and one insertion whatever the order of arrival, and a full table holds at most about 250 KiB for each reassembly in flight.
TCP is read a segment at a time. TCPLayer is the header and
payload_of(TCPLayer) the segment's octets. TCPReassembler, fed the
dissected frames of a capture in order, hands out TCPStreamData: each
direction's octets in order, with an offset, a count of octets given up on, a
connection number and an end marker; it counts what it met in
TCPStreamStats, and read_tcp_streams does both from a capture.
Everything a capture controls is bounded; the signatures, rules and bounds
are in pktcap/_streams/AGENTS.md.
The UDP datagram view
What a UDP protocol library works on, built on the dissection above.
read_datagrams(source, *, dissector=None, max_frame_size=262144) -> Iterator[CapturedDatagram]
— read_dissected through DissectedFrame.datagram: every UDP datagram of a
capture, in capture order, IP fragments reassembled. Frames that carry no UDP
are not yielded; pass a dissector and read its stats to tell an empty
capture from one of a link type nothing dissects.
CapturedDatagram(time, source, destination, payload, fragmented=False, truncated=False)
— a named tuple: one UDP datagram from a capture.
sourceanddestinationare(host, port)pairs, the host as address text: usable as a socket address, and asnetimps.SocketAddress.payloadis the octets after the UDP header, whatever dissected them further: a protocol's registered dissector does not change it.- An address on the wire is not rewritten. A v4-mapped IPv6 address stays
mapped, and is written
::ffff:10.0.0.5on every Python (the standard library writes::ffff:a00:5before 3.13). timeof a reassembled datagram is that of the fragment that completed it.fragmented:payloadis only what the first IP fragment carried. Set only under a dissector built withreassemble=False.truncated:payloadis shorter than the datagram's own length field says, because the capture's snap length cut the frame. Check it before reading a short payload as a short message.
Writing a capture, and writing or reading a record
PcapWriter(target) and PcapngWriter(target) write a capture that
tcpdump and Wireshark read; their contract is in pktcap/_formats/AGENTS.md,
with CaptureWriter. A capture format takes the frame or datagram itself; a
record format takes a record: plain data (dict, list, str, int,
float, bool, None) describing it.
OUTPUT_FORMATS — ("pcap", "pcapng", "json", "yaml", "toml", "ini", "text"),
and RECORD_FORMATS — json, yaml, toml and ini. text is a line a
person reads, not a record: dumps_record and loads_record refuse it, as they
refuse pcap. yaml needs the yaml extra and toml the toml extra; a
format whose extra is missing stays in both tuples.
frame_summary(frame) -> str — one escaped line for a DissectedFrame,
what the text format writes. Each layer may have a summary() method.
pktcap/_formats/AGENTS.md.
has_output_format(name) -> bool — whether that format can be written on
this installation. UnsupportedFormatError for a name that is no format.
dumps_record(record, format="json") -> str — one record as text, ending
in a newline. MissingExtraError when the format's extra is missing.
loads_record(text, format="json") -> Dict[str, Any] — the one record in
text: the inverse of dumps_record. RecordFormatError when it is not one
record, MissingExtraError when it cannot be read here; the rules are in
pktcap/_formats/AGENTS.md.
datagram_record(datagram) -> Dict[str, Any] and frame_record(frame) -> Dict[str, Any]
— the record of a CapturedDatagram and of a DissectedFrame; what each holds
is in pktcap/_formats/AGENTS.md, under "What a record is".
CaptureWriter — one writer for all seven: a growing file, or one file per
record under a name pattern. It takes a CapturedDatagram or a
DissectedFrame; a capture format writes the item itself and a record format
the record given, or datagram_record or frame_record of the item. Its
signature, name pattern, file budget and checks are in
pktcap/_formats/AGENTS.md. copy_frames and its result CopyResult write
a source of dissected frames, filtered, into one of these writers, and
command_hook runs a program for each frame written; both are in
pktcap/_copy/AGENTS.md.
Filtering
Clauses key=value or key!=value joined by and:
proto=tcp and host=10.0.0.0/8 and port!=22. The grammar is shared; what a
key means is the builder's.
parse_capture_filter(text) -> Tuple[FilterClause, ...] — the clauses, in
order. None, empty or blank text is no clause.
compile_capture_filter(text, build) -> Callable[[T], bool] — one
predicate over the caller's own item type. build(clause) is called once per
clause, when the filter is compiled and never per item, and returns the test
for that clause; a != clause is inverted for it; the predicate is true when
every clause holds. None or blank text matches everything.
buildraisesValueErrorfor a key it does not know or a value that does not convert. It is re-raised asCaptureFilterErrornaming the clause, the original chained: one error at start-up, not one per packet. Any other exception propagates; abuildthat returns something not callable is aTypeError.
frame_filter(clause) -> Callable[[DissectedFrame], bool] — the build
for the built-in layers: compile_capture_filter(text, frame_filter) filters
dissected frames. A comma in a value means "any of". FRAME_FILTER_KEYS
is the keys, as a tuple, in this order:
| Key | Matches |
|---|---|
src, dst, host |
the source, the destination, or either address of the outermost IP layer: an address or a network (10.0.0.5, 10.0.0.0/8, 2001:db8::/32). A v4-mapped address is the IPv4 host it stands for |
sport, dport, port |
the source, the destination, or either port of the UDP or TCP layer |
proto |
a layer the frame has, by name (udp, tcp, ipv4, ipv6, vlan, ethernet; for a registered dissector's layer, its class name in lower case without a trailing Layer), or an IP protocol number (17) |
vlan |
a VLAN identifier on any tag of the frame |
linktype |
the capture's link-type number |
LAYER.FIELD |
a field of a layer, by the type of its value: ipv4.ttl=64, udp.destination_port=9999. LAYER is a built-in layer's name (ethernet, vlan, linuxcooked, loopback, ipv4, ipv6, ipv6extension, ipv6fragment, udp, tcp) or one a registry declares |
A frame without the layer a key asks about fails the clause, so its != form
holds. A protocol library adds its own keys by wrapping: its build answers
the keys it knows and returns frame_filter(clause) for the rest.
A registry also holds layers and the filter keys over them:
DissectorRegistry.register_layer declares a layer's class and its keys,
frame_filter_for(registry) is the build that knows them (frame_filter
itself reads no registry) and frame_filter_keys(registry=None) lists every
key that compiles. The rule, the bounds and what is refused are in
pktcap/_plugins/AGENTS.md.
FilterClause(key, value, negated=False) — a named tuple. key is as
written (case kept), value the text after the operator with surrounding
space removed, negated whether the operator was !=.
FilterClause.values is value split on commas, each item stripped and
empty ones dropped. str(clause) is the canonical text, and parsing the
clauses' texts joined by and gives the same clauses back.
The dialect: and joins clauses in any letter case and needs space on both
sides. There is no or: the word alone is refused by name, so a value
cannot contain or or and. A key is one or more of
A-Z a-z 0-9 _ . -; the first = ends it and a ! just before that =
negates; everything after is the value and must not be blank, so a value may
contain =. An expression is at most 4,096 characters. Each violation raises
CaptureFilterError; text that is not str or None is a TypeError.
Replaying a capture
What a capture holds again, in order and in time. Three functions over one
schedule; the options speed, max_delay and limit mean the same in each.
source (ReplaySource) is a path or binary stream of a pcap or pcapng
capture, read as its UDP datagrams, or any iterable of items with a time
in seconds: CapturedDatagram, CapturedFrame or DissectedFrame, so
read_dissected(path) replays every frame, and a generator expression
chooses which. The waits are the gaps between the items given.
replay_schedule(source, *, speed=1.0, max_delay=5.0, limit=None) -> Iterator[Tuple[float, item]]
— (delay, item) for each item. delay is the seconds to wait before it: the
time since the one before it, divided by speed. It reads no clock and
waits for nothing, so an event loop drives it with its own sleep.
replay(source, deliver, *, speed=1.0, max_delay=5.0, limit=None) -> int
— blocks: sleeps each delay, calls deliver(item), and returns how many were
delivered. An exception from deliver ends the replay and propagates. This is
the replay for anything that is not UDP: what to do with a TCP segment or
an ARP frame is the callable's.
replay_to(source, dst, port, *, endpoint=None, speed=1.0, max_delay=5.0, limit=None) -> ReplayResult
— blocks: sends the payload of each UDP datagram to (dst, port).
- The destination is always the caller's. The addresses recorded in the
capture are never sent to.
dstisnetimps.HostLike; a name is looked up once.portis 1 to 65535. - Only datagrams are sent, as UDP payloads through an ordinary socket. An
item that is not a
CapturedDatagramis aTypeError. This library never writes a raw frame to a network. - A datagram marked
fragmentedortruncatedis not sent: it is counted inReplayResult.partial. - By default a UDP socket is made with
netimps.bind()fordst's address family (any free port, broadcast off) and closed on return. Pass anetimps.UDPEndpointasendpointto choose the source port or interface or to allow broadcast; it is left open. OSErrorwhendstdoes not resolve (netimps.ResolutionError) or a send fails, a payload too long for the family included.
ReplayResult(sent, partial) — a named tuple: datagrams sent, and partial
datagrams passed over.
| Option | Meaning |
|---|---|
speed |
1.0 keeps the recorded timing and is the default, so a replay sends no faster than the capture did; 2.0 halves every wait; None removes them and has to be asked for |
max_delay |
the longest single wait, in seconds. A capture controls its timestamps, and a jump of a year must not hang a replay. A step backwards in time, or a time that is not a number, is a wait of zero |
limit |
stop after this many items; the source is not read past it |
ValueError for a speed that is not positive and finite, a max_delay or
limit below zero, a port outside 1-65535 or an empty dst; TypeError
for an option of the wrong type, a deliver that is not callable or an item
with no time.
Capturing live, and from a socket
Frames also come from an interface, on Linux with CAP_NET_RAW, and from UDP
sockets the caller bound, on every platform with no privilege. Both are
passive, and the detail is in pktcap/_sources/AGENTS.md.
has_live_capture,LiveCapture,sniff_framesandsniff: every packet on an interface (Linux), as frames, dissected frames or UDP datagrams.UDPCapture,sniff_udpandasniff_udp: the datagrams that reach endpoints you bound, as dissected frames, with an asynchronous twin. The IP header of such a frame is made up, and a socket never sees other hosts' traffic.datagram_framebuilds the frame of one datagram.
Plugins
load_plugins(registry, plugins=None, *, always=()) -> Tuple[LoadedPlugin, ...]
imports the modules a list names and calls each one's hook,
pktcap_plugin(registry), into registry. The list is plugins: the library
reads no variable and no file, so a program passes a list only from a place it
trusts. always names plugins the caller's own code loads whatever the user
listed. A plugin that fails is a CapturePluginError and leaves the registry
as it was. More: pktcap/_plugins/AGENTS.md.
Command line
pip install "pktcap[cli]" installs the pktcap command (also
python -m pktcap) with capture, replay, convert and plugins, each a
call into this API (sniff_frames, replay_to, copy_frames, load_plugins).
pktcap.cli is in no export: main and the command classes (Loading,
Selecting, Writing, Capture, Convert, Replay, Plugins) are the base a
protocol library subclasses. PKTCAP_MCP=stdio serves convert and plugins
as tools. Every option, status and override point: pktcap/cli/AGENTS.md.
Exceptions
Every exception pktcap raises on its own account descends from
PktcapError(Exception), and each one also inherits the builtin a caller
would already catch. A caller's own mistake (a bad option, a wrong argument
type) is a plain ValueError or TypeError, never a PktcapError.
| Class | Bases | Raised for |
|---|---|---|
PktcapError |
Exception |
the base; catch it for "anything pktcap reported" |
CaptureFormatError |
PktcapError, ValueError |
input that is not a pcap or pcapng capture, or is a damaged one |
CaptureFilterError |
PktcapError, ValueError |
a capture-filter expression that cannot be parsed or compiled |
UnsupportedFormatError |
PktcapError, ValueError |
an output format that does not exist, or that a file name does not tell |
MissingExtraError |
PktcapError, ImportError |
a format that exists and whose extra is not installed |
RecordFormatError |
PktcapError, ValueError |
text that is not one record in its format: format and lineno; the message never quotes the text |
DissectError |
PktcapError, ValueError |
raised by a dissector for octets that are not its layer. A caller of FrameDissector never sees it: it becomes the frame's error |
LiveCaptureError |
PktcapError, OSError |
a platform with no AF_PACKET, asked to capture live |
CapturePluginError |
PktcapError, ValueError |
a plugin the user named that cannot be loaded: plugin and source say which, and from where |
CaptureHookError |
PktcapError, OSError |
a hook command that failed or ran past its time limit, under fail_fast: status and timed_out |
MissingExtraError(message, *, format, extra) has format ("toml") and
extra (the extra to install, "toml"), and its message names the extra: a
caller that offers the format under its own extra words its own. A process
that may not open a packet socket gets the kernel's PermissionError, and
OSError from a file or a socket is let through as it is.
CaptureFormatError.offset is how many octets of the input had been read when
the problem was found, or None; the message ends with it and never quotes the
file.
Environment variables
The library reads none, and never at import: they belong to the command line
(pktcap/cli/AGENTS.md).
PKTCAP_LOAD— the plugins the command line loads;,;:or space between items,nonefor none.PKTCAP_CONFIG— the command line's settings file: an absolute path; read as it is.XDG_CONFIG_HOME(POSIX),APPDATA(Windows) — wherepktcap/pktcap.iniis looked for.
Gotchas
- The readers and
replay_schedulereturn iterators. Arguments are checked at the call; the file is opened and a damaged capture raises only while iterating. Wrap the loop, not the call. - Zero datagrams is not "no traffic" until the counters agree. Pass a
FrameDissectorand readstats:unsupportedequal toframesis a link type nothing dissects,malformeda capture cut too short. register_dissectorchanges the process. EveryFrameDissectorbuilt without a registry sees it. A library that must not affect its host builds its ownDissectorRegistryand passes it.- Installing a package registers nothing. Entry points are not read: a
dissector is in a registry because some code put it there, or because a
plugin the user named (
load_plugins) did. - A
CapturedDatagramis notnetimps.Datagram. One is read from a capture (time, both addresses, payload); the other is received on a socket. - A writer replaces an existing file at the first write, not when it is
built.
CaptureWriter(append=True)adds to a record file (not withper_record). - Nothing here is safe to share between threads. A frame dissector, a writer and a live capture each keep state.
- This library configures no logging, installs no signal handler and
prints nothing. It logs at
WARNINGonpktcap._dissect(a dissector that failed) andpktcap._output(the file budget reached), and atERRORonpktcap._copy._hook(a hook command that failed).