Changelog
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
Nothing yet.
0.1.1 - 2026-10-14
Changed
- The
cliextra requiresduho>=0.7.4,<0.8. With it every error of the settings file is one line,pktcap: error: ..., status 2: a key or table nobody has no longer prints the usage lines before that line. Aconfigkey in the file is refused by duho's own check, in its words (duho: config file <file>: unknown key 'config'). - On Windows a relative
APPDATAis ignored when the default settings file is looked for, as a relativeXDG_CONFIG_HOMEalready was elsewhere.
0.1.0 - 2026-10-12
Added
Python 3.9 or newer; netimps>=0.4.1,<0.5 is the one dependency. Optional
extras: yaml (PyYAML>=6.0,<7), toml (tomli-w>=1.0,<2, and
tomli>=2.0,<3 before Python 3.11) and cli (duho>=0.7.3,<0.8). import
pktcap imports none of them, reads no variable and imports no plugin.
The package
- The package, with one exception base,
PktcapError, and__version__. MissingExtraError, anImportErrorand aPktcapError, is what a format whose optional dependency is not installed raises.formatis the format's name andextrathe pktcap extra that installs what it needs ("toml"), so a library that offers the format under its own extra words its own message without reading this one.
Reading captures
read_frames(source, *, max_frame_size=262144)reads every frame of a pcap or pcapng capture, from a path or from a stream that cannot seek, asCapturedFrame(time, linktype, data, interface=None)named tuples, whatever the link type. Every length the file states is checked against a ceiling before it is read: a 48-octet file whose record claims 1 GiB raisesCaptureFormatErrorhaving allocated under 1 MiB. Any damaged capture raisesCaptureFormatError, aValueError, with the octet offset.
Dissecting frames
FrameDissector(registry=None, *, reassemble=True, max_reassemblies=256, reassembly_timeout=30.0)andread_dissected(source, *, dissector=None)dissect frames layer by layer intoDissectedFramenamed tuples: the layer records in order, the octets after each, and what no dissector read. A frame is never raised for: a layer cut short is countedmalformedand named inDissectedFrame.error, a link type with no dissector is countedunsupportedand returned whole, and at most 32 dissectors run on one frame (DissectStats).FrameDissector.unsupported_linktypesis a read-only snapshot mapping each link type that had no dissector to the number of frames of it ({105: 3}), so a caller can say which link type a capture it could not read was made on. The first 64 distinct link types are told apart;DissectStats.unsupportedcounts every frame.- Built-in dissectors for Ethernet, 802.1Q and 802.1ad tags, Linux cooked
capture v1 and v2, BSD loopback, raw IP, IPv4, IPv6 with its hop-by-hop,
routing, destination and fragment headers, UDP and TCP, each making a named
tuple:
EthernetLayer,VLANLayer,LinuxCookedLayer,LoopbackLayer,IPv4Layer,IPv6Layer,IPv6ExtensionLayer,IPv6FragmentLayer,UDPLayer,TCPLayer. TCP is read a segment at a time and no checksum is verified. An IPv6 address keeps its text (the 1,024 a process used last), since a capture names few hosts and every frame names two. - A dissector is any callable
(data: bytes) -> Dissected(layer, payload, next=(), fragment=None).DissectorRegistry(*, builtins=True)maps a selector(kind, number)(linktype,ethertype,ip,udp,tcp, or any other kind) to one, refusing a selector that is taken unlessreplace=True;default_registry()andregister_dissector(kind, value, dissector)are the process-wide one. Entry points are not read. A registered dissector that raises costs that frame one layer, is countedfailedand logged once per selector; the reader goes on.check_dissector(dissector, samples)runs the contract check the built-in dissectors pass.DissectErroris theValueErrorthey raise. - IP reassembly, for any protocol, is on by default and bounded: 256 datagrams in flight, 65,535 octets and 1,024 fragments each, 30 seconds of capture time. A fragment costs a binary search whatever the order of arrival, and a fragment that overlaps another discards its datagram.
read_datagrams(source, *, dissector=None)andDissectedFrame.datagram()give the UDP view,CapturedDatagram(time, source, destination, payload, fragmented, truncated); a datagram cut by the snap length is markedtruncated.- A layer may say how it reads: a layer record may have a method
summary()returning one line saying what it holds. Every built-in layer has one, and a registered layer without one is described by its name.frame_summary(frame)is a frame's time (UTC), its two socket addresses when it has them, the name of its innermost layer and that layer's summary. The line is escaped to printable ASCII, cut at 512 characters, and asummary()that raises or gives no text is logged once for its class and never stops anything.
Filtering
parse_capture_filter(text)andcompile_capture_filter(text, build)read thekey=value and key!=valuefilter expression intoFilterClausenamed tuples and one predicate;buildgives each key its meaning, once per clause, and itsValueErrorbecomes aCaptureFilterErrornaming the clause.frame_filteris thebuildfor dissected frames, with the keys ofFRAME_FILTER_KEYS:src,dst,host,sport,dport,port,proto,vlan,linktype.oris refused by name.DissectorRegistry.register_layer(layer, *, name=None, keys=None, replace=False),unregister_layer(name)andlayers()declare the class of a layer a dissector returns, and optionally the filter keys over it.frame_filter_for(registry)is the filterbuildthat knows them:LAYER.FIELDreads any field of a built-in or declared layer by the type of its value (ipv4.ttl=64,udp.destination_port=0x270f),LAYER.KEYand the bareKEYread a library's own keys, and a wrong key or a value that can never match is refused when the filter is compiled.frame_filter_keys(registry=None)lists every key that compiles.frame_filterreads no registry; it answersLAYER.FIELDof the built-in layers.
TCP stream reassembly
TCPReassembler(*, max_streams=1024, max_buffered=16777216, idle_timeout=300.0)puts TCP streams back together from dissected frames:add(frame)returns theTCPStreamDatathe frame made deliverable, each direction's octets in order with anoffset, the octets given up on before them (missing), a connection number (stream) and anendmarker;flush()gives what is still held at the end of a capture;statsis aTCPStreamStatsof twelve counters. A reader of a capture is passive, so each choice it has to make is a stated rule:- Sequence numbers wrap and offsets do not; octets already delivered are final; of two copies not yet delivered the first captured wins.
- A hole is waited for. It is given up, and what was held is delivered with the count of octets missing, when the other side acknowledges past it, when a bound is reached, or when the direction, the connection or the capture ends. An acknowledgment beyond everything seen from the sender changes nothing.
- A FIN ends a direction and an in-sequence reset ends the connection. A SYN on a live connection that is not the connection's own is set aside until a segment confirms it, so one forged SYN ends nothing; on a connection that has ended it starts a new stream at once. A SYN-ACK may acknowledge the octets a TCP Fast Open SYN carried.
- A segment, FIN or reset more than 16,777,216 octets beyond the furthest octet believed from its sender is set aside until the sender's next segment follows on from it, so one forged or corrupted sequence number moves nothing.
- A segment is as long as its IP header says: the octets a snap length cut
are given up at once and reported in
missing. - Not done: the checksum is not verified, a segment in an IP fragment that was not reassembled is ignored, VLAN and interface do not tell connections apart, and no dissector is given a stream.
Everything a capture controls is bounded: connections (max_streams),
capture time of silence (idle_timeout), octets held out of order over all
connections (max_buffered, each held piece charged its length plus 64) and
pieces held in one direction (1,024, a piece being a run of held octets).
Reaching the last two gives up the longest wait and drops nothing; a
forgotten connection drops what it held, counted in dropped.
- read_tcp_streams(source, *, reassembler=None, dissector=None,
max_frame_size=262144) reads the TCP streams of a capture in one call:
read_dissected through TCPReassembler.add, then flush() at the end.
Writing, and the record formats
PcapWriter(target)andPcapngWriter(target)write captures:write_frame(frame)writes a captured frame back with its link type, octet for octet, andwrite(time, source, destination, payload)writes a UDP datagram as raw IP under IPv4 or IPv6 and UDP headers with valid checksums. A pcap file holds one link type and refuses another; pcapng holds any mix. The file is opened by the first write, not by the constructor. A payload over 65,507 octets (IPv4) or 65,527 (IPv6), a frame over 262,144, a port outside 0-65535 and a time outside 0 to 2**32 raiseValueErrorand write nothing.CaptureWriter(target, format=None, *, per_record=False, append=False, fields=(), max_files=1000)writes datagrams or dissected frames aspcaporpcapng, or the records made of them asjson(one line each),yaml(one document each),tomlorini(one file each), or astext, into one growing file or one file per record under a name pattern.write(item, record=None, *, names=None)takes the record, or renders it itself, so the octets of a record are one library's, and makes the directories above a growing file at its first write, as it does for a per-record file.append=Trueadds to a record file; withper_recordit raisesValueError.dumps_record(record, format)writes one record as text;datagram_record(datagram)andframe_record(frame)are the default records;OUTPUT_FORMATS,RECORD_FORMATSandhas_output_format(name)say what can be written.yamlandtomlneed the extras of those names and raiseMissingExtraErrornaming the extra without them. Every record format writes printable ASCII."text"is inOUTPUT_FORMATS(endings.txtand.log):CaptureWriterwrites a frame'sframe_summary, or a line for a datagram, many lines to a file or one file a line withper_record. It is not a record format, soRECORD_FORMATSdoes not hold it anddumps_recordandloads_recordrefuse it, as they refusepcap.- A per-record writer creates at most
max_filesfiles and makes each field value safe for a file name. A value longer than 64 characters is cut to 55 and ends in-and 8 hexadecimal digits of the SHA-256 of the whole value, so two long values that start alike name two files. A value cannot make a directory of a per-record path ({client}/{index}.json) a name Windows opens as a device: the part gets a leading_on every platform, as the file's own name does. A part the pattern spells out is kept as written. copy_frames(frames, writer, *, select=None, datagrams=False, limit=None, names=None, each=None)writes the frames a predicate accepts into aCaptureWriterthe caller owns, optionally as the UDP datagram of each, and stops reading atlimit; it returnsCopyResult(read, written, skipped, refused).namesis called for each frame kept to give the writer the values of itsfields, andeachwith the frame after its item is written, not for a frame that was dropped, had no datagram or was turned away by the file budget.datagram_frame(datagram, *, interface=None, ident=0)builds the raw-IP frame of aCapturedDatagram, the octetsPcapWriterwrites for it, so that dissecting it gives the datagram back for a whole datagram; a payload that is not bytes-like is aTypeError.
Reading a record back
loads_record(text, format="json")reads one record back from the textdumps_recordwrites, in the same four formats, as its inverse: for every recorddumps_recordtakes,loads_record(dumps_record(r, f), f) == r, with the exceptions the formats force, which the header lists (a key that was not text comes back as text in JSON and INI, a lone surrogate in a value is U+FFFD in TOML, and a few more). A name a format could not write so that it reads back is refused with aValueErrorthat quotes nothing of the record: a lone surrogate in a TOML key, a lone surrogate or an empty name in an INI section or option name. Text that is not one record raisesRecordFormatError(message, *, format, lineno=None), aPktcapErrorand aValueError, whose message never quotes the text and which chains nothing.- The text is untrusted: a key written twice,
NaNand the infinities in JSON, a YAML tag that is not plain, a YAML merge key, a YAML alias, a date in TOML or YAML, and nesting past the recursion limit are refused. The YAML writer writes no anchor and no alias: an object a record holds twice is written twice. In INI a value that is not JSON is its text, so a file written by hand reads. YAML needs theyamlextra; TOML is read withtomllibfrom Python 3.11 and needstomlibefore it, which thetomlextra installs there.
Replay
replay_schedule(source, *, speed=1.0, max_delay=5.0, limit=None)yields(delay, item)for a capture's datagrams or any iterable of datagrams or frames, and reads no clock;replay(source, deliver)sleeps each delay and callsdeliver;replay_to(source, dst, port, *, endpoint=None)sends each UDP payload to the one destination named, through anetimpssocket, and returnsReplayResult(sent, partial). The addresses in the capture are never sent to, no raw frame is ever sent, the recorded timing is the default, each wait is capped atmax_delay, and afragmentedortruncateddatagram is counted and not sent.
Live capture, and the UDP socket source
LiveCapture(interface=None, *, timeout=1.0)captures every packet on Linux through anAF_PACKETsocket, which needsCAP_NET_RAW, as Linux cooked v2 frames a writer can save for other tools;sniff_frames(interface=None, *, stop=None, dissector=None)yields every frame seen live, dissected, asread_dissecteddoes for a file;sniff(interface=None, *, stop=None, dissector=None)is its UDP datagram view;has_live_capture()says whether the platform has one, andLiveCaptureError, anOSError, is raised where it does not.- A UDP socket is a capture source, on every platform and with no privilege.
UDPCapture(endpoints, *, timeout=1.0, max_size=65535)reads datagrams from boundnetimps.UDPEndpointobjects (at most 256; a repeated endpoint is aValueError) and returns each as a raw-IP frame (link type 101) with the arrival interface when the host reports one; a datagram overmax_sizeis counted intruncatedand not returned, and one an endpoint does not admit (UDPEndpoint.admits) is counted innot_admittedand gives no frame.sniff_udp(endpoints, *, stop=None, dissector=None)yields them dissected, with the registered dissector of the port applied, and closes the sockets when it ends, fails, is closed or is dropped.asniff_udp(endpoints, *, dissector=None)is its asynchronous twin onUDPEndpoint.arecv, withUDPCapture.aread()andaclose(); every task is cancelled and every socket closed when it stops, andimport pktcapimports noasyncio. The IP header of such a frame is made up, and a socket never sees other hosts' traffic, the link layer or fragments.
Hooks
command_hook(command, *, format="json", timeout=10.0, fail_fast=False, names=None, datagrams=False)is a hook for a copy that runs a program for each frame written: the record of the frame on standard input, the valuesnames(frame)gives asPKTCAP_HOOK_<FIELD>variables (each passing the file-name rule ofCaptureWriter), no argument and no shell. The program is found once, as an absolute path (a bare name in the directoriesPATHlists and nowhere else), and a.bator.cmdfile is refused on Windows. The run is the program's own exit: a process it leaves holding its streams delays nothing. Pasttimeoutthe program and everything it started are killed (a job object on Windows, started suspended and resumed through documented calls; the process group on POSIX). A program that cannot be started is a failure like a non-zero exit. A failure is counted in the callable'sfailures, logged onpktcap._copy._hookwith a bounded, escaped tail of the error output (the first, then at most one a minute), and withfail_fastraisesCaptureHookError(message, *, status=None, timed_out=False), aPktcapErrorand anOSError.
Plugins
load_plugins(registry, plugins=None, *, always=())imports the modules a list names and calls each one's hook,pktcap_plugin(registry)(or anyMODULE.CALLABLEtaking the registry), so a protocol library's dissector and filter keys are registered with no code of the user's. The list is the one it is given: the library reads no variable and no configuration file, and a program passes a list only from a place it trusts.LoadedPluginis one result. A plugin that cannot be loaded raisesCapturePluginErrorand leaves the registry as it was.alwaysloads the plugins a caller's own code names before the user's list, with the source"always". They are never read fromPKTCAP_LOADor the configuration file, which still decide the user's list. An item of the user's list that is the same hook is skipped; a different hook claiming a selector that is taken is still aCapturePluginError, and a failure anywhere leaves the registry as it was.
The command line and its settings file
- The
pktcapcommand, alsopython -m pktcap, behind thecliextra (pip install "pktcap[cli]", which bringsduho). Without the extra the command prints the one line naming it and exits 1.import pktcapimports neitherduhonor the command package,pktcap.cli, which is not library API. Exit status: 0 done, 1 the operation failed, 2 the invocation was wrong. A command whose standard output the reader closed ends with status 1 and no line on standard error, on Windows as elsewhere; anEINVALof a file or a socket stays an error with its line. pktcap convert --input FILE|- [--output TARGET] [--format FMT] [--filter EXPR] [--datagrams] [--per-record] [--append] [--max-files N] [--limit N]copies a pcap or pcapng capture into pcap, pcapng, records (json, yaml, toml, ini) or text, throughcopy_frames. Standard output is the default target and may be the capture; the summary is on standard error. An output file ending in.txtor.logis text.--format tomloriniwithout--per-recordis status 2 with a line that names--per-record;--appendwith--per-recordis status 2.pktcap replay --input FILE|- --to HOST:PORT [--filter EXPR] [--speed X | --no-delay] [--max-delay SECONDS] [--limit N] [--source-port N] [--broadcast] [--json]sends the UDP payloads of a capture, in order and in time, throughreplay_to.--tois the only destination there is: the addresses in the file are never sent to. The result is the one linesent 12, partial 0, or an object with--json.pktcap capture [--interface NAME | --listen SPEC...] [--filter EXPR] [--output TARGET] [--format FMT] [--datagrams] [--per-record] [--append] [--max-files N] [--count N] [--duration SECONDS] [--hook COMMAND] [--hook-fail-fast] [--hook-timeout SECONDS]captures live throughsniff_framesandcopy_frames(Linux,CAP_NET_RAW). Ctrl-C ends it with status 0 and the summary; off Linux the one line says to pipe a capture tool intopktcap convert.--listen SPEC(repeatable, excludes--interface) captures the datagrams that arrive at UDP ports, on every platform and with no privilege;SPECis the grammar ofnetimps.parse_listen, bound withnetimps.bind_listen.--hookruns a program (or a PythonMODULE:FUNCTION) for each record written, ascommand_hookdoes; a hook is given the format the writer writes (the ending of--outputwhen it decides), and the sockets a command bound are closed when the source refuses them.--counthas no short form.- Every command takes
--load NAME(repeatable;nonefor none), loading into a registry of its own before the filter is compiled, sopktcap capture -f "proto=dhcp"and a library's own keys work with no code written. The plugin list of a command is the fieldload(--load,PKTCAP_LOAD, the keyload); a variable that is only white space is a list with no plugin, and only an empty one is unset.pktcap plugins [--layer NAME] [--json]shows the settings file the root read, what was loaded and every filter key. A hook and a plugin list come from the command line, not from a settings file or a variable a library's root applies to every field: such a value is status 2 naming the layer. - The root takes
--config/-c FILEbefore the command's name, elsePKTCAP_CONFIG, else the user's ownpktcap/pktcap.ini. The file is duho's layout (top-level keys for the root, a table for each command), in INI, TOML, JSON or YAML by its name, and a[convert]table sets the options ofconvert. A top-levelloadis the list of every command. The argument wins over the variable, the variable over the file. An empty path (--config "",PKTCAP_CONFIG=) means no file, the user's own included. An unknown key or table (duho's usage error, which names the key and never holds a value), aconfigkey, a[pktcap]table and a file that is not valid are status 2 with one line that holds no text of the file. The default file is checked where owners are: on POSIX it must be the user's or root's and not writable by everyone. A relativeAPPDATAis taken as it is on Windows. Thecliextra needs duho 0.7.3, which publishesrunning_mode(): a command tells a tool call by it.
The command classes as a base
- The command classes are the base of a protocol library's commands.
pktcap.cliexportsmain,Loading,Selecting,Writing,Capture,Convert,ReplayandPlugins, bound on first use so that importing it still needs noduho. A subclass sets_plugins_,_filter_,_fields_,_format_,_interruptible_,_default_port_,_listen_and_env_prefix_, and overrides_select,_frames,_names,_limit,_hook,_report,_endpoints,_stop,_datagramsand_replay; the loop thatconvertandcaptureshare isWriting.__call__. Capture._endpoints(self, dissector)is given the dissector the frames are read with, so a protocol is registered on the port that was bound without keeping the dissector in an attribute.Capture._listen_(defaultNone: every interface) is what the command listens on when neither--listennor--interfaceis given.Capture._stopis asked whatever the options are.Capture._hook_from_(default("argument",)) lists the layers a subclass takes a hook from, of"argument","environment"and"file";Loading._load_from_names the layers a class takes the plugin list from, and the plugin list never comes from a root's layers beyond them.Loading._env_prefix_(defaultNone) gives every option of a class that names no variable, inherited ones among them, the variable made of the prefix and its field name in upper case (MYPROG_CAPTURE_FORMAT); the logging options and--load, which keepsPKTCAP_LOAD, are not renamed, andMeta(env="")keeps an option without a variable.- No command has a configuration option, so a library that subclasses the
commands names its settings file on its own root and meets no clash. Every
private member of the command classes that is not an override point begins
_pk_, and a subclass defines and uses none.
Tools served over MCP
PKTCAP_MCP=stdio pktcapservesconvertas a tool (pktcap.convert) andpluginsaspktcap.plugins: the records come back as the tool's result. A call asking forpcaporpcapngon standard output has its octets written to the server's output target (file descriptor 3, or whatPKTCAP_MCP_OUTPUTnames: a descriptor number, a file or a pipe) and the result says how many bytes went where; with no target the call fails with duho's message.captureandreplayare not served: one runs until stopped and needs a privilege, the other puts datagrams on a network.- A tool call that names
loadorappendis refused, and the server's ownPKTCAP_LOADdecides the plugins. The settings file a served tree reads is the server's own default file, checked as any default is; neitherPKTCAP_CONFIGnor aconfiga call names is read.