pydhcp
A pure-Python DHCPv4 library with a server, a client, a relay agent and a capture tool, each in a blocking and an asyncio form over typed messages and options. It targets Python 3.9 and newer: packet parsing and the structured formats are portable, while actually serving DHCP still depends on OS socket permissions and platform-specific UDP behavior. The documentation is at https://jose-pr.github.io/pydhcp/.
Features
- Packet parsing and construction — every DHCPv4 message, a
DHCPOptionCodemember and a typed codec for each IANA option, and a JSON form of every message (and YAML, TOML and INI with thepktcapextra). Liberal on receive, strict on send. - Server base — an overridable foundation with lease and option policy hooks, an in-memory and a file lease backend, and a threaded and an asyncio driver over one core.
- Relay agent — RFC 1542 / RFC 2131 §4.1 forwarding with hop-limit loop protection and optional RFC 3046 option-82 tagging, in both drivers.
- Packet client — builders for the five client messages, a DORA exchange with the RFC 2131 retransmission schedule, in both drivers. It does not configure interfaces.
- Capture — with the
pktcapextra, DHCP as a layer of pktcap's dissection (sniff_dhcp,DHCPLayer, filter keys), and thecaptureandreplaycommands: pktcap's, with DHCP's defaults, writing JSON, YAML, TOML, INI, text, pcap and pcapng, with a filter expression, command hooks and reading a capture file back. - Command line —
pydhcp(interfaces,server,relay,packet,capture,replay), configurable by option, environment variable or file.
Installation
pip install pydhcp
The library (packets, options, server, client, relay) needs nothing else. What else you ask of it is an extra, one for each capability:
| Extra | Adds | Needed for |
|---|---|---|
cli |
duho |
the pydhcp command (python -m pydhcp) |
pktcap |
pktcap |
captures: pydhcp capture and pydhcp replay, the DHCP dissector and its filter keys, sniff_dhcp; a message as YAML, TOML or INI text (--format, DHCPMessage.to_text), which also needs pktcap[yaml] or pktcap[toml] |
For example pip install "pydhcp[cli,pktcap]". Without the cli extra the pydhcp
command prints that it needs it and exits with status 1; without the pktcap extra
pydhcp capture and pydhcp replay print that line and exit with status 1, and every other
command runs. A message as JSON, and a JSON or INI configuration file, need no extra. The one
required dependency is netimps, which has none of its
own. Requires Python 3.9+.
Quick start
Parsing and building a message
from pydhcp.options import DHCPOptionCode
from pydhcp.packet import DHCPMessage, DHCPMessageType, DHCPOpcode
message = DHCPMessage(
DHCPOpcode.BOOTREQUEST, xid=0x1234ABCD, chaddr=b"\x00\x11\x22\x33\x44\x55"
)
message.options[DHCPOptionCode.DHCP_MESSAGE_TYPE] = DHCPMessageType.DHCPDISCOVER
message.options[DHCPOptionCode.HOSTNAME] = "laptop"
wire = message.encode() # 300 octets, END-terminated
again = DHCPMessage.decode(wire)
assert again.options.get(DHCPOptionCode.HOSTNAME) == "laptop"
print(again.to_text("json")) # "yaml", "toml" and "ini" too, with the pktcap extra
Synchronous server
from pydhcp.server import DHCPServer
with DHCPServer(listen="*") as server: # binds; does not serve yet
server.serve_forever() # until shutdown(); Ctrl-C raises KeyboardInterrupt
serve_forever() receives on the calling thread. start() receives on a daemon
thread instead and returns; shutdown() ends either and never blocks, so a
handler may call it; wait_closed() blocks until receiving has stopped; close()
(which leaving the with block calls) does both and then releases the sockets.
Closed is final. The library installs no signal handler: the pydhcp commands
turn Ctrl-C into shutdown(), and an application that wants the same catches
KeyboardInterrupt around serve_forever().
The built-in server intentionally keeps allocation policy small. It offers the address a
client asks for in its DISCOVER (holding it for OFFER_HOLD_SECONDS, two minutes, until the
client's REQUEST commits it) and renews existing leases, while applications can subclass DHCPServer
or provide a custom lease backend for pools, reservations, and site-specific options.
You can also bind explicit endpoints or multiple ports when you do not want wildcard behavior. A socket bound to an address hears no broadcast on Linux, so this serves unicast peers and tests, not clients that have no address yet:
from pydhcp.server import DHCPServer
server = DHCPServer(listen=[("127.0.0.1", [6767, 6768])], per_interface=True)
server.serve_forever()
Asynchronous server
Every listener has an asyncio counterpart -- AsyncDHCPListener,
AsyncDHCPServer and AsyncDHCPRelay. Each pair is two
sibling drivers over one private core that owns no socket, thread or clock, so a
fix to the rules reaches both; neither is a subclass of the other, and a test
fails if a receive-path line is duplicated between them.
import asyncio
from pydhcp.server import AsyncDHCPServer
async def main():
async with AsyncDHCPServer() as server: # binds; does not serve yet
await server.serve_forever() # until shutdown() or cancelled
asyncio.run(main())
serve_forever() receives in the calling task. await server.start() receives
in background tasks and returns once the sockets are bound, for an application that
has its own work to await; shutdown() (never blocks, safe from a handler) ends
either one, await wait_closed() returns once receiving has stopped, and
await aclose() (which leaving async with awaits) releases the sockets. Handlers
run on a worker thread, so a blocking handle() will not stall the rest of your
application.
Relay agent
from pydhcp.relay import DHCPRelay
with DHCPRelay(listen="*", server_addresses=["192.0.2.1"]) as relay:
relay.serve_forever() # forwards to 192.0.2.1:67 and back
Capture
import time
from pydhcp.capture import DHCPLayer, sniff_dhcp
deadline = time.monotonic() + 60 # listen for a minute
def expired():
return time.monotonic() > deadline
for frame in sniff_dhcp("*", stop=expired): # needs the pktcap extra
print(frame.layer(DHCPLayer).summary())
pktcap's own commands filter DHCP with the same short keys when they are told to load the
plugin (pktcap_plugin; it registers nothing until pktcap names it):
PKTCAP_LOAD=pydhcp.capture pktcap convert -i heard.pcap -f "msg_type=DHCPDISCOVER and chaddr=00:11:22:33:44:55"
Basic packet client
DHCPClient is a packet-level helper for tests and troubleshooting. It sends DHCP
messages and queues matching replies, but it does not configure host network interfaces.
from pydhcp.client import DHCPClient
client = DHCPClient(listen=("127.0.0.1", 6768))
discover = client.build_discover(b"\x00\x11\x22\x33\x44\x55")
client.send(discover, dst="127.0.0.1", port=6767)
AsyncDHCPClient is the same client on an event loop: send, discover_offer and
dora are coroutines with the same keywords, and the receive tasks run between
await client.start() and await client.aclose().
import asyncio
from pydhcp.client import AsyncDHCPClient
async def main() -> None:
async with AsyncDHCPClient(listen=("127.0.0.1", 6768)) as client:
await client.start()
ack = await client.dora(
b"\x00\x11\x22\x33\x44\x55", destination="127.0.0.1", port=6767
)
print(ack) # raises DHCPTimeoutError, or DHCPRefusedError for a DHCPNAK
asyncio.run(main())
Command line
pip install "pydhcp[cli]" installs the pydhcp command; python -m pydhcp is the same.
It lists network adapters, decodes and encodes packets, serves, relays, captures and
replays.
# List all network interfaces
pydhcp interfaces
# Decode a hex-encoded DHCP packet from stdin as JSON
pydhcp packet --decode --input - --format json
# Decode a hex-encoded DHCP packet from stdin as a compact text summary
pydhcp packet --decode --input - --format summary
# Encode structured packet text back to hex
pydhcp packet --encode --input packet.json --format json --output packet.hex
# Capture DHCPDISCOVER packets as newline-delimited JSON on stdout
pydhcp capture --listen 127.0.0.1:6767 --filter msg_type=DHCPDISCOVER --output -
# Record the datagrams as the clients sent them, in a file tcpdump and Wireshark open
pydhcp capture --listen 127.0.0.1:6767 --output heard.pcap
# Read a capture file instead of listening; send its requests again to a test server
pydhcp capture --read heard.pcap --filter msg_type=DHCPDISCOVER
pydhcp replay --input heard.pcap --to 192.0.2.1 --no-delay
# Write one structured file per record (at most --max-files files, 1000 by default:
# the records past them are counted, and the capture ends with status 1 and says how many)
pydhcp capture --listen 127.0.0.1:6767 --output "output/{client_id}/{timestamp}_{msg_type}.{format}" --per-record --format json
# Invoke a trusted local command hook with each captured packet on stdin
# (a program, run with no argument and no shell; on Windows an .exe, not a .cmd)
pydhcp capture --listen 127.0.0.1:6767 --hook ./on-dhcp-capture
# Start the DHCP server from a JSON, TOML or INI configuration file
pydhcp --config config.json server
# Listen on multiple explicit endpoints while debugging
pydhcp server --listen 127.0.0.1:6767,127.0.0.1:6768
# Keep the leases in a file, so they survive a restart
pydhcp server --listen 127.0.0.1:6767 --lease-file leases.json
# Relay requests to an upstream server, tagging them with option 82 (circuit id in hex)
pydhcp relay --listen 127.0.0.1:6767 --server 127.0.0.1:6768 --insert-relay-agent-info --circuit-id 65746830
# Increase logging while debugging (-v is repeatable; --loglevel targets one logger)
pydhcp server --listen 127.0.0.1:6767 -v
pydhcp server --listen 127.0.0.1:6767 --loglevel pydhcp:DEBUG
Exit status and errors
pydhcp exits 0 on success (also after Ctrl-C), 1 when a run fails (an address in
use, a file that cannot be written) and 2 when the invocation is wrong (a bad option, a
malformed --listen, relay without --server, a configuration file that cannot be
used). An error is one line on stderr, pydhcp: error: ...; results go to stdout.
pydhcp.cli.main(argv) returns the same status instead of exiting.
Settings and environment variables
Every option can also come from an environment variable, and server, relay, capture
and replay from a settings file. The order is: the option, then the variable, then the
file, then the default. The file is named, never searched for: --config FILE before the
command name, or PYDHCP_CONFIG (an empty path means no file). Its ending says its format (.json, .toml, .yaml,
.yml, .ini, .cfg); duho reads it (pip install "duho[yaml]" for YAML, "duho[config]"
for TOML before Python 3.11), and duho/AGENTS.md in the installed package says what each
format holds. It has one section per command and the keys are the field names:
# server.toml
[server]
listen = "127.0.0.1:6767"
lease_file = "/var/lib/pydhcp/leases.json"
[relay]
server = ["192.0.2.1", "192.0.2.2"]
The same file in YAML:
# server.yaml
server:
listen: "127.0.0.1:6767"
lease_file: /var/lib/pydhcp/leases.json
relay:
server: [192.0.2.1, 192.0.2.2]
pydhcp --config server.toml server
A file that does not parse, a section or key the command does not have (a misspelled
[sever], another command's section) is refused by name with status 2, naming the file and
the position; nothing starts on defaults by mistake.
| Variable | Sets |
|---|---|
PYDHCP_CONFIG |
--config, before the command name |
PYDHCP_TRACEBACK |
1, true, yes, on (any case) print a traceback for an error; 0, false, no, off or empty do not; any other text is an error |
PYDHCP_SERVER_LISTEN, PYDHCP_SERVER_PER_INTERFACE, PYDHCP_SERVER_LEASE_FILE, PYDHCP_SERVER_LENIENT_REPLY_PORTS |
server --listen, --per-interface, --lease-file, --lenient-reply-ports |
PYDHCP_RELAY_LISTEN, PYDHCP_RELAY_SERVER (comma-separated), PYDHCP_RELAY_MAX_HOPS, PYDHCP_RELAY_INSERT_RELAY_AGENT_INFO, PYDHCP_RELAY_CIRCUIT_ID, PYDHCP_RELAY_REMOTE_ID, PYDHCP_RELAY_PER_INTERFACE |
relay --listen, --server, --max-hops, --insert-relay-agent-info, --circuit-id, --remote-id, --per-interface |
PYDHCP_CAPTURE_LISTEN, PYDHCP_CAPTURE_PER_INTERFACE, PYDHCP_CAPTURE_FILTER, PYDHCP_CAPTURE_FORMAT, PYDHCP_CAPTURE_OUTPUT, PYDHCP_CAPTURE_PER_RECORD, PYDHCP_CAPTURE_MAX_FILES, PYDHCP_CAPTURE_DATAGRAMS, PYDHCP_CAPTURE_APPEND, PYDHCP_CAPTURE_INTERFACE, PYDHCP_CAPTURE_READ, PYDHCP_CAPTURE_COUNT, PYDHCP_CAPTURE_DURATION, PYDHCP_CAPTURE_HOOK, PYDHCP_CAPTURE_HOOK_FAIL_FAST, PYDHCP_CAPTURE_HOOK_TIMEOUT |
capture --listen, --per-interface, --filter, --format, --output, --per-record, --max-files, --datagrams, --append, --interface, --read, --count, --duration, --hook, --hook-fail-fast, --hook-timeout |
PYDHCP_REPLAY_INPUT, PYDHCP_REPLAY_TO, PYDHCP_REPLAY_FILTER, PYDHCP_REPLAY_SPEED, PYDHCP_REPLAY_NO_DELAY, PYDHCP_REPLAY_MAX_DELAY, PYDHCP_REPLAY_LIMIT, PYDHCP_REPLAY_SOURCE_PORT, PYDHCP_REPLAY_BROADCAST, PYDHCP_REPLAY_JSON_OUT |
replay --input, --to, --filter, --speed, --no-delay, --max-delay, --limit, --source-port, --broadcast, --json |
PYDHCP_PACKET_INPUT, PYDHCP_PACKET_OUTPUT, PYDHCP_PACKET_FORMAT |
packet --input, --output, --format |
PYDHCP_INTERFACES_FORMAT |
interfaces --format |
A boolean accepts 1, true, yes, on and 0, false, no, off. PYDHCP_MCP=stdio
serves packet and interfaces as tools over standard input and output; the other commands are not tools. A command hook is given PKTCAP_HOOK_CLIENT_ID,
PKTCAP_HOOK_MSG_TYPE, PKTCAP_HOOK_XID and PKTCAP_HOOK_FORMAT.
API overview
| Module | Purpose |
|---|---|
pydhcp.server |
DHCPServer, AsyncDHCPServer: the rules, the hooks you override, the lease policy |
pydhcp.lease |
DHCPLease, the LeaseBackend protocol, InMemoryLeaseBackend, FileLeaseBackend |
pydhcp.client |
DHCPClient, AsyncDHCPClient: the message builders, discover_offer, dora |
pydhcp.relay |
DHCPRelay, AsyncDHCPRelay |
pydhcp.capture |
DHCP for pktcap (the pktcap extra): sniff_dhcp, asniff_dhcp, DHCPLayer, capture_dissector, dissect_dhcp, register_dhcp_dissector, pktcap_plugin |
pydhcp.listener |
DHCPListener, AsyncDHCPListener, the listen forms, the transports, DHCPRequestContext, DHCPMetrics |
pydhcp.packet |
DHCPMessage and its enums; pydhcp.packet.structured holds loads and dumps for the four formats |
pydhcp.options |
DHCPOptions, DHCPOptionCode, every payload codec, the contract for writing one |
pydhcp.exceptions |
DHCPError and its subclasses |
pydhcp.cli |
main(argv) and the command classes (cli extra) |
pydhcp itself exports what the common task needs (the roles and their asyncio twins,
the message and its enums, the option container and its generic codecs, the lease
backends, the exceptions); every other name is imported from the module above that owns
it. The complete reference, with every signature and gotcha and the module each name
lives in, is src/pydhcp/AGENTS.md
and the headers beside the code it lists; they ship inside the package.
Differences from dnsmasq and ISC dhclient
The references are dnsmasq 2.92 for the server and for the relay (--dhcp-relay),
and ISC dhclient 4.4.3-P1 for the client. The suite replays what they answered in 25
recorded cases, recorded on Fedora 44 by tests/conformance/record.py and stored under
tests/conformance/cases/, so a run on any system, with no peer installed, checks them.
Fidelity is to the messages: for the same input and configuration, every header field,
which options are present with each one's octets and order, how many replies there are
(none included) and where each is sent. Not claimed: the text of option 56, timing (the
retransmission schedule is RFC 2131 section 4.1's), logs, lease files, the command line and
which free address a pool gives. BusyBox 1.37 udhcpc is run against the roles as an
interoperability peer, and is not a reference.
It differs on purpose in these ways, each asserted by the suite as this library behaves:
| Difference | What differs |
|---|---|
siaddr-left-zero |
dnsmasq 2.92 writes its own address in 'siaddr' of every reply; this library leaves it 0.0.0.0, because the field names the next bootstrap server and this library supplies none. |
server-option-order |
The server lists its options in another order than dnsmasq 2.92 (this library: message type, subnet mask, broadcast address, router, then lease time, renewal and rebinding times and server identifier); the order carries no meaning. |
offer-broadcast-without-address |
To a client with no address whose broadcast flag is clear, dnsmasq 2.92 unicasts the OFFER to the offered address; this library broadcasts it, because a plain UDP socket cannot deliver to an address the client does not own yet (set UNICAST_TO_UNCONFIGURED_CLIENT for a transport that can). |
reply-port-68 |
A renewal sent from a port other than 68 is answered by dnsmasq 2.92 to that port; this library answers to port 68 (its lenient mode, STRICT_REPLY_PORTS = False, answers to the sender's port). |
offer-held |
While the only address is on offer to one client, dnsmasq 2.92 offers it again to a second client that asks; this library holds it for the first and leaves the second unanswered until the hold ends. |
relayed-reply-hops-zero |
In the reply to a relayed request dnsmasq 2.92 echoes the request's 'hops'; this library sends 0. |
host-name-not-echoed |
dnsmasq 2.92 echoes the client's host name (option 12) in the ACK when the client lists it among the parameters it asks for; this library holds no value for it and does not send one. |
client-broadcast-flag |
ISC dhclient 4.4.3-P1 leaves the broadcast flag clear in DISCOVER, REQUEST and DECLINE; this library's client sets it (broadcast=True, the default), because a plain UDP socket cannot receive a unicast reply before the host holds the address. |
client-option-order |
ISC dhclient 4.4.3-P1 lists the message type, then the server identifier, then the requested address in REQUEST and DECLINE; this library lists the requested address before the server identifier; the order carries no meaning. |
relay-hop-limit |
A request whose 'hops' is 5 is relayed by dnsmasq 2.92 and dropped by this library, whose limit is 4 and is set by max_hops. |
The features each role covers, and which are implemented, are in the conformance page of the documentation.
Development
py -3.14 -m venv .venv/3.14-nt-amd64 # or python3.14 -m venv .venv/3.14-posix-x86_64
.venv/3.14-nt-amd64/Scripts/pip install -e ".[dev,docs]"
.venv/3.14-nt-amd64/Scripts/python -m pytest -q
.venv/3.14-nt-amd64/Scripts/python -m black --check src tests benchmarks examples
.venv/3.14-nt-amd64/Scripts/python -m mkdocs build --strict
python benchmarks/run.py # print a summary; --save writes benchmarks/results/<name>.json
The suite, the skips each platform expects and the traps of running it are in
tests/AGENTS.md; the layout
and the conventions are in AGENTS.md.
Benchmarks are run on demand, never per push: see
benchmarks/README.md
for the schema and for why local timings are not a performance claim. The test workflow
also takes a manual workflow_dispatch run and safe ci-* tags; its run_benchmarks
input or a ci-bench-* tag adds the benchmark job.
Releasing
This project follows Semantic Versioning and keeps a
CHANGELOG.md and a
RELEASENOTES.md. Pushing a tag matching v* triggers the release
workflow. The docs site is rebuilt and published when a release completes and when a
documentation change reaches main, and can be rebuilt on demand from any ref (docs.yml,
run manually).
License
MIT — see LICENSE.
Metadata
Release files for pydhcp 0.8.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pydhcp-0.8.1.tar.gz | 720.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pydhcp-0.8.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.0 MB
Release files / pydhcp-0.8.1.tar.gz
| Download URL | pydhcp-0.8.1.tar.gz |
|---|---|
| Size | 720.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ec664e0c42a98cb27b79ce61ba26ce457f85342272502dba8791c89032cee6ab
|
|
BLAKE2b-256 checksum How to use checksums |
9529454de2a98aeee7d1cdd381492526f2bf439068b4a3b00a325e637b1d7f83
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logRelease files / pydhcp-0.8.1-py3-none-any.whl
| Download URL | pydhcp-0.8.1-py3-none-any.whl |
|---|---|
| Size | 305.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
56b6a244fd24685e2de3a5f38ac03dc204c90a50dbe79f429dd2428b4f51971e
|
|
BLAKE2b-256 checksum How to use checksums |
da163273e090d24067ad82ff108918d3d59bf2d65b0660f26047956a8ff35e86
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency log