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()andto_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),offeredandoptions. Onlyipis required on reading;expiresalso takes aZsuffix or an awaredatetime.DHCPOptions.to_mapping()/from_mapping(): theoptionsform a message's document already held — names, readable values and{"hex": ...}where those would not give the same octets back — on the bag itself.DHCPMessageandDHCPLeaseboth write their options this way.OptionCodeEnum, the base ofDHCPOptionCode, for an option-code enum of your own:class SiteCodes(OptionCodeEnum): SITE_NAME = 224. It brings unnamed members for codes without a name andregister_type/get_type, with its own codec table, and works as aDHCPOptions(codemap=...).pydhcp.packet.UNSPECIFIED_ADDRESS(IPv4Address("0.0.0.0"), what an address header field holds when it carries none) andINFINITE_LEASE_TIME(0xFFFFFFFF, RFC 2131's "infinity" for option 51), so a caller need not define its own.DHCPLeasecarries the BOOTP boot fieldssiaddr,snameandfile, 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 byreplace(), the mapping and text forms, pickling andFileLeaseBackend's file.DHCPLease(..., strict=False)andlease.validate(): a lease built non-strict may hold0.0.0.0, for a reservation with no address yet, andvalidate()raisesDHCPValueErrorfor one that has none. Strict, the default, is unchanged.from_mapping(strict=False)reads a document with noipthat way, and the server treats an address-less lease returned byacquire_leaseas no lease.DHCPOptionType.from_json(data), the inverse ofto_json(): the documented codec law is nowCodec.from_json(value.to_json()) == value, and a test holds every built-in codec to it.Bytes.from_jsonreads hex text; records with several fields read theirto_jsonlist;List[T]reads each item withT.from_json.from_mappinguses it.List.item_type(): the codec of a list codec's items (List[U16].item_type()isU16,CCCOption.item_type()isCCCSubOption), 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 (soDHCPMessageandDHCPLeasedocuments) and the capture filter'soption.NAMEall 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 byIPXEOption(iPXE'sDHCP_EB_*numbers), each read by its codec through.get("https"), the feature flags through.features(),BUS_IDasIPXEBusID(PCI vendor and device) andVERSIONasIPXEVersion; a server builds one from(name, value)pairs. Its presence is how to tell iPXE from a PXE ROM. AlsoI8, 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 isX64_UEFI, 9 isEBC, an unknown number an unnamed member); option 94 isClientNDI(UNDI 3.16); option 97 isClientMachineID, whose.uuidreads a type 0 GUID. LenientUserClassreads 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 opaqueBytes, and the strictUserClassnow namesLenientUserClasswhen 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 longerBytes, 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 anIPXEOptions, notBytes, 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()(andfrom_text()) require onlyop. A header field the document leaves out takes the constructor's default (0.0.0.0for an address,0for a number, an emptychaddr) where it was aValueError, andoptionsmay be left out or null.- The
ImportErrorfor a document format without thepktcapextra 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.leaseis a package now, with the same import path and names.
Fixed
- The codec law
Codec(value.to_json()) == valuewas false for 19 codecs:Bytesrefuses text,UserClassrefuses its own hex entries, and every record with several fields (TLVOption, theVI*,MoS*andCCC*records,CCCASBackoffRetry) takes them as separate arguments. The law now namesfrom_json, which all of them satisfy. DHCPMessage(op=2)built a message whoseto_mapping()raisedAttributeError. The constructor kept the number; it now storesop,htypeandflagsas their enums, the addresses asIPv4Addressandsecsas atimedelta, and refuses a value that is none of these with aDHCPValueErrornaming 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_madeDHCPOptionCode(250)raiseTypeErroronce 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
cliextra requiresduho>=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)andasniff_dhcp(listen=None, *, per_interface=None, dissector=None): the DHCP messages that reachlistenas pktcap's dissected frames (pktcap.DissectedFrame), on threads and on an event loop. They arenetimps.bind_listenwith DHCP's defaults (ports 67 and 68, IPv4, broadcast allowed) handed topktcap.sniff_udpandasniff_udp, withcapture_dissector()when nodissectoris given; the last layer of a frame that holds a message is aDHCPLayer. The sockets are bound by the call and closed when the iterator ends, fails, is closed, dropped or (asynchronous) cancelled. They need thepktcapextra and use noDHCPListener.DHCPLayer.summary(): one line (DHCPOFFER xid=1A52B52E client=01:02:00:00:99:00:02 yiaddr=10.99.0.100), the text pktcap'stextformat shows for a frame whose innermost layer is DHCP.pydhcp.capture.capture_dissector(ports=(67, 68)): apktcap.FrameDissectorthat reads DHCP onports, over a copy of pktcap's registry in which the layer and its filter keys are declared, sopktcap.frame_filter_for(dissector.registry)compilesmsg_type=DHCPOFFER.pydhcp.capture.pktcap_plugin(registry): the hook pktcap loads by name, soPKTCAP_LOAD=pydhcp.capture pktcap convert -i heard.pcap -f "msg_type=DHCPDISCOVER"filters DHCP with the keysop,msg_type,xid,client_id,chaddrandoption.NAME_OR_CODE, each refusing when the filter is compiled a value no message could match. It declaresDHCPLayerin the registry and callsregister_dhcp_dissector(registry); when that raises, nothing is registered.src,dst,sportanddportstay pktcap's own, andinterfaceis not a key of a frame. Importingpydhcp.captureregisters nothing;pydhcp captureandpydhcp replayload the plugin themselves.PYDHCP_MCP=stdioservespydhcp.packetandpydhcp.interfacesas MCP tools (needs thecliextra), over standard input and output instead of running a command.server,relay,captureandreplayare not tools. A tool call has no standard input, sopacketwithoutinputfails naming--input.pydhcp.MACAddressisnetimps.MACAddress, the same object, exported from the root becauseNetworkInterface.machands one to a caller. No other name ofnetimpsis 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, andexpires=Noneis a lease that never ends.DHCPServer.STRICT_REPLY_PORTSandpydhcp server --lenient-reply-ports(PYDHCP_SERVER_LENIENT_REPLY_PORTS, thelenient_reply_portskey). The default,True, is the RFC 1542 rule (see Changed): a reply goes to port 67 for a relay and 68 for a client.Falseanswers the port the request came from (giaddrat 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.Falsesends 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 is0for 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 readerpydhcp packet --decodeused 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, andreadin 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'sreplaywithdport=67as its own filter.PYDHCP_REPLAY_*.pydhcp.capture.dissect_dhcp,DHCPLayerandregister_dhcp_dissector(registry=None): DHCP as a pktcap dissector on UDP ports 67 and 68, so a pcap or pcapng capture read withpktcap.read_dissectedcarries aDHCPLayer(op,xid,message_type,client_id, andmessage, theto_mapping()of the message) on each frame that holds a message, andproto=dhcpselects them. Nothing registers on import. Octets that are not a message are apktcap.DissectErrorwith 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 (andpktcap.read_datagramsreads back octet for octet), or as the frames pktcap builds for them with--datagramsleft out; the ending of--outputnames the format (.pcap,.cap,.pcapng, and.jsonland.ndjsonbeside.json,.yaml,.yml,.toml,.ini,.txt,.log).DHCPRequestContextgains a last fieldpayload: bytes | None = None, the datagram as it arrived (the listener keeps one reference to the receivedbytes, no copy).DHCPConfigError(pydhcp.exceptions, re-exported at the root): a configuration file that cannot be used. It carriespath,lineno,colnoandmsg, is also aValueError, andstr()is one line,path:line:col: msg, with no text from the document.pydhcp interfaces --format text|json:textis one tab-separated line per address (name, address, MAC or-, network) and no banner;jsonis one array of objects.pydhcp.exceptions, re-exported at the root:DHCPError(the base),DHCPDecodeErrorandDHCPValueError(each also aValueError) andNoClientIdentityError. Every decoder raisesDHCPDecodeErrorand nothing else for malformed octets:DHCPMessage.decodeand each option codec used to raise a bareValueError(or, for a short address, anipaddress.AddressValueError, for a bad UTF-8 label aUnicodeDecodeError, for a destination with host bits set anipaddressValueError).except ValueErrorkeeps working.DHCPTimeoutErrorandDHCPRefusedError(pydhcp.exceptions, re-exported at the root): what a client exchange raises instead of returningNone.DHCPTimeoutError(DHCPError, TimeoutError)says no usable reply arrived;DHCPRefusedError(DHCPError)carries the server's DHCPNAK as.nak. Both copy and pickle.deadline=onDHCPClientandAsyncDHCPClientdora()anddiscover_offer(): seconds for the whole call (both halves of adora()together), wheretimeoutstays 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 aValueError.- Four counters in
DHCPMetrics:packets_decoded_leniently,packets_dropped_no_client_id,packets_dropped_other_serverandaddresses_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 whosegiaddris one of the relay's own addresses),packets_dropped_unknown_giaddr(a reply whosegiaddris not) andpackets_dropped_reused_transaction(a request that reuses the transaction of a pending one from another source address).relay_info_omittedalso counts a request the relay forwarded without option 82 because it would not fit. AsyncDHCPClient(pydhcpandpydhcp.client):DHCPClienton an event loop.send,discover_offeranddoraare coroutines taking the same keywords as the synchronous client's and returning the same results, with the same retransmission schedule,secsand reply matching;next_replyis a coroutine anddrain_repliesand thebuild_*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. Itson_replyhook runs on the event loop, not on a worker thread.DHCPRequestContext.destinationand.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 shows255.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.Nonewhen 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.listenaccepts 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 anetimps.Interfaceornetimps.MACAddress, in the constructors,--listenand 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 newDHCPMetrics.packets_dropped_other_interface. The adapter is looked up bybind(), and one that does not exist is aValueErrorthere. Cannot be combined withper_interface=True. On Linux the socket is also bound to that device (netimps.has_device_binding()), so the kernel delivers only its datagrams andpackets_dropped_other_interfacestays 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.
LeaseBackendgainsoffer(client_id, ip, hold_seconds, options=None)andcommit(client_id, ttl),DHCPLeasegainsoffered(keyword-only, defaultFalse, part of equality and hash), andlookupreports the state through it. An offer holds the address forhold_secondsand is an expired record after that;committurns it into a binding.allocatestays as an offer and its commit in one call. The lease file gains astatefield ("offered"or"bound"); a file written before it, with nostate, 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) andoffers_withdrawn(offers dropped because the client's REQUEST named another server). - Two more counters in
DHCPMetrics:informs_ignored(a DHCPINFORM whoseciaddrthe server will not answer) andrelay_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 onAsyncDHCPServer).is_quarantinedsays whether an address is out of the pool after a DHCPDECLINE; the server asks it of every lease a hook returns.lookup_leaseis 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 incodes.- Four counters in
DHCPMetrics:packets_dropped_malformed_option,options_ignored_malformed,declines_ignoredandquarantines_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/_codecsandcli), 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_configand the exceptions on the API page, the capture filter grammar inpktcap_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.mddescribes 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.mdhas 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_textandfrom_text,pydhcp.packet.structured.dumpsandloadsandpydhcp packet --formatwrite and read JSON with the standard library, as before (indented, two spaces), andyaml,tomlandiniwithpktcap.dumps_recordandloads_record: they needpip install "pydhcp[pktcap]", and YAML and TOML also pktcap'syamlandtomlextras, whose own error namespip 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 thehexform before a plain one), where it wrote them in that other order. pydhcp captureandpydhcp replayare pktcap's commands (pktcap.cli.CaptureandReplay) with DHCP's defaults:--filteris ANDed withproto=dhcp, ports are 67 and 68,--listenkeeps pydhcp's grammar and binds withnetimps.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 --helpand pktcap's header have each one), andpydhcp capturegains--interface,--datagrams,--append,--duration/-d,--hook-timeoutand--load, and--format text.- A record is pktcap's record of the frame:
time,linktype,length,layers(ipv4,udpanddhcp) andpayload. The message the old line held is thedhcplayer'smessage:jq -c '.layers[-1].message'. A record file is replaced unless--appendis given (it was appended to).--format textwritesDHCPLayer.summary(), one line a person reads. - Renamed:
--output-mode per-captureis--per-record(see Removed);capture -fis--filter(--formathas no short form) and-cfor--countis gone; the filter keyssrc_portanddst_portaresportanddport,srcanddsttake networks, andinterface=is gone (use--listen NAME).--listenwith--interfaceor--readis 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, forcapture, a settings key of that field's name. The plugin list is--load, with pktcap's ownPKTCAP_LOADand the keyload. - Hooks are pktcap's: a hook comes from
--hook,PYDHCP_CAPTURE_HOOKand the settings file the user named; it getsPKTCAP_HOOK_CLIENT_ID,PKTCAP_HOOK_MSG_TYPE,PKTCAP_HOOK_XIDandPKTCAP_HOOK_FORMAT(notPYDHCP_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 was01:02:00:00:99:00:02); a failed hook is counted and the capture goes on unless--hook-fail-fast;--hook-timeoutbounds it (10 seconds); amodule:functionhook is called with apktcap.DissectedFrame(it was aCaptureEvent) and its module must be onPYTHONPATH(the working directory is not added, where it was); on Windows a.cmdor.batfile is refused, name a program. - Behaviours that differ: an
--outputending 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 capturehas a file budget". - pktcap is an optional dependency, the
pktcapextra.pip install pydhcpneedsnetimpsalone; importingpydhcp,pydhcp.captureandpydhcp.cliloads neither pktcap nor asyncio, and a server, a relay, a client andpydhcp packet --format jsonrun without it. A capture function (sniff_dhcp,asniff_dhcp,capture_dissector,dissect_dhcp,register_dhcp_dissector,pktcap_plugin) raises anImportError, andpydhcp captureandpydhcp replayprint and exit with status 1, with the one linecaptures need the 'pktcap' extra: pip install "pydhcp[pktcap]".devinstallspydhcp[cli,pktcap],pktcap[yaml,toml]andduho[config,yaml]. - A
PCPServerListentry 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 andto_json()are the same. - Importing the package does not import
asyncio.AsyncDHCPListener,AsyncDHCPServer,AsyncDHCPClientandAsyncDHCPRelayare bound when first accessed, frompydhcpand from their own packages; their names and import paths are unchanged, and a program that uses only the blocking classes never loads it. duhois thecliextra, and pktcap is thepktcapextra; neither is installed with the package (0.7.0 installedduhoandPyYAML):pip install pydhcpgives the library, andpip install "pydhcp[cli]"thepydhcpcommand,pip install "pydhcp[pktcap]"the captures and a message as YAML, TOML or INI text.import pydhcpandimport pydhcp.cliimport neither, nortomllibortomli. Breaking for an install that relied onpip install pydhcpfor the command or for YAML. Thedevextra includescliandpktcap, pktcap'syamlandtoml, and duho'sconfigandyaml.- Without the
cliextra thepydhcpcommand (andpython -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.mainreturns 1 the same way.pydhcp.cli.Appand the six command classes load on first use and are anImportErrornaming the extra withoutduho. - A missing optional dependency is an error that names the extra, not
NotImplementedError:DHCPMessage.to_text/from_textandpydhcp.packet.structured.dumps/loadsraiseImportErrorwithpip install "pydhcp[pktcap]"for YAML, TOML and INI text (and pktcap's own line for itsyamlandtomlextras); a.tomlor.yamlconfiguration file whose reader is missing raisesDHCPConfigErrorwhose line is duho's, namingduho[config]orduho[yaml]. Breaking for code that caughtNotImplementedErrorthere. No default needs an extra:packet --formatis JSON, and a settings file is JSON, INI or whatever its name says. - The sdist no longer holds the repository's
AGENTS.mdorbenchmarks/, and leaves outCLAUDE*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.8andpktcap>=0.1.0,<0.2. - Breaking:
pydhcp.cliexportsApp,mainand the six commands, and nothing else.PACKET_FORMATSandCAPTURE_FORMATSare no longer re-exported from it (the capture formats arepktcap.OUTPUT_FORMATS).server,relayandcapturedeclare--listenand--per-interfaceonce, in a shared base; those two options, their variables and their help text are unchanged.pydhcp packet --decodereads its hex text throughDHCPMessage.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, aValueError). These expressions mean something new:msg_type!=DHCPOFFERand everykey!=valueselect what the clause does not (it was refused as an unsupported keymsg_type!); a comma means "any of" for every key butoption.NAME, somsg_type=DHCPDISCOVER,DHCPREQUESTselects both (it compared the whole text and matched nothing);opandmsg_typetake a member's name in any letter case or a number; an expression that ends inand, or inandand nothing, is refused (option.12=x andcompiled, with the word in the value). The renamed keys aside (src_port,dst_portandinterface, above), a filter that compiled and could match before selects what it did. pydhcp.cli.main(argv=None) -> inttakes the argument vector and returns the exit status instead of readingsys.argvand exiting;python -m pydhcpand the console script exit with it. Breaking for a caller that relied onSystemExit. The status is 0 for success (including--help,--versionand Ctrl-C), 1 for a run that failed and 2 for a wrong invocation: a malformed--listen,relaywithout--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 --serveris required at the parser, and takes several addresses separated by commas as well as a repeated option.pydhcp interfacesprints no banner and one line per address (see Added);pydhcp packet --format summaryopens withOP XID=...and no longer prints aSrc: capture Dst: decodedthat was never true. A packet that cannot be read or decoded is reported aspydhcp: error: cannot process the packet: ....PYDHCP_MCP=stdiono longer serves every command as a tool:packetandinterfacesalone are served (see Added).server,relayandcapturedo not return until they are stopped, andcaptureandreplaystart 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.conffile is no longer parsed as JSON. - The settings file is the root's, and
duho.configreads it (Breaking): pydhcp --config FILE COMMAND(or-c FILE, orPYDHCP_CONFIG), where it waspydhcp COMMAND --config FILE. After the command name the option is refused with the line to use.replayreads the file too (a[replay]table);packetandinterfacesdo not. An empty path (--config "", orPYDHCP_CONFIGset 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,.inior.cfg..yamland.ymlare read (they needpip 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 (Listenis notlisten). - 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
--confignames it (it was 1), and status 1 whenPYDHCP_CONFIGdoes. pydhcp.exceptions.DHCPConfigErrorkeeps its fields and its one line;load_config(source, format=None)callsduho.configand needs thecliextra (itsImportErrornames 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.relayandcaptureread the file too, and any field of a command can be set from it. The file is explicit (--config FILEbefore the command name, orPYDHCP_CONFIG), with no default location. PYDHCP_TRACEBACKis a boolean:0,false,noandoffno longer enable tracebacks; any text that is neither a true nor a false word is an error naming the variable.pydhcp capturehas 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 oldMAX_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-timeoutsets (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_SECONDSis gone. - Every option has a help line:
packet --decode,--encodeandcapture --formathad none, andcapture --filtershowed the field namePACKET_FILTER. - The relay follows RFC 3046 section 2.1 on the request side. It adds option 82 only to
a request that arrives with
giaddr0 (a request another relay already stamped is forwarded as it is, where it used to be given a second option), drops a request whosegiaddris 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 thesnameandfilefields (a request advertising 576 in option 57 used to be forwarded as 548 octets with the option split intosname), and encodes a request at the relay's ownmax_packet_size, not the client's option 57. A request the option would not fit is forwarded without it and counted inrelay_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()anddiscover_offer()raise where they returnedNone. TheNonestood for four outcomes at once, anddora()returned it after retransmitting its REQUEST against a DHCPNAK for about 14 seconds. They now return the message or raiseDHCPTimeoutError(nothing usable arrived; also aTimeoutError) orDHCPRefusedError(a DHCPNAK, which ends the exchange at once, RFC 2131 section 3.1). Callers that tested forNonecatch 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()andAsyncDHCPClient.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:
DHCPRelayandAsyncDHCPRelayrefuse contradictory options.insert_relay_agent_info=Truewith neithercircuit_idnorremote_id(it inserted nothing and said nothing) and either id without the flag (it was ignored) are aValueErrorat construction, andpydhcp relay --insert-relay-agent-infowith 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 whosegiaddris not an address of the relay (including 0) is dropped and counted inpackets_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 atMAX_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 inpackets_dropped_reused_transaction; it used to be logged as ignored and forwarded all the same. The reused-transaction record is logged bypydhcp.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)" forciaddrand "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; withgiaddr = 127.0.0.2a bystander bound there received the OFFER. The two ports are the class attributesREPLY_TO_RELAY_PORT(67) andREPLY_TO_CLIENT_PORT(68) ofDHCPServerandAsyncDHCPServer; 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
listenthat is not an IPv4 address is an interface name."eth0","localhost:6767"and("localhost", 6767)raisedipaddress.AddressValueErrorand are now read as adapter names (a host name is never resolved), so the error is aValueErrorfrombind()("no interface matches ...") when the host has no such adapter. IPv6 addresses are still refused withAddressValueError. - Breaking: the
listenargument 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 orboolas 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 second0.0.26.111), and its port may be digit text. A pair is read bynetimps.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 withValueErrorwhere they bound no socket and leftserve_forever()to fail or spin;pydhcp server|relay|capture --listen ""is an error and no longer the wildcard.Noneas a host is the wildcard everywhere:(None, 6767)was127.0.0.1:6767and is now0.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:
NoClientIdentityis nowpydhcp.NoClientIdentityError(pydhcp.exceptions), no longer importable frompydhcp.packet.message. No alias is kept. It is aValueErroras 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:
AsyncDHCPListenerno longer has the plain attributespackets_dropped_truncatedandpackets_dropped_error, which nothing ever incremented (the counts are on.metrics, as on the sync listener);DHCPListener(max_packet_size=...)defaults toNone, 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 whenbind()runs, not when it is constructed. The async listener's error record for a failing handler now reads like the sync one's. - Breaking:
AsyncDHCPServeris no longer a subclass ofDHCPServer. 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 raisedAttributeErrorafter 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_optionsandget_lease_seconds; each is an ordinary blocking method that runs on the one handler thread of either driver. - Breaking:
AsyncDHCPRelayis no longer a subclass ofDHCPRelay. 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'swith,close()andstart(cancellation_token)as the asynchronous server did. The pending table ages on the monotonic time stamped on each context, andpydhcp.relayis now a package (same import path and names;PendingClientis private); the relay's records are logged bypydhcp.relay._core. pydhcp.captureis 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,DHCPClientand their asyncio counterparts).listen()isserve_forever(),stop()isshutdown(),wait()iswait_closed(timeout=None)(it returnsFalsewhen the timeout passed first), andstart()returnsNoneand 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 withclose()and awithblock, the asynchronous ones withawait aclose()andasync with, neither starting to serve (the block binds). Closed is final:start(),serve_forever(),bind()andwithafterclose()raiseRuntimeError, wherestart()used to serve again. A secondstart()while serving raisesRuntimeErrortoo: on the asynchronous classes it added a second receive task per socket and stranded a pendingwait().serve_forever()no longer closes the sockets on its way out (close()does), andstart()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 uncaughtValueErrororOSError; on Linux it never ended,wait()blocked andstart()returned nothing.shutdown()ends the wait at once instead of after the poll interval.- The library installs no signal handler.
DHCPListener.start()no longer claimsSIGINTfor the process, andclose()no longer gives it back. Ctrl-C reachesserve_forever()asKeyboardInterrupt; thepydhcp serverandrelaycommands catch it, log the same line and exit with status 0, as before, andpydhcp captureends 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 toDHCPListener,DHCPServer,DHCPRelay,DHCPClientand the asyncio counterparts; a seventh positional argument is aTypeError. The same position meant different things on the two drivers (select_timeoutagainstmax_packet_size), and the shipped header'sDHCPRelaysignature omittedtrust_client_relay_agent_info, so a positionalselect_timeoutwritten from it turned that trust on.select_timeoutis nowpoll_intervalon the synchronous classes (how often a wait looks at the shutdown flag; a shutdown no longer waits for it).reuse_addressandreceive_buffer_sizeare new keywords; each defaults to theREUSE_ADDRESS/RECEIVE_BUFFER_SIZEclass 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 defaultleases.jsonin the current directory is gone) and reads nothing:open()reads the file once, and the first lease operation orwithcalls it, so a missing file creates nothing and an unreadable one is not set aside until then. - Breaking:
SocketAddress(sock)isSocketAddress.from_socket(sock), andSocketAddress(ip, port)needs both arguments (TypeErrorotherwise, where it wasValueError). pydhcp.clientis a package (same import path and names). The message builders, the reply matching and the retransmission schedule are one private core underDHCPClientandAsyncDHCPClient, soDHCPClient's behaviour on the wire is unchanged. A record the client logs (an OFFER without a server identifier) is namedpydhcp.client._core, and a test that patchespydhcp.client.UDPTransportpatchespydhcp.client._sync.UDPTransport.DHCPRequestContextcarries the time its datagram arrived: two new trailing fields,received_at(timezone-aware UTC) andreceived_monotonic(time.monotonic()seconds), stamped by the listener. A context built by hand hasNonefor 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 withipalone, as before, it reads the driver's clock.DHCPClient.discover_offer()and.dora()name the keywords they forward (xid,client_identifier,parameter_request_listand, fordiscover_offer,broadcast) instead of taking**discover_kwargs. The same calls work; an unknown keyword is now aTypeErrorat the call.- Breaking:
acquire_leasetakes a keywordcommitand runs on the server itself. The signature isacquire_lease(client_id, server_id, msg, *, commit=True): an override must acceptcommit(one that does not raisesTypeErrorfor every DISCOVER). DHCPDISCOVER makes one call withcommit=False, DHCPREQUEST makes two,commit=Falseto decide andcommit=Trueto commit the ACK, and the override must do nothing that extends or creates a binding whencommitis 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_backendinside an override is the real backend on both calls; only the base implementation reads through a view that does not extend a binding whencommitis 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 addsparse_listen,bind_listen,ListenLikeandUDPEndpoint(interfaces=), which the listeners now read and bindlistenthrough. - The
listengrammar and the binding policy are netimps'.pydhcp.listener.ListenLikeisnetimps.ListenLike, and every acceptedlistennames 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 inlistento hear fewer. - The message of a refused
listenis 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 atbind()) isnetimps.NetimpsValueError, still aValueError. - An IPv6 address as text (
"::1","[::1]:67","fe80::1%eth0",("fe80::1", 6767)) is refused withnetimps.NetimpsValueError, where it wasipaddress.AddressValueError: code that caught that subclass by name catchesValueErrorinstead. - An
IPv6Addressobject as a binding is refused the same way, as a value of the wrong family, where it wasTypeError: text and object now agree. host:porttext is read strictly. Inlisten=,--listenand eachpydhcp 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 raiseValueError(netimps'NetimpsValueError), and the message names the port or the brackets.host_ip_interfaces(family=)acceptssocket.AF_INETandsocket.AF_INET6as well as4and6.- 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_PATCHis now an error. With it set,import pydhcpraisesValueErrorat import; useNETIMPS_SOCKET_PATCH=0to disable thesocketpatch. - Breaking:
NetworkInterface.macis anetimps.MACAddress, andpydhcp.MACAddressis that type, not a subclass of it.str(mac)is nowaa:bb:cc:dd:ee:ffwhere it wasAA-BB-CC-DD-EE-FF;pydhcp interfacesstill prints the hyphenated upper-case form, throughmac.format("-", upper=True). The netimps type has noas_str(): use.format(sep=":", *, upper=False); instances are read-only,copyandpicklekeep the subclass, a bad value raisesnetimps.NetimpsValueError(still aValueError) andMACAddress.try_parse()raisesTypeErrorfor a non-str.pydhcp.network.APIPAisnetimps.LINK_LOCAL_V4, anIPv4Network, andhost_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 (Linuxlowith 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,.nvtand.networkarepydhcp._log,._config,._constants,._metrics,._nvtand._network;pydhcp.packet.enumsand.messagearepydhcp.packet._enumsand._message;pydhcp.options.code,.registryand.typearepydhcp.options._codes,._registryand._codecs(andoptions/base.pyjoined_codes.py), each codec module under it with a leading underscore; thepydhcp.listenermodules (sync,aio,interfaces,receive,transport) are_sync,_asyncio,_interfaces,_receiveand_transport, andbindingandspecare gone (netimps owns thelistengrammar and the binding); thepydhcp.clicommand modules are_capture,_interfaces,_packet,_relayand_server(capture_hookis gone).pydhcp._utilsis split into_genericand_missing. No module is left behind to re-export from its old path. Import a name frompydhcp,pydhcp.packet,pydhcp.options,pydhcp.listeneror the other public modules;pydhcp.log.LOGGERislogging.getLogger("pydhcp"). - A module's logger is named for its module, so the records of a moved module
change name:
pydhcp.log,pydhcp.configandpydhcp.metricsbecomepydhcp._log,pydhcp._configandpydhcp._metrics,pydhcp.listener.syncand the other listener modulespydhcp.listener._syncand so on. A--loglevelor a filter naming one of them follows; one namingpydhcporpydhcp.listeneris 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.DHCPFlagsandpydhcp.DHCPPortare new (the same objects aspydhcp.packet's). Each name below is gone frompydhcp, 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
yamlextra and duho's hold it now. A.yamlor.ymlsettings file is read throughduho[yaml]. - The settings file's own spellings:
--configafter a command name (refused with the line to use), pydhcp's own JSON, TOML and INI readers, and thetomlextra (pip install "duho[config]"reads TOML below Python 3.11). - Everything pktcap does is out of
pydhcp.captureand 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)andasniff_dhcp(listen)(pktcap frames), withpktcap.copy_frames(frames, writer, select=..., each=...). ADhcpCapturesubclass overridinghandleis anAsyncDHCPListenerorDHCPListenersubclass overridinghandle.CaptureEvent,CaptureHook,CapturePredicate,CaptureSink:pktcap.DissectedFramewith aDHCPLayer; the message object isDHCPMessage.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"))(itsmax_filesdefaults to 1000).pydhcp.cli.HOOK_TIMEOUT_SECONDS:pktcap.command_hook(itstimeoutdefaults to 10 seconds) andpktcap.CaptureHookError. Thepydhcpmodule no longer exportsCaptureEvent,DhcpCapture,AsyncDhcpCaptureorcompile_capture_filter. The asynchronous capture isasniff_dhcp, an iterator and not a class with a sink.pydhcp capture --output-modeis gone, and--per-recordreplaces it: the modestreamand the modesingleran one code path, told apart only by whether--outputwas-, so the only choice left is one file per record, and--output-mode per-captureis--per-record. Breaking for a command line that said--output-mode.- Breaking:
pydhcp.capture.validate_filename_pattern,CaptureEvent.format_filenameandpydhcp.cli.MAX_PER_CAPTURE_FILESare gone.pktcap.CaptureWritermakes the same checks of a file-name pattern and raisesValueError(the message names the field and lists the fields), and itsmax_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(waspydhcp.network) holds only pydhcp's own types (HardwareAddressType,NetworkInterface,SocketAddress,host_ip_interfaces).IPv4,IPv6,IP,IPv4Network,IPv6Network,IPNetwork,IPv4Interface,APIPAandSocketOptionare gone from it: importipaddress.IPv4Addressand the others fromipaddress, andLINK_LOCAL_V4(forAPIPA) andSocketOptionfromnetimps.WILDCARD_V4is not public: writeipaddress.IPv4Address("0.0.0.0"). No alias is kept. - Breaking:
SocketAddress.listen()is gone. The listener callsnetimps.bind()directly with the arguments it always passed (broadcast=True,connreset=False, an exclusive address unlessreuse_addressis set), so what a bound socket does on the wire is unchanged; the method's ownconnreset=Truedefault was never reached. pydhcp.server.handlers,pydhcp.server.policyandpydhcp.server.replyare gone (they exported nothing); the layers are private modules ofpydhcp.server, so a record they log is namedpydhcp.server._handlersand so on.- Breaking:
DHCPOptionCode.ensure_registered()is gone. The built-in codecs are bound to the standard option codes whenpydhcp.optionsis imported, so a lookup never depends on an earlier call; there is no alias to call. A codec registered withregister_typeafterwards replaces the built-in one, as before.DHCPMessageTypeis defined in the options package (pydhcp.packet.DHCPMessageTypeandpydhcp.DHCPMessageTypeare the same object).
Fixed
encode()names the header field it cannot send. A message built with a value that has no wire form inop,htype,flags,secsor one of the four addresses (DHCPMessage("BOOTREQUEST"),ciaddr="192.0.2.1",secs=5) raisedAttributeError,ValueErrororstruct.errorfrom the line that packs the header; it raisesDHCPValueErrornaming the field and what it holds. An opcode given as its number is sent as that number, ashtypeandflagsalready were:DHCPMessage(1)is a BOOTREQUEST, where it raisedAttributeError.- 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
DHCPClientandAsyncDHCPClient. - An interrupt that lands between a sync listener's claim and its receive loop no longer makes
close()wait five seconds.serve_forever()andstart()marked the listener as serving and only then entered thetrythat releases the claim, so aKeyboardInterrupt(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 onetry/finallythat 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.CaptureFilterErrornaming the clause, instead of compiling and reporting nothing (which looks like a quiet segment):msg_type=DISCOVER(the names areDHCPDISCOVERand the rest; the message lists them),op=REQUEST, a message type or opcode above 255,client_idorchaddrthat is not whole octets of hexadecimal digits (or past 255 and 16 octets),xidoutside 32 bits, a port outside 0 to 65535, andoption.999.msg_type=dhcpdiscover,msg_type=1,op=bootrequestandop=1now select the message (they compared names case-sensitively and matched nothing).msg_type=UNKNOWNandmsg_type=TYPE_99still select a message with no type and one of type 99. - The README's
--loglevel pydhcp=DEBUGexample 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.cmdfile. - A lease backend that is falsy when empty is no longer replaced.
lease_backendwas tested for truthiness, so a backend defining__len__was discarded while empty and the server used a private store; it is now tested withis 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, byclose()andaclose().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 inmsg.- A reply is no longer decoded again after it is built unless DEBUG is on, and a
DHCPNAK no longer assigns
siaddr,snameandfiletwice. acquire_lease, DHCPRELEASE, DHCPDECLINE and the withdrawal of an offer asklookup_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_SECONDSset, a change reaches the disk within the interval. A change inside the interval stayed unwritten until the next change,flush()orclose(), whichDHCPServer.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()andclose()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 ofhandle(). A message whose option 50 or 54 is unusable is dropped (counted inpackets_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. AtMAX_DECLINED_ADDRESSESa new address is refused (quarantines_refused) rather than the oldest evicted. The quarantine is applied to whatever leaseacquire_leasereturns, 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, defaultFalse) 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 toUDPTransport.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
dstfilter key could not select broadcasts. A broadcast now shows255.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.2was answered from127.0.0.1. The reply source is now the destination of every unicast;SERVER_IDENTIFIERand the pool still come from an address the adapter lists. - Every public annotation resolves with
typing.get_type_hintson Python 3.9.X | Yin a signature, atyping_extensions.Selfimported only underTYPE_CHECKINGand names a function could not see failed there with aTypeErrororNameError(28 of 242 public callables). Signatures useOptionalandUnion,Selfis a boundTypeVarper class, and every module hasfrom __future__ import annotations;typing_extensionsis 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
pydhcpitself, so a level set onpydhcp.serversilenced nothing. A record is now named for its module (pydhcp.server._handlers,pydhcp.listener._receive); a handler or level onpydhcpstill sees all of them, since children propagate. pydhcp capture --hook ./nameruns the file it names. The command was started by the name with its./dropped, which POSIX looks up onPATHonly: the hook was not found (a traceback per packet) or a program of the same name onPATHran instead. The hook is now resolved to an absolute path when the capture starts, so./nameis the file in the working directory on every platform and survives a later change of directory, and a barenameis looked up onPATH(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 alistenthat 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 calledper_interface=Truethe portable deterministic path andexamples/listener.pyused it; both now say this andexamples/listener.pylistens 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,AsyncDHCPServerandAsyncDHCPRelaytakemax_queued(default 1024 datagrams): the datagram that finds the backlog full is dropped, counted in the newmetrics.packets_dropped_backlogand 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-datagramgetsockname()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 onlythreading.excepthook, leaving the listener "started":wait()blocked for ever and a secondstart()returnedNone.start()now binds on the caller's thread and raises whatbind()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(),withon 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 fromflags.ClientFQDNgainspartial(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 raisesValueError, 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 raisesIndexError. - Re-binding a started
AsyncDHCPListenerwhose listen list shrank retires the dropped socket's receive task cleanly; it used to wait for ever. SocketAddressrefuses a port outside 0-65535 withDHCPValueError;str()of one with a port such as 70000 used to raise.options.get(code)of a list option (ROUTER,DNS,PARAMETER_REQUEST_LISTand the others) pickles, copies and deep-copies to an equal value, and so do aDHCPOptionsbag and aDHCPMessageholding them;pickle.dumpsof one raisedPicklingError.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 usessupports_pktinfo, the interface cache (cache=,clear_interface_cache),AddressInUseError,UdpEndpoint.arecv(),backoff_delays(jitter_seconds=)andDatagram.truncated, all added in that release. - A taken port raises
netimps.AddressInUseErrorfromDhcpListener.bind()andSocketAddress.listen()— anOSErrorsubclass, soexcept OSErrorstill catches it, but never aPermissionError. Windows reports a port held exclusively elsewhere as WSAEACCES, which Python maps toPermissionErroron 3.12+ and to a plainOSErroron 3.9. SocketAddress.listen()takes keyword-onlybroadcast,allow_address_takeoverandconnreset, forwarded tonetimps.bind().REUSE_ADDRESS=Truenow meansallow_address_takeover=True; the exclusive default is netimps' on every platform.pydhcp.network.SocketOptionisnetimps.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-onlycache. MACAddress.hex()is inherited from netimps (identical behaviour) rather than overridden, andstr(SocketAddress)is built withnetimps.join_host.DhcpClient's retransmission schedule comes fromnetimps.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 duplicatedUdpTransport's rule that a0.0.0.0destination means the limited broadcast. Send through aTransportinstead.
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;0keeps the OS default) throughnetimps.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 meantsname, so a reply with a setsnamemoved it into option 66, overflowed, and raisedOverflowErrorwhile an emptyfilewould 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_PKTINFOonly 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 synthetic0.0.0.0/32interface and, measured, received a DISCOVER and allocated and sent nothing. Receiving now goes throughnetimps.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 withUdpEndpoint.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
ConnectionResetErroron 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+.
PktInfoUdpTransportpacked the POSIX control message by hand, behind the sameIP_PKTINFOfeature test. It now sends throughnetimps.UdpEndpoint.send(src=...)on every platform, and takes an optionalendpointargument.
0.6.1 - 2026-09-28
Changed
duhofloor 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.exitreturn typing, an unconditional stderr handler, and logger levels pinned on descendant loggers) are fixed in the release.duho0.6 also added an MCP-over-stdio launch mode, triggered by settingPYDHCP_MCP=stdioin the environment; thepydhcpCLI (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
AsyncDhcpRelayandAsyncDhcpCapture, completing async parity. Mixed ontoAsyncDhcpListenerrather than copied, so theIP_PKTINFOreceive 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, withflush(),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(alsolease_fileunder[server]in a config file).docs/deployment.mdhad 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-interfaceandpydhcp relay --per-interface— both classes already took the argument; only the CLI could not reach it.python -m pydhcpas an entry point.UncompressedDomainList, andRecordList[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/fileinstead 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 againstciaddrbefore 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 makingencoderaise 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,fileandchaddrraise instead of being silently truncated, andhlenis bounded to whatdecodeaccepts. 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:decodeconsumes options 52, 66 and 67 — the framing that sayssname/filehold 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
ANDcompiled, 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 itselfpydhcp. - 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 asBytes.
Changed
DhcpServer.release_lease()returnsbooland 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.DhcpMetricsgainsleases_declinedandreleases_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
NullHandlerand logs underpydhcp.<module>. With logging otherwise unconfigured this silences the WARNING-and-above thatlogging.lastResortused to print; configure a handler to see them. DhcpOptionsrejects codes 0 and 255 on assignment — they are structural markers, not options. Receive stays liberal.- Both import cycles removed;
HardwareAddressTypenow lives inpydhcp.networkand is re-exported frompydhcp.packet. netimpsis 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'sbind_error_hintread a WindowsWSAEACCESas POSIXEACCESand 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.Stringandpydhcp.Booleanare no longer re-exported from the top level. They are option codecs, and their bare names did not say so —pydhcp.IPv4Addresswas the codec rather thanipaddress.IPv4Address, soisinstance(interface.ip, pydhcp.IPv4Address)was False while looking exactly like the check you meant to write. Import them frompydhcp.options.type, where they have always lived. The stdlib address type remainspydhcp.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.Missingare gone.constants.pyholds protocol constants — packet sizes, port numbers, the infinite-lease sentinel — and this is a Python idiom for "argument not supplied whereNoneis itself a value". It now lives inpydhcp._utilsand 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
duhois now required as>=0.5.0,<0.6(was>=0.4.1). The old floor predated duho 0.5.0, which changed how alist/set/tuplefield used as an option parses:--x a bused to accumulate both values in one occurrence (nargs="*"), and an option field now takes one value per occurrence. The CLI declaresserver: List[str]as--server/-son therelaysubcommand 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.netimpsis 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 freshbytearray, so neither structural edits nor in-place mutation of a payload obtained fromget(..., decode=False)can write through to the original.
Changed
- The
netimpsrequirement floor is raised to>=0.2.2. The previous>=0.0.1predated 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_responseassignedresp.options = lease.options, so the response and the lease backend shared oneDhcpOptionsobject. Everything the response pipeline did to it edited the allocation store:IP_ADDRESS_LEASE_TIME,SERVER_IDENTIFIER,DHCP_MESSAGE_TYPEand the echoedRELAY_AGENT_INFORMATION(option 82) were injected into the lease;PARAMETER_REQUEST_LISTfiltering permanently deleted from the lease every option the client did not ask for; andhandle_informstripped the lease time.InMemoryLeaseBackend.renewcarries the same object across renewals andFileLeaseBackendpersisted the damage to disk. The response now takeslease.options.copy(). DhcpRelay._pending_clientsis bounded. Thexid -> original client addressmap grew without limit for xids whose replies never arrived, leaking memory in a long-running relay. It is now capped atDhcpRelay.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 realbytes.hexsignature it forwards to (it had untyped*args, **kwargs, which also left its body unchecked).- Removed two dead
isinstance(e, KeyboardInterrupt)re-raise branches inlistener.py:KeyboardInterruptdoes not subclassException, so neither could ever run. The interrupt already propagates to the outer handler. listener.py's socket-close bareexcept:is nowexcept Exception:.DhcpMessage.dumps()printedsiaddrtwice, 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.pyis rewritten onduho, a declarative CLI framework, replacing hand-builtargparse. Each subcommand (interfaces,server,relay,packet,capture) is now aduho.Cmdclass with annotated fields instead ofadd_argumentcalls. Behavior and flags are preserved, with additions:pydhcp --versionnow works (resolved from installed package metadata).- Per-subcommand
--log-levelis replaced by duho's shared verbosity scheme:-v/-q(repeatable) and--loglevel KEY=VALUE. - New short flags:
packetgains-i/-o/-f(--input/--output/--format);capturegains-l/-o/-f/-c(--listen/--output/--format/--count);serverandrelaygain-l(--listen);relayalso gains-s(--server). --helpnow shows(default: X)next to any option with a non-empty default.- Added
duhoas a runtime dependency.
0.4.1 - 2026-07-22
Changed
- Network enumeration now comes from
netimps.network/platform.py-- 503 lines of ctypes (GetAdaptersInfo,getifaddrswith hand-written sockaddr structs, plusipconfig/ip addrtext-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
/8instead of falling through to a synthetic/32. host_ip_interfaces()gainsfamily=4as the default, preserving the IPv4-only behaviour callers relied on.MACAddressis now anetimps.MACAddresssubclass. The uppercase-hyphenated rendering is unchanged; it is no longer abytessubclass, so use.packedfor raw bytes (.hex()is kept as a passthrough). It gains.oui,.is_multicast,.is_localand ordering.SocketAddress.listen()delegates tonetimps.bind(), which closes the socket before any exception propagates.- Bind failures use
netimps.bind_error_hint, which recognises the POSIX errnos and the WindowsWinErrorcodes. The DHCP-specific suggestions are appended rather than replacing the diagnosis.
Fixed
_split_host_portmis-parsed IPv6 listen specs:"[::1]:67"returned("[::1]:67", None), silently dropping the port. It now delegates tonetimps.normalize_host.
0.4.0 - 2026-07-14
Added
- Added a basic listener-based
DhcpClientfor building, sending, and collecting DHCP client packets without configuring OS interfaces. - Added reusable DHCP capture primitives and a
pydhcp captureCLI with safeandfilters, structured output streams/files/per-capture files, and trusted Python or command hooks. pydhcp.config.load_config(andpydhcp server --config) now accepts YAML and TOML in addition to JSON and INI, matching the formats already supported bypydhcp.packet.structured.- Added
DhcpClient.discover_offer()andDhcpClient.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. DhcpServernow echoesRELAY_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) intests/test_property_roundtrip.py, plushypothesisas adevextra. - Documented
pydhcp.client,pydhcp.capture,pydhcp.packet,pydhcp.network, andpydhcp.leaseon 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 (stampinggiaddr, incrementinghops, dropping packets past a configurablemax_hops) and forwards server replies back to the original client, with optionalRELAY_AGENT_INFORMATION(option 82) circuit-id/remote-id tagging. Includes apydhcp relayCLI subcommand.
Changed
- Reorganized package internals into clearer
packet,options, andnetworksubpackages;DhcpOptionCodenow lives underpydhcp.options, and option codec classes live underpydhcp.options.type. - Moved the message-level enums (
DhcpMessageType,OpCode,Flags,DhcpPort,HardwareAddressType) frompydhcp.enumtopydhcp.packet.enums, re-exported frompydhcp.packet; thepydhcp.enumpackage is gone. - Renamed the misspelled
pydhcp.constants.INIFINITE_LEASE_TIMEconstant toINFINITE_LEASE_TIME. - Split the 1,252-line
pydhcp.options.typemodule into a subpackage (base,net,scalar,vendor,mossubmodules); the public import pathpydhcp.options.typeand every name it exposes are unchanged. - Breaking: removed the global mutable
pydhcp.metrics.METRICSsingleton.DhcpListener(andDhcpServer/DhcpClient) now own a per-instanceself.metrics: DhcpMetrics, so counters no longer leak across independent listeners/servers in the same process (e.g. in tests). AddedDhcpMetrics.snapshot() -> dict[str, int]. - Precompiled the DHCP fixed-header
struct.Structinpydhcp.packet.messageinstead of re-parsing the format string on every encode/decode call, roughly 5x faster packet parsing (see structured JSON benchmark reports inbenchmarks/README.md). - Breaking: standardized the
pydhcp packetCLI on the same conventionscapturealready used:--input <path>and--output <path>each accept-for stdin/stdout (POSIX convention) and default to-, and the five separate--json/--yaml/--toml/--ini/--summaryflags collapsed into one--format <fmt>flag (defaultjson). Removed--stdin,--input-file,--output-file, and inline--input <text>(unused/undocumented) — use--input -for stdin and a path for files.
Fixed
mypy --strictnow 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 withexpires=Noneinstead of the established "does not expire" sentinelmath.inf.- Fixed
DomainListdecoding silently dropping domains that follow a compression-pointer-terminated domain in the same option (affectsDOMAIN_SEARCHand any other option backed byDomainList); found by the new Hypothesis property tests.
0.3.0 - 2026-07-13
Added
- Added CCC option codec coverage and registered
DhcpOptionCode.CCCfor typed round trips. - Clarified
DhcpServercustomization 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
NotImplementedErrormessages whentomliortomli-wis unavailable. - Added stricter short-packet diagnostics and
pydhcp packet --decode --summaryfor 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_interfaceconstructor parity on sync and async servers. - Added optional JSON benchmark report output for
benchmarks/bench_options.pyand 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.pyso 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.pywrapper so maintainers can chooseparseoroptionsbenchmarks and optionally write structured JSON output without expanding the released package CLI. - Added structured packet I/O for
pydhcp packetwith JSON, YAML, TOML, and INI round trips plus explicit stdin, inline, file, and output-file handling.
Fixed
- Removed the stale
pydhcp packet --encodeCLI 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
ClasslessRoutetruncation checks. - Added wildcard listener
per_interfacesupport and exportedPktInfoUdpTransport. - 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 andsecspacking behavior.
0.2.0 - 2026-07-12
Added
- Created
benchmarks/bench_parse.pypacket parsing and serialization performance benchmarks. - Added
benchmarks/README.mddocumenting performance baselines. - Added
AsyncDhcpListenerandAsyncDhcpServerclasses implementing asyncio-based event loop integration. - Added integration tests for async server in
tests/test_async.py. - Configured MkDocs documentation with Material theme and
mkdocstringsauto-generated API references. - Added
Transport,UdpTransport, andRequestContextclasses for structured transport-layer abstraction and Interface tracking. - Added comprehensive unit testing coverage for packet deserialization and type-safe dictionary operations in
test_message.pyandtest_options.py. - Added integration tests verifying standard client DORA sequences and RFC 2131 routing logic in
tests/integration/test_dora.py. - Added missing
ALL_VPNSoption (Tag 254) toDhcpOptionCodeafter comparing against the latest IANA registry. - Populated default
ROUTERandDNSlease options inDhcpServer.acquire_leaseusing 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.pyandtests/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.pyandtests/test_malformed_packets.py. - Added command-line interface (CLI) with
interfaces,server,packet, andbenchsubcommands. - Added JSON-based configuration loading support in
config.py. - Added in-process metrics counters (
packets_received,packets_sent, etc.) inmetrics.pyto 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.tomland resolved all mypy type-checking errors across the library. - Refactored
DhcpMessageTypeandOptionOverloadto implement theDhcpOptionTypeinterface directly and avoid PEP 561 / type conflicts withBaseFixedLengthIntegerand Enums. - Refactored
DhcpServer.handleto acceptRequestContextinstead ofSocketSession, split processing into message-type specific handlers (handle_discover, etc.), and comply strictly with RFC 2131 routing paths. - Enriched
NetworkInterfaceandhost_ip_interfaces()to yield fully detailed adapter metadata. - Refactored
DhcpServerandAsyncDhcpServerto accept and delegate lease lifecycle events (allocate, lookup, renew, release) to a configurablelease_backend. - Refactored options parsing in
DhcpOptions.decodeto 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.pytoconstants.pyand updated all internal references. - Added strict
__all__public exports list tosrc/pydhcp/__init__.py.
Fixed
- Fixed bug in
ClasslessRoutedestination descriptor parsing/serialization that caused incorrect length calculations for CIDR/8. - Fixed forward reference type resolution issue for
DhcpOptionin_options.py. - Fixed options encoding
OverflowErrorwhen 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.