Skip to content

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.9.0 - 2026-10-17

Options 94, 97 and 175 decode to structured values where they were Bytes, and option 93's entries are named; the octets on the wire are the same. "Changed" lists these and the rest of what behaves differently.

Added

  • DHCPOptions.update(*options, **overrides) merges several bags in one call. Each positional — a mapping of code to value, another bag, or (code, value) pairs — is applied in order, then each keyword, named after an option in the bag's codemap in any letter case: options.update(scope, image, router="10.0.0.1"). Another bag's raw payloads are copied without being decoded. The whole call is all or nothing: a refused code, name or value leaves the bag as it was. A read-only bag refuses it.
  • DHCPOptions.copy(*options, **overrides) copies the bag and merges the arguments into the copy, so a merged bag is one expression and the original is left alone. copy() with no arguments is unchanged.
  • DHCPLease.to_mapping() / from_mapping() and to_text() / from_text(): a lease as plain data or as a JSON, YAML, TOML or INI document, and back to an equal lease — ip, expires (ISO 8601 in UTC, left out for a lease that never ends), offered and options. Only ip is required on reading; expires also takes a Z suffix or an aware datetime.
  • DHCPOptions.to_mapping() / from_mapping(): the options form a message's document already held — names, readable values and {"hex": ...} where those would not give the same octets back — on the bag itself. DHCPMessage and DHCPLease both write their options this way.
  • OptionCodeEnum, the base of DHCPOptionCode, for an option-code enum of your own: class SiteCodes(OptionCodeEnum): SITE_NAME = 224. It brings unnamed members for codes without a name and register_type / get_type, with its own codec table, and works as a DHCPOptions(codemap=...).
  • pydhcp.packet.UNSPECIFIED_ADDRESS (IPv4Address("0.0.0.0"), what an address header field holds when it carries none) and INFINITE_LEASE_TIME (0xFFFFFFFF, RFC 2131's "infinity" for option 51), so a caller need not define its own.
  • DHCPLease carries the BOOTP boot fields siaddr, sname and file, and the server puts them in the OFFER and ACK it builds from the lease (never in a NAK), so a server built on pydhcp can answer a PXE client. They are blank by default, as replies were; checked to fit their 64- and 128-octet fields; kept by replace(), the mapping and text forms, pickling and FileLeaseBackend's file.
  • DHCPLease(..., strict=False) and lease.validate(): a lease built non-strict may hold 0.0.0.0, for a reservation with no address yet, and validate() raises DHCPValueError for one that has none. Strict, the default, is unchanged. from_mapping(strict=False) reads a document with no ip that way, and the server treats an address-less lease returned by acquire_lease as no lease.
  • DHCPOptionType.from_json(data), the inverse of to_json(): the documented codec law is now Codec.from_json(value.to_json()) == value, and a test holds every built-in codec to it. Bytes.from_json reads hex text; records with several fields read their to_json list; List[T] reads each item with T.from_json. from_mapping uses it.
  • List.item_type(): the codec of a list codec's items (List[U16].item_type() is U16, CCCOption.item_type() is CCCSubOption), so a caller need not read the private _args_.
  • BaseDHCPOptionCode.resolve(name), one reading of an option's name: a number, option-67, or a member name in any case with - and _ alike (bootfile-name), an alias included. DHCPOptions.update() keywords, from_mapping() keys (so DHCPMessage and DHCPLease documents) and the capture filter's option.NAME all go through it; from_mapping() and the filter took exact member names only.
  • Option 175, iPXE's encapsulated options, decodes as IPXEOptions: the sub-options named by IPXEOption (iPXE's DHCP_EB_* numbers), each read by its codec through .get("https"), the feature flags through .features(), BUS_ID as IPXEBusID (PCI vendor and device) and VERSION as IPXEVersion; a server builds one from (name, value) pairs. Its presence is how to tell iPXE from a PXE ROM. Also I8, a signed one-octet integer codec.
  • The PXE options of RFC 4578 are structured: option 93 is a List[ClientArchitecture], named from IANA's Processor Architecture Types registry (7 is X64_UEFI, 9 is EBC, an unknown number an unnamed member); option 94 is ClientNDI (UNDI 3.16); option 97 is ClientMachineID, whose .uuid reads a type 0 GUID.
  • LenientUserClass reads option 77 in either form clients send, RFC 3004's length-prefixed classes or iPXE's one flat string: options.get(77, decode=LenientUserClass). Option 77 itself stays opaque Bytes, and the strict UserClass now names LenientUserClass when it meets a flat payload, where it said "truncated".

Changed

  • Options 93, 94 and 97 decode to the new codecs: an architecture still compares equal to its number (options.get(93) == [7]), but options 94 and 97 are records, no longer Bytes, and a document writes option 93 by name (["X64_UEFI"]). The octets on the wire are unchanged, and a document written by 0.8, which holds options 94 and 97 as hex text, still loads.
  • options.get(175) is an IPXEOptions, not Bytes, and a document writes option 175 as [name, value] pairs in place of hex text, which it still reads. The octets on the wire are unchanged.
  • DHCPMessage.from_mapping() (and from_text()) require only op. A header field the document leaves out takes the constructor's default (0.0.0.0 for an address, 0 for a number, an empty chaddr) where it was a ValueError, and options may be left out or null.
  • The ImportError for a document format without the pktcap extra reads "a message or a lease as YAML, TOML or INI text needs the 'pktcap' extra".
  • The leases have their own API header, pydhcp/lease/AGENTS.md, out of the server's; pydhcp.lease is a package now, with the same import path and names.

Fixed

  • The codec law Codec(value.to_json()) == value was false for 19 codecs: Bytes refuses text, UserClass refuses its own hex entries, and every record with several fields (TLVOption, the VI*, MoS* and CCC* records, CCCASBackoffRetry) takes them as separate arguments. The law now names from_json, which all of them satisfy.
  • DHCPMessage(op=2) built a message whose to_mapping() raised AttributeError. The constructor kept the number; it now stores op, htype and flags as their enums, the addresses as IPv4Address and secs as a timedelta, and refuses a value that is none of these with a DHCPValueError naming the field.
  • An unnamed option code belonged to whichever enum made it first. The unnamed members and the codec table were shared by every class, so an enum that reused DHCPOptionCode's _missing_ made DHCPOptionCode(250) raise TypeError once it had made 250 itself, and the other way round. Each code enum now keeps its own.

0.8.1 - 2026-10-14

Changed

  • A settings file that names a section or a key the command does not have is refused in one line. A misspelled section, another command's section and an unknown key print pydhcp: error: duho: config file <file>: <problem> and exit with status 2, as before; duho's usage lines no longer come before that line. Every error of the settings file is now one line.
  • The cli extra requires duho>=0.7.4,<0.8, the release of duho that reports a settings file error that way (0.7.3 printed the usage lines).

0.8.0 - 2026-10-12

This release breaks the documented API: names, public module paths, constructor signatures, the lifecycle of every role, the extras and what malformed input raises. Old names are not kept as aliases. "Added" lists what is new, "Changed" has the detail of each behaviour that differs, "Renamed" is the lookup from an old name to its new one, "Removed" lists what is gone and "Fixed" the defects corrected on the way. RELEASENOTES.md has the short version for someone upgrading from 0.7.0.

Added

  • pydhcp.capture.sniff_dhcp(listen=None, *, per_interface=None, stop=None, dissector=None) and asniff_dhcp(listen=None, *, per_interface=None, dissector=None): the DHCP messages that reach listen as pktcap's dissected frames (pktcap.DissectedFrame), on threads and on an event loop. They are netimps.bind_listen with DHCP's defaults (ports 67 and 68, IPv4, broadcast allowed) handed to pktcap.sniff_udp and asniff_udp, with capture_dissector() when no dissector is given; the last layer of a frame that holds a message is a DHCPLayer. The sockets are bound by the call and closed when the iterator ends, fails, is closed, dropped or (asynchronous) cancelled. They need the pktcap extra and use no DHCPListener.
  • DHCPLayer.summary(): one line (DHCPOFFER xid=1A52B52E client=01:02:00:00:99:00:02 yiaddr=10.99.0.100), the text pktcap's text format shows for a frame whose innermost layer is DHCP.
  • pydhcp.capture.capture_dissector(ports=(67, 68)): a pktcap.FrameDissector that reads DHCP on ports, over a copy of pktcap's registry in which the layer and its filter keys are declared, so pktcap.frame_filter_for(dissector.registry) compiles msg_type=DHCPOFFER.
  • pydhcp.capture.pktcap_plugin(registry): the hook pktcap loads by name, so PKTCAP_LOAD=pydhcp.capture pktcap convert -i heard.pcap -f "msg_type=DHCPDISCOVER" filters DHCP with the keys op, msg_type, xid, client_id, chaddr and option.NAME_OR_CODE, each refusing when the filter is compiled a value no message could match. It declares DHCPLayer in the registry and calls register_dhcp_dissector(registry); when that raises, nothing is registered. src, dst, sport and dport stay pktcap's own, and interface is not a key of a frame. Importing pydhcp.capture registers nothing; pydhcp capture and pydhcp replay load the plugin themselves.
  • PYDHCP_MCP=stdio serves pydhcp.packet and pydhcp.interfaces as MCP tools (needs the cli extra), over standard input and output instead of running a command. server, relay, capture and replay are not tools. A tool call has no standard input, so packet without input fails naming --input.
  • pydhcp.MACAddress is netimps.MACAddress, the same object, exported from the root because NetworkInterface.mac hands one to a caller. No other name of netimps is re-exported.
  • DHCPLease.replace(*, ip, expires, options, offered): a new lease with the named fields changed and the others carried over; the lease itself stays a frozen value. A given field is checked as the constructor checks it, and expires=None is a lease that never ends.
  • DHCPServer.STRICT_REPLY_PORTS and pydhcp server --lenient-reply-ports (PYDHCP_SERVER_LENIENT_REPLY_PORTS, the lenient_reply_ports key). The default, True, is the RFC 1542 rule (see Changed): a reply goes to port 67 for a relay and 68 for a client. False answers the port the request came from (giaddr at that port, at 67 when it is 68), which lets a sender choose where its reply goes.
  • DHCPServer.RENEWAL_TIMES (class attribute, True): an OFFER and an ACK carry the renewal time (option 58, half the lease time) and the rebinding time (option 59, seven eighths of it), in whole seconds rounded down and taken from the lease time the same reply carries, so a renewal follows the time left (RFC 2131 s4.4.5). An option the lease's options already hold is kept, a parameter request list does not remove them, and the ACK to a DHCPINFORM and an infinite lease carry neither. The values have no random fuzz. False sends only what the lease holds.
  • A Bound: <address> record (INFO) after each listening socket is bound, with the port the kernel gave: Listening on: is logged before the bind and names the port that was asked for, which is 0 for an ephemeral one.
  • DHCPMessage.from_hex(text): decode a message written as hexadecimal text (spaces, line ends and colons between the digits are ignored), the reader pydhcp packet --decode used to keep to itself.
  • pydhcp capture --read FILE: the DHCP messages of a pcap or pcapng capture (- is standard input), through the same filter, output formats and hook as a live capture, with no socket bound. A capture cut in the middle writes the records before the cut and exits 2 naming the file; frames nothing could read are counted in the summary line. PYDHCP_CAPTURE_READ, and read in a configuration file.
  • pydhcp replay --input FILE --to HOST[:PORT]: send again the requests a capture shows going to port 67, to the destination you name and never to an address from the capture, with the recorded waits unless --no-delay (--speed, --max-delay, --limit, --filter, --source-port, --broadcast, --json, --load). It is pktcap's replay with dport=67 as its own filter. PYDHCP_REPLAY_*.
  • pydhcp.capture.dissect_dhcp, DHCPLayer and register_dhcp_dissector(registry=None): DHCP as a pktcap dissector on UDP ports 67 and 68, so a pcap or pcapng capture read with pktcap.read_dissected carries a DHCPLayer (op, xid, message_type, client_id, and message, the to_mapping() of the message) on each frame that holds a message, and proto=dhcp selects them. Nothing registers on import. Octets that are not a message are a pktcap.DissectError with a fixed text that quotes none of them.
  • pydhcp capture --format pcap|pcapng: the datagrams a capture heard in a file tcpdump and Wireshark open (and pktcap.read_datagrams reads back octet for octet), or as the frames pktcap builds for them with --datagrams left out; the ending of --output names the format (.pcap, .cap, .pcapng, and .jsonl and .ndjson beside .json, .yaml, .yml, .toml, .ini, .txt, .log). DHCPRequestContext gains a last field payload: bytes | None = None, the datagram as it arrived (the listener keeps one reference to the received bytes, no copy).
  • DHCPConfigError (pydhcp.exceptions, re-exported at the root): a configuration file that cannot be used. It carries path, lineno, colno and msg, is also a ValueError, and str() is one line, path:line:col: msg, with no text from the document.
  • pydhcp interfaces --format text|json: text is one tab-separated line per address (name, address, MAC or -, network) and no banner; json is one array of objects.
  • pydhcp.exceptions, re-exported at the root: DHCPError (the base), DHCPDecodeError and DHCPValueError (each also a ValueError) and NoClientIdentityError. Every decoder raises DHCPDecodeError and nothing else for malformed octets: DHCPMessage.decode and each option codec used to raise a bare ValueError (or, for a short address, an ipaddress.AddressValueError, for a bad UTF-8 label a UnicodeDecodeError, for a destination with host bits set an ipaddress ValueError). except ValueError keeps working.
  • DHCPTimeoutError and DHCPRefusedError (pydhcp.exceptions, re-exported at the root): what a client exchange raises instead of returning None. DHCPTimeoutError(DHCPError, TimeoutError) says no usable reply arrived; DHCPRefusedError(DHCPError) carries the server's DHCPNAK as .nak. Both copy and pickle.
  • deadline= on DHCPClient and AsyncDHCPClient dora() and discover_offer(): seconds for the whole call (both halves of a dora() together), where timeout stays the first retransmission interval. No transmission starts after it and the last wait is cut to what it leaves; a value that is not positive is a ValueError.
  • Four counters in DHCPMetrics: packets_decoded_leniently, packets_dropped_no_client_id, packets_dropped_other_server and addresses_refused, for what a decoder forgave and for three server drops that had no counter.
  • pydhcp.__version__, read from the installed distribution's metadata.
  • Three relay counters in DHCPMetrics: packets_dropped_relay_loop (a request whose giaddr is one of the relay's own addresses), packets_dropped_unknown_giaddr (a reply whose giaddr is not) and packets_dropped_reused_transaction (a request that reuses the transaction of a pending one from another source address). relay_info_omitted also counts a request the relay forwarded without option 82 because it would not fit.
  • AsyncDHCPClient (pydhcp and pydhcp.client): DHCPClient on an event loop. send, discover_offer and dora are coroutines taking the same keywords as the synchronous client's and returning the same results, with the same retransmission schedule, secs and reply matching; next_reply is a coroutine and drain_replies and the build_* methods are plain methods. It uses the listener's lifecycle (await start(), shutdown(), await wait_closed(), await aclose(), async with). Cancelling a pending exchange abandons it and leaves no task, socket or accepted transaction behind, and several exchanges may be pending on one client. Its on_reply hook runs on the event loop, not on a worker thread.
  • DHCPRequestContext.destination and .is_unicast (appended, with defaults, so a context built by hand keeps working): the address the datagram was sent to, and whether it names one host. A broadcast DISCOVER shows 255.255.255.255, a RENEWING client's unicast shows the server's address, which is what RFC 2131 section 4.3.2 tells RENEWING from REBINDING by. None when the socket reports no packet info.
  • DHCPMetrics.replies_dropped_pin: broadcast replies dropped because they could not be pinned to the interface the request arrived on.
  • listen accepts an interface: an adapter name ("eth1", "eth1:67"), a MAC in any spelling ("aa-bb-cc-dd-ee-ff:67", ("aa:bb:cc:dd:ee:ff", 67)), or a netimps.Interface or netimps.MACAddress, in the constructors, --listen and a configuration file. The listener binds one wildcard socket (so a broadcast is still heard, which an address-bound socket on Linux is not) and drops, before decoding, a datagram that arrived on another interface, counting it in the new DHCPMetrics.packets_dropped_other_interface. The adapter is looked up by bind(), and one that does not exist is a ValueError there. Cannot be combined with per_interface=True. On Linux the socket is also bound to that device (netimps.has_device_binding()), so the kernel delivers only its datagrams and packets_dropped_other_interface stays 0; where the option is refused, or the socket serves several adapters, the drop does the whole work, with one warning for a refusal. Windows, macOS and FreeBSD have no such option.
  • An offered state in the lease store. LeaseBackend gains offer(client_id, ip, hold_seconds, options=None) and commit(client_id, ttl), DHCPLease gains offered (keyword-only, default False, part of equality and hash), and lookup reports the state through it. An offer holds the address for hold_seconds and is an expired record after that; commit turns it into a binding. allocate stays as an offer and its commit in one call. The lease file gains a state field ("offered" or "bound"); a file written before it, with no state, loads as bindings.
  • DHCPServer.OFFER_HOLD_SECONDS (class attribute, 120.0): how long an address offered in a DHCPOFFER is held for the client it was offered to.
  • Two counters in DHCPMetrics: leases_offered (addresses held by an OFFER) and offers_withdrawn (offers dropped because the client's REQUEST named another server).
  • Two more counters in DHCPMetrics: informs_ignored (a DHCPINFORM whose ciaddr the server will not answer) and relay_info_omitted (a reply sent without option 82 because it would not fit the options field).
  • DHCPServer.is_quarantined(ip, *, now=None) and .lookup_lease(client_id) (and the same on AsyncDHCPServer). is_quarantined says whether an address is out of the pool after a DHCPDECLINE; the server asks it of every lease a hook returns. lookup_lease is the override point for what the server holds for a client (default: lease_backend.lookup), asked by DHCPDECLINE, DHCPRELEASE and a REQUEST naming another server.
  • DHCPOptions.retain(codes): keep only the options whose code is in codes.
  • Four counters in DHCPMetrics: packets_dropped_malformed_option, options_ignored_malformed, declines_ignored and quarantines_refused.
  • Documentation: the shipped API header is a top header, src/pydhcp/AGENTS.md, and one header per package (listener, server, client, relay, capture, packet, options, options/_codecs and cli), each printing the signatures of its names, with the exceptions, the command line, the environment variables and the gotchas; a test checks every name and every printed signature against the live object. The README follows the standard layout and its Python examples run under test; the site has a changelog page, pydhcp.packet.structured, load_config and the exceptions on the API page, the capture filter grammar in pktcap_plugin's header, and a table of every named option code with its codec, RFC and departures, generated from the registry (docs/options.md); tests/AGENTS.md describes the suite.
  • Conformance: the server and the relay are compared with dnsmasq 2.92 and the client with ISC dhclient 4.4.3-P1, over 25 recorded exchanges (tests/conformance/) that replay on every platform with no peer installed. The README section "Differences from dnsmasq and ISC dhclient" states how far the comparison goes and lists the ten differences that are on purpose; docs/conformance.md has the table of what each role covers and the RFC text behind each difference. No difference is open.

Changed

  • A message as text beyond JSON is pktcap's. DHCPMessage.to_text and from_text, pydhcp.packet.structured.dumps and loads and pydhcp packet --format write and read JSON with the standard library, as before (indented, two spaces), and yaml, toml and ini with pktcap.dumps_record and loads_record: they need pip install "pydhcp[pktcap]", and YAML and TOML also pktcap's yaml and toml extras, whose own error names pip install "pktcap[yaml]" or "pktcap[toml]". The INI form has a [record] section for the header fields where it had [message], and a document with [message] and [options] is still read. A list or string subclass in the mapping (a domain list, option 119) is written as the builtin, so a message with one round-trips in YAML, where it was refused. TOML refuses, with the reason, a message whose options it would write in another order (an option held as octets in the hex form before a plain one), where it wrote them in that other order.
  • pydhcp capture and pydhcp replay are pktcap's commands (pktcap.cli.Capture and Replay) with DHCP's defaults: --filter is ANDed with proto=dhcp, ports are 67 and 68, --listen keeps pydhcp's grammar and binds with netimps.bind_listen (IPv4, broadcast allowed, a 1 MiB receive buffer, --per-interface), and with no source at all it listens on *. Their options, statuses, the summary on stderr and the file budget are pktcap's (pktcap --help and pktcap's header have each one), and pydhcp capture gains --interface, --datagrams, --append, --duration/-d, --hook-timeout and --load, and --format text.
  • A record is pktcap's record of the frame: time, linktype, length, layers (ipv4, udp and dhcp) and payload. The message the old line held is the dhcp layer's message: jq -c '.layers[-1].message'. A record file is replaced unless --append is given (it was appended to). --format text writes DHCPLayer.summary(), one line a person reads.
  • Renamed: --output-mode per-capture is --per-record (see Removed); capture -f is --filter (--format has no short form) and -c for --count is gone; the filter keys src_port and dst_port are sport and dport, src and dst take networks, and interface= is gone (use --listen NAME). --listen with --interface or --read is status 2. An option of 0.7.0 that is gone is refused by the parser (status 2).
  • New variables and keys: every option of the two commands has PYDHCP_<COMMAND>_<FIELD> and, for capture, a settings key of that field's name. The plugin list is --load, with pktcap's own PKTCAP_LOAD and the key load.
  • Hooks are pktcap's: a hook comes from --hook, PYDHCP_CAPTURE_HOOK and the settings file the user named; it gets PKTCAP_HOOK_CLIENT_ID, PKTCAP_HOOK_MSG_TYPE, PKTCAP_HOOK_XID and PKTCAP_HOOK_FORMAT (not PYDHCP_CAPTURE_*) and the record on standard input, and the client identifier is cleaned as a file name's is (01_02_00_00_99_00_02, where it was 01:02:00:00:99:00:02); a failed hook is counted and the capture goes on unless --hook-fail-fast; --hook-timeout bounds it (10 seconds); a module:function hook is called with a pktcap.DissectedFrame (it was a CaptureEvent) and its module must be on PYTHONPATH (the working directory is not added, where it was); on Windows a .cmd or .bat file is refused, name a program.
  • Behaviours that differ: an --output ending that names no format is status 2 (it was JSON), and the warning for a pattern that names no unique field is gone. The file budget is below, under "pydhcp capture has a file budget".
  • pktcap is an optional dependency, the pktcap extra. pip install pydhcp needs netimps alone; importing pydhcp, pydhcp.capture and pydhcp.cli loads neither pktcap nor asyncio, and a server, a relay, a client and pydhcp packet --format json run without it. A capture function (sniff_dhcp, asniff_dhcp, capture_dissector, dissect_dhcp, register_dhcp_dissector, pktcap_plugin) raises an ImportError, and pydhcp capture and pydhcp replay print and exit with status 1, with the one line captures need the 'pktcap' extra: pip install "pydhcp[pktcap]". dev installs pydhcp[cli,pktcap], pktcap[yaml,toml] and duho[config,yaml].
  • A PCPServerList entry is a tuple of address texts, where it was a list, so an entry cannot be changed past the checks: servers[0].append(...) put a 64th address or text that is no address into an option, which failed only when it was packed. Replace the entry (servers[0] = [...]) or append a new one. A decoded option compares equal to a list of tuples; the display text and to_json() are the same.
  • Importing the package does not import asyncio. AsyncDHCPListener, AsyncDHCPServer, AsyncDHCPClient and AsyncDHCPRelay are bound when first accessed, from pydhcp and from their own packages; their names and import paths are unchanged, and a program that uses only the blocking classes never loads it.
  • duho is the cli extra, and pktcap is the pktcap extra; neither is installed with the package (0.7.0 installed duho and PyYAML): pip install pydhcp gives the library, and pip install "pydhcp[cli]" the pydhcp command, pip install "pydhcp[pktcap]" the captures and a message as YAML, TOML or INI text. import pydhcp and import pydhcp.cli import neither, nor tomllib or tomli. Breaking for an install that relied on pip install pydhcp for the command or for YAML. The dev extra includes cli and pktcap, pktcap's yaml and toml, and duho's config and yaml.
  • Without the cli extra the pydhcp command (and python -m pydhcp) prints one line, pydhcp: error: the command line needs the 'cli' extra: pip install "pydhcp[cli]", and exits with status 1; pydhcp.cli.main returns 1 the same way. pydhcp.cli.App and the six command classes load on first use and are an ImportError naming the extra without duho.
  • A missing optional dependency is an error that names the extra, not NotImplementedError: DHCPMessage.to_text/from_text and pydhcp.packet.structured.dumps/ loads raise ImportError with pip install "pydhcp[pktcap]" for YAML, TOML and INI text (and pktcap's own line for its yaml and toml extras); a .toml or .yaml configuration file whose reader is missing raises DHCPConfigError whose line is duho's, naming duho[config] or duho[yaml]. Breaking for code that caught NotImplementedError there. No default needs an extra: packet --format is JSON, and a settings file is JSON, INI or whatever its name says.
  • The sdist no longer holds the repository's AGENTS.md or benchmarks/, and leaves out CLAUDE* and *.local.* whatever tree it is built from; the build requires hatchling 1.27 or later.
  • Dependency ranges are scoped to a series and their floors install: netimps>=0.4.1,<0.5, duho>=0.7.3,<0.8 and pktcap>=0.1.0,<0.2.
  • Breaking: pydhcp.cli exports App, main and the six commands, and nothing else. PACKET_FORMATS and CAPTURE_FORMATS are no longer re-exported from it (the capture formats are pktcap.OUTPUT_FORMATS). server, relay and capture declare --listen and --per-interface once, in a shared base; those two options, their variables and their help text are unchanged. pydhcp packet --decode reads its hex text through DHCPMessage.from_hex.
  • A capture writes a line feed at the end of every line on every platform. On Windows a record file, and standard output, ended each line with CR LF; they no longer do. Standard output is UTF-8 on every platform.
  • The capture filter's grammar is pktcap's: what each DHCP key means stays in pydhcp (pktcap_plugin). A malformed or unusable expression is status 2 with one line that names the clause (pktcap.CaptureFilterError, a ValueError). These expressions mean something new: msg_type!=DHCPOFFER and every key!=value select what the clause does not (it was refused as an unsupported key msg_type!); a comma means "any of" for every key but option.NAME, so msg_type=DHCPDISCOVER,DHCPREQUEST selects both (it compared the whole text and matched nothing); op and msg_type take a member's name in any letter case or a number; an expression that ends in and, or in and and nothing, is refused (option.12=x and compiled, with the word in the value). The renamed keys aside (src_port, dst_port and interface, above), a filter that compiled and could match before selects what it did.
  • pydhcp.cli.main(argv=None) -> int takes the argument vector and returns the exit status instead of reading sys.argv and exiting; python -m pydhcp and the console script exit with it. Breaking for a caller that relied on SystemExit. The status is 0 for success (including --help, --version and Ctrl-C), 1 for a run that failed and 2 for a wrong invocation: a malformed --listen, relay without --server, a bad option, packet --encode --format summary. All of these exited 1 or with a traceback before. An error is one line, pydhcp: error: ..., on stderr; a closed stdout ends a command quietly. Nothing is logged as "starting" before the arguments are accepted.
  • pydhcp relay --server is required at the parser, and takes several addresses separated by commas as well as a repeated option.
  • pydhcp interfaces prints no banner and one line per address (see Added); pydhcp packet --format summary opens with OP XID=... and no longer prints a Src: capture Dst: decoded that was never true. A packet that cannot be read or decoded is reported as pydhcp: error: cannot process the packet: ....
  • PYDHCP_MCP=stdio no longer serves every command as a tool: packet and interfaces alone are served (see Added). server, relay and capture do not return until they are stopped, and capture and replay start a program per packet or send datagrams.
  • Configuration errors are status 2 and end in one line: a malformed JSON, TOML, YAML or INI file, a top level that is not a mapping and a file name that no format claims each print pydhcp: error: <file>:<line>:<col>: <problem> with no text from the document; a misspelled section (sever:), another command's section and a key the command does not have print duho's usage lines and then one line that names the file and the section or key. They were a traceback (YAML, INI), a bare parser message (JSON, TOML) or silently started a server on every interface (a misspelled section). An INI value is no longer interpolated, so a % in a path is text; a .conf file is no longer parsed as JSON.
  • The settings file is the root's, and duho.config reads it (Breaking):
  • pydhcp --config FILE COMMAND (or -c FILE, or PYDHCP_CONFIG), where it was pydhcp COMMAND --config FILE. After the command name the option is refused with the line to use. replay reads the file too (a [replay] table); packet and interfaces do not. An empty path (--config "", or PYDHCP_CONFIG set empty) means no file is read.
  • A file that names a section or a key the command does not have is refused once the arguments are parsed, with duho's usage lines before the line that names the file and the key; a file that cannot be read is still the one line.
  • The format is the ending of the file's name: .json, .toml, .yaml, .yml, .ini or .cfg. .yaml and .yml are read (they need pip install "duho[yaml]"), and so is .cfg; a name that none of them claims is refused.
  • INI is duho's dialect: the keys of [DEFAULT] are top-level keys (and so refused as an unknown section, since a command's keys go in its own section), sections do not inherit them, and a key keeps its case (Listen is not listen).
  • The text of a read error is duho's, with the same path, line and column; a settings file that does not exist is status 2 from the parser when --config names it (it was 1), and status 1 when PYDHCP_CONFIG does.
  • pydhcp.exceptions.DHCPConfigError keeps its fields and its one line; load_config(source, format=None) calls duho.config and needs the cli extra (its ImportError names it).
  • The [capture] and [replay] tables may set every inherited option, load (the pktcap plugins to import) among them.
  • Settings follow one order for every command: the option, then an environment variable (PYDHCP_<COMMAND>_<OPTION>, listed in the README and the shipped header), then the configuration file, then the default. relay and capture read the file too, and any field of a command can be set from it. The file is explicit (--config FILE before the command name, or PYDHCP_CONFIG), with no default location.
  • PYDHCP_TRACEBACK is a boolean: 0, false, no and off no longer enable tracebacks; any text that is neither a true nor a false word is an error naming the variable.
  • pydhcp capture has a file budget, and ends when it cannot write a record: with --per-record, a record that needs a file past --max-files (new, default 1000, the old MAX_PER_CAPTURE_FILES) is counted as refused and the capture goes on, ending with status 1 and the count in its summary line; a record that cannot be written (a directory the pattern names is a file, a full disk) ends the capture with status 1 and one line that names the path and says why. Both used to leave the capture running, recording nothing and exiting 0 or never exiting, with a traceback per packet.
  • A value interpolated into a capture filename is at most 64 characters: a longer one (a client identifier is up to 255 octets, 765 characters as text) is cut and ends in a hash of the whole, so two long values still name two files. It used to make every write fail.
  • The command hook is bounded, and quiet about what the program prints: the program and the processes it started are killed on the time limit (only the program was), which --hook-timeout sets (10 seconds); a hook that fails or is killed is counted and logged with a bounded, escaped tail of its error output, and nothing else it prints is logged (its output was decoded with the locale's encoding and logged whole, so a program printing other bytes was a decode error, or a mis-decode on Windows). Breaking: pydhcp.cli.HOOK_TIMEOUT_SECONDS is gone.
  • Every option has a help line: packet --decode, --encode and capture --format had none, and capture --filter showed the field name PACKET_FILTER.
  • The relay follows RFC 3046 section 2.1 on the request side. It adds option 82 only to a request that arrives with giaddr 0 (a request another relay already stamped is forwarded as it is, where it used to be given a second option), drops a request whose giaddr is one of its own addresses (it used to forward it, with insertion on or off), puts option 82 last in the options field and never through option 52 or the sname and file fields (a request advertising 576 in option 57 used to be forwarded as 548 octets with the option split into sname), and encodes a request at the relay's own max_packet_size, not the client's option 57. A request the option would not fit is forwarded without it and counted in relay_info_omitted. A reply loses option 82 only when it is the option this relay added; an option a trusted downstream element added (trust_client_relay_agent_info=True, insertion off) now reaches that element.
  • Breaking: dora() and discover_offer() raise where they returned None. The None stood for four outcomes at once, and dora() returned it after retransmitting its REQUEST against a DHCPNAK for about 14 seconds. They now return the message or raise DHCPTimeoutError (nothing usable arrived; also a TimeoutError) or DHCPRefusedError (a DHCPNAK, which ends the exchange at once, RFC 2131 section 3.1). Callers that tested for None catch the exceptions instead. A DHCPOFFER that offers no address or names no server (it used to be selected, or logged and abandoned), and a DHCPACK from a server other than the one the REQUEST selected (it used to be returned), are ignored and the wait goes on.
  • DHCPClient.send() and AsyncDHCPClient.send() no longer register the message's transaction (they used to, and nothing removed it, so 5000 RELEASEs left 5000 keys and stopped the client from queuing unrelated replies). An exchange registers its own for as long as it runs, before its first send.
  • Breaking: DHCPRelay and AsyncDHCPRelay refuse contradictory options. insert_relay_agent_info=True with neither circuit_id nor remote_id (it inserted nothing and said nothing) and either id without the flag (it was ignored) are a ValueError at construction, and pydhcp relay --insert-relay-agent-info with no id is the same error instead of a warning.
  • The relay's reply path is chosen from the reply, not from remembered state. A reply leaves from its giaddr, out of the interface that holds it (RFC 1542 section 4.1.2), where it used to use an entry recorded when the request arrived: 1024 forged requests from distinct clients evicted a real client's entry, and its reply then went through a plain transport that no interface was pinned to. A reply whose giaddr is not an address of the relay (including 0) is dropped and counted in packets_dropped_unknown_giaddr (it was broadcast to the client segment). The pending table now holds only the port of a client that is not on port 68, evicts the oldest entry at MAX_PENDING_CLIENTS (an evicted client's reply goes to port 68), and expires from its front, so the cost of a request or a reply no longer grows with the entries a flood left. A request that reuses the transaction of a pending one from another source address is dropped and counted in packets_dropped_reused_transaction; it used to be logged as ignored and forwarded all the same. The reused-transaction record is logged by pydhcp.relay._pending.
  • Breaking, in behaviour: a reply goes to port 67 when it goes to a relay and port 68 otherwise, whatever port the request came from (RFC 1542 section 5.4: "The UDP destination port MUST be set to BOOTPS (67)" for giaddr, "to BOOTPC (68)" for ciaddr and "in any case"). The server used to reply to the request's source port (except that a relay's port 68 became 67), so one unauthenticated datagram made it send a DHCP reply to any port on any host; with giaddr = 127.0.0.2 a bystander bound there received the OFFER. The two ports are the class attributes REPLY_TO_RELAY_PORT (67) and REPLY_TO_CLIENT_PORT (68) of DHCPServer and AsyncDHCPServer; a test harness whose relay or client listens on another port sets them. A real client (port 68) and a real relay (port 67) are unaffected.
  • Breaking: text in listen that is not an IPv4 address is an interface name. "eth0", "localhost:6767" and ("localhost", 6767) raised ipaddress.AddressValueError and are now read as adapter names (a host name is never resolved), so the error is a ValueError from bind() ("no interface matches ...") when the host has no such adapter. IPv6 addresses are still refused with AddressValueError.
  • Breaking: the listen argument has one grammar, and some forms mean something else. ("127.0.0.1", "6767") bound ports 6 and 7 and ("127.0.0.1", True) bound port 1; both, and any bare number or bool as an address or a port, are now refused (TypeError) before anything is bound. A pair may be a list, so the ["127.0.0.1", 6767] a JSON or TOML file delivers is one binding (it was two, the second 0.0.26.111), and its port may be digit text. A pair is read by netimps.split_host: a port outside 0-65535 and a port written twice that disagrees (("*:67", 68) bound 68) are refused. listen="", " , ", [] and a pair with no port are refused with ValueError where they bound no socket and left serve_forever() to fail or spin; pydhcp server|relay|capture --listen "" is an error and no longer the wildcard. None as a host is the wildcard everywhere: (None, 6767) was 127.0.0.1:6767 and is now 0.0.0.0:6767, as is ("", 6767).
  • A line a sender can provoke is rate-limited at every site. A flood of junk datagrams, forged requests, a failing handler or a failing pin used to write one WARNING or ERROR per datagram (300 forged packets gave 300 records at each server and relay site, 500 one-octet datagrams 500 records). Each reason now writes its first occurrence, then at most one line per 60 s carrying the running count; a traceback goes with the first occurrence only. The reasons are the undecodable, oversized and control-truncated datagram, a receive or handler error (by exception class), a pinned send that fails, the server's BOOTREPLY, unidentifiable client, unusable or unhandled message type, message for another server, INIT-REBOOT from an unknown client, DHCPDECLINE, DHCPRELEASE for an address not held, refused address and a reply that does not decode, the relay's five, an OFFER with no server identifier, and a failing capture hook (callable or command). Text a sender wrote that a line names is escaped and cut at 80 characters. The decoders log at DEBUG instead of WARNING (a text field that is not UTF-8, an option cut short); the listener counts such datagrams. The existing counters stay exact.
  • Breaking: NoClientIdentity is now pydhcp.NoClientIdentityError (pydhcp.exceptions), no longer importable from pydhcp.packet.message. No alias is kept. It is a ValueError as before.
  • The two listeners share one private base for their configuration, binding and the handling of one datagram, and one is not the other's lookalike any more. Three differences between them are gone: AsyncDHCPListener no longer has the plain attributes packets_dropped_truncated and packets_dropped_error, which nothing ever incremented (the counts are on .metrics, as on the sync listener); DHCPListener(max_packet_size=...) defaults to None, as the header says, which means the largest UDP payload exactly as the old default constant did; and a listener that serves a wildcard through one socket per address (per_interface=True, or no packet info) lists the host's interfaces when bind() runs, not when it is constructed. The async listener's error record for a failing handler now reads like the sync one's.
  • Breaking: AsyncDHCPServer is no longer a subclass of DHCPServer. The two are sibling drivers over one private core that holds the lease policy, the reply building and one handler per message type, and owns no socket, thread or clock. isinstance(server, DHCPServer) is false for an asynchronous server, and the methods it used to pick up from the synchronous listener are gone: with AsyncDHCPServer(...), .close() (both raised AttributeError after closing the sockets) and .start(cancellation_token). Subclass either driver and override the same hooks as before: handle_discover, handle_request, handle_decline, handle_release, handle_inform, acquire_lease, release_lease, get_inform_options and get_lease_seconds; each is an ordinary blocking method that runs on the one handler thread of either driver.
  • Breaking: AsyncDHCPRelay is no longer a subclass of DHCPRelay. Same split as the server: one private core holds the forwarding rules, the pending-client table and the hop and relay-agent-information checks, and the two relays are sibling drivers over it. isinstance(relay, DHCPRelay) is false for an asynchronous relay, which has lost the synchronous listener's with, close() and start(cancellation_token) as the asynchronous server did. The pending table ages on the monotonic time stamped on each context, and pydhcp.relay is now a package (same import path and names; PendingClient is private); the relay's records are logged by pydhcp.relay._core.
  • pydhcp.capture is a package (same import path), and its capture classes are gone (see Removed).
  • Breaking: one lifecycle vocabulary on every listener and role (DHCPListener, DHCPServer, DHCPRelay, DHCPClient and their asyncio counterparts). listen() is serve_forever(), stop() is shutdown(), wait() is wait_closed(timeout=None) (it returns False when the timeout passed first), and start() returns None and takes no cancellation token; no alias is kept for the old names. shutdown() never blocks and is safe from any thread and from a handler. The synchronous classes close with close() and a with block, the asynchronous ones with await aclose() and async with, neither starting to serve (the block binds). Closed is final: start(), serve_forever(), bind() and with after close() raise RuntimeError, where start() used to serve again. A second start() while serving raises RuntimeError too: on the asynchronous classes it added a second receive task per socket and stranded a pending wait(). serve_forever() no longer closes the sockets on its way out (close() does), and start() no longer returns the receive thread.
  • close() on a running thread-based listener ends it cleanly. It shuts the loop down through a wake-up socket, waits for the receive thread (up to five seconds) and then releases the sockets; called on the receive thread itself, from a handler, it shuts down and returns, and the loop releases the sockets as it ends. On Windows the receive thread used to die with an uncaught ValueError or OSError; on Linux it never ended, wait() blocked and start() returned nothing. shutdown() ends the wait at once instead of after the poll interval.
  • The library installs no signal handler. DHCPListener.start() no longer claims SIGINT for the process, and close() no longer gives it back. Ctrl-C reaches serve_forever() as KeyboardInterrupt; the pydhcp server and relay commands catch it, log the same line and exit with status 0, as before, and pydhcp capture ends with its summary line and status 0.
  • Breaking: constructors take listen (and, for the relays, server_addresses) positionally and every other option by keyword. This applies to DHCPListener, DHCPServer, DHCPRelay, DHCPClient and the asyncio counterparts; a seventh positional argument is a TypeError. The same position meant different things on the two drivers (select_timeout against max_packet_size), and the shipped header's DHCPRelay signature omitted trust_client_relay_agent_info, so a positional select_timeout written from it turned that trust on. select_timeout is now poll_interval on the synchronous classes (how often a wait looks at the shutdown flag; a shutdown no longer waits for it). reuse_address and receive_buffer_size are new keywords; each defaults to the REUSE_ADDRESS / RECEIVE_BUFFER_SIZE class attribute, which still works.
  • Constructors perform no I/O. A listener no longer asks a socket whether the platform has packet info, and no longer lists adapters, until bind(). FileLeaseBackend(filepath) now requires its path (the default leases.json in the current directory is gone) and reads nothing: open() reads the file once, and the first lease operation or with calls it, so a missing file creates nothing and an unreadable one is not set aside until then.
  • Breaking: SocketAddress(sock) is SocketAddress.from_socket(sock), and SocketAddress(ip, port) needs both arguments (TypeError otherwise, where it was ValueError).
  • pydhcp.client is a package (same import path and names). The message builders, the reply matching and the retransmission schedule are one private core under DHCPClient and AsyncDHCPClient, so DHCPClient's behaviour on the wire is unchanged. A record the client logs (an OFFER without a server identifier) is named pydhcp.client._core, and a test that patches pydhcp.client.UDPTransport patches pydhcp.client._sync.UDPTransport.
  • DHCPRequestContext carries the time its datagram arrived: two new trailing fields, received_at (timezone-aware UTC) and received_monotonic (time.monotonic() seconds), stamped by the listener. A context built by hand has None for both and a hook then reads the driver's clock, so a test that builds contexts keeps working. quarantine_address(ip, *, now=None) accepts the monotonic time; called with ip alone, as before, it reads the driver's clock.
  • DHCPClient.discover_offer() and .dora() name the keywords they forward (xid, client_identifier, parameter_request_list and, for discover_offer, broadcast) instead of taking **discover_kwargs. The same calls work; an unknown keyword is now a TypeError at the call.
  • Breaking: acquire_lease takes a keyword commit and runs on the server itself. The signature is acquire_lease(client_id, server_id, msg, *, commit=True): an override must accept commit (one that does not raises TypeError for every DISCOVER). DHCPDISCOVER makes one call with commit=False, DHCPREQUEST makes two, commit=False to decide and commit=True to commit the ACK, and the override must do nothing that extends or creates a binding when commit is false. The call used to run on a throwaway copy of the server for the first of those, so an address pool that kept its next free host in an attribute (self.next_host += 1) gave every client the same address; the attribute is now the server's own. self.lease_backend inside an override is the real backend on both calls; only the base implementation reads through a view that does not extend a binding when commit is false.
  • Requires netimps 0.4.1 (netimps>=0.4.1,<0.5, was >=0.3.3,<0.4). netimps renamed its public names without an alias, so no range covers both series; 0.4.1 adds parse_listen, bind_listen, ListenLike and UDPEndpoint(interfaces=), which the listeners now read and bind listen through.
  • The listen grammar and the binding policy are netimps'. pydhcp.listener.ListenLike is netimps.ListenLike, and every accepted listen names the same sockets as before. The listener keeps the default ports, the receive buffer, the records and the warning that a socket bound to an address hears no broadcast. What a user can see change:
  • A second bind() on a listener that is bound opens nothing, closes nothing and does not read the host's addresses, so it does not pick up an address the host gained: close the listener and make another.
  • A listener that binds one socket per address (per_interface=True, or a host whose sockets report no packet info) binds every IPv4 address the host holds, the link-local ones (169.254/16) among them, where it left those out. Name the addresses in listen to hear fewer.
  • The message of a refused listen is netimps' (listen names no address: [], where it was ... it is empty).
  • An error that was a plain ValueError (an empty specification, a bad port, a port written twice, an unknown interface at bind()) is netimps.NetimpsValueError, still a ValueError.
  • An IPv6 address as text ("::1", "[::1]:67", "fe80::1%eth0", ("fe80::1", 6767)) is refused with netimps.NetimpsValueError, where it was ipaddress.AddressValueError: code that caught that subclass by name catches ValueError instead.
  • An IPv6Address object as a binding is refused the same way, as a value of the wrong family, where it was TypeError: text and object now agree.
  • host:port text is read strictly. In listen=, --listen and each pydhcp relay --server, the port is ASCII digits only and square brackets may enclose only an IPv6 literal. "127.0.0.1:+6767", "127.0.0.1: 6767", "127.0.0.1:8_0" and "[127.0.0.1]:6767" were read as a port or an address before; they now raise ValueError (netimps' NetimpsValueError), and the message names the port or the brackets.
  • host_ip_interfaces(family=) accepts socket.AF_INET and socket.AF_INET6 as well as 4 and 6.
  • A socket-buffer shortfall is now reported twice: by pydhcp at INFO, naming the address, and by netimps at WARNING, once per process for each distinct request and grant.
  • On Windows a disconnected adapter no longer lists a 169.254 address (netimps leaves out an address Windows marks tentative or duplicate), so host_ip_interfaces(filter=False) can return fewer entries there.
  • Deployments: NETIMPS_NO_SOCKET_PATCH is now an error. With it set, import pydhcp raises ValueError at import; use NETIMPS_SOCKET_PATCH=0 to disable the socket patch.
  • Breaking: NetworkInterface.mac is a netimps.MACAddress, and pydhcp.MACAddress is that type, not a subclass of it. str(mac) is now aa:bb:cc:dd:ee:ff where it was AA-BB-CC-DD-EE-FF; pydhcp interfaces still prints the hyphenated upper-case form, through mac.format("-", upper=True). The netimps type has no as_str(): use .format(sep=":", *, upper=False); instances are read-only, copy and pickle keep the subclass, a bad value raises netimps.NetimpsValueError (still a ValueError) and MACAddress.try_parse() raises TypeError for a non-str. pydhcp.network.APIPA is netimps.LINK_LOCAL_V4, an IPv4Network, and host_ip_interfaces()'s default filter is unchanged.
  • A failed bind raises netimps' own error, with its own message. The suggestions "try port N+1000" and "Try 6767 for testing" are no longer appended to it.
  • An adapter holding loopback and routable addresses answers from the routable one. The address a reply is sent from, when the request's destination names none of the adapter's addresses, is Interface.primary_ip() (routable, then loopback, link-local last); it was the first address in the adapter's order that was not link-local. Only an adapter listing both kinds (Linux lo with an address added to it) is affected.
  • A request received without an arrival interface is judged by Datagram.is_unicast. A destination that is the subnet broadcast of an adapter this host holds is now treated like the limited broadcast (no local address is taken from it); only the limited broadcast and multicast were before.
  • Breaking: every module that is not an import path of its own is private. pydhcp.log, .config, .constants, .metrics, .nvt and .network are pydhcp._log, ._config, ._constants, ._metrics, ._nvt and ._network; pydhcp.packet.enums and .message are pydhcp.packet._enums and ._message; pydhcp.options.code, .registry and .type are pydhcp.options._codes, ._registry and ._codecs (and options/base.py joined _codes.py), each codec module under it with a leading underscore; the pydhcp.listener modules (sync, aio, interfaces, receive, transport) are _sync, _asyncio, _interfaces, _receive and _transport, and binding and spec are gone (netimps owns the listen grammar and the binding); the pydhcp.cli command modules are _capture, _interfaces, _packet, _relay and _server (capture_hook is gone). pydhcp._utils is split into _generic and _missing. No module is left behind to re-export from its old path. Import a name from pydhcp, pydhcp.packet, pydhcp.options, pydhcp.listener or the other public modules; pydhcp.log.LOGGER is logging.getLogger("pydhcp").
  • A module's logger is named for its module, so the records of a moved module change name: pydhcp.log, pydhcp.config and pydhcp.metrics become pydhcp._log, pydhcp._config and pydhcp._metrics, pydhcp.listener.sync and the other listener modules pydhcp.listener._sync and so on. A --loglevel or a filter naming one of them follows; one naming pydhcp or pydhcp.listener is unaffected.
  • Breaking: the root exports the packet enums and no longer exports the address aliases or the specialised codec families. pydhcp.DHCPMessageType, pydhcp.DHCPOpcode, pydhcp.DHCPFlags and pydhcp.DHCPPort are new (the same objects as pydhcp.packet's). Each name below is gone from pydhcp, no alias is kept; import it from where it is listed:
Was Now
pydhcp.IPv4 ipaddress.IPv4Address
pydhcp.IPv4Interface ipaddress.IPv4Interface
pydhcp.IPv4Network ipaddress.IPv4Network
pydhcp.CccOption pydhcp.options.CCCOption
pydhcp.CccSubOption pydhcp.options.CCCSubOption
pydhcp.CccPrimaryDhcpServerAddress pydhcp.options.CCCPrimaryDHCPServerAddress
pydhcp.CccSecondaryDhcpServerAddress pydhcp.options.CCCSecondaryDHCPServerAddress
pydhcp.CccProvisioningServerAddress pydhcp.options.CCCProvisioningServerAddress
pydhcp.CccProvisioningServerFqdn pydhcp.options.CCCProvisioningServerFQDN
pydhcp.CccKerberosRealmName pydhcp.options.CCCKerberosRealmName
pydhcp.CccAsReqAsRepBackoffRetry pydhcp.options.CCCASBackoffRetry
pydhcp.CccApReqApRepBackoffRetry pydhcp.options.CCCAPBackoffRetry
pydhcp.CccTicketGrantingServerUtilization pydhcp.options.CCCTicketGrantingServerUtilization
pydhcp.CccProvisioningTimer pydhcp.options.CCCProvisioningTimer
pydhcp.CccSecurityTicketControl pydhcp.options.CCCSecurityTicketControl
pydhcp.CccKdcServerAddressList pydhcp.options.CCCKDCServerAddressList
pydhcp.CccPrimaryDhcpServerAddressSubOption pydhcp.options.CCCPrimaryDHCPServerAddressSubOption
pydhcp.CccSecondaryDhcpServerAddressSubOption pydhcp.options.CCCSecondaryDHCPServerAddressSubOption
pydhcp.CccProvisioningServerAddressSubOption pydhcp.options.CCCProvisioningServerAddressSubOption
pydhcp.CccAsReqAsRepBackoffRetrySubOption pydhcp.options.CCCASBackoffRetrySubOption
pydhcp.CccApReqApRepBackoffRetrySubOption pydhcp.options.CCCAPBackoffRetrySubOption
pydhcp.CccKerberosRealmNameSubOption pydhcp.options.CCCKerberosRealmNameSubOption
pydhcp.CccTicketGrantingServerUtilizationSubOption pydhcp.options.CCCTicketGrantingServerUtilizationSubOption
pydhcp.CccProvisioningTimerSubOption pydhcp.options.CCCProvisioningTimerSubOption
pydhcp.CccSecurityTicketControlSubOption pydhcp.options.CCCSecurityTicketControlSubOption
pydhcp.CccKdcServerAddressSubOption pydhcp.options.CCCKDCServerAddressSubOption
pydhcp.ViVendorSpecificInformationRecord pydhcp.options.VIVendorSpecificInformationRecord
pydhcp.ViVendorSpecificInformation pydhcp.options.VIVendorSpecificInformation
pydhcp.ViVendorClassRecord pydhcp.options.VIVendorClassRecord
pydhcp.ViVendorClass pydhcp.options.VIVendorClass
pydhcp.MoSIpv4AddressRecord pydhcp.options.MoSIPv4AddressRecord
pydhcp.MoSFqdnRecord pydhcp.options.MoSFQDNRecord
pydhcp.MoSIpv4AddressList pydhcp.options.MoSIPv4AddressList
pydhcp.MoSFqdnList pydhcp.options.MoSFQDNList
- Every public module declares __all__. pydhcp.options, .client, .relay,
.capture, .lease and .packet.structured gained one; a name outside it is not API.
No __all__ lists a type variable, a logger or an underscore name: pydhcp.cli.LOGGER
leaves pydhcp.cli.__all__, and pydhcp.options.T and .C (type variables that
from pydhcp.options import * used to bring in) are gone.
- Breaking: pydhcp.listener and pydhcp.cli no longer re-export underscore names
(_bind_sockets, _arrival, _TruncatedDatagram, _split_host_port,
_load_capture_hook, _write_capture_record and the rest). They are in the private
modules that define them.
- DHCPMetrics is pydhcp.listener.DHCPMetrics, where it was
pydhcp.metrics.DhcpMetrics. pydhcp.options exports
MIN_OPTION_CODE, MAX_OPTION_CODE and every option codec in one list, and
from pydhcp.server import DHCPLease is not API (it is pydhcp.lease.DHCPLease).
- Option codecs are values. The record codecs (ClasslessRoute,
ClientFQDN, StatusCode, SIPServers, RDNSSSelection, TLVOption, the
VI*, MoS* and CCC* records) are read-only: assigning or deleting an
attribute raises AttributeError, and a list a record holds is a read-only
copy (TypeError on any change), so a record put in a set keeps its hash.
SIPServers.values is a tuple. Flag() == other returns NotImplemented for
another type, as the rest do.
- repr() of a codec is the constructor call that rebuilds it. It was the
display text: repr(IPv4AddressOption("192.0.2.1")) was 192.0.2.1 and
repr(DHCPMessageType.DHCPACK) was DHCPACK; they are
IPv4AddressOption('192.0.2.1') and DHCPMessageType.DHCPACK, and a list is
List[IPv4AddressOption]([IPv4AddressOption('192.0.2.1')]). repr() of an
empty ClientIdentifier no longer raises. What DHCPMessage.dumps(), the
command and the capture formats print is unchanged.
- Every list codec normalizes on every change: append, extend, insert,
+=, item assignment and slice assignment. A refused item raises
DHCPValueError (or TypeError for a wrong type) and leaves the list as it
was. The classes List[...] builds are named List[IPv4AddressOption] and
belong to the module of List.
- Breaking: DHCPLease is a value, not a tuple. `DHCPLease(ip, expires=None,
options=None)` is read-only, hashable and equal by content, and it no longer
unpacks or indexes. ip is required (None raises TypeError, 0.0.0.0
raises DHCPValueError). expires is a timezone-aware datetime or None
for a lease that never ends; a naive datetime raises DHCPValueError and
math.inf or any other type raises TypeError, so an acquire_lease
override or a LeaseBackend that built `DHCPLease(ip, datetime.now() + ttl,
options)orDHCPLease(ip, math.inf, options)` builds
datetime.now(timezone.utc) + ttl or None. None was a lease with no time
left on the reply path and an infinite one in the reply; it is infinite
throughout. options is copied in and read-only (TypeError on any change;
lease.options.copy() is a bag to change), so a lease a backend stored cannot
be altered through the one it handed out. The stock backends hold UTC
instants, and FileLeaseBackend writes them with an offset and still loads
a file that holds the naive local times it wrote before.
- A DHCPINFORM is answered from its options. The ACK is built without a
lease (it used to be built from one with the wildcard address and an infinite
expiry, and then had its address and lease time removed); what the client
receives is unchanged.
- Breaking: one name per conversion.
- DHCPMessage.summary() is the display dumps() was; nothing reads it back,
and dumps now means only what json.dumps means. HardwareAddressType.dumps
is format_address.
- A structured document is DHCPMessage.from_text(text, format) and
message.to_text(format). pydhcp.packet.structured holds `loads(text,
format)anddumps(data, format)for a mapping;load_message`,
dump_message, load_mapping and dump_mapping are gone, from
pydhcp.packet as well. A document an earlier version wrote, by the capture
command included, still loads.
- DHCPMessage.encode() returns bytes, not bytearray, and bytes(message)
works. Write bytearray(message.encode()) to patch an octet.
- DHCPOptions.decode(data) is a classmethod that returns the options; it no
longer fills an instance, takes a base_offset or returns the unconsumed tail.
- SocketAddress.compat() is to_tuple().
- DHCPMessage.client_id(func=None) is get_client_id(func=None) and
DHCPServer.lease_seconds(msg) (a hook) is get_lease_seconds(msg).
- DHCPClient.send and AsyncDHCPClient.send take the message and then
keywords: send(message, *, dst=..., port=...) (destination is dst).
DHCPTransport.send(data, dst, *, port, client_mac) takes its destination as
dst and the port and hardware address as keywords; a transport a caller
wrote accepts the same call.
- Bytes(value=None) is built from bytes only (its keyword was src); hex
text goes through Bytes.parse(text). A structured document still writes
and reads octets as hex.
- **Breaking: the contracts a caller implements are typing.Protocols, and the
codec contract has public names.**
- OptionCodec (pydhcp.options) is the protocol of an option payload codec;
DHCPOptionType is the base class that supplies its defaults. A codec writes
its constructor, unpack_from(option) -> (value, octets taken) and
pack_into(buffer) -> octets written, and may set fixed_size(), to_json()
and display_text(); Codec.unpack(data) and value.pack() are built on
those. The old spellings are gone: _dhcp_read, _dhcp_write,
_dhcp_len_hint, _dhcp_decode, _dhcp_encode, __json__ (now to_json)
and the display hook _display_text (now display_text). The contract is
pack/unpack and not encode/decode because Bytes is a bytes and
String a str, which already have decode and encode.
- register_type accepts any class with the methods of OptionCodec, not
only a DHCPOptionType subclass, and options[code] = value builds the
codec of the code from value as before.
- DHCPTransport is a protocol: anything with `send(data, dst, *, port,
client_mac)is a transport (it was a class whosesend` raised
NotImplementedError). OptionCode is the protocol of a code enum
(DHCPOptions(codemap=...)); BaseDHCPOptionCode is its base class.
- Breaking: DHCPMessage takes its op and keywords. Every header field but
op has a default (Ethernet, zeros, the wildcard address, no names, a fresh
option bag) and is keyword-only, so DHCPMessage(DHCPOpcode.BOOTREQUEST) is a
message that encodes to a legal 300-octet datagram. hlen is the length of
chaddr when it is left out and must equal it when given: DHCPValueError at
construction, and from encode() for a message changed since. decode() stays
liberal and keeps the hlen the sender wrote, whatever the hardware type, so a
relay still forwards what it received.
- Text a value type accepts is Type.parse(text) (and `Type.try_parse(text,
default=None), which answersdefault` for text that does not parse): on
SocketAddress ("192.0.2.1:67", what str() writes), ClasslessRoute
("10.0.0.0/8 via 192.0.2.1"), StatusCode ("2 message"), ClientFQDN (the
name, then [flags=0x05 rcode1=0 rcode2=0 partial] when any of those is set)
and Bytes (hex). Each raises DHCPValueError for text that is not a value
of the type and TypeError for an argument that is not text, and
parse(str(value)) == value. str() of a ClasslessRoute, StatusCode and
ClientFQDN is now that text.
- DHCPMessage.message_type is option 53 read once: the
DHCPMessageType member, or None when there is no option 53 or its payload is
not one octet. The server, the client's reply matching and
DHCPLayer read it there.
- Named aliases for what a function accepts, exported and listed in the
headers: IPv4AddressLike (pydhcp), ListenLike (pydhcp.listener, was
ListenSpec), ServerAddressLike (pydhcp.relay, was ServerAddress) and
ClientIdentifierLike (pydhcp.client). ListenAddress, ListenBinding and
ListenPort are no longer exported.
- Codecs read what real peers send. Each reading is an RFC allowance, and
what is written is as strict as before.
- Option 120 (SIPServers) reads compressed names (RFC 3361 section 3.1:
clients MUST support them), with the bounds of the search list (255 octets
a name, 127 pointers); a pointer counts from the encoding octet. It still
writes uncompressed names.
- A classless route (121, 249) whose destination has host bits set is read
with them zeroed (RFC 3442 section 3); one such route no longer costs the
whole option. ClasslessRoute(...) stays strict.
- A static route (33) to 0.0.0.0 is read; building or writing one still
raises.
- Option 146 (RDNSSSelection) keeps the root name that marks the default
RDNSS (RFC 6731 section 4.3), which "." spells as well as "", and
ignores the six reserved bits of flags. It and option 124
(VIVendorClass) are built from the form their to_json emits.
- Option 82 (RelayAgentInformation) reads sub-options 0 and 255 as codes
(RFC 3046 section 2.0 defines no pad and no end); before, they ended or
skipped the option.
- DHCPMessageType has an unnamed member for every octet that names no
type: DHCPMessageType(99) has no .name, .label() is TYPE_99, and
DHCPMessage.message_type returns it, so a relay forwards what it received.
message_type is None only for an option 53 that is not one octet.
A capture names such a type TYPE_<n> (DHCPLayer.message_type, the msg_type
filter key and file-name field), where it was UNKNOWN, and the server logs that
name. A number over 255 still raises ValueError.
- The flags field keeps its reserved bits. DHCPFlags is an IntFlag; a
decoded message holds the sixteen bits it received and encode writes them
back, where a decode cleared the fifteen reserved ones (a relay changed the
field it forwarded). DHCPMessage.broadcast tests the bit that is assigned,
DHCPFlags.label() names a value, and to_mapping writes a number for a value
with a reserved bit set. A comparison message.flags is DHCPFlags.BROADCAST
is true only when no other bit is set: use broadcast.
- DHCPOptions compares raw payloads. == is true for the same codes with
the same octets in any order, and nothing is decoded: it no longer raises for a
truncated payload, two decodes of one datagram are equal (so are two
DHCPMessages), and octets that read alike (b"abc\x00junk" and b"abc" under
option 12) are different. Against another type, a dict(bag) included, it is
NotImplemented; a lease's frozen bag compares the same way.
- DHCPOptions.append is atomic, as __setitem__ is: a value its codec
refuses leaves the bag as it was, where an absent code was left present and
empty. setdefault answers the stored bytearray whether or not the code was
there. A refused value now says which option and what kind of value
(option 12 (HOSTNAME) cannot hold a NoneType: ...), with the same exception
class.
- Breaking: a text option takes text. String(None), String(5) and
options[12] = ["a"] raise TypeError, where str() of the value was stored
(b"None"); octets are read as text. Delete an option to leave it out.
- Breaking: integer codecs hold exactly their range and whole numbers.
I32(2**31) raises DHCPValueError when built, where it was built and failed
at encode() with OverflowError; U8(1.9) raises TypeError, where it was
U8(1).
- CCCKerberosRealmName read from the wire keeps the case it arrived in; a
realm built by hand is still upper-cased. A DomainList encoded past 16,383
octets writes a name in full where a compression pointer could not reach the
suffix it shares, and a summary names an unnamed hardware type HTYPE_<n>.
- DHCPMessage.encode lays the options out in one pass. It no longer copies the
option bag and encodes it twice for a message that fits the options field, and
the overload layouts are tried only when it does not. A message that fits
encodes to the same octets as before, and so does one that needed sname or
file and fitted when the options were written in order (the recorded
conversations re-encode unchanged); parse.encode_reply, a twelve-option ACK,
joins parse.encode_packet in the benchmark suite.
- A message the encoder used to refuse now encodes when a layout exists.
- A decoded message whose option 66 or 67 is longer than its field (a UEFI
HTTP boot URL in option 67, say) encodes again, and so does one with a
sname or file set that long: the name travels as option 66 or 67, the
field it would have filled carries options, and decode reads it back.
- A message whose options fit the three fields only with each option placed
whole (the round-trip property test's saved example needs 494 octets of 497)
encodes: when writing the options in order does not hold them, each is
placed in the field with room for it. Option 53 and then option 52 lead the
options field, then a relocated option 66 and 67, and every field keeps the
order the options were set in.
- encode at the smallest sizes. max_packetsize is a datagram, so a message
is padded to 300 octets or to max_packetsize - 28, whichever is smaller:
encode(280) is 252 octets, where it was 280 and its datagram 308. Option 52
stays in the options field, so a receiver learns that sname holds options; a
message that cannot hold options 53 and 52 (a datagram under 275) is refused.
Between 269 and 271 an option that does not fit raises the OverflowError the
documentation names, where it raised a ValueError about a word size.
- A refusal names what did not fit. The OverflowError says which option did
not fit and by how many octets the room is short. A sname or file that must
travel as option 66 or 67 while that option holds other octets raises
DHCPValueError naming both, where the name was dropped without a word.
- encode writes SUBNET_MASK before ROUTER when both are set, whatever the
order they were set in (RFC 2132 section 3.3).
- Codecs refuse on write what the wire cannot hold, and name it. A TLVOption
(relay agent and encapsulated sub-options) with a code outside 0 to 255 or a
value over 255 octets, and a PCPServerList entry of more than 63 addresses,
raise DHCPValueError when built, where bytearray said `byte must be in
range(0, 256). AClientIdentifierof one octet and an emptySIPServers`
raise DHCPValueError when written; both still decode.
- from_mapping reads a value once. The second reading of a value its codec
refused, as bare hex, is gone: "SUBNET_MASK": "dead" loaded as a two-octet
mask and "IP_ADDRESS_LEASE_TIME": "abcd" as a two-octet lease time, and
"255.255.255.0/24" reported the hex parser's complaint. A value the codec
refuses raises TypeError or ValueError naming the option and the kind of
value (option 1 (SUBNET_MASK) cannot hold a str: ...). Octets are read from
{"hex": "..."} for any option, and as hex text only for an option whose codec
is opaque bytes (VENDOR_SPECIFIC_INFORMATION, an unnamed code); a number for
such an option is a TypeError, where Bytes(5) was five zero octets. A missing
header field is a ValueError naming it, where it was a bare KeyError; an
option name nobody knows is a ValueError naming it.
Breaking for a document that relied on bare hex for a typed option: write
{"hex": "..."}.
- from_mapping and from_text take codemap=, the code map to_mapping
named the options with (default DHCPOptionCode).
- sname and file are byte-exact through the mapping and every format.
A name that is not UTF-8 is written as {"hex": "..."} (it was text with U+FFFD
for each such octet, so from_mapping(to_mapping()) changed it, and a name
taken from JSON or YAML changed too); a name that is UTF-8 is still text.
from_mapping also takes bytes and the hex form for either field. A document
written before still loads, with the U+FFFD text it holds.
- decode accepts a missing END marker, as it always did: the documentation
said it raised DHCPDecodeError, and the check could not fire. A message that
stops after a complete option, inside one, or after the cookie decodes and
keeps what arrived.
- Breaking: a DHCPDISCOVER no longer reserves an address for the lease time.
253 forged DISCOVERs, each asking for a day, held a whole /24 for a day: the
stock allocator stored a full binding on the DISCOVER. It now holds the address
as an offer for OFFER_HOLD_SECONDS (120 s) and the client's REQUEST commits
it, so a forged flood holds the pool for the hold and no longer, and commits
nothing. The OFFER still advertises the lease the ACK would grant.
leases_allocated counts commits (an accepted offer, or an address allocated and
committed in one step); leases_offered counts offers. A REQUEST that names
another server gives back an address held by an offer and keeps a binding, which
anyone could have cancelled by naming another server.
- **Breaking: LeaseBackend has six methods, and a backend lacking one is refused
when the server is built.** A backend written for the four methods allocate,
lookup, release and renew raises `TypeError: lease_backend X does not
implement LeaseBackend: it has no offer, commit (see pydhcp.lease.LeaseBackend)` from
DHCPServer(...) and AsyncDHCPServer(...), where it used to fail on the first
DISCOVER; it works once it gains offer and commit. allocate and offer refuse
an address another client holds in either state (allocate used to store one
address for two clients), and renew extends a bound lease only.
InMemoryLeaseBackend.lookup_by_ip is answered from an address index.
- **Breaking: the stock server answers a DHCPREQUEST from what it holds, shape by
shape (RFC 2131 section 4.3.2).** A SELECTING request (option 54 names this server)
it cannot satisfy gets a DHCPNAK where it got silence, and one with no offer
behind it allocates nothing (it allocated the address on the spot). A RENEWING or
REBINDING request (ciaddr, no options 50 and 54) from a client the server holds
nothing for is silence and creates no binding (it was ACKed, and an address was
bound). An INIT-REBOOT request is answered through acquire_lease, so a server that
returns leases and stores nothing in the lease backend answers a rebooting client
(it stayed silent for every one); a lease for another address is a NAK. A request
with no option 54, no option 50 and no ciaddr is dropped. The stock
acquire_lease returns None for a DHCPREQUEST from a client with no record, and an
override that allocated on any request is unchanged.
- The DHCPNAK carries what RFC 2131 Table 3 gives it. The client identifier is
returned exactly as the client sent it and is absent when it sent none (RFC 6842
section 3); the NAK carried an invented one, the hardware type and chaddr, for a
client that sent none. It holds the server identifier, the message type, a
DHCP_MESSAGE text and the relay agent information, and nothing else; through a
relay it sets the broadcast bit and keeps the client's other flag bits (it replaced
them).
- **The stock allocator refuses a message whose giaddr is outside the served
network**, counted in addresses_refused: a client behind a relay on another network
was offered an address of this server's own network with its mask.
- **A DHCPINFORM is answered only when ciaddr is the datagram's source address or lies
in the served network** (the ACK goes to ciaddr); any other, and an INFORM with no
ciaddr, is dropped and counted in informs_ignored.
- **The echoed relay agent information option (82) is the last option of a reply and is
never placed in sname or file** (RFC 3046 section 2.2): a reply that would need
the overloaded fields, or that does not fit at all, is sent without it and counted in
relay_info_omitted.
- pydhcp.lease re-exports its names from private modules (_lease_store,
_lease_file): the log lines of the two stores come from the loggers
pydhcp._lease_store and pydhcp._lease_file, not pydhcp.lease.

Renamed

Breaking. Every public class name spells its acronyms in capitals, the generic names take the DHCP prefix, the lifecycle and conversion methods take the standard's names, and the modules that are not an import path of their own are private. No alias is kept: an old name is not importable. Replace each name in the left column with the one beside it.

Was Now
DhcpListener DHCPListener
AsyncDhcpListener AsyncDHCPListener
DhcpServer DHCPServer
AsyncDhcpServer AsyncDHCPServer
DhcpClient DHCPClient
DhcpRelay DHCPRelay
AsyncDhcpRelay AsyncDHCPRelay
DhcpCapture removed; pydhcp.capture.sniff_dhcp (see Removed)
AsyncDhcpCapture removed; pydhcp.capture.asniff_dhcp (see Removed)
DhcpMessage DHCPMessage
DhcpOptions DHCPOptions
DhcpOption DHCPOption
DhcpOptionCode DHCPOptionCode
DhcpOptionCodes DHCPOptionCodes
DhcpOptionType DHCPOptionType
BaseDhcpOptionCode BaseDHCPOptionCode
DhcpMetrics DHCPMetrics
DhcpMessageType DHCPMessageType
DhcpPort DHCPPort
DhcpLease DHCPLease
OpCode DHCPOpcode
Flags DHCPFlags
RequestContext DHCPRequestContext
UdpTransport UDPTransport
PktInfoUdpTransport PktInfoUDPTransport
TlvOption TLVOption
UriList URIList
RdnssSelection RDNSSSelection
ClientFqdn ClientFQDN
SipServers SIPServers
PcpServerList PCPServerList
ViVendorClass VIVendorClass
ViVendorClassRecord VIVendorClassRecord
ViVendorSpecificInformation VIVendorSpecificInformation
ViVendorSpecificInformationRecord VIVendorSpecificInformationRecord
MoSIpv4AddressRecord MoSIPv4AddressRecord
MoSIpv4AddressList MoSIPv4AddressList
MoSFqdnRecord MoSFQDNRecord
MoSFqdnList MoSFQDNList
CccOption CCCOption
CccSubOption CCCSubOption
CccPrimaryDhcpServerAddress CCCPrimaryDHCPServerAddress
CccPrimaryDhcpServerAddressSubOption CCCPrimaryDHCPServerAddressSubOption
CccSecondaryDhcpServerAddress CCCSecondaryDHCPServerAddress
CccSecondaryDhcpServerAddressSubOption CCCSecondaryDHCPServerAddressSubOption
CccProvisioningServerAddress CCCProvisioningServerAddress
CccProvisioningServerAddressSubOption CCCProvisioningServerAddressSubOption
CccProvisioningServerFqdn CCCProvisioningServerFQDN
CccKerberosRealmName CCCKerberosRealmName
CccKerberosRealmNameSubOption CCCKerberosRealmNameSubOption
CccTicketGrantingServerUtilization CCCTicketGrantingServerUtilization
CccTicketGrantingServerUtilizationSubOption CCCTicketGrantingServerUtilizationSubOption
CccProvisioningTimer CCCProvisioningTimer
CccProvisioningTimerSubOption CCCProvisioningTimerSubOption
CccSecurityTicketControl CCCSecurityTicketControl
CccSecurityTicketControlSubOption CCCSecurityTicketControlSubOption
CccKdcServerAddressList CCCKDCServerAddressList
CccKdcServerAddressSubOption CCCKDCServerAddressSubOption
CccAsReqAsRepBackoffRetry CCCASBackoffRetry
CccAsReqAsRepBackoffRetrySubOption CCCASBackoffRetrySubOption
CccApReqApRepBackoffRetry CCCAPBackoffRetry
CccApReqApRepBackoffRetrySubOption CCCAPBackoffRetrySubOption
Transport DHCPTransport
IPv4Address (the codec in pydhcp.options.type) IPv4AddressOption
NoClientIdentity NoClientIdentityError
pydhcp.network.WILDCARD_IPv4 WILDCARD_V4
HardwareAddressType.IP_ARP_over_ISO_7816_3 HardwareAddressType.IP_ARP_OVER_ISO_7816_3
DhcpOptionCode.GRD DHCPOptionCode.SIXRD, with GRD kept as an alias member
ListenSpec ListenLike
ListenAddress, ListenBinding, ListenPort private, or documented in a header
ServerAddress ServerAddressLike
IPv4 \| str (13 annotations) IPv4AddressLike (Union[IPv4Address, str]), with ClientIdentifierLike (pydhcp.client), ListenLike and ServerAddressLike
pydhcp.network.IPv4, IPv6, IP, IPNetwork and the other aliases of netimps or ipaddress objects removed; callers import IPv4Address, IPv6Address, IPAddress, IPNetwork from netimps or ipaddress
.listen() .serve_forever()
.stop() .shutdown()
.wait() .wait_closed(timeout=None)
async .stop() then nothing; the inherited .close() raises await .aclose(); async with
.start(cancellation_token=None) -> Thread \| None .start() -> None, raising what bind() raised
start() after close() serves again closed is final
DhcpMessage.dumps(codemap=None) DHCPMessage.summary(codemap=None)
HardwareAddressType.dumps(address) HardwareAddressType.format_address(address)
load_message(text, format), dump_message(message, format) DHCPMessage.from_text(text, format), DHCPMessage.to_text(format)
load_mapping(text, format), dump_mapping(data, format) pydhcp.packet.structured.loads(text, format), dumps(data, format)
SocketAddress.compat() SocketAddress.to_tuple()
SocketAddress(sock) SocketAddress.from_socket(sock)
SocketAddress.listen(...) removed; netimps.bind(...) at the one call site
DhcpOptions.decode(options, base_offset), which fills self DHCPOptions.decode(data) classmethod; the in-place form private
DhcpMessage.encode() -> bytearray DHCPMessage.encode() -> bytes, and __bytes__
the codec contract _dhcp_read, _dhcp_write, _dhcp_len_hint, _dhcp_decode, _dhcp_encode, __json__, __concrete__ unpack_from, pack_into, fixed_size, unpack, pack, to_json, display_text; the contract is the OptionCodec protocol
Transport (a class whose send raises NotImplementedError), BaseDhcpOptionCode DHCPTransport and OptionCode/BaseDHCPOptionCode are typing.Protocols or abstract bases
DhcpMessage.client_id(func=None) DHCPMessage.get_client_id()
DhcpServer.lease_seconds(msg) DHCPServer.get_lease_seconds(msg)
Transport.send, DhcpClient.send: dest, destination DHCPTransport.send(data, dst, *, port, client_mac) and DHCPClient.send(message, *, dst, port); discover_offer and dora keep destination=
Bytes: src value
listener/{sync,aio,binding,interfaces,receive,spec,transport}.py listener/{_sync,_asyncio,_interfaces,_receive,_transport}.py; binding and spec are netimps'
server/{handlers,policy,reply}.py server/{_handlers,_policy,_reply}.py, with the drivers in server/{_core,_sync,_asyncio}.py
options/{base,code,registry}.py options/{_codes,_registry}.py; no base.py
options/type/ and its ten modules options/_codecs/...; the name type stops shadowing the builtin
packet/{enums,message}.py packet/{_enums,_message}.py; packet/structured.py stays public if loads and dumps live there
log.py, config.py, constants.py, metrics.py, nvt.py _log.py, _config.py, _constants.py, _metrics.py, _nvt.py, their public names re-exported from the root or a public module
_utils.py named for what it owns: _missing.py, _generic.py
network/ _network/, private; its public names are re-exported from the root or a public module

Removed

  • No PyYAML requirement in pydhcp. 0.7.0 installed it with the package; pktcap's yaml extra and duho's hold it now. A .yaml or .yml settings file is read through duho[yaml].
  • The settings file's own spellings: --config after a command name (refused with the line to use), pydhcp's own JSON, TOML and INI readers, and the toml extra (pip install "duho[config]" reads TOML below Python 3.11).
  • Everything pktcap does is out of pydhcp.capture and the root, and pydhcp's own capture classes, filter and hook runner with it. Each name of 0.7.0 and what replaces it:
  • DhcpCapture, AsyncDhcpCapture: pydhcp.capture.sniff_dhcp(listen) and asniff_dhcp(listen) (pktcap frames), with pktcap.copy_frames(frames, writer, select=..., each=...). A DhcpCapture subclass overriding handle is an AsyncDHCPListener or DHCPListener subclass overriding handle.
  • CaptureEvent, CaptureHook, CapturePredicate, CaptureSink: pktcap.DissectedFrame with a DHCPLayer; the message object is DHCPMessage.decode(frame.datagram().payload).
  • compile_capture_filter(text): pktcap.compile_capture_filter(text, pktcap.frame_filter_for(capture_dissector().registry)).
  • FILENAME_FIELDS, UNIQUE_FILENAME_FIELDS: pktcap.CaptureWriter(target, format, per_record=..., fields=("client_id", "msg_type", "xid")) (its max_files defaults to 1000).
  • pydhcp.cli.HOOK_TIMEOUT_SECONDS: pktcap.command_hook (its timeout defaults to 10 seconds) and pktcap.CaptureHookError. The pydhcp module no longer exports CaptureEvent, DhcpCapture, AsyncDhcpCapture or compile_capture_filter. The asynchronous capture is asniff_dhcp, an iterator and not a class with a sink.
  • pydhcp capture --output-mode is gone, and --per-record replaces it: the mode stream and the mode single ran one code path, told apart only by whether --output was -, so the only choice left is one file per record, and --output-mode per-capture is --per-record. Breaking for a command line that said --output-mode.
  • Breaking: pydhcp.capture.validate_filename_pattern, CaptureEvent.format_filename and pydhcp.cli.MAX_PER_CAPTURE_FILES are gone. pktcap.CaptureWriter makes the same checks of a file-name pattern and raises ValueError (the message names the field and lists the fields), and its max_files (1000) is the budget. The cleaning of a file-name value is pktcap's, the same rule: a name that Windows opens as a device (NUL) gets a leading _.
  • Breaking: pydhcp._network (was pydhcp.network) holds only pydhcp's own types (HardwareAddressType, NetworkInterface, SocketAddress, host_ip_interfaces). IPv4, IPv6, IP, IPv4Network, IPv6Network, IPNetwork, IPv4Interface, APIPA and SocketOption are gone from it: import ipaddress.IPv4Address and the others from ipaddress, and LINK_LOCAL_V4 (for APIPA) and SocketOption from netimps. WILDCARD_V4 is not public: write ipaddress.IPv4Address("0.0.0.0"). No alias is kept.
  • Breaking: SocketAddress.listen() is gone. The listener calls netimps.bind() directly with the arguments it always passed (broadcast=True, connreset=False, an exclusive address unless reuse_address is set), so what a bound socket does on the wire is unchanged; the method's own connreset=True default was never reached.
  • pydhcp.server.handlers, pydhcp.server.policy and pydhcp.server.reply are gone (they exported nothing); the layers are private modules of pydhcp.server, so a record they log is named pydhcp.server._handlers and so on.
  • Breaking: DHCPOptionCode.ensure_registered() is gone. The built-in codecs are bound to the standard option codes when pydhcp.options is imported, so a lookup never depends on an earlier call; there is no alias to call. A codec registered with register_type afterwards replaces the built-in one, as before. DHCPMessageType is defined in the options package (pydhcp.packet.DHCPMessageType and pydhcp.DHCPMessageType are the same object).

Fixed

  • encode() names the header field it cannot send. A message built with a value that has no wire form in op, htype, flags, secs or one of the four addresses (DHCPMessage("BOOTREQUEST"), ciaddr="192.0.2.1", secs=5) raised AttributeError, ValueError or struct.error from the line that packs the header; it raises DHCPValueError naming the field and what it holds. An opcode given as its number is sent as that number, as htype and flags already were: DHCPMessage(1) is a BOOTREQUEST, where it raised AttributeError.
  • A deadline no longer lets one more datagram out after the last wait. The clients cut the wait the deadline falls in to what it leaves, but then read the clock again and sent once more when it still showed time left, which a timer firing a clock tick early (15.6 ms on Windows) made happen. The wait the deadline cuts is the last one, whatever the clock says, in DHCPClient and AsyncDHCPClient.
  • An interrupt that lands between a sync listener's claim and its receive loop no longer makes close() wait five seconds. serve_forever() and start() marked the listener as serving and only then entered the try that releases the claim, so a KeyboardInterrupt (or any exception) in between left it looking busy: close() waited its full five seconds and logged "did not end within". The claim and the loop now run under one try/finally that releases exactly the claim this call made. The pair of sockets the claim makes to wake the loop is the listener's from the statement that makes it, so an interrupt inside the claim no longer leaves the pair open and unowned.
  • A capture filter whose value no packet could match is refused when it is compiled, with pktcap.CaptureFilterError naming the clause, instead of compiling and reporting nothing (which looks like a quiet segment): msg_type=DISCOVER (the names are DHCPDISCOVER and the rest; the message lists them), op=REQUEST, a message type or opcode above 255, client_id or chaddr that is not whole octets of hexadecimal digits (or past 255 and 16 octets), xid outside 32 bits, a port outside 0 to 65535, and option.999. msg_type=dhcpdiscover, msg_type=1, op=bootrequest and op=1 now select the message (they compared names case-sensitively and matched nothing). msg_type=UNKNOWN and msg_type=TYPE_99 still select a message with no type and one of type 99.
  • The README's --loglevel pydhcp=DEBUG example is --loglevel pydhcp:DEBUG: the option takes [NAME:]LEVEL, and the line shown was rejected by the parser. Every command line in the README, the docs and the shipped headers is now fed to the real parser by a test, and the README's lines run as processes against loopback. The README says what a command hook is on Windows: a program (an .exe), not a .cmd file.
  • A lease backend that is falsy when empty is no longer replaced. lease_backend was tested for truthiness, so a backend defining __len__ was discarded while empty and the server used a private store; it is now tested with is None.
  • close() does not close a backend the caller passed in. The caller owns it (flush or close it yourself); a backend the server created is closed with the server, by close() and aclose().
  • release_lease(client_id, server_id, msg) is always told this server's own address. After a REQUEST naming another server it was told that server's address from the packet; that address is in msg.
  • A reply is no longer decoded again after it is built unless DEBUG is on, and a DHCPNAK no longer assigns siaddr, sname and file twice.
  • acquire_lease, DHCPRELEASE, DHCPDECLINE and the withdrawal of an offer ask lookup_lease, so a server that keeps its leases elsewhere overrides one method to answer them.
  • A lease file is read whole or not at all. A file with one bad entry loaded the entries before it, logged "starting with no leases", and the next save wrote the partial store over it. A bad entry, an address held by two clients, or more live leases than MAX_LEASES (which was not applied on load) now leaves the store empty and moves the file aside; the error names the count and the bound.
  • A lease file set aside is never overwritten. An unreadable file was moved to <path>.corrupt, replacing an earlier one; it is now .corrupt.1, .corrupt.2, ... when the name is taken.
  • With SAVE_INTERVAL_SECONDS set, a change reaches the disk within the interval. A change inside the interval stayed unwritten until the next change, flush() or close(), which DHCPServer.close() did not call. A daemon timer thread (pydhcp-lease-save, started only when the interval is above zero) now writes it at the end of the interval; flush() and close() write at once and cancel it.
  • The lease file is written with LF line endings on every platform (Windows wrote CRLF). A file of either form loads.
  • A lease time is advertised as the nearest second to the time left. It was rounded up from the time left since the datagram arrived, so a lease made while the datagram was handled (a pool granting 600 seconds) was advertised as 601.
  • A wrong-length option 50, 51, 54 or 57 no longer raises out of handle(). The options the server acts on are decoded once, at the top of handle(). A message whose option 50 or 54 is unusable is dropped (counted in packets_dropped_malformed_option); an unusable 51 or 57 is treated as absent (options_ignored_malformed). Each is logged once per interval with the XID and the client, where the listener used to log a traceback per datagram.
  • A DHCPDECLINE is believed within bounds. Any client could quarantine any address for ten minutes, an address outside the network or the server's own included, and 1024 such reports evicted a genuine one. The address a DHCPDECLINE names is quarantined whoever sent it (RFC 2131 s4.3.3: the server "MUST mark the network address as not available") when it is in the served network, is not the server's own and, when option 54 is present, option 54 names this server; the sender's own record for it, if any, is released. Anything else changes nothing and is counted in declines_ignored. At MAX_DECLINED_ADDRESSES a new address is refused (quarantines_refused) rather than the oldest evicted. The quarantine is applied to whatever lease acquire_lease returns, so an override is not offered a declined address again. The holder of an address another client declined is answered DHCPNAK at its next renewal, for as long as the quarantine lasts. DHCPServer.DECLINE_REQUIRES_LEASE = True (class attribute, default False) marks only an address the sender holds as a binding or an outstanding offer, which is stricter than the RFC.
  • A unicast reply whose route leaves by another interface arrives. Every reply on a wildcard socket was pinned to the arrival interface's index, so a unicast (a reply to a relay's giaddr, a RENEWING client) whose route is through another interface was put on the arrival interface, ARPed for there and lost with no error. A unicast is now pinned to the address alone and the routing table picks the interface; a broadcast keeps the address and the index.
  • A broadcast reply whose pin fails never reaches another segment. It was retried unpinned and left by whichever interface the routing table picks (a reply to a client on segment A appeared on segment B). It is now retried with the address alone, then dropped and counted in replies_dropped_pin; a failed pin of a unicast is still retried unpinned.
  • A full send buffer no longer loses the reply. The asyncio listener makes its sockets non-blocking and replies from its worker thread, where a full buffer raised BlockingIOError, which the pin's error path read as a failed pin and retried unpinned (and so failed again). A reply now waits up to UDPTransport.SEND_WAIT_SECONDS (1 s) for room and is sent as it was.
  • A capture reports the address a datagram was sent to as its destination. It reported the receiving interface's own address, so a broadcast DISCOVER showed as addressed to the host, and the dst filter key could not select broadcasts. A broadcast now shows 255.255.255.255 (or the subnet broadcast).
  • A unicast to an address the adapter does not list is answered from that address. The reply (and its source pin) came from the adapter's first address, a source the client never addressed: a request to 127.0.0.2 was answered from 127.0.0.1. The reply source is now the destination of every unicast; SERVER_IDENTIFIER and the pool still come from an address the adapter lists.
  • Every public annotation resolves with typing.get_type_hints on Python 3.9. X | Y in a signature, a typing_extensions.Self imported only under TYPE_CHECKING and names a function could not see failed there with a TypeError or NameError (28 of 242 public callables). Signatures use Optional and Union, Self is a bound TypeVar per class, and every module has from __future__ import annotations; typing_extensions is no longer imported.
  • Each module logs on its own logger. Records from the listener, server, relay, client, lease store and the rest were emitted on pydhcp itself, so a level set on pydhcp.server silenced nothing. A record is now named for its module (pydhcp.server._handlers, pydhcp.listener._receive); a handler or level on pydhcp still sees all of them, since children propagate.
  • pydhcp capture --hook ./name runs the file it names. The command was started by the name with its ./ dropped, which POSIX looks up on PATH only: the hook was not found (a traceback per packet) or a program of the same name on PATH ran instead. The hook is now resolved to an absolute path when the capture starts, so ./name is the file in the working directory on every platform and survives a later change of directory, and a bare name is looked up on PATH (a bare name that is only a file in the working directory is refused, naming ./name). A command hook that does not exist, or is not executable on POSIX, is refused at start-up (status 2).
  • Address-bound listening is documented as it behaves, and warns. A socket bound to an address (per_interface=True, or a listen that names one) hears no broadcast on Linux (measured: 0 of 3 limited and 0 of 3 subnet broadcasts, against 3 of 3 for the wildcard; macOS and the BSDs are expected to match, unmeasured), so an unconfigured client is served only through the wildcard there. The documentation called per_interface=True the portable deterministic path and examples/listener.py used it; both now say this and examples/listener.py listens on the wildcard. Binding a non-wildcard, non-loopback address on a platform other than Windows logs one WARNING per process.
  • The async listener's queue behind its handler is bounded. Every datagram was queued for the one worker thread with no limit, so a slow handler (a lease-file write) let a flood grow memory by tens of MiB a second. AsyncDHCPListener, AsyncDHCPServer and AsyncDHCPRelay take max_queued (default 1024 datagrams): the datagram that finds the backlog full is dropped, counted in the new metrics.packets_dropped_backlog and reported at WARNING at most once a minute. stop() discards what is still queued, counted in the same counter, instead of letting the worker log one ERROR with a traceback for each queued datagram; the handler already running finishes. The per-datagram getsockname() for the DEBUG log is skipped when DEBUG is off.
  • DHCPListener.start() raises when the bind fails. It used to return the receive thread, whose bind error reached only threading.excepthook, leaving the listener "started": wait() blocked for ever and a second start() returned None. start() now binds on the caller's thread and raises what bind() raised (AddressInUseError, PermissionError), with nothing bound and the listener startable again; listen() clears its token when its own bind fails.
  • A bind() that fails partway no longer keeps the sockets it had opened (DHCPListener(listen=[good, bad]).bind(), with on such a listener): they are closed before the error is raised, so the ports are not held.
  • The Client FQDN option (81) reads every form RFC 4702 defines. A partial name (no terminating label) and an empty Domain Name field used to raise ValueError, and so did a flags octet with a reserved bit set, which the RFC says receivers MUST ignore; options.get(81) now returns the value, with the reserved bits dropped from flags. ClientFQDN gains partial (a keyword argument and an attribute, also in its mapping form) so a partial name is encoded without the terminator it was received without, and an empty field is encoded as empty. Constructing with a reserved flag bit set now raises ValueError, as the RFC requires senders to clear them.
  • A domain list can no longer cost a datagram's worth of CPU and memory. Decoding options 119, 141, 88 and 146 followed compression pointers with no limit, so one datagram of chained pointers could cost far more CPU and memory than its size. A decoded name is now at most 255 octets (RFC 1035 s2.3.4), a name follows at most 127 pointers, and a pointer must point strictly backwards to the start of a label of an earlier name; otherwise the option raises ValueError. A last name left unfinished at the end of the option is discarded (RFC 3397 s3) rather than kept, and a final octet that starts a pointer no longer raises IndexError.
  • Re-binding a started AsyncDHCPListener whose listen list shrank retires the dropped socket's receive task cleanly; it used to wait for ever.
  • SocketAddress refuses a port outside 0-65535 with DHCPValueError; str() of one with a port such as 70000 used to raise.
  • options.get(code) of a list option (ROUTER, DNS, PARAMETER_REQUEST_LIST and the others) pickles, copies and deep-copies to an equal value, and so do a DHCPOptions bag and a DHCPMessage holding them; pickle.dumps of one raised PicklingError. insert, += and slice assignment on a list codec stored items un-normalized (StaticRoute[0] = (...) skipped the default-destination check).

0.7.0 - 2026-10-03

Changed

  • Requires netimps 0.3.3 (netimps>=0.3.3,<0.4, was >=0.3.1): it uses supports_pktinfo, the interface cache (cache=, clear_interface_cache), AddressInUseError, UdpEndpoint.arecv(), backoff_delays(jitter_seconds=) and Datagram.truncated, all added in that release.
  • A taken port raises netimps.AddressInUseError from DhcpListener.bind() and SocketAddress.listen() — an OSError subclass, so except OSError still catches it, but never a PermissionError. Windows reports a port held exclusively elsewhere as WSAEACCES, which Python maps to PermissionError on 3.12+ and to a plain OSError on 3.9.
  • SocketAddress.listen() takes keyword-only broadcast, allow_address_takeover and connreset, forwarded to netimps.bind(). REUSE_ADDRESS=True now means allow_address_takeover=True; the exclusive default is netimps' on every platform.
  • pydhcp.network.SocketOption is netimps.SocketOption (same three fields).
  • Host-address lookups use netimps' enumeration cache (one-second TTL), cleared on every bind, in place of pydhcp's own two per-bind caches. Same cost bound — measured, a 1000-datagram burst cost 2 enumerations — and an address the host gains or loses is now noticed within a second instead of only at the next bind. host_ip_interfaces() gains a keyword-only cache.
  • MACAddress.hex() is inherited from netimps (identical behaviour) rather than overridden, and str(SocketAddress) is built with netimps.join_host.
  • DhcpClient's retransmission schedule comes from netimps.backoff_delays(jitter_seconds=1.0). Two deliberate differences from the hand-rolled one, both closer to RFC 2131 §4.1: the ±1 s jitter is applied after the 64 s cap, so a backed-off wait is 63–65 s instead of clamped one-sidedly below 64 (which re-synchronises clients exactly where they spend their time), and the amplitude is capped at the current delay rather than a quarter of it.

Removed

  • pydhcp.network.SocketSession, which nothing in pydhcp used and which duplicated UdpTransport's rule that a 0.0.0.0 destination means the limited broadcast. Send through a Transport instead.

Fixed

  • A burst of requests overflowed the socket buffer on Windows. Its 64 KiB default holds about 220 typical DHCP datagrams; measured, a 1000-datagram burst at a wildcard server delivered 220 and the kernel dropped the rest. Listeners now ask for a 1 MiB receive buffer (RECEIVE_BUFFER_SIZE, a new class attribute; 0 keeps the OS default) through netimps.set_buffer_size, logging any shortfall the OS imposes. The same burst now delivers all 1000.
  • DhcpMessage.encode() refused messages it could encode. The overload field was chosen from how far the options overshot, ignoring whether it was free: one octet over meant sname, so a reply with a set sname moved it into option 66, overflowed, and raised OverflowError while an empty file would have held everything — a PXE-shaped reply, lost. Each overload choice is now tried, cheapest first, and an occupied field is relocated only when nothing cheaper fits. Found by the round-trip property test.
  • A wildcard listener did not know which interface a datagram arrived on on CPython 3.9–3.11 anywhere (socket.IP_PKTINFO only exists from 3.12), on macOS (which zero-fills the field the old code read) and on Windows (whose packet-info layout differs). The server then resolved a synthetic 0.0.0.0/32 interface and, measured, received a DISCOVER and allocated and sent nothing. Receiving now goes through netimps.UdpEndpoint, which handles every platform and interpreter; a broadcast is answered from the receiving interface's own address.
  • listen="0.0.0.0:67" and "*:67" were not treated as wildcards: they skipped packet info and bound one socket per address, which on Linux hears no broadcast DISCOVER (measured: 0 of 3, against 3 of 3 for ("0.0.0.0", 67)). Wildcard-ness is now decided on the parsed host.
  • The async listener had no packet info on Windows' default proactor loop, where it fell back to a DatagramProtocol. It now receives with UdpEndpoint.arecv() on every loop type.
  • A client that went away cost the server an ERROR on Windows. An ICMP port-unreachable provoked by a reply surfaced as ConnectionResetError on the next, unrelated receive, logged with a traceback and counted as a dropped datagram. Listener sockets are now bound with netimps' connreset=False.
  • Replies were only pinned to the receiving address on Linux 3.12+. PktInfoUdpTransport packed the POSIX control message by hand, behind the same IP_PKTINFO feature test. It now sends through netimps.UdpEndpoint.send(src=...) on every platform, and takes an optional endpoint argument.

0.6.1 - 2026-09-28

Changed

  • duho floor raised to >=0.6.0,<0.7 (was >=0.5.0,<0.6). No pydhcp code change was needed: the three duho-0.6 regressions found against a pre-release snapshot (sys.exit return typing, an unconditional stderr handler, and logger levels pinned on descendant loggers) are fixed in the release. duho 0.6 also added an MCP-over-stdio launch mode, triggered by setting PYDHCP_MCP=stdio in the environment; the pydhcp CLI (App) is left at that default (no opt-out) rather than disabling it.

0.6.0 - 2026-09-21

The 0.5.2 review remediation: 122 commits closing all 339 findings of a multi-agent review, verified per-commit on Windows and Linux and against ISC dhclient on a veth pair.

Added

  • AsyncDhcpRelay and AsyncDhcpCapture, completing async parity. Mixed onto AsyncDhcpListener rather than copied, so the IP_PKTINFO receive path and every fix it gains are shared; a test parses both classes and fails if any receive-path line is duplicated. Verified with ISC dhclient over a veth pair.
  • DhcpListener.bound_addresses / AsyncDhcpListener.bound_addresses — what a listener is actually bound to, read from the sockets. This is how a caller that passed port 0 learns the port it was given.
  • FileLeaseBackend.SAVE_INTERVAL_SECONDS, with flush(), close() and context-manager support: opt-in write coalescing. Off by default — coalescing trades a property the operator cannot see going wrong (leases lost on a crash) for one they can already measure, which is the wrong way round for a default.
  • pydhcp server --lease-file PATH (also lease_file under [server] in a config file). docs/deployment.md had always told operators to mount lease storage; until now the CLI had no way to write to it, so the volume stayed empty and every client renumbered on restart.
  • pydhcp server --per-interface and pydhcp relay --per-interface — both classes already took the argument; only the CLI could not reach it.
  • python -m pydhcp as an entry point.
  • UncompressedDomainList, and RecordList[T] for codecs whose entries are records.

Fixed

The one that mattered most. On Linux and macOS the default wildcard server received nothing at all: the IP_PKTINFO path discarded the local address and interface index at the line that needed them, so the server answered with identifier 0.0.0.0 and no client ever completed a DORA. Measured against ISC dhclient 4.4.3: retransmitting DISCOVER until it gave up, before; bound, after.

  • Server (RFC 2131). A DHCPDISCOVER no longer extends the lease it is only probing, and a REQUEST about to be NAKed no longer renews the address it is refusing. Replies carry the server's siaddr/sname/file instead of echoing the client's — a client could nominate its own next-server and boot file, which is the pair a PXE client acts on. A DHCPRELEASE is checked against ciaddr before it frees anything. Option 54 is compared against every address this host holds, so a second socket on the same segment no longer deletes the binding the first just granted. Option 57 below the RFC 2132 minimum is floored rather than making encode raise and the client get nothing. A lease with no time left produces no OFFER and NAKs a REQUEST rather than ACKing an empty reply. Lease length is the server's policy, honouring the RFC 2132 infinity sentinel. The base server no longer claims to be the network's router and resolver.
  • Wire format. Every encoded packet leads with the message type on both encode paths. sname, file and chaddr raise instead of being silently truncated, and hlen is bounded to what decode accepts. Domain labels are measured in octets, not characters — every non-ASCII name was going out corrupt. Options 88 and 146 encode uncompressed, as RFC 4280 §4.6 and RFC 6731 require; 119 and 141 still compress, as theirs require. Outgoing messages are padded to the 300-octet BOOTP minimum. Six option codecs match the wire forms their RFCs define. An option-overloaded message now round-trips: decode consumes options 52, 66 and 67 — the framing that says sname/file hold option fragments, and the two options the encoder parked the real values in — instead of handing them back alongside the fields it restored them into. A decoded message no longer claims an overload it is not carrying, nor reports its server name and boot file twice under two spellings.
  • Relay. A request is dropped only when its hop count exceeds the threshold (RFC 1542 §4.1.1) — it was refused one hop early — and the default is now the RFC's 4 rather than its ceiling of 16. A relayed exchange is identified by client as well as transaction, so a forged xid cannot redirect an OFFER.
  • Client. Retransmissions back off with jitter instead of repeating one interval, and carry a real secs. Replies match on (xid, chaddr), so two exchanges on one client no longer consume each other's replies.
  • Async. stop() called from a handler no longer hangs the loop on Linux — nothing it touched was thread-safe, and the selector loop never woke.
  • Lease store. Bounded, so a forged-identity flood cannot exhaust it. The file survives a crash and no longer loses leases silently.
  • Capture and CLI. Filter keywords match whatever their case — an uppercase AND compiled, matched nothing and reported nothing. Filter values are checked once at startup instead of raising per packet. A per-capture filename pattern is validated before binding. A capture run cannot create unbounded files per client identifier. The CLI calls itself pydhcp.
  • Registry. A subscripted generic is the same class every time. A user's register_type() is no longer overwritten by the lazy registry load, and a failed load is retried rather than leaving every code as Bytes.

Changed

  • DhcpServer.release_lease() returns bool and no longer touches metrics — an orderly release, a DECLINE reporting an address conflict, and a reclaim after the client chose another server all arrive there, and only the caller knows which.
  • DhcpMetrics gains leases_declined and releases_ignored. DECLINE used to count as a release, so an address-conflict storm read as orderly shutdowns.
  • DhcpRelay(max_hops=...) defaults to 4 and must be 0..16.
  • DhcpClient(timeout=...) is the initial retransmission interval, so a default call now takes up to ~14 s rather than ~6 s before giving up.
  • The package installs a NullHandler and logs under pydhcp.<module>. With logging otherwise unconfigured this silences the WARNING-and-above that logging.lastResort used to print; configure a handler to see them.
  • DhcpOptions rejects codes 0 and 255 on assignment — they are structural markers, not options. Receive stays liberal.
  • Both import cycles removed; HardwareAddressType now lives in pydhcp.network and is re-exported from pydhcp.packet.
  • netimps is now required as >=0.3.1,<0.4 (was >=0.2.2). The floor is 0.3.1 rather than 0.3.0 for one reason: 0.3.0's bind_error_hint read a Windows WSAEACCES as POSIX EACCES and called a taken port a privilege problem — "permission denied binding port 64514", plus advice to become Administrator, on a platform that has no privileged ports. pydhcp carried its own interception for that; 0.3.1 fixes it upstream, so the workaround is gone and the diagnosis is made in one place again. A port held by another socket is still reported as in use and not as a privilege failure, and still suggests an alternative port; only the wording moves.

The six entry points this package uses — normalize_host, bind, bind_error_hint, iter_addresses, APIPA, MACAddress — keep their signatures across 0.2.x → 0.3.1. Several of 0.3.0's documented breaks are real (MACAddress no longer compares equal to its own string spelling, mixed MAC separators are rejected, normalize_host raises on an ambiguous multi-colon host); none is reachable from this package, which was checked rather than assumed. pydhcp never compares a hardware address to a string — leases and capture filters key on str client identifiers on both sides — and the normalize_host inputs that now raise used to fail a step later in IPv4() anyway, so the CLI reports a better message for them rather than a worse one. The upper bound is the pre-1.0 rule that a minor bump means the documented API broke.

Removed

These are the breaking changes in this release, and the reason it needs a MINOR bump rather than a patch: pre-1.0, MINOR means the documented API broke. Nothing else here is breaking.

  • pydhcp.IPv4Address, pydhcp.List, pydhcp.Bytes, pydhcp.String and pydhcp.Boolean are no longer re-exported from the top level. They are option codecs, and their bare names did not say so — pydhcp.IPv4Address was the codec rather than ipaddress.IPv4Address, so isinstance(interface.ip, pydhcp.IPv4Address) was False while looking exactly like the check you meant to write. Import them from pydhcp.options.type, where they have always lived. The stdlib address type remains pydhcp.IPv4.

The other 49 codecs stay, including U8/U16/U32 and the Ccc*/MoS*/ Vi* families: nothing in the stdlib or typing is called any of those, so the bare name already says it is a pydhcp type.

  • pydhcp.constants.MISSING / pydhcp.constants.Missing are gone. constants.py holds protocol constants — packet sizes, port numbers, the infinite-lease sentinel — and this is a Python idiom for "argument not supplied where None is itself a value". It now lives in pydhcp._utils and is private; nothing outside the package needed it.

No deprecation aliases were left behind for either, which is this project's convention: a name that still resolves is a name nobody migrates off.

Documentation

  • The option code registry — 163 RFC-documented members — reaches the API reference for the first time.
  • A repo-root AGENTS.md, which two shipped headers already cited.
  • README and CHANGELOG links that only resolved inside a checkout, and CHANGELOG compare links naming three tags that never existed.

0.5.2 - 2026-08-16

Changed

  • duho is now required as >=0.5.0,<0.6 (was >=0.4.1). The old floor predated duho 0.5.0, which changed how a list/set/tuple field used as an option parses: --x a b used to accumulate both values in one occurrence (nargs="*"), and an option field now takes one value per occurrence. The CLI declares server: List[str] as --server/-s on the relay subcommand and documents it as "(repeatable)", so a resolver was free to install a duho where that flag did not behave the way its own help text describes. The upper bound is the pre-1.0 rule that a minor bump means the documented API broke.
  • netimps is now required as >=0.2.0,<0.3 (was >=0.2.2), normalising it to the same minor-series form. Nothing this package calls (normalize_host, bind, bind_error_hint, iter_addresses, APIPA, MACAddress) was added after 0.2.0, so the exact-patch floor was stricter than the code justified.

0.5.1 - 2026-08-16

Added

  • DhcpOptions.copy() — returns an independent container: the codemap is preserved and every payload is copied into a fresh bytearray, so neither structural edits nor in-place mutation of a payload obtained from get(..., decode=False) can write through to the original.

Changed

  • The netimps requirement floor is raised to >=0.2.2. The previous >=0.0.1 predated the API this package actually calls (normalize_host, bind, bind_error_hint, iter_addresses, APIPA, MACAddress), so a resolver was free to install a release that could not satisfy it.

Fixed

  • Server responses no longer mutate the stored lease. DhcpServer._create_response assigned resp.options = lease.options, so the response and the lease backend shared one DhcpOptions object. Everything the response pipeline did to it edited the allocation store: IP_ADDRESS_LEASE_TIME, SERVER_IDENTIFIER, DHCP_MESSAGE_TYPE and the echoed RELAY_AGENT_INFORMATION (option 82) were injected into the lease; PARAMETER_REQUEST_LIST filtering permanently deleted from the lease every option the client did not ask for; and handle_inform stripped the lease time. InMemoryLeaseBackend.renew carries the same object across renewals and FileLeaseBackend persisted the damage to disk. The response now takes lease.options.copy().
  • DhcpRelay._pending_clients is bounded. The xid -> original client address map grew without limit for xids whose replies never arrived, leaking memory in a long-running relay. It is now capped at DhcpRelay.MAX_PENDING_CLIENTS (1024) entries, oldest evicted first; an evicted entry only costs the port-68 fallback a real client listens on.
  • MACAddress.hex() carries the real bytes.hex signature it forwards to (it had untyped *args, **kwargs, which also left its body unchecked).
  • Removed two dead isinstance(e, KeyboardInterrupt) re-raise branches in listener.py: KeyboardInterrupt does not subclass Exception, so neither could ever run. The interrupt already propagates to the outer handler.
  • listener.py's socket-close bare except: is now except Exception:.
  • DhcpMessage.dumps() printed siaddr twice, as "Server Address" and "Next Server"; it is one field and now prints once as "Next Server (siaddr)".

0.5.0 - 2026-07-24

Changed

  • cli.py is rewritten on duho, a declarative CLI framework, replacing hand-built argparse. Each subcommand (interfaces, server, relay, packet, capture) is now a duho.Cmd class with annotated fields instead of add_argument calls. Behavior and flags are preserved, with additions:
  • pydhcp --version now works (resolved from installed package metadata).
  • Per-subcommand --log-level is replaced by duho's shared verbosity scheme: -v/-q (repeatable) and --loglevel KEY=VALUE.
  • New short flags: packet gains -i/-o/-f (--input/--output/ --format); capture gains -l/-o/-f/-c (--listen/--output/ --format/--count); server and relay gain -l (--listen); relay also gains -s (--server).
  • --help now shows (default: X) next to any option with a non-empty default.
  • Added duho as a runtime dependency.

0.4.1 - 2026-07-22

Changed

  • Network enumeration now comes from netimps. network/platform.py -- 503 lines of ctypes (GetAdaptersInfo, getifaddrs with hand-written sockaddr structs, plus ipconfig/ip addr text-scraping fallbacks) -- is deleted. Every address the old code found is still found, with identical prefixes and MACs, verified against a captured baseline before switching.
  • Adapter names are now human-readable ("Wi-Fi") rather than Windows GUIDs.
  • Loopback is now enumerated, so a loopback-bound socket resolves to a real interface with its true /8 instead of falling through to a synthetic /32.
  • host_ip_interfaces() gains family=4 as the default, preserving the IPv4-only behaviour callers relied on.
  • MACAddress is now a netimps.MACAddress subclass. The uppercase-hyphenated rendering is unchanged; it is no longer a bytes subclass, so use .packed for raw bytes (.hex() is kept as a passthrough). It gains .oui, .is_multicast, .is_local and ordering.
  • SocketAddress.listen() delegates to netimps.bind(), which closes the socket before any exception propagates.
  • Bind failures use netimps.bind_error_hint, which recognises the POSIX errnos and the Windows WinError codes. The DHCP-specific suggestions are appended rather than replacing the diagnosis.

Fixed

  • _split_host_port mis-parsed IPv6 listen specs: "[::1]:67" returned ("[::1]:67", None), silently dropping the port. It now delegates to netimps.normalize_host.

0.4.0 - 2026-07-14

Added

  • Added a basic listener-based DhcpClient for building, sending, and collecting DHCP client packets without configuring OS interfaces.
  • Added reusable DHCP capture primitives and a pydhcp capture CLI with safe and filters, structured output streams/files/per-capture files, and trusted Python or command hooks.
  • pydhcp.config.load_config (and pydhcp server --config) now accepts YAML and TOML in addition to JSON and INI, matching the formats already supported by pydhcp.packet.structured.
  • Added DhcpClient.discover_offer() and DhcpClient.dora() to run a full DISCOVER/OFFER/REQUEST/ACK exchange (with retries and a timeout) in a single call, instead of hand-building each message.
  • DhcpServer now echoes RELAY_AGENT_INFORMATION (option 82) unchanged from request to reply per RFC 3046 §2.2, when a relay agent includes it.
  • Added Hypothesis-based property round-trip tests for the core option codecs (U8/U16/U32/I32, Boolean, String, Bytes, List[IPv4Address], DomainList, ClasslessRoute) in tests/test_property_roundtrip.py, plus hypothesis as a dev extra.
  • Documented pydhcp.client, pydhcp.capture, pydhcp.packet, pydhcp.network, and pydhcp.lease on the API reference site, and added FAQ entries for running a DORA handshake and picking a config file format.
  • Added DhcpRelay, an RFC 1542 / RFC 2131 §4.1 / RFC 3046 DHCP relay agent: forwards client broadcasts to one or more configured upstream servers (stamping giaddr, incrementing hops, dropping packets past a configurable max_hops) and forwards server replies back to the original client, with optional RELAY_AGENT_INFORMATION (option 82) circuit-id/remote-id tagging. Includes a pydhcp relay CLI subcommand.

Changed

  • Reorganized package internals into clearer packet, options, and network subpackages; DhcpOptionCode now lives under pydhcp.options, and option codec classes live under pydhcp.options.type.
  • Moved the message-level enums (DhcpMessageType, OpCode, Flags, DhcpPort, HardwareAddressType) from pydhcp.enum to pydhcp.packet.enums, re-exported from pydhcp.packet; the pydhcp.enum package is gone.
  • Renamed the misspelled pydhcp.constants.INIFINITE_LEASE_TIME constant to INFINITE_LEASE_TIME.
  • Split the 1,252-line pydhcp.options.type module into a subpackage (base, net, scalar, vendor, mos submodules); the public import path pydhcp.options.type and every name it exposes are unchanged.
  • Breaking: removed the global mutable pydhcp.metrics.METRICS singleton. DhcpListener (and DhcpServer/DhcpClient) now own a per-instance self.metrics: DhcpMetrics, so counters no longer leak across independent listeners/servers in the same process (e.g. in tests). Added DhcpMetrics.snapshot() -> dict[str, int].
  • Precompiled the DHCP fixed-header struct.Struct in pydhcp.packet.message instead of re-parsing the format string on every encode/decode call, roughly 5x faster packet parsing (see structured JSON benchmark reports in benchmarks/README.md).
  • Breaking: standardized the pydhcp packet CLI on the same conventions capture already used: --input <path> and --output <path> each accept - for stdin/stdout (POSIX convention) and default to -, and the five separate --json/--yaml/--toml/--ini/--summary flags collapsed into one --format <fmt> flag (default json). Removed --stdin, --input-file, --output-file, and inline --input <text> (unused/undocumented) — use --input - for stdin and a path for files.

Fixed

  • mypy --strict now passes cleanly across the whole package (was 40 errors in 8 files), surfacing and fixing one real bug along the way: a synthetic DHCPINFORM lease built with expires=None instead of the established "does not expire" sentinel math.inf.
  • Fixed DomainList decoding silently dropping domains that follow a compression-pointer-terminated domain in the same option (affects DOMAIN_SEARCH and any other option backed by DomainList); found by the new Hypothesis property tests.

0.3.0 - 2026-07-13

Added

  • Added CCC option codec coverage and registered DhcpOptionCode.CCC for typed round trips.
  • Clarified DhcpServer customization hooks, added DHCPINFORM option-only responses, and repaired examples so they match the current lease API.
  • Made TOML packet support optional at import time, with clear NotImplementedError messages when tomli or tomli-w is unavailable.
  • Added stricter short-packet diagnostics and pydhcp packet --decode --summary for compact DHCP capture inspection.
  • Added multi-endpoint listener parsing for host:port strings, comma-separated CLI listen specs, and one-address/many-port tuples, plus per_interface constructor parity on sync and async servers.
  • Added optional JSON benchmark report output for benchmarks/bench_options.py and uploaded the opt-in CI benchmark run as a workflow artifact for easier comparison across iterations.
  • Added optional JSON benchmark report output for benchmarks/bench_parse.py so both benchmark entry points now share the same local artifact pattern.
  • Extended the opt-in benchmark workflow to archive both parse and options benchmark JSON reports.
  • Added the repo-local benchmarks/run.py wrapper so maintainers can choose parse or options benchmarks and optionally write structured JSON output without expanding the released package CLI.
  • Added structured packet I/O for pydhcp packet with JSON, YAML, TOML, and INI round trips plus explicit stdin, inline, file, and output-file handling.

Fixed

  • Removed the stale pydhcp packet --encode CLI flag and replaced it with a real packet encoding mode backed by structured packet helpers.

0.2.1-rc.1 - 2026-07-12

Added

  • Expanded DHCP option-type registrations so common well-defined options decode to typed values.
  • Added a "Common DHCP Options" docs page with typed examples and updated the custom-options example to prefer typed assignment.
  • Added stricter option-type validation and ClasslessRoute truncation checks.
  • Added wildcard listener per_interface support and exported PktInfoUdpTransport.
  • Added typed codecs and registrations for policy filters, static routes, user classes, encapsulated vendor/relay sub-options, name service search, subnet selection, and RDNSS selection.

Fixed

  • Made DhcpMessage.encode() idempotent and tolerant of reserved flag bits during decode.
  • Corrected DhcpMessage.dumps() field labels and secs packing behavior.

0.2.0 - 2026-07-12

Added

  • Created benchmarks/bench_parse.py packet parsing and serialization performance benchmarks.
  • Added benchmarks/README.md documenting performance baselines.
  • Added AsyncDhcpListener and AsyncDhcpServer classes implementing asyncio-based event loop integration.
  • Added integration tests for async server in tests/test_async.py.
  • Configured MkDocs documentation with Material theme and mkdocstrings auto-generated API references.
  • Added Transport, UdpTransport, and RequestContext classes for structured transport-layer abstraction and Interface tracking.
  • Added comprehensive unit testing coverage for packet deserialization and type-safe dictionary operations in test_message.py and test_options.py.
  • Added integration tests verifying standard client DORA sequences and RFC 2131 routing logic in tests/integration/test_dora.py.
  • Added missing ALL_VPNS option (Tag 254) to DhcpOptionCode after comparing against the latest IANA registry.
  • Populated default ROUTER and DNS lease options in DhcpServer.acquire_lease using the server's interface IP.
  • Added pluggable lease backend API (LeaseBackend) and implementations (InMemoryLeaseBackend, FileLeaseBackend) for state preservation and persistence.
  • Added unit and integration tests for lease backends in tests/test_lease_backend.py and tests/integration/test_dora.py.
  • Added validation checks for hardware address length (hlen <= 16) during packet decoding.
  • Added socket binding error diagnostics that offer actionable suggestions for permission and address-in-use errors.
  • Added unit tests for binding error handling and malformed packet input in tests/test_permissions.py and tests/test_malformed_packets.py.
  • Added command-line interface (CLI) with interfaces, server, packet, and bench subcommands.
  • Added JSON-based configuration loading support in config.py.
  • Added in-process metrics counters (packets_received, packets_sent, etc.) in metrics.py to support observability.
  • Added option parsing and lease allocation performance benchmarks in benchmarks/bench_options.py.
  • Added async DHCP server concurrency stress tests under tests/test_async_concurrency.py.

Changed

  • Configured strict typechecking configuration in pyproject.toml and resolved all mypy type-checking errors across the library.
  • Refactored DhcpMessageType and OptionOverload to implement the DhcpOptionType interface directly and avoid PEP 561 / type conflicts with BaseFixedLengthInteger and Enums.
  • Refactored DhcpServer.handle to accept RequestContext instead of SocketSession, split processing into message-type specific handlers (handle_discover, etc.), and comply strictly with RFC 2131 routing paths.
  • Enriched NetworkInterface and host_ip_interfaces() to yield fully detailed adapter metadata.
  • Refactored DhcpServer and AsyncDhcpServer to accept and delegate lease lifecycle events (allocate, lookup, renew, release) to a configurable lease_backend.
  • Refactored options parsing in DhcpOptions.decode to handle truncated option lengths gracefully by logging a warning and parsing remaining bytes instead of crashing.
  • Added debug-level logging for packet arrival and lease allocation including client XIDs.
  • Renamed misspelled contants.py to constants.py and updated all internal references.
  • Added strict __all__ public exports list to src/pydhcp/__init__.py.

Fixed

  • Fixed bug in ClasslessRoute destination descriptor parsing/serialization that caused incorrect length calculations for CIDR/8.
  • Fixed forward reference type resolution issue for DhcpOption in _options.py.
  • Fixed options encoding OverflowError when option size limit is infinite.
  • Fixed BaseDhcpOptionCode.__int__ returning constant zero, correcting enum integer conversion for option codes.

0.1.0 - 2026-07-11

Added

  • Initial release.