Skip to main content

pydhcp

Version Python versions License: MIT Docs CI

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 DHCPOptionCode member and a typed codec for each IANA option, and a JSON form of every message (and YAML, TOML and INI with the pktcap extra). 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 pktcap extra, DHCP as a layer of pktcap's dissection (sniff_dhcp, DHCPLayer, filter keys), and the capture and replay commands: 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.9.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pydhcp 0.9.0
File Size Uploaded
pydhcp-0.9.0.tar.gz 748.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pydhcp 0.9.0
File Interpreter ABI Platform
pydhcp-0.9.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.1 MB

Release files / pydhcp-0.9.0.tar.gz

Download URL pydhcp-0.9.0.tar.gz
Size 748.7 kB
Tags Source
SHA-256 checksum
How to use checksums
0cf1b576ec4ac1ba9b18125a26846c9054c536890cdea8a2447284a02fb6033c
BLAKE2b-256 checksum
How to use checksums
951a8fdf04ec50f4e30196692c62151dd6ef0aca37585e1351ff42d50ae20fc5
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 16, 2026.

Transparency log

Release files / pydhcp-0.9.0-py3-none-any.whl

Download URL pydhcp-0.9.0-py3-none-any.whl
Size 327.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1560d20c4e01bf8031e6dd9eea3d1c435806f0b726e137e547989eb72b0c204e
BLAKE2b-256 checksum
How to use checksums
642691707f1c241f7965f8d9086fdde9fb29d92acc9abdf964f3cdea328324b9
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 16, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.9.0 This release

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page