Skip to content

API Reference

This section provides references for the primary classes in the pydhcp package.

DHCPMessage

pydhcp.packet.DHCPMessage dataclass

Bases: _MessageDisplay

A DHCPv4 message (RFC 2131 s2): the fixed BOOTP header, then options.

The fields, decode/encode, to_mapping/from_mapping, client_id and the display helpers are inherited from the layers in pydhcp.packet; the dataclass __init__, __repr__ and __eq__ are the base layer's, and report this class.

Source code in src/pydhcp/packet/_message.py
12
13
14
15
16
17
18
19
class DHCPMessage(_MessageDisplay):
    """A DHCPv4 message (RFC 2131 s2): the fixed BOOTP header, then options.

    The fields, `decode`/`encode`, `to_mapping`/`from_mapping`, `client_id` and
    the display helpers are inherited from the layers in `pydhcp.packet`; the
    dataclass `__init__`, `__repr__` and `__eq__` are the base layer's, and
    report this class.
    """

The first four octets of the 'options' field of the DHCP message decimal values: 99, 130, 83 and 99

Smallest DHCP message every implementation must be able to handle: 548.

RFC 2131 s2: 576 octets is the minimum IP datagram an IP host must be prepared to accept, and a DHCP client "MUST be prepared to receive DHCP messages with an 'options' field of at least length 312 octets". Those are the same statement -- the arithmetic closes exactly:

576 - 20 (IPv4) - 8 (UDP)            = 548   this message
548 - 236 (fixed header, op..file)   = 312   the options field

So this is a floor on capability, not on any particular packet, which is why nothing enforces it: decode() deliberately accepts shorter messages (241 octets and up), and plenty of real senders emit them. The floor pydhcp applies to what it sends is a different and smaller number, BOOTP_MIN_PACKET_SIZE (300).

broadcast property

Whether the broadcast bit of flags is set (RFC 2131 s2).

The reserved bits are carried in flags as they were received.

chaddr = chaddr instance-attribute

Client hardware address.

ciaddr = _typed('ciaddr', _ipaddress.IPv4Address, ciaddr) instance-attribute

Client IP address; only filled in if client is in BOUND, RENEW or REBINDING state and can respond to ARP requests.

file = file instance-attribute

Boot file name, null terminated string; "generic" name or null in DHCPDISCOVER, fully qualified directory-path name in DHCPOFFER.

flags = _typed('flags', _enum.DHCPFlags, flags) instance-attribute

Only use for the BROADCAST flag in clients

giaddr = _typed('giaddr', _ipaddress.IPv4Address, giaddr) instance-attribute

Relay agent IP address, used in booting via a relay agent.

hlen = hlen instance-attribute

Hardware address length

hops = hops instance-attribute

Client sets to zero, optionally used by relay agents when booting via a relay agent.

htype = _typed('htype', _enum.HardwareAddressType, htype) instance-attribute

Hardware address type, see ARP section in "Assigned Numbers" RFC

message_type property

The DHCP message type (option 53), or None when there is none to read.

None for a message with no option 53 and for one whose payload is not a message type this package knows (the wrong length, or an unassigned number); the option's octets are still there to forward.

op = _typed('op', _enum.DHCPOpcode, op) instance-attribute

Message op code / message type

options = DHCPOptions() if options is None else options instance-attribute

Optional parameters field.

secs = secs if isinstance(secs, _dt.timedelta) else _dt.timedelta(seconds=_typed('secs', int, secs)) instance-attribute

Filled in by client, seconds elapsed since client began address acquisition or renewal process.

siaddr = _typed('siaddr', _ipaddress.IPv4Address, siaddr) instance-attribute

IP address of next server to use in bootstrap; returned in DHCPOFFER, DHCPACK by server.

sname = sname instance-attribute

Optional server host name, null terminated string.

xid = xid instance-attribute

Transaction ID, a random number chosen by the client, used by the client and server to associate messages and responses between a client and a server.

yiaddr = _typed('yiaddr', _ipaddress.IPv4Address, yiaddr) instance-attribute

'your' (client) IP address.

__contains__(__key)

Whether the message carries option __key (a code or DHCPOptionCode).

Source code in src/pydhcp/packet/_display.py
122
123
124
def __contains__(self, __key: object) -> bool:
    """Whether the message carries option `__key` (a code or `DHCPOptionCode`)."""
    return self.options.__contains__(__key)

__init__(op, *, htype=_enum.HardwareAddressType.ETHERNET, hlen=None, hops=0, xid=0, secs=_dt.timedelta(0), flags=_enum.DHCPFlags.UNICAST, ciaddr=_const.UNSPECIFIED_ADDRESS, yiaddr=_const.UNSPECIFIED_ADDRESS, siaddr=_const.UNSPECIFIED_ADDRESS, giaddr=_const.UNSPECIFIED_ADDRESS, chaddr=b'', sname='', file='', options=None)

A message with every header field but op defaulted.

hlen is the length of chaddr when left out, and must equal it when given: the header's hardware address length is the length of the address in the same header, so a message that says otherwise is refused here and by encode. (decode reads whatever hlen the sender wrote, and the address is that many octets, so a received message always agrees.) A fresh option bag is made when options is not given. op, htype and flags may be given as their numbers, the addresses as anything IPv4Address takes and secs as seconds: each is stored as its type, so a method reading op.name or secs.total_seconds() later cannot fail.

Source code in src/pydhcp/packet/_fields.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def __init__(
    self,
    op: _enum.DHCPOpcode,
    *,
    htype: _enum.HardwareAddressType = _enum.HardwareAddressType.ETHERNET,
    hlen: _ty.Optional[int] = None,
    hops: int = 0,
    xid: int = 0,
    secs: _dt.timedelta = _dt.timedelta(0),
    flags: _enum.DHCPFlags = _enum.DHCPFlags.UNICAST,
    ciaddr: _ipaddress.IPv4Address = _const.UNSPECIFIED_ADDRESS,
    yiaddr: _ipaddress.IPv4Address = _const.UNSPECIFIED_ADDRESS,
    siaddr: _ipaddress.IPv4Address = _const.UNSPECIFIED_ADDRESS,
    giaddr: _ipaddress.IPv4Address = _const.UNSPECIFIED_ADDRESS,
    chaddr: bytes = b"",
    sname: str = "",
    file: str = "",
    options: _ty.Optional[DHCPOptions] = None,
) -> None:
    """A message with every header field but `op` defaulted.

    `hlen` is the length of `chaddr` when left out, and must equal it when
    given: the header's hardware address length is the length of the address
    in the same header, so a message that says otherwise is refused here and
    by `encode`. (`decode` reads whatever `hlen` the sender wrote, and the
    address is that many octets, so a received message always agrees.) A
    fresh option bag is made when `options` is not given. `op`, `htype` and
    `flags` may be given as their numbers, the addresses as anything
    `IPv4Address` takes and `secs` as seconds: each is stored as its type, so
    a method reading `op.name` or `secs.total_seconds()` later cannot fail.
    """
    if hlen is None:
        hlen = len(chaddr)
    elif hlen != len(chaddr):
        raise DHCPValueError(
            f"hlen={hlen} is not the length of chaddr ({len(chaddr)} octets)"
        )
    self.op = _typed("op", _enum.DHCPOpcode, op)
    self.htype = _typed("htype", _enum.HardwareAddressType, htype)
    self.hlen = hlen
    self.hops = hops
    self.xid = xid
    self.secs = (
        secs
        if isinstance(secs, _dt.timedelta)
        else _dt.timedelta(seconds=_typed("secs", int, secs))
    )
    self.flags = _typed("flags", _enum.DHCPFlags, flags)
    self.ciaddr = _typed("ciaddr", _ipaddress.IPv4Address, ciaddr)
    self.yiaddr = _typed("yiaddr", _ipaddress.IPv4Address, yiaddr)
    self.siaddr = _typed("siaddr", _ipaddress.IPv4Address, siaddr)
    self.giaddr = _typed("giaddr", _ipaddress.IPv4Address, giaddr)
    self.chaddr = chaddr
    self.sname = sname
    self.file = file
    self.options = DHCPOptions() if options is None else options

decode(data) classmethod

Parse one wire message.

Liberal on receive, deliberately: there is no minimum size (a 241-octet message -- fixed header, magic cookie, END -- decodes), an htype with no IANA name is kept as an unnamed member rather than rewritten, hlen = 0 is accepted (RFC 4390 requires it for IPoIB), and sname/file text that is not valid UTF-8 is preserved octet for octet. A relay must forward what it received. RFC 2132 s9.3 overload is honoured: options packed into file and sname are read back and the fields take their literal values from options 67/66. A missing END marker is accepted, in the options field and in an overloaded field alike (RFC 2131 s4.1 requires one; a message without it keeps what arrived). Constructs cls, so a subclass decodes to itself.

Raises:

Type Description
DHCPDecodeError

shorter than the fixed header or magic cookie, a wrong magic cookie, op that is neither request nor reply, or hlen > 16. A ValueError too.

Source code in src/pydhcp/packet/_decode.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
@classmethod
def decode(
    cls: "type[_Decoded]", data: _ty.Union[bytes, bytearray, memoryview]
) -> _Decoded:
    """Parse one wire message.

    Liberal on receive, deliberately: there is no minimum size (a 241-octet
    message -- fixed header, magic cookie, END -- decodes), an `htype` with no
    IANA name is kept as an unnamed member rather than rewritten, `hlen = 0` is
    accepted (RFC 4390 requires it for IPoIB), and `sname`/`file` text that is
    not valid UTF-8 is preserved octet for octet. A relay must forward what it
    received. RFC 2132 s9.3 overload is honoured: options packed into `file`
    and `sname` are read back and the fields take their literal values from
    options 67/66. A missing END marker is accepted, in the options field and
    in an overloaded field alike (RFC 2131 s4.1 requires one; a message
    without it keeps what arrived). Constructs `cls`, so
    a subclass decodes to itself.

    Raises:
        DHCPDecodeError: shorter than the fixed header or magic cookie, a
            wrong magic cookie, `op` that is neither request nor reply, or
            `hlen > 16`. A `ValueError` too.
    """
    if not isinstance(data, memoryview):
        data = memoryview(data)
    if len(data) < _FIXED_HEADER_SIZE:
        raise DHCPDecodeError(
            f"Packet is too short for DHCP fixed header: got {len(data)} bytes, need at least {_FIXED_HEADER_SIZE}"
        )
    if len(data) < _MAGIC_COOKIE_END:
        raise DHCPDecodeError(
            f"Packet is too short for DHCP magic cookie at offset 236: got {len(data)} bytes, need at least {_MAGIC_COOKIE_END}"
        )
    (
        op,
        htype,
        hlen,
        hops,
        xid,
        secs,
        flags,
        ciaddr,
        yiaddr,
        siaddr,
        giaddr,
    ) = _HEADER_STRUCT.unpack_from(data, 0)
    if hlen > 16:
        raise DHCPDecodeError(
            f"Hardware address length {hlen} exceeds maximum of 16"
        )
    try:
        op = _enum.DHCPOpcode(op)
    except ValueError as exc:
        raise DHCPDecodeError(str(exc)) from exc
    # Never rewritten: an unnamed type keeps its octet, so a relay forwards
    # the htype it received. The warning went too -- it fired once per
    # packet, which let one client flood the log.
    htype = _enum.HardwareAddressType(htype)
    secs = _dt.timedelta(seconds=secs)
    # The reserved bits are kept: a relay forwards what it received.
    flags = _enum.DHCPFlags(flags)
    ciaddr = _ipaddress.IPv4Address(ciaddr)
    yiaddr = _ipaddress.IPv4Address(yiaddr)
    siaddr = _ipaddress.IPv4Address(siaddr)
    giaddr = _ipaddress.IPv4Address(giaddr)
    chaddr = data[28 : 28 + hlen].tobytes()
    sname_data = data[44:108]
    file_data = data[108:236]

    if cls.MAGIC_COOKIE != data[236:240]:
        raise DHCPDecodeError(
            f"Invalid magic cookie at offset 236: expected {cls.MAGIC_COOKIE.hex()}, got {data[236:240].hex()}"
        )

    options = DHCPOptions()
    options._decode_into(data[240:], base_offset=240)

    overload = options.get(
        DHCPOptionCode.OPTION_OVERLOAD,
        default=_type.OptionOverload.NONE,
        decode=_type.OptionOverload,
    )

    # rfc3396 order
    if overload is not None and bool(
        overload.value & _type.OptionOverload.FILE.value
    ):
        options._decode_into(file_data, base_offset=108)
        file_raw: _ty.Optional[memoryview] = None
    else:
        file_raw = file_data

    if overload is not None and bool(
        overload.value & _type.OptionOverload.SNAME.value
    ):
        options._decode_into(sname_data, base_offset=44)
        sname_raw: _ty.Optional[memoryview] = None
    else:
        sname_raw = sname_data

    # Option 52 is *framing*, like PAD and END, and `decode` has just
    # consumed it: the options it pointed at have been moved out of
    # sname/file, and those fields hold their literal values. Keeping it
    # would leave the decoded message asserting "my sname/file hold
    # options" and break round-tripping: a message encoded at a size that
    # overloads would come back carrying an option its original never had.
    # `encode()` deletes it too, so a relay forwarding a decoded reply never
    # tells the receiver to parse sname/file as options.
    if int(DHCPOptionCode.OPTION_OVERLOAD) in options:
        del options[int(DHCPOptionCode.OPTION_OVERLOAD)]

    sname_str: str = ""
    if sname_raw is not None:
        sname_str = _decode_bootp_field(sname_raw, "sname")
    else:
        # Same reasoning as option 52 above, one level down. `encode` moves
        # `sname` into option 66 when it overloads the field, because the
        # field itself is carrying option fragments. Reading it back into
        # `sname` and *leaving* the option behind hands the caller the name
        # twice, under two spellings -- and the decoded message then no
        # longer matches the one that was encoded. Consume it, so the pair
        # stays inverse.
        tftp_val = options.get(
            DHCPOptionCode.TFTP_SERVER, default="", decode=_type.String
        )
        if tftp_val is not None:
            sname_str = str(tftp_val)
        if int(DHCPOptionCode.TFTP_SERVER) in options:
            del options[int(DHCPOptionCode.TFTP_SERVER)]

    file_str: str = ""
    if file_raw is not None:
        file_str = _decode_bootp_field(file_raw, "file")
    else:
        # As with option 66 above: option 67 is where `encode` parked
        # `file` to free the field for option fragments, so decode takes
        # it back out rather than reporting it in both places.
        bootfile_val = options.get(
            DHCPOptionCode.BOOTFILE_NAME, default="", decode=_type.String
        )
        if bootfile_val is not None:
            file_str = str(bootfile_val)
        if int(DHCPOptionCode.BOOTFILE_NAME) in options:
            del options[int(DHCPOptionCode.BOOTFILE_NAME)]

    # opts -> file -> sname

    # `cls`, not `DHCPMessage`: a subclass decodes to itself, as
    # `from_mapping` does.
    return cls(
        op,
        htype=htype,
        hlen=hlen,
        hops=hops,
        xid=xid,
        secs=secs,
        flags=flags,
        ciaddr=ciaddr,
        yiaddr=yiaddr,
        siaddr=siaddr,
        giaddr=giaddr,
        chaddr=chaddr,
        sname=sname_str,
        file=file_str,
        options=options,
    )

encode(max_packetsize=_const.DHCP_MIN_LEGAL_PACKET_SIZE)

Serialize the message, fitting it into max_packetsize octets.

max_packetsize is the whole IP datagram, not the DHCP message, so the options field gets what is left after a fixed overhead:

max_options_field_size = max_packetsize - 268
268 = 20 (IPv4) + 8 (UDP) + 236 (fixed header) + 4 (magic cookie)

The default, DHCP_MIN_LEGAL_PACKET_SIZE (576), is RFC 2131 s2's minimum datagram and leaves 308 octets for options. 269 is the floor -- 268 of overhead plus the one octet the END marker needs -- and anything below it raises ValueError, including an explicit 0: a caller passing 0 has a wrong belief worth reporting, and encoding to another size instead would hide it. 576 is not a lower bound: RFC 2132 s9.10's 576 minimum constrains what a client may advertise in option 57, and encode(280) is a legitimate call.

The options are written in one pass when they fit the options field. When they do not, sname and file carry some (RFC 2131 s4.1, RFC 3396; see _layout): option 53 and option 52 stay first in the options field, an occupied name moves into option 66 or 67, and a name too long for its field travels that way too. A message needs at least 275 for any overload, since the options field then holds options 53 and 52.

The result is padded up to BOOTP_MIN_PACKET_SIZE (300) with PAD octets after END, never past max_packetsize less the 28 octets of IPv4 and UDP header.

Raises:

Type Description
ValueError

max_packetsize is below 269, or a header field is out of range, or chaddr does not fit its fixed field.

OverflowError

the options, with the names that move into options 66 and 67, do not fit the three fields. The message names the option that did not fit and how many octets short the room is.

DHCPValueError

a name cannot travel as an option because option 66 or 67 already holds other octets.

Source code in src/pydhcp/packet/_encode.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def encode(self, max_packetsize: int = _const.DHCP_MIN_LEGAL_PACKET_SIZE) -> bytes:
    """Serialize the message, fitting it into `max_packetsize` octets.

    `max_packetsize` is the whole **IP datagram**, not the DHCP message, so
    the options field gets what is left after a fixed overhead:

        max_options_field_size = max_packetsize - 268
        268 = 20 (IPv4) + 8 (UDP) + 236 (fixed header) + 4 (magic cookie)

    The default, `DHCP_MIN_LEGAL_PACKET_SIZE` (576), is RFC 2131 s2's
    minimum datagram and leaves 308 octets for options. **269** is the floor
    -- 268 of overhead plus the one octet the END marker needs -- and
    anything below it raises `ValueError`, **including an explicit 0**: a
    caller passing 0 has a wrong belief worth reporting, and encoding to
    another size instead would hide it. 576 is *not* a lower bound: RFC 2132
    s9.10's 576 minimum constrains what a client may advertise in option 57,
    and `encode(280)` is a legitimate call.

    The options are written in one pass when they fit the options field. When
    they do not, `sname` and `file` carry some (RFC 2131 s4.1, RFC 3396; see
    `_layout`): option 53 and option 52 stay first in the options field, an
    occupied name moves into option 66 or 67, and a name too long for its
    field travels that way too. A message needs at least 275 for any
    overload, since the options field then holds options 53 and 52.

    The result is padded up to `BOOTP_MIN_PACKET_SIZE` (300) with PAD octets
    after END, never past `max_packetsize` less the 28 octets of IPv4 and UDP
    header.

    Raises:
        ValueError: `max_packetsize` is below 269, or a header field is out
            of range, or `chaddr` does not fit its fixed field.
        OverflowError: the options, with the names that move into options 66
            and 67, do not fit the three fields. The message names the
            option that did not fit and how many octets short the room is.
        DHCPValueError: a name cannot travel as an option because option 66
            or 67 already holds other octets.
    """
    max_packetsize = int(max_packetsize)
    if max_packetsize < _MIN_ENCODE_PACKET_SIZE:
        raise ValueError(
            f"max_packetsize={max_packetsize} cannot hold a DHCP packet: "
            f"the minimum is {_MIN_ENCODE_PACKET_SIZE}, being "
            f"{_const.UDP_MIN_PACKET_SIZE} octets of IPv4 and UDP header, "
            f"{_FIXED_HEADER_SIZE} of fixed header, "
            f"{_MAGIC_COOKIE_END - _FIXED_HEADER_SIZE} of magic cookie and "
            "1 for the END marker"
        )
    options_field, sname_bytes, file_bytes = _layout.pack(
        self.options._options,
        _nvt.encode(self.sname),
        _nvt.encode(self.file),
        max_packetsize - _ENCODE_FIXED_OVERHEAD,
        max_packetsize,
        self.options._describe,
    )

    # Validate what is actually packed, not what the caller set: encode()
    # legitimately *moves* a long sname/file out into options 66 and 67 when
    # it overloads, and `sname_bytes`/`file_bytes` are then the option
    # fragments `partial_encode` produced, already bounded by the field
    # width it was given.
    _check_bootp_field("sname", sname_bytes, _SNAME_FIELD_SIZE)
    _check_bootp_field("file", file_bytes, _FILE_FIELD_SIZE)
    _check_bootp_field("chaddr", self.chaddr, _CHADDR_FIELD_SIZE)
    if self.hlen != len(self.chaddr):
        raise DHCPValueError(
            f"hlen={self.hlen} is not the length of chaddr "
            f"({len(self.chaddr)} octets)"
        )

    data = bytearray(28)
    try:
        _HEADER_STRUCT.pack_into(
            data,
            0,
            int(self.op),
            int(self.htype),
            # 16, not 255: `chaddr` is a 16-octet field, so a larger `hlen` is a
            # lie the receiver acts on -- it reads past chaddr into `sname`.
            # `decode` already rejects it with the same bound, and the two
            # disagreed: hlen=17 encoded happily and would not decode back.
            _check_header_int("hlen", self.hlen, _CHADDR_FIELD_SIZE),
            _check_header_int("hops", self.hops, 0xFF),
            _check_header_int("xid", self.xid, 0xFFFFFFFF),
            # `secs` is clamped rather than rejected: it is an elapsed time the
            # client reports, an overlong one is not a caller error, and RFC
            # 2131 s4.4.1 only requires it to be monotonic within an exchange.
            min(0xFFFF, max(0, int(self.secs.total_seconds()))),
            int(self.flags),
            int(self.ciaddr),
            int(self.yiaddr),
            int(self.siaddr),
            int(self.giaddr),
        )
    except DHCPValueError:
        raise  # `hlen`, `hops` or `xid`, refused by name inside the call
    except (AttributeError, TypeError, ValueError, _struct.error) as error:
        raise _unsendable(self, error) from None
    # No trailing `[:width]` slice: it was the silent truncation, and
    # `_check_bootp_field` above has already refused anything longer.
    data.extend(self.chaddr.ljust(_CHADDR_FIELD_SIZE, b"\x00"))
    data.extend(sname_bytes.ljust(_SNAME_FIELD_SIZE, b"\x00"))
    data.extend(file_bytes.ljust(_FILE_FIELD_SIZE, b"\x00"))
    data.extend(self.MAGIC_COOKIE)
    data.extend(options_field)

    # Pad to the minimal BOOTP message. These are PAD octets after END, which
    # every parser skips, but their absence is not inert: RFC 1542 s2.1 has a
    # relay agent verify the datagram could hold 300 octets and silently
    # discard it otherwise. Every message pydhcp emits with few options -- all
    # five client builders, and the server's NAK -- is under that unpadded
    # (measured at 244-250 octets), while ISC dhclient was measured padding
    # to exactly 300 on the wire.
    floor = min(
        _const.BOOTP_MIN_PACKET_SIZE, max_packetsize - _const.UDP_MIN_PACKET_SIZE
    )
    if len(data) < floor:
        data.extend(bytes(floor - len(data)))
    return bytes(data)

from_hex(text) classmethod

Decode a message written as hexadecimal text.

Spaces, tabs, line ends and colons between the digits are ignored, so 01:01:06:00 and a hexdump's lines read the same. ValueError for anything else that is not a hexadecimal digit or for an odd number of digits; DHCPDecodeError when the octets are not a message.

Source code in src/pydhcp/packet/_mapping.py
154
155
156
157
158
159
160
161
162
163
@classmethod
def from_hex(cls: "type[_Mapped]", text: str) -> _Mapped:
    """Decode a message written as hexadecimal text.

    Spaces, tabs, line ends and colons between the digits are ignored, so
    `01:01:06:00` and a hexdump's lines read the same. `ValueError` for
    anything else that is not a hexadecimal digit or for an odd number of
    digits; `DHCPDecodeError` when the octets are not a message.
    """
    return cls.decode(bytes.fromhex(strip_hex_text(text)))

from_mapping(data, *, codemap=None) classmethod

Build a message from a mapping in the shape to_mapping produces.

Only op is required: a header field left out (or options) takes the constructor's default -- 0.0.0.0 for an address, 0 for a number, an empty chaddr, hlen its length. options is read by DHCPOptions.from_mapping with codemap, so to_mapping -> from_mapping round-trips exactly when codemap is the one the options were named with (the default is DHCPOptionCode). sname and file take text, null (empty), bytes or the hex form. A MAC written unquoted in YAML (read as a sexagesimal integer) is refused with a message naming the cause. Constructs cls.

Raises:

Type Description
TypeError

options is not a mapping, or a field or option value has the wrong type.

ValueError

op is missing, or a field or option value cannot be coerced.

Source code in src/pydhcp/packet/_mapping.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
@classmethod
def from_mapping(
    cls: "type[_Mapped]",
    data: _ty.Mapping[str, _ty.Any],
    *,
    codemap: _ty.Optional[type[OptionCode]] = None,
) -> _Mapped:
    """Build a message from a mapping in the shape `to_mapping` produces.

    Only `op` is required: a header field left out (or `options`) takes the
    constructor's default -- `0.0.0.0` for an address, 0 for a number, an
    empty `chaddr`, `hlen` its length. `options` is read by
    `DHCPOptions.from_mapping` with `codemap`, so `to_mapping` ->
    `from_mapping` round-trips exactly when `codemap` is the one the options
    were named with (the default is `DHCPOptionCode`). `sname` and `file`
    take text, null (empty), `bytes` or the hex form. A MAC written unquoted
    in YAML (read as a sexagesimal integer) is refused with a message naming
    the cause. Constructs `cls`.

    Raises:
        TypeError: `options` is not a mapping, or a field or option value has
            the wrong type.
        ValueError: `op` is missing, or a field or option value cannot be
            coerced.
    """
    if "op" not in data:
        raise ValueError("the message has no 'op' field")
    fields = {
        name: read(data[name]) for name, read in _HEADER.items() if name in data
    }
    return cls(
        coerce_enum_value(_enum.DHCPOpcode, data["op"]),
        options=DHCPOptions.from_mapping(data.get("options"), codemap=codemap),
        **fields,
    )

from_text(text, format, *, codemap=None) classmethod

Build a message from a document to_text (or the capture command) wrote.

pydhcp.packet.structured.loads then from_mapping, with the same codemap and the same errors.

Source code in src/pydhcp/packet/_mapping.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@classmethod
def from_text(
    cls: "type[_Mapped]",
    text: str,
    format: str,
    *,
    codemap: _ty.Optional[type[OptionCode]] = None,
) -> _Mapped:
    """Build a message from a document `to_text` (or the capture command) wrote.

    `pydhcp.packet.structured.loads` then `from_mapping`, with the same
    `codemap` and the same errors.
    """
    return cls.from_mapping(_structured.loads(text, format), codemap=codemap)

get_client_id(func=None)

Stable identity for this client, used to key leases.

Option 61 when present, else the hardware type and address. Raises NoClientIdentityError when the message carries neither: hlen may legally be 0 (RFC 4390 requires exactly that for IPoIB, which supplies option 61 instead): falling back to the hardware-type octet alone would give one identifier, "01", to every such client, so two of them would take over each other's lease and a RELEASE from either would free both.

Source code in src/pydhcp/packet/_display.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def get_client_id(
    self, func: _ty.Optional[_ty.Callable[["DHCPMessage"], bytearray]] = None
) -> str:
    """Stable identity for this client, used to key leases.

    Option 61 when present, else the hardware type and address. Raises
    `NoClientIdentityError` when the message carries neither: `hlen` may legally
    be 0 (RFC 4390 requires exactly that for IPoIB, which supplies option 61
    instead): falling back to the hardware-type octet alone would give one
    identifier, `"01"`, to every such client, so two of them would take over
    each other's lease and a RELEASE from either would free both.
    """
    cid = self.options.get(DHCPOptionCode.CLIENT_IDENTIFIER, decode=False)
    if not cid:
        if func:
            # The layers are private and only ever composed into
            # DHCPMessage, so every instance reaching here is one.
            cid = func(_ty.cast("DHCPMessage", self))
        if not cid:
            if not self.chaddr:
                raise NoClientIdentityError(
                    "message has neither a client identifier (option 61) nor "
                    f"a hardware address (hlen=0, htype={self.htype.label()})"
                )
            cid = bytearray([self.htype.value])
            cid.extend(self.chaddr)
    return cid.hex(":").upper()

log(src, dst, level)

Log the packet at level.

Never raises and never does work the level does not call for: callers on the receive and send paths invoke this before handling or sending, so a failure here would silently cost a packet its handler or its reply.

Source code in src/pydhcp/packet/_display.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def log(self, src: _ty.Any, dst: _ty.Any, level: int) -> None:
    """Log the packet at `level`.

    Never raises and never does work the level does not call for: callers on the
    receive and send paths invoke this before handling or sending, so a failure
    here would silently cost a packet its handler or its reply.
    """
    if not LOGGER.isEnabledFor(level):
        return
    try:
        header = (
            f"{'#' * 10} {self.op.name} XID={self.xid:08X} "
            f"Src: {src} Dst: {dst} {'#' * 10}"
        )
        LOGGER.log(level, f"\n{header}\n{self.summary()}\n{'#' * len(header)}")
    except Exception:  # pragma: no cover - defensive, summary() is already tolerant
        LOGGER.log(level, "Could not format packet for logging", exc_info=True)

log_str(src, dst)

summary() under a one-line header naming the op, XID, source and destination.

Source code in src/pydhcp/packet/_display.py
115
116
117
118
119
120
def log_str(self, src: _ty.Any, dst: _ty.Any) -> str:
    """`summary()` under a one-line header naming the op, XID, source and destination."""
    return (
        f"{self.op.name} XID={self.xid:08X} Src: {src} Dst: {dst}\n"
        f"{self.summary()}"
    )

summary(codemap=None)

A human-readable multi-line summary: header fields, then each option.

What log/log_str and the CLI's --format summary print. Text fields go through pydhcp._nvt.display, so it is always safe for a terminal. codemap names the options; it defaults to the message's own.

Source code in src/pydhcp/packet/_display.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def summary(self, codemap: _ty.Optional[type[OptionCode]] = None) -> str:
    """A human-readable multi-line summary: header fields, then each option.

    What `log`/`log_str` and the CLI's ``--format summary`` print. Text fields go
    through `pydhcp._nvt.display`, so it is always safe for a terminal. `codemap`
    names the options; it defaults to the message's own.
    """
    lines = []
    for name, value in [
        ("OP", self.op.name),
        ("Time Since Boot", str(self.secs)),
        ("Hops", str(self.hops)),
        ("Transaction ID", str(self.xid)),
        ("Flags", self.flags.label()),
        ("Client Current Address", str(self.ciaddr)),
        ("Allocated Address", str(self.yiaddr)),
        ("Gateway Address", str(self.giaddr)),
        (
            "Hardware Address",
            f"{self.htype.label()}({self.htype.format_address(self.chaddr)})",
        ),
        ("Next Server (siaddr)", str(self.siaddr)),
        ("Server Host Name", _nvt.display(self.sname)),
        ("Bootfile", _nvt.display(self.file)),
    ]:
        lines.append(f"{name: <40}: {value}")
    lines.append("OPTIONS:")
    _codemap = codemap or self.options._codemap
    for _code, _raw in self.options._options.items():
        # Render per option, never as a batch: an unregistered code (93 of 254 are
        # not enum members) or one malformed payload must not cost the whole dump.
        # Same fallback `to_mapping` uses.
        try:
            code = _codemap.from_code(_code)
            opt_val: _ty.Any = code.get_type().unpack(_raw)
        except Exception:
            code = _code  # type: ignore[assignment]
            opt_val = _type.Bytes(_raw)
        decoded_str = _option_text(opt_val)
        decoded_lines = decoded_str.splitlines()
        SPACE = " " * 42
        if decoded_lines:
            first = _tw.fill(
                decoded_lines[0],
                width=100,
                initial_indent="",
                subsequent_indent=SPACE,
            )
        else:
            first = ""
        lines.append(f"  {repr(code): <38}: {first}")
        for line in decoded_lines[1:]:
            lines.append(
                _tw.fill(
                    line, width=100, initial_indent=SPACE, subsequent_indent=SPACE
                )
            )

    return "\n".join(lines)

to_mapping()

The message as a plain dict: header fields, then options.

The basis of the JSON/YAML/TOML/INI helpers, and byte-exact: options is DHCPOptions.to_mapping's form, and sname and file are text, or {HEX_VALUE_KEY: "..."} when they are not UTF-8. Integers are plain int.

Source code in src/pydhcp/packet/_mapping.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
def to_mapping(self) -> dict[str, _ty.Any]:
    """The message as a plain dict: header fields, then `options`.

    The basis of the JSON/YAML/TOML/INI helpers, and byte-exact: `options`
    is `DHCPOptions.to_mapping`'s form, and `sname` and `file` are text, or
    ``{HEX_VALUE_KEY: "..."}`` when they are not UTF-8. Integers are plain `int`.
    """
    return {
        "op": self.op.name,
        # label(), not .name: an unnamed hardware type has no .name at all,
        # so a mapping would carry a null where a string is expected and
        # from_mapping() could not read it back.
        "htype": self.htype.label(),
        "hlen": self.hlen,
        "hops": self.hops,
        "xid": self.xid,
        "secs": int(self.secs.total_seconds()),
        # The name when the field is one of the two assigned values, the
        # number when a reserved bit is set (a name cannot carry it).
        "flags": (
            self.flags.label()
            if int(self.flags) in (0, 0x8000)
            else int(self.flags)
        ),
        "ciaddr": str(self.ciaddr),
        "yiaddr": str(self.yiaddr),
        "siaddr": str(self.siaddr),
        "giaddr": str(self.giaddr),
        "chaddr": self.chaddr.hex(":").upper(),
        # Text a strict serializer can write, or the octets as hex when the
        # name is not UTF-8.
        "sname": _text_value(self.sname),
        "file": _text_value(self.file),
        "options": self.options.to_mapping(),
    }

to_text(format)

The message as a document in format: "json", "yaml", "toml" or "ini".

to_mapping written out by pydhcp.packet.structured.dumps; a format that is not one of the four raises ValueError.

Source code in src/pydhcp/packet/_mapping.py
131
132
133
134
135
136
137
def to_text(self, format: str) -> str:
    """The message as a document in `format`: ``"json"``, ``"yaml"``, ``"toml"`` or ``"ini"``.

    `to_mapping` written out by `pydhcp.packet.structured.dumps`; a format
    that is not one of the four raises `ValueError`.
    """
    return _structured.dumps(self.to_mapping(), format)

DHCPOptions

pydhcp.options.DHCPOptions

Bases: MutableMapping[int, bytearray]

The option bag: a mutable mapping of option code to raw payload.

Two members deliberately do not mean what MutableMapping says they mean, and both type: ignore[override]s below mark exactly that:

  • get() decodes; [] does not. options[53] is bytearray(b'\x05'), options.get(53) is DHCPMessageType.DHCPACK. Everything the ABC supplies -- values(), pop(), setdefault(), popitem(), and dict(options) -- routes through __getitem__, so all of it yields raw bytearrays like [], not decoded values like get(). Reach for get(code, decode=False) when you want the bytes and you want to say so.
  • items() returns a list, not a view. The decoded form has to build DHCPOption pairs, so there is nothing to keep a live view of; only items(decoded=False) is the ABC's ItemsView.

This asymmetry is the API, not drift: get(..., decode=...) is the documented surface every caller uses, and making it return raw bytes to satisfy the ABC would trade a real API for a formal one. It is pinned by tests/test_options.py::test_get_decodes_and_getitem_does_not, which fails if anyone "fixes" it the other way.

Equality is on the raw payloads. Two bags are equal when they hold the same codes with the same octets, in any order (a mapping has none to keep), whatever their code maps; nothing is decoded, so comparing never raises on a malformed payload and octets that read alike are still different. A bag is unequal to anything that is not a DHCPOptions, a dict of its own items included.

Source code in src/pydhcp/options/__init__.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
class DHCPOptions(_ty.MutableMapping[int, bytearray]):
    """The option bag: a mutable mapping of option code to **raw** payload.

    Two members deliberately do not mean what `MutableMapping` says they mean,
    and both `type: ignore[override]`s below mark exactly that:

    * **`get()` decodes; `[]` does not.** `options[53]` is
      `bytearray(b'\\x05')`, `options.get(53)` is `DHCPMessageType.DHCPACK`.
      Everything the ABC supplies -- `values()`, `pop()`, `setdefault()`,
      `popitem()`, and `dict(options)` -- routes through
      `__getitem__`, so all of it yields raw `bytearray`s like `[]`, not
      decoded values like `get()`. Reach for `get(code, decode=False)` when you
      want the bytes and you want to say so.
    * **`items()` returns a `list`, not a view.** The decoded form has to build
      `DHCPOption` pairs, so there is nothing to keep a live view of; only
      `items(decoded=False)` is the ABC's `ItemsView`.

    This asymmetry is the API, not drift: `get(..., decode=...)` is the
    documented surface every caller uses, and making it return raw bytes to
    satisfy the ABC would trade a real API for a formal one. It is pinned by
    `tests/test_options.py::test_get_decodes_and_getitem_does_not`, which fails
    if anyone "fixes" it the other way.

    **Equality is on the raw payloads.** Two bags are equal when they hold the
    same codes with the same octets, in any order (a mapping has none to keep),
    whatever their code maps; nothing is decoded, so comparing never raises on
    a malformed payload and octets that read alike are still different. A bag
    is unequal to anything that is not a `DHCPOptions`, a `dict` of its own
    items included.
    """

    def __init__(
        self, codemap: _ty.Optional[_builtins.type[OptionCode]] = None
    ) -> None:
        if codemap is None:
            codemap = DHCPOptionCode
        self._codemap = codemap
        self._options: _ty.OrderedDict[int, bytearray] = _ty.OrderedDict()

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({list(self._options.keys())})"

    @classmethod
    def decode(
        cls: "_builtins.type[_OptionsT]",
        data: _ty.Union[bytes, bytearray, memoryview],
        *,
        codemap: _ty.Optional[_builtins.type[OptionCode]] = None,
    ) -> _OptionsT:
        """Parse a TLV options buffer into a new bag.

        Liberal on receive: PAD octets are skipped, parsing stops at END, and a
        truncated option keeps what arrived (logged at DEBUG and counted by a listener). Octets after END
        are ignored. A repeated code is joined (RFC 3396).
        """
        options = cls(codemap)
        options._decode_into(memoryview(data))
        return options

    def _decode_into(self, options: memoryview, base_offset: int = 0) -> memoryview:
        """Add the options in `options` to this bag; the unconsumed tail comes back.

        `base_offset` is where the buffer starts in the message, for log lines.
        """
        return _wire.decode_into(self._options, options, base_offset)

    def partial_encode(
        self, maxsize: _ty.Optional[float], word_size: int = 1
    ) -> tuple[bytearray, _ty.Optional["DHCPOptions"]]:
        """Encode up to `maxsize` octets, returning the bytes and the leftovers.

        An option that does not fit whole is split into instances of its code
        (RFC 3396), and what is not written comes back as a bag of its own.
        `word_size` pads the END marker out to a multiple of that many octets,
        so the options field finishes on a word boundary: at 4, END is
        `ff 00 00 00` rather than a bare `ff`. It also reserves that much room
        rather than one octet when deciding what still fits. DHCP itself needs
        no alignment; the padding is for a caller filling a word-aligned buffer.
        """
        written, extra = _wire.partial_encode(self._options, maxsize, word_size)
        if not extra:
            return written, None
        leftover = DHCPOptions(self._codemap)
        leftover._options = extra
        return written, leftover

    def encode(self, word_size: int = 1) -> bytearray:
        encoded, _ = self.partial_encode(None, word_size)
        return encoded

    def copy(self, *options: _OptionSource, **overrides: _ty.Any) -> "DHCPOptions":
        """Return an independent copy sharing no mutable state with `self`.

        The codemap is preserved and every payload is copied into a fresh
        `bytearray`, so mutating either container (or a payload handed out by
        `get(..., decode=False)`) cannot write through to the other. Arguments
        are then merged into the copy as `update()` merges them; `self` is
        left as it was, whether or not the merge is refused.
        """
        copied = DHCPOptions(self._codemap)
        copied._options = _ty.OrderedDict(
            (code, bytearray(value)) for code, value in self._options.items()
        )
        if options or overrides:
            copied.update(*options, **overrides)
        return copied

    def update(self, *options: _OptionSource, **overrides: _ty.Any) -> None:
        """Merge each of `options` in order, then each keyword; the last write wins.

        A positional is a mapping of code to value (another bag's raw payloads
        are copied, not decoded) or an iterable of `(code, value)` pairs; a
        keyword names an option in this bag's codemap, in any letter case
        (`update(defaults, ROUTER="10.0.0.1")`). Every value is stored as
        `options[code] = value` stores it. All or nothing: a refused code,
        name or value leaves the bag as it was.
        """
        _merge.merge(self, options, overrides)

    def to_mapping(self) -> dict[str, _ty.Any]:
        """The options as plain data: the code's name (its number when it has
        none) to the value's `to_json()` form, in order.

        Byte-exact: an option whose readable form would load back as other
        octets is ``{"hex": "..."}``, which every option takes.
        """
        from . import _mapping

        return _mapping.to_mapping(self)

    @classmethod
    def from_mapping(
        cls: "_builtins.type[_OptionsT]",
        data: _ty.Optional[_ty.Mapping[_ty.Any, _ty.Any]],
        *,
        codemap: _ty.Optional[_builtins.type[OptionCode]] = None,
    ) -> _OptionsT:
        """A bag from what `to_mapping` wrote; `None` is an empty one.

        A key is a name in `codemap` or a number; a value is what its codec takes,
        octets only as ``{"hex": ...}`` or as hex text for an opaque option.
        """
        from . import _mapping

        options = cls(codemap)
        _mapping.fill_from_mapping(options, data)
        return options

    def retain(self, codes: _ty.Iterable[int]) -> None:
        """Keep only the options whose code is in `codes`; the order of the rest is kept."""
        keep = {int(code) for code in codes}
        for code in [code for code in self._options if code not in keep]:
            del self[code]

    def __getitem__(self, _key: int) -> bytearray:
        return self._options[_key]

    # Deliberate ABC deviation -- see the class docstring. `Mapping.get` is
    # declared to return the mapping's value type (`bytearray`); this one
    # decodes by default and returns a `DHCPOptionType`.
    @_ty.overload  # type: ignore[override]
    def get(
        self, __key: int, default: _ty.Any = None, *, decode: _builtins.type[_T]
    ) -> _ty.Optional[_T]: ...

    @_ty.overload
    def get(
        self,
        __key: int,
        default: _ty.Any = None,
        *,
        decode: _ty.Callable[[bytearray], _R],
    ) -> _ty.Optional[_R]: ...

    @_ty.overload
    def get(
        self, __key: int, default: _ty.Any = None, *, decode: _ty.Literal[True]
    ) -> _ty.Optional[DHCPOptionType]: ...

    @_ty.overload
    def get(
        self, __key: int, default: _ty.Any = None, *, decode: _ty.Literal[False]
    ) -> _ty.Optional[bytearray]: ...

    @_ty.overload
    def get(
        self, __key: int, default: _ty.Any = None
    ) -> _ty.Optional[DHCPOptionType]: ...

    def get(
        self,
        __key: int,
        default: _ty.Any = None,
        decode: _ty.Union[
            bool, _builtins.type[DHCPOptionType], _ty.Callable[[bytearray], _ty.Any]
        ] = True,
    ) -> _ty.Any:
        value = self._options.get(__key, _MISSING)
        if value is _MISSING:
            return default
        assert isinstance(value, (bytes, bytearray))
        if decode:
            target_decoder: _ty.Union[
                _builtins.type[DHCPOptionType], _ty.Callable[[bytearray], _ty.Any]
            ]
            if decode is True:
                target_decoder = self._codemap.from_code(__key).get_type()
            else:
                target_decoder = decode

            if isinstance(target_decoder, _builtins.type) and is_codec_class(
                target_decoder
            ):
                return _ty.cast(_builtins.type[OptionCodec], target_decoder).unpack(
                    value
                )
            return _ty.cast(_ty.Callable[[bytearray], _ty.Any], target_decoder)(value)
        else:
            return value

    def _ensuretype(
        self, option: _ty.Union[DHCPOption, tuple[int, _ty.Any]]
    ) -> DHCPOption:
        if isinstance(option, DHCPOption):
            return option
        return self._codemap.normalize(*option)

    def _describe(self, code: int) -> str:
        """`option 12 (HOSTNAME)`, or `option 230` for a code with no name."""
        try:
            label = self._codemap.from_code(code).label()
        except Exception:
            label = "UNKNOWN"
        return f"option {code}" if label == "UNKNOWN" else f"option {code} ({label})"

    @_contextlib.contextmanager
    def _naming(self, code: int, value: _ty.Any) -> _ty.Iterator[None]:
        """Say which option and which kind of value a refusal was about.

        The exception keeps its class, so a caller's `except` still catches it.
        """
        try:
            yield
        except (TypeError, ValueError) as exc:
            message = (
                f"{self._describe(code)} cannot hold a "
                f"{_builtins.type(value).__name__}: {exc}"
            )
            try:
                renamed = _builtins.type(exc)(message)
            except Exception:
                raise exc from None
            raise renamed from exc

    def append(self, option: _ty.Union[DHCPOption, tuple[int, _ty.Any]]) -> None:
        """Add `option`'s octets after those already stored under its code.

        All or nothing: the payload is built first, so an option that is
        refused leaves the bag as it was, a code that was absent still absent.
        """
        raw_code, value = option
        code = _check_code(int(raw_code))
        with self._naming(code, value):
            payload = bytearray()
            self._ensuretype(option).value.pack_into(payload)
        self._options.setdefault(code, bytearray()).extend(payload)

    def replace(self, option: _ty.Union[DHCPOption, tuple[int, _ty.Any]]) -> None:
        opt = self._ensuretype(option)
        self[int(opt.code)] = opt.value

    def __setitem__(self, __key: int, __value: _ty.Any) -> None:
        """Store an option, building the payload before it is visible.

        * **Atomic.** The payload is built in a scratch buffer and stored only
          on success, so a codec that raises part-way leaves the previous value,
          or no key. A zero-length option is legal on the wire (RFC 4039's
          RAPID_COMMIT), so a half-built one would encode and send cleanly as
          an option meaning something else.
        * **Non-aliasing.** A `bytearray` argument is copied, never kept: the
          caller's later writes to their buffer do not reach the stored option.

        Assigning an existing key keeps its position; the options order is
        wire-visible. The key is checked first (`_check_code`).
        """
        key = _check_code(__key)
        with self._naming(key, __value):
            if not isinstance(__value, (bytes, memoryview, bytearray)) and not is_codec(
                __value
            ):
                __value = self._codemap.from_code(key).get_type()(__value)
            data = bytearray()
            if is_codec(__value):
                __value.pack_into(data)
            else:
                data.extend(__value)
        self._options[key] = data

    def setdefault(self, key: int, default: _ty.Any = None) -> bytearray:
        """The raw payload under `key`; `default` is stored first when absent."""
        if key not in self._options:
            self[key] = default
        return self._options[key]

    def __delitem__(self, __key: int) -> None:
        return self._options.__delitem__(__key)

    def __len__(self) -> int:
        return len(self._options)

    def __iter__(self) -> _ty.Iterator[int]:
        return self._options.__iter__()

    # Deliberate ABC deviation -- see the class docstring. The decoded forms
    # return a `list[DHCPOption]`, not an `ItemsView`: decoding builds new pairs,
    # so there is no live view to hand back, and an `ItemsView` annotation
    # would promise `.mapping` and set operations that a list does not have.
    # Only `decoded=False` is the mapping's own view.
    @_ty.overload  # type: ignore[override]
    def items(self) -> list[DHCPOption]: ...

    @_ty.overload
    def items(self, decoded: _ty.Literal[False]) -> _ty.ItemsView[int, bytearray]: ...

    @_ty.overload
    def items(self, decoded: _ty.Literal[True]) -> list[DHCPOption]: ...

    @_ty.overload
    def items(self, decoded: _builtins.type[_C]) -> list[DHCPOption]: ...

    def items(
        self, decoded: _ty.Union[bool, _builtins.type[OptionCode]] = True
    ) -> _ty.Union[list[DHCPOption], _ty.ItemsView[int, bytearray]]:
        raw = self._options.items()
        if not decoded:
            return raw
        codemap = self._codemap if decoded is True else decoded
        return [codemap.decode(code, value) for code, value in raw]

    def __contains__(self, __key: object) -> bool:
        return self._options.__contains__(__key)

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, DHCPOptions):
            return NotImplemented
        mine, theirs = self._options, other._options
        return len(mine) == len(theirs) and all(
            code in theirs and theirs[code] == payload for code, payload in mine.items()
        )

    __hash__ = None  # type: ignore[assignment]

__setitem__(__key, __value)

Store an option, building the payload before it is visible.

  • Atomic. The payload is built in a scratch buffer and stored only on success, so a codec that raises part-way leaves the previous value, or no key. A zero-length option is legal on the wire (RFC 4039's RAPID_COMMIT), so a half-built one would encode and send cleanly as an option meaning something else.
  • Non-aliasing. A bytearray argument is copied, never kept: the caller's later writes to their buffer do not reach the stored option.

Assigning an existing key keeps its position; the options order is wire-visible. The key is checked first (_check_code).

Source code in src/pydhcp/options/__init__.py
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
def __setitem__(self, __key: int, __value: _ty.Any) -> None:
    """Store an option, building the payload before it is visible.

    * **Atomic.** The payload is built in a scratch buffer and stored only
      on success, so a codec that raises part-way leaves the previous value,
      or no key. A zero-length option is legal on the wire (RFC 4039's
      RAPID_COMMIT), so a half-built one would encode and send cleanly as
      an option meaning something else.
    * **Non-aliasing.** A `bytearray` argument is copied, never kept: the
      caller's later writes to their buffer do not reach the stored option.

    Assigning an existing key keeps its position; the options order is
    wire-visible. The key is checked first (`_check_code`).
    """
    key = _check_code(__key)
    with self._naming(key, __value):
        if not isinstance(__value, (bytes, memoryview, bytearray)) and not is_codec(
            __value
        ):
            __value = self._codemap.from_code(key).get_type()(__value)
        data = bytearray()
        if is_codec(__value):
            __value.pack_into(data)
        else:
            data.extend(__value)
    self._options[key] = data

append(option)

Add option's octets after those already stored under its code.

All or nothing: the payload is built first, so an option that is refused leaves the bag as it was, a code that was absent still absent.

Source code in src/pydhcp/options/__init__.py
374
375
376
377
378
379
380
381
382
383
384
385
def append(self, option: _ty.Union[DHCPOption, tuple[int, _ty.Any]]) -> None:
    """Add `option`'s octets after those already stored under its code.

    All or nothing: the payload is built first, so an option that is
    refused leaves the bag as it was, a code that was absent still absent.
    """
    raw_code, value = option
    code = _check_code(int(raw_code))
    with self._naming(code, value):
        payload = bytearray()
        self._ensuretype(option).value.pack_into(payload)
    self._options.setdefault(code, bytearray()).extend(payload)

copy(*options, **overrides)

Return an independent copy sharing no mutable state with self.

The codemap is preserved and every payload is copied into a fresh bytearray, so mutating either container (or a payload handed out by get(..., decode=False)) cannot write through to the other. Arguments are then merged into the copy as update() merges them; self is left as it was, whether or not the merge is refused.

Source code in src/pydhcp/options/__init__.py
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def copy(self, *options: _OptionSource, **overrides: _ty.Any) -> "DHCPOptions":
    """Return an independent copy sharing no mutable state with `self`.

    The codemap is preserved and every payload is copied into a fresh
    `bytearray`, so mutating either container (or a payload handed out by
    `get(..., decode=False)`) cannot write through to the other. Arguments
    are then merged into the copy as `update()` merges them; `self` is
    left as it was, whether or not the merge is refused.
    """
    copied = DHCPOptions(self._codemap)
    copied._options = _ty.OrderedDict(
        (code, bytearray(value)) for code, value in self._options.items()
    )
    if options or overrides:
        copied.update(*options, **overrides)
    return copied

decode(data, *, codemap=None) classmethod

Parse a TLV options buffer into a new bag.

Liberal on receive: PAD octets are skipped, parsing stops at END, and a truncated option keeps what arrived (logged at DEBUG and counted by a listener). Octets after END are ignored. A repeated code is joined (RFC 3396).

Source code in src/pydhcp/options/__init__.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
@classmethod
def decode(
    cls: "_builtins.type[_OptionsT]",
    data: _ty.Union[bytes, bytearray, memoryview],
    *,
    codemap: _ty.Optional[_builtins.type[OptionCode]] = None,
) -> _OptionsT:
    """Parse a TLV options buffer into a new bag.

    Liberal on receive: PAD octets are skipped, parsing stops at END, and a
    truncated option keeps what arrived (logged at DEBUG and counted by a listener). Octets after END
    are ignored. A repeated code is joined (RFC 3396).
    """
    options = cls(codemap)
    options._decode_into(memoryview(data))
    return options

from_mapping(data, *, codemap=None) classmethod

A bag from what to_mapping wrote; None is an empty one.

A key is a name in codemap or a number; a value is what its codec takes, octets only as {"hex": ...} or as hex text for an opaque option.

Source code in src/pydhcp/options/__init__.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
@classmethod
def from_mapping(
    cls: "_builtins.type[_OptionsT]",
    data: _ty.Optional[_ty.Mapping[_ty.Any, _ty.Any]],
    *,
    codemap: _ty.Optional[_builtins.type[OptionCode]] = None,
) -> _OptionsT:
    """A bag from what `to_mapping` wrote; `None` is an empty one.

    A key is a name in `codemap` or a number; a value is what its codec takes,
    octets only as ``{"hex": ...}`` or as hex text for an opaque option.
    """
    from . import _mapping

    options = cls(codemap)
    _mapping.fill_from_mapping(options, data)
    return options

partial_encode(maxsize, word_size=1)

Encode up to maxsize octets, returning the bytes and the leftovers.

An option that does not fit whole is split into instances of its code (RFC 3396), and what is not written comes back as a bag of its own. word_size pads the END marker out to a multiple of that many octets, so the options field finishes on a word boundary: at 4, END is ff 00 00 00 rather than a bare ff. It also reserves that much room rather than one octet when deciding what still fits. DHCP itself needs no alignment; the padding is for a caller filling a word-aligned buffer.

Source code in src/pydhcp/options/__init__.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def partial_encode(
    self, maxsize: _ty.Optional[float], word_size: int = 1
) -> tuple[bytearray, _ty.Optional["DHCPOptions"]]:
    """Encode up to `maxsize` octets, returning the bytes and the leftovers.

    An option that does not fit whole is split into instances of its code
    (RFC 3396), and what is not written comes back as a bag of its own.
    `word_size` pads the END marker out to a multiple of that many octets,
    so the options field finishes on a word boundary: at 4, END is
    `ff 00 00 00` rather than a bare `ff`. It also reserves that much room
    rather than one octet when deciding what still fits. DHCP itself needs
    no alignment; the padding is for a caller filling a word-aligned buffer.
    """
    written, extra = _wire.partial_encode(self._options, maxsize, word_size)
    if not extra:
        return written, None
    leftover = DHCPOptions(self._codemap)
    leftover._options = extra
    return written, leftover

retain(codes)

Keep only the options whose code is in codes; the order of the rest is kept.

Source code in src/pydhcp/options/__init__.py
268
269
270
271
272
def retain(self, codes: _ty.Iterable[int]) -> None:
    """Keep only the options whose code is in `codes`; the order of the rest is kept."""
    keep = {int(code) for code in codes}
    for code in [code for code in self._options if code not in keep]:
        del self[code]

setdefault(key, default=None)

The raw payload under key; default is stored first when absent.

Source code in src/pydhcp/options/__init__.py
418
419
420
421
422
def setdefault(self, key: int, default: _ty.Any = None) -> bytearray:
    """The raw payload under `key`; `default` is stored first when absent."""
    if key not in self._options:
        self[key] = default
    return self._options[key]

to_mapping()

The options as plain data: the code's name (its number when it has none) to the value's to_json() form, in order.

Byte-exact: an option whose readable form would load back as other octets is {"hex": "..."}, which every option takes.

Source code in src/pydhcp/options/__init__.py
239
240
241
242
243
244
245
246
247
248
def to_mapping(self) -> dict[str, _ty.Any]:
    """The options as plain data: the code's name (its number when it has
    none) to the value's `to_json()` form, in order.

    Byte-exact: an option whose readable form would load back as other
    octets is ``{"hex": "..."}``, which every option takes.
    """
    from . import _mapping

    return _mapping.to_mapping(self)

update(*options, **overrides)

Merge each of options in order, then each keyword; the last write wins.

A positional is a mapping of code to value (another bag's raw payloads are copied, not decoded) or an iterable of (code, value) pairs; a keyword names an option in this bag's codemap, in any letter case (update(defaults, ROUTER="10.0.0.1")). Every value is stored as options[code] = value stores it. All or nothing: a refused code, name or value leaves the bag as it was.

Source code in src/pydhcp/options/__init__.py
227
228
229
230
231
232
233
234
235
236
237
def update(self, *options: _OptionSource, **overrides: _ty.Any) -> None:
    """Merge each of `options` in order, then each keyword; the last write wins.

    A positional is a mapping of code to value (another bag's raw payloads
    are copied, not decoded) or an iterable of `(code, value)` pairs; a
    keyword names an option in this bag's codemap, in any letter case
    (`update(defaults, ROUTER="10.0.0.1")`). Every value is stored as
    `options[code] = value` stores it. All or nothing: a refused code,
    name or value leaves the bag as it was.
    """
    _merge.merge(self, options, overrides)

DHCPOptionCode

The standard IANA option-code registry: an IntEnum whose members carry, in their own docstrings, the RFC text that defines each option. Codes without a member resolve to an opaque pseudo-member (get_type() returns Bytes, label() reports UNKNOWN) rather than raising, so an unknown code never costs a client its lease.

pydhcp.options.DHCPOptionCode

Bases: OptionCodeEnum

The IANA DHCPv4 option codes, each member documented with its RFC text.

Source code in src/pydhcp/options/_codes.py
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
class DHCPOptionCode(OptionCodeEnum):
    """The IANA DHCPv4 option codes, each member documented with its RFC text."""

    #
    #   VENDOR EXTENSIONS
    #   DEFINED in RFC 1497
    #
    PAD = 0
    """The pad option can be used to cause subsequent fields to align on
   word boundaries.

   The code for the pad option is 0, and its length is 1 octet."""
    SUBNET_MASK = 1
    """The subnet mask option specifies the client's subnet mask as per RFC
   950.
   If both the subnet mask and the router option are specified in a DHCP
   reply, the subnet mask option MUST be first.

   The code for the subnet mask option is 1, and its length is 4 octets."""
    TIME_OFFSET = 2
    """The time offset field specifies the offset of the client's subnet in
   seconds from Coordinated Universal Time (UTC).  The offset is
   expressed as a two's complement 32-bit integer.  A positive offset
   indicates a location east of the zero meridian and a negative offset
   indicates a location west of the zero meridian.

   The code for the time offset option is 2, and its length is 4 octets."""
    ROUTER = 3
    """The router option specifies a list of IP addresses for routers on the
   client's subnet.  Routers SHOULD be listed in order of preference.

   The code for the router option is 3.  The minimum length for the
   router option is 4 octets, and the length MUST always be a multiple
   of 4."""
    RFC868_TIMESERVER = 4
    """The time server option specifies a list of RFC 868 time servers
   available to the client.  Servers SHOULD be listed in order of
   preference.

   The code for the time server option is 4.  The minimum length for
   this option is 4 octets, and the length MUST always be a multiple of
   4."""
    IEN116_NAMESERVER = 5
    """The name server option specifies a list of IEN 116 [7] name servers
   available to the client.  Servers SHOULD be listed in order of
   preference.

   The code for the name server option is 5.  The minimum length for
   this option is 4 octets, and the length MUST always be a multiple of
   4."""
    DNS = 6
    """The domain name server option specifies a list of Domain Name System
   (STD 13, RFC 1035 [8]) name servers available to the client.  Servers
   SHOULD be listed in order of preference.

   The code for the domain name server option is 6.  The minimum length
   for this option is 4 octets, and the length MUST always be a multiple
   of 4."""
    LOG_SERVER = 7
    """The log server option specifies a list of MIT-LCS UDP log servers
   available to the client.  Servers SHOULD be listed in order of
   preference.

   The code for the log server option is 7.  The minimum length for this
   option is 4 octets, and the length MUST always be a multiple of 4."""
    COOKIE_SERVER = 8
    """ The cookie server option specifies a list of RFC 865 [9] cookie
   servers available to the client.  Servers SHOULD be listed in order
   of preference.

   The code for the log server option is 8.  The minimum length for this
   option is 4 octets, and the length MUST always be a multiple of 4."""
    LPR_SERVER = 9
    """The LPR server option specifies a list of RFC 1179 [10] line printer
   servers available to the client.  Servers SHOULD be listed in order
   of preference.

   The code for the LPR server option is 9.  The minimum length for this
   option is 4 octets, and the length MUST always be a multiple of 4."""
    IMPRESS_SERVER = 10
    """  The Impress server option specifies a list of Imagen Impress servers
   available to the client.  Servers SHOULD be listed in order of
   preference.

   The code for the Impress server option is 10.  The minimum length for
   this option is 4 octets, and the length MUST always be a multiple of
   4."""
    RLP_SERVER = 11
    """his option specifies a list of RFC 887 [11] Resource Location
   servers available to the client.  Servers SHOULD be listed in order
   of preference.

   The code for this option is 11.  The minimum length for this option
   is 4 octets, and the length MUST always be a multiple of 4."""
    HOSTNAME = 12
    """This option specifies the name of the client.  The name may or may
   not be qualified with the local domain name (see section 3.17 for the
   preferred way to retrieve the domain name).  See RFC 1035 for
   character set restrictions.

   The code for this option is 12, and its minimum length is 1."""
    BOOT_FILE_SIZE = 13
    """This option specifies the length in 512-octet blocks of the default
   boot image for the client.  The file length is specified as an
   unsigned 16-bit integer.

   The code for this option is 13, and its length is 2."""
    MERIT_DUMP_FILE = 14
    """This option specifies the path-name of a file to which the client's
   core image should be dumped in the event the client crashes.  The
   path is formatted as a character string consisting of characters from
   the NVT ASCII character set.

   The code for this option is 14.  Its minimum length is 1."""
    DOMAIN_NAME = 15
    """This option specifies the domain name that client should use when
   resolving hostnames via the Domain Name System.

   The code for this option is 15.  Its minimum length is 1."""
    SWAP_SERVER = 16
    """This specifies the IP address of the client's swap server.

   The code for this option is 16 and its length is 4."""
    ROOT_PATH = 17
    """This option specifies the path-name that contains the client's root
   disk.  The path is formatted as a character string consisting of
   characters from the NVT ASCII character set.

   The code for this option is 17.  Its minimum length is 1."""
    EXTENSION_FILE = 18
    """A string to specify a file, retrievable via TFTP, which contains
   information which can be interpreted in the same way as the 64-octet
   vendor-extension field within the BOOTP response, with the following
   exceptions:
          - the length of the file is unconstrained;
          - all references to Tag 18 (i.e., instances of the
            BOOTP Extensions Path field) within the file are
            ignored.

   The code for this option is 18.  Its minimum length is 1."""
    #
    # IP Layer Parameters per Host
    # This section details the options that affect the operation of the IP
    # layer on a per-host basis.
    #
    IP_FORWARDING = 19
    """This option specifies whether the client should configure its IP
   layer for packet forwarding.  A value of 0 means disable IP
   forwarding, and a value of 1 means enable IP forwarding.

   The code for this option is 19, and its length is 1."""
    NON_LOCAL_SOURCE_ROUTING = 20
    """This option specifies whether the client should configure its IP
   layer to allow forwarding of datagrams with non-local source routes
   (see Section 3.3.5 of [4] for a discussion of this topic).  A value
   of 0 means disallow forwarding of such datagrams, and a value of 1
   means allow forwarding.

   The code for this option is 20, and its length is 1."""
    POLICY_FILTER = 21
    """This option specifies policy filters for non-local source routing.
   The filters consist of a list of IP addresses and masks which specify
   destination/mask pairs with which to filter incoming source routes.

   Any source routed datagram whose next-hop address does not match one
   of the filters should be discarded by the client.

   The code for this option is 21.  The minimum length of this option is
   8, and the length MUST be a multiple of 8."""
    MAX_DATAGRAM_REASSEMBLY_SIZE = 22
    """This option specifies the maximum size datagram that the client
   should be prepared to reassemble.  The size is specified as a 16-bit
   unsigned integer.  The minimum value legal value is 576.

   The code for this option is 22, and its length is 2."""
    IP_TTL = 23
    """This option specifies the default time-to-live that the client should
   use on outgoing datagrams.  The TTL is specified as an octet with a
   value between 1 and 255.

   The code for this option is 23, and its length is 1."""
    MTU_TIMEOUT = 24
    """This option specifies the timeout (in seconds) to use when aging Path
   MTU values discovered by the mechanism defined in RFC 1191 [12].  The
   timeout is specified as a 32-bit unsigned integer.

   The code for this option is 24, and its length is 4."""
    MTU_PLATEAU = 25
    """This option specifies a table of MTU sizes to use when performing
   Path MTU Discovery as defined in RFC 1191.  The table is formatted as
   a list of 16-bit unsigned integers, ordered from smallest to largest.
   The minimum MTU value cannot be smaller than 68.

   The code for this option is 25.  Its minimum length is 2, and the
   length MUST be a multiple of 2."""

    #
    #   IP Layer Parameters per Interface
    #
    # This section details the options that affect the operation of the IP
    # layer on a per-interface basis.  It is expected that a client can
    # issue multiple requests, one per interface, in order to configure
    # interfaces with their specific parameters.
    #
    INTERFACE_MTU = 26
    """ This option specifies the MTU to use on this interface.  The MTU is
   specified as a 16-bit unsigned integer.  The minimum legal value for
   the MTU is 68.

   The code for this option is 26, and its length is 2."""
    ALL_SUBNETS_ARE_LOCAL = 27
    """This option specifies whether or not the client may assume that all
   subnets of the IP network to which the client is connected use the
   same MTU as the subnet of that network to which the client is
   directly connected.  A value of 1 indicates that all subnets share
   the same MTU.  A value of 0 means that the client should assume that
   some subnets of the directly connected network may have smaller MTUs.

   The code for this option is 27, and its length is 1."""
    BROADCAST_ADDRESS = 28
    """   This option specifies the broadcast address in use on the client's
   subnet.  Legal values for broadcast addresses are specified in
   section 3.2.1.3 of [4].

   The code for this option is 28, and its length is 4."""
    MASK_DISCOVERY = 29
    """ This option specifies whether or not the client should perform subnet
   mask discovery using ICMP.  A value of 0 indicates that the client
   should not perform mask discovery.  A value of 1 means that the
   client should perform mask discovery.

   The code for this option is 29, and its length is 1."""
    MASK_SUPPLIER = 30
    """This option specifies whether or not the client should respond to
   subnet mask requests using ICMP.  A value of 0 indicates that the
   client should not respond.  A value of 1 means that the client should
   respond.

   The code for this option is 30, and its length is 1."""
    ROUTER_DISCOVERY = 31
    """This option specifies whether or not the client should solicit
   routers using the Router Discovery mechanism defined in RFC 1256
   [13].  A value of 0 indicates that the client should not perform
   router discovery.  A value of 1 means that the client should perform
   router discovery.

   The code for this option is 31, and its length is 1."""
    ROUTER_SOLICITATION_ADDRESS = 32
    """This option specifies the address to which the client should transmit
   router solicitation requests.

   The code for this option is 32, and its length is 4."""
    STATIC_ROUTE = 33
    """ This option specifies a list of static routes that the client should
   install in its routing cache.  If multiple routes to the same
   destination are specified, they are listed in descending order of
   priority.

   The routes consist of a list of IP address pairs.  The first address
   is the destination address, and the second address is the router for
   the destination.

   The default route (0.0.0.0) is an illegal destination for a static
   route.  See section 3.5 for information about the router option.

   The code for this option is 33.  The minimum length of this option is
   8, and the length MUST be a multiple of 8."""

    #
    #   Link Layer Parameters per Interface
    # This section lists the options that affect the operation of the data
    # link layer on a per-interface basis.
    #
    #

    TRAILER_ENCAPSULATION = 34
    """This option specifies whether or not the client should negotiate the
   use of trailers (RFC 893 [14]) when using the ARP protocol.  A value
   of 0 indicates that the client should not attempt to use trailers.  A
   value of 1 means that the client should attempt to use trailers.

   The code for this option is 34, and its length is 1."""
    ARP_TIMEOUT = 35
    """ This option specifies the timeout in seconds for ARP cache entries.
   The time is specified as a 32-bit unsigned integer.

   The code for this option is 35, and its length is 4."""
    ETHERNET_ENCAPSULATION = 36
    """This option specifies whether or not the client should use Ethernet
   Version 2 (RFC 894 [15]) or IEEE 802.3 (RFC 1042 [16]) encapsulation
   if the interface is an Ethernet.  A value of 0 indicates that the
   client should use RFC 894 encapsulation.  A value of 1 means that the
   client should use RFC 1042 encapsulation.

   The code for this option is 36, and its length is 1."""

    #
    #
    #   TCP Parameters:
    #
    #    This section lists the options that affect the operation of the TCP
    #    layer on a per-interface basis.
    #
    #
    TCP_DEFAULT_TTL = 37
    """This option specifies the default TTL that the client should use when
   sending TCP segments.  The value is represented as an 8-bit unsigned
   integer.  The minimum value is 1.

   The code for this option is 37, and its length is 1."""
    TCP_KEEPALIVE_INTERVAL = 38
    """This option specifies the interval (in seconds) that the client TCP
   should wait before sending a keepalive message on a TCP connection.
   The time is specified as a 32-bit unsigned integer.  A value of zero
   indicates that the client should not generate keepalive messages on
   connections unless specifically requested by an application.

   The code for this option is 38, and its length is 4."""

    TCP_KEEPALIVE_GARBAGE = 39
    """This option specifies the whether or not the client should send TCP
   keepalive messages with a octet of garbage for compatibility with
   older implementations.  A value of 0 indicates that a garbage octet
   should not be sent. A value of 1 indicates that a garbage octet
   should be sent.

   The code for this option is 39, and its length is 1."""

    #
    #
    #   Application and Service Parameters:
    #
    #    This section details some miscellaneous options used to configure
    #    miscellaneous applications and services.
    #
    #

    NIS_DOMAIN = 40
    """   This option specifies the name of the client's NIS [17] domain.  The
   domain is formatted as a character string consisting of characters
   from the NVT ASCII character set.

   The code for this option is 40.  Its minimum length is 1."""

    NIS_SERVERS = 41
    """This option specifies a list of IP addresses indicating NIS servers
   available to the client.  Servers SHOULD be listed in order of
   preference.

   The code for this option is 41.  Its minimum length is 4, and the
   length MUST be a multiple of 4."""
    NTP_SERVERS = 42
    """This option specifies a list of IP addresses indicating NTP [18]
   servers available to the client.  Servers SHOULD be listed in order
   of preference.

   The code for this option is 42.  Its minimum length is 4, and the
   length MUST be a multiple of 4."""
    VENDOR_SPECIFIC_INFORMATION = 43
    """This option is used by clients and servers to exchange vendor-
   specific information.  The information is an opaque object of n
   octets, presumably interpreted by vendor-specific code on the clients
   and servers.  The definition of this information is vendor specific.
   The vendor is indicated in the vendor class identifier option.
   Servers not equipped to interpret the vendor-specific information
   sent by a client MUST ignore it (although it may be reported).
   Clients which do not receive desired vendor-specific information
   SHOULD make an attempt to operate without it, although they may do so
   (and announce they are doing so) in a degraded mode.

   If a vendor potentially encodes more than one item of information in
   this option, then the vendor SHOULD encode the option using
   "Encapsulated vendor-specific options" as described below:

   The Encapsulated vendor-specific options field SHOULD be encoded as a
   sequence of code/length/value fields of identical syntax to the DHCP
   options field with the following exceptions:

      1) There SHOULD NOT be a "magic cookie" field in the encapsulated
         vendor-specific extensions field.

      2) Codes other than 0 or 255 MAY be redefined by the vendor within
         the encapsulated vendor-specific extensions field, but SHOULD
         conform to the tag-length-value syntax defined in section 2.

      3) Code 255 (END), if present, signifies the end of the
         encapsulated vendor extensions, not the end of the vendor
         extensions field. If no code 255 is present, then the end of
         the enclosing vendor-specific information field is taken as the
         end of the encapsulated vendor-specific extensions field.

   The code for this option is 43 and its minimum length is 1."""
    # Brackets escaped below (hence the raw docstring): mkdocstrings renders these
    # docstrings as markdown, and python-markdown reads the adjacent pair "[19] [20]"
    # as a reference link, which mkdocs-autorefs then cannot resolve --
    # "Could not find cross-reference target '20'", one warning, which is enough to
    # abort `mkdocs build --strict`. This is the only adjacent pair among the 163
    # members, so the isolated RFC citations elsewhere are safe.
    NBNS_SERVERS = 44
    r"""The NetBIOS name server (NBNS) option specifies a list of RFC
   1001/1002 \[19\] \[20\] NBNS name servers listed in order of preference.

   The code for this option is 44.  The minimum length of the option is
   4 octets, and the length must always be a multiple of 4."""
    NBDD_SERVERS = 45
    """The NetBIOS datagram distribution server (NBDD) option specifies a
   list of RFC 1001/1002 NBDD servers listed in order of preference. The
   code for this option is 45.  The minimum length of the option is 4
   octets, and the length must always be a multiple of 4."""
    NETBIOS_NODE_TYPE = 46
    """   The NetBIOS node type option allows NetBIOS over TCP/IP clients which
   are configurable to be configured as described in RFC 1001/1002.  The
   value is specified as a single octet which identifies the client type
   as follows:

      Value         Node Type

      -----         ---------

      0x1           B-node

      0x2           P-node

      0x4           M-node

      0x8           H-node

   In the above chart, the notation '0x' indicates a number in base-16
   (hexadecimal).

   The code for this option is 46.  The length of this option is always
   1."""
    NETBIOS_SCOPE = 47
    """
   The NetBIOS scope option specifies the NetBIOS over TCP/IP scope
   parameter for the client as specified in RFC 1001/1002. See [19],
   [20], and [8] for character-set restrictions.

   The code for this option is 47.  The minimum length of this option is
   1."""
    X_WINDOW_FONT_SERVER = 48
    """This option specifies a list of X Window System [21] Font servers
   available to the client. Servers SHOULD be listed in order of
   preference.

   The code for this option is 48.  The minimum length of this option is
   4 octets, and the length MUST be a multiple of 4."""
    X_WINDOW_MANAGER = 49
    """This option specifies a list of IP addresses of systems that are
   running the X Window System Display Manager and are available to the
   client.

   Addresses SHOULD be listed in order of preference.

   The code for the this option is 49. The minimum length of this option
   is 4, and the length MUST be a multiple of 4."""
    NETWARE_DOMAIN = 62
    NETWARE_OPTION = 63
    NIS_PLUS_DOMAIN = 64
    """ This option specifies the name of the client's NIS+ [17] domain.  The
   domain is formatted as a character string consisting of characters
   from the NVT ASCII character set.

   The code for this option is 64.  Its minimum length is 1."""
    NIS_PLUS_SERVERS = 65
    """ This option specifies a list of IP addresses indicating NIS+ servers
   available to the client.  Servers SHOULD be listed in order of
   preference.

   The code for this option is 65.  Its minimum length is 4, and the
   length MUST be a multiple of 4."""
    HOME_AGENT_ADDRESSES = 68
    """This option specifies a list of IP addresses indicating mobile IP
   home agents available to the client.  Agents SHOULD be listed in
   order of preference.

   The code for this option is 68.  Its minimum length is 0 (indicating
   no home agents are available) and the length MUST be a multiple of 4.
   It is expected that the usual length will be four octets, containing
   a single home agent's address."""
    SMTP_SERVER = 69
    """The SMTP server option specifies a list of SMTP servers available to
   the client.  Servers SHOULD be listed in order of preference.

   The code for the SMTP server option is 69.  The minimum length for
   this option is 4 octets, and the length MUST always be a multiple of
   4."""
    POP3_SERVER = 70
    """ The POP3 server option specifies a list of POP3 available to the
   client.  Servers SHOULD be listed in order of preference.

   The code for the POP3 server option is 70.  The minimum length for
   this option is 4 octets, and the length MUST always be a multiple of
   4."""
    NNTP_SERVER = 71
    """
   The NNTP server option specifies a list of NNTP available to the
   client.  Servers SHOULD be listed in order of preference.

   The code for the NNTP server option is 71. The minimum length for
   this option is 4 octets, and the length MUST always be a multiple of
   4."""
    WWW_SERVER = 72
    """The WWW server option specifies a list of WWW available to the
   client.  Servers SHOULD be listed in order of preference.

   The code for the WWW server option is 72.  The minimum length for
   this option is 4 octets, and the length MUST always be a multiple of
   4."""
    FINGER_SERVER = 73
    """   The Finger server option specifies a list of Finger available to the
   client.  Servers SHOULD be listed in order of preference.

   The code for the Finger server option is 73.  The minimum length for
   this option is 4 octets, and the length MUST always be a multiple of
   4."""
    IRC_SERVER = 74
    """The IRC server option specifies a list of IRC available to the
   client.  Servers SHOULD be listed in order of preference.

   The code for the IRC server option is 74.  The minimum length for
   this option is 4 octets, and the length MUST always be a multiple of
   4."""
    STREETTALK_SERVER = 75
    """The StreetTalk server option specifies a list of StreetTalk servers
   available to the client.  Servers SHOULD be listed in order of
   preference.

   The code for the StreetTalk server option is 75.  The minimum length
   for this option is 4 octets, and the length MUST always be a multiple
   of 4."""
    STDA_SERVER = 76
    """ The StreetTalk Directory Assistance (STDA) server option specifies a
   list of STDA servers available to the client.  Servers SHOULD be
   listed in order of preference.

   The code for the StreetTalk Directory Assistance server option is 76.
   The minimum length for this option is 4 octets, and the length MUST
   always be a multiple of 4."""

    #
    #
    # DHCP Extensions:
    #   This section details the options that are specific to DHCP.
    #

    REQUESTED_IP = 50
    """This option is used in a client request (DHCPDISCOVER) to allow the
   client to request that a particular IP address be assigned.

   The code for this option is 50, and its length is 4."""
    IP_ADDRESS_LEASE_TIME = 51
    """This option is used in a client request (DHCPDISCOVER or DHCPREQUEST)
   to allow the client to request a lease time for the IP address.  In a
   server reply (DHCPOFFER), a DHCP server uses this option to specify
   the lease time it is willing to offer.

   The time is in units of seconds, and is specified as a 32-bit
   unsigned integer.

   The code for this option is 51, and its length is 4."""
    OPTION_OVERLOAD = 52
    """   This option is used to indicate that the DHCP 'sname' or 'file'
   fields are being overloaded by using them to carry DHCP options. A
   DHCP server inserts this option if the returned parameters will
   exceed the usual space allotted for options.

   If this option is present, the client interprets the specified
   additional fields after it concludes interpretation of the standard
   option fields.

   The code for this option is 52, and its length is 1.  Legal values
   for this option are:

           Value   Meaning
           -----   --------
             1     the 'file' field is used to hold options
             2     the 'sname' field is used to hold options
             3     both fields are used to hold options"""
    DHCP_MESSAGE_TYPE = 53
    """   This option is used to convey the type of the DHCP message.  The code
   for this option is 53, and its length is 1.  Legal values for this
   option are:

           Value   Message Type
           -----   ------------
             1     DHCPDISCOVER
             2     DHCPOFFER
             3     DHCPREQUEST
             4     DHCPDECLINE
             5     DHCPACK
             6     DHCPNAK
             7     DHCPRELEASE
             8     DHCPINFORM"""
    SERVER_IDENTIFIER = 54
    """This option is used in DHCPOFFER and DHCPREQUEST messages, and may
   optionally be included in the DHCPACK and DHCPNAK messages.  DHCP
   servers include this option in the DHCPOFFER in order to allow the
   client to distinguish between lease offers.  DHCP clients use the
   contents of the 'server identifier' field as the destination address
   for any DHCP messages unicast to the DHCP server.  DHCP clients also
   indicate which of several lease offers is being accepted by including
   this option in a DHCPREQUEST message.

   The identifier is the IP address of the selected server.

   The code for this option is 54, and its length is 4."""
    PARAMETER_REQUEST_LIST = 55
    """This option is used by a DHCP client to request values for specified
   configuration parameters.  The list of requested parameters is
   specified as n octets, where each octet is a valid DHCP option code
   as defined in this document.

   The client MAY list the options in order of preference.  The DHCP
   server is not required to return the options in the requested order,
   but MUST try to insert the requested options in the order requested
   by the client.

   The code for this option is 55.  Its minimum length is 1."""
    DHCP_MESSAGE = 56
    """   This option is used by a DHCP server to provide an error message to a
   DHCP client in a DHCPNAK message in the event of a failure. A client
   may use this option in a DHCPDECLINE message to indicate the why the
   client declined the offered parameters.  The message consists of n
   octets of NVT ASCII text, which the client may display on an
   available output device.

   The code for this option is 56 and its minimum length is 1."""
    MAXIMUM_DHCP_MESSAGE_SIZE = 57
    """
   This option specifies the maximum length DHCP message that it is
   willing to accept.  The length is specified as an unsigned 16-bit
   integer.  A client may use the maximum DHCP message size option in
   DHCPDISCOVER or DHCPREQUEST messages, but should not use the option
   in DHCPDECLINE messages.

   The code for this option is 57, and its length is 2.  The minimum
   legal value is 576 octets."""
    RENEWAL_TIME = 58
    """ This option specifies the time interval from address assignment until
   the client transitions to the RENEWING state.

   The value is in units of seconds, and is specified as a 32-bit
   unsigned integer.

   The code for this option is 58, and its length is 4."""
    REBINDING_TIME = 59
    """This option specifies the time interval from address assignment until
   the client transitions to the REBINDING state.

   The value is in units of seconds, and is specified as a 32-bit
   unsigned integer.

   The code for this option is 59, and its length is 4."""
    VENDOR_CLASS_IDENTIFIER = 60
    """ This option is used by DHCP clients to optionally identify the vendor
   type and configuration of a DHCP client.  The information is a string
   of n octets, interpreted by servers.  Vendors may choose to define
   specific vendor class identifiers to convey particular configuration
   or other identification information about a client.  For example, the
   identifier may encode the client's hardware configuration.  Servers
   not equipped to interpret the class-specific information sent by a
   client MUST ignore it (although it may be reported). Servers that

   respond SHOULD only use option 43 to return the vendor-specific
   information to the client.

   The code for this option is 60, and its minimum length is 1."""
    CLIENT_IDENTIFIER = 61
    """  This option is used by DHCP clients to specify their unique
   identifier.  DHCP servers use this value to index their database of
   address bindings.  This value is expected to be unique for all
   clients in an administrative domain.

   Identifiers SHOULD be treated as opaque objects by DHCP servers.

   The client identifier MAY consist of type-value pairs similar to the
   'htype'/'chaddr' fields defined in [3]. For instance, it MAY consist
   of a hardware type and hardware address. In this case the type field
   SHOULD be one of the ARP hardware types defined in STD2 [22].  A
   hardware type of 0 (zero) should be used when the value field
   contains an identifier other than a hardware address (e.g. a fully
   qualified domain name).

   For correct identification of clients, each client's client-
   identifier MUST be unique among the client-identifiers used on the
   subnet to which the client is attached.  Vendors and system
   administrators are responsible for choosing client-identifiers that
   meet this requirement for uniqueness.

   The code for this option is 61, and its minimum length is 2."""
    TFTP_SERVER = 66
    """This option is used to identify a TFTP server when the 'sname' field
   in the DHCP header has been used for DHCP options.

   The code for this option is 66, and its minimum length is 1."""
    BOOTFILE_NAME = 67
    """This option is used to identify a bootfile when the 'file' field in
   the DHCP header has been used for DHCP options.

   The code for this option is 67, and its minimum length is 1."""

    #
    # EXTRAS
    #
    USER_CLASS = 77
    DIRECTORY_AGENT = 78
    SERVICE_SCOPE = 79
    RAPID_COMMIT = 80
    CLIENT_FQDN = 81
    RELAY_AGENT_INFORMATION = 82
    ISNS = 83
    NDS_SERVERS = 85
    NDS_TREE_NAME = 86
    NDS_CONTEXT = 87
    BCMCS_DOMAIN_NAME_LIST = 88
    BCMCS_IPV4_ADDRESS = 89
    AUTHENTICATION = 90
    CLIENT_LAST_TRANSACTION_TIME = 91
    ASSOCIATED_IP = 92
    CLIENT_SYSTEM_ARCHITECTURE = 93
    CLIENT_NDI = 94
    LDAP = 95
    """IANA currently lists option 95 as unassigned/reclaimed per RFC 3679.

   Interoperability with a standards-conformant peer should not be assumed."""

    # 96 UNASSIGNED
    UUID = 97
    USERAUTH = 98
    GEOCONF_CIVIC = 99
    PCODE = 100
    TCODE = 101
    # 102-107 UNASSIGNED
    IPV6_ONLY = 108
    DHCP4_OVER_DHCP6_SOURCE_ADDRESS = 109
    # 110-111 UNASSIGNED
    NETINFO_ADDRESS = 112
    NETINFO_TAG = 113
    DHCP_CAPTIVE_PORTAL = 114
    # 115
    AUTO_CONFIG = 116
    NAME_SERVICE_SEARCH = 117
    SUBNET_SELECTION_OPTION = 118
    DOMAIN_SEARCH = 119
    SIP_SERVERS = 120
    CLASSLESS_STATIC_ROUTE = 121
    CCC = 122
    GEOCONF = 123
    VI_VENDOR_CLASS = 124
    VI_VENDOR_SPECIFIC_INFORMATION = 125
    # 126-127
    PXE_VENDOR_SPECIFIC_1 = 128
    """ VENDOR SPECIFIC 128-135
    """
    PXE_VENDOR_SPECIFIC_2 = 129
    PXE_VENDOR_SPECIFIC_3 = 130
    PXE_VENDOR_SPECIFIC_4 = 131
    PXE_VENDOR_SPECIFIC_5 = 132
    PXE_VENDOR_SPECIFIC_6 = 133
    PXE_VENDOR_SPECIFIC_7 = 134
    PXE_VENDOR_SPECIFIC_8 = 135
    PANA_AGENT = 136
    V4_LOST = 137
    CAPWAP_AC_V4 = 138
    # RFC 5678 MoS sub-options use service-type values 1 (IS), 2 (CS), 3 (ES).
    # Option 139 carries IPv4 address lists per sub-option; option 140 carries
    # FQDN label sequences per sub-option with no DNS compression.
    IPV4_ADDRESS_MOS = 139
    IPV4_FQDN_MOS = 140
    SIP_UA_CONFIG_SERVICE_DOMAINS = 141
    IPV4_ADDRESS_ANDSF = 142
    V4_SZTP_REDIRECT = 143
    GEOLOC = 144
    FORCERENEW_NONCE_CAPABLE = 145
    RDNSS_SELECTION = 146
    V4_DOTS_RI = 147
    V4_DOTS_ADDRESS = 148
    # 149
    TFTP_SERVER_ADDRESS = 150
    """Can replace siaddr and also provide multiple addresses instead of just one. Only IP Address

    May also be use by ETHERBOOT

    GRUB configuration path name
    """
    STATUS_CODE = 151
    BASE_TIME = 152
    START_TIME_OF_STATE = 153
    QUERY_START_TIME = 154
    QUERY_END_TIME = 155
    DHCP_STATE = 156
    DATA_SOURCE = 157
    V4_PCP_SERVER = 158
    V4_PORTPARAMS = 159
    # 160
    MUD_URL_V4 = 161
    V4_DNR = 162
    # 163-174
    ETHERBOOT = 175
    IP_TELEPHONE = 176
    LEGACY_CCC = 177
    """This option was historically used for PacketCable/CableHome CCC
   (Client Configuration for CableHome), later superseded by option 122
   (CCC). IANA's registry separately lists 177 under a tentative 2005
   Etherboot assignment as well; do not conflate the two."""
    # 178-207
    PXE_LINUX_MAGIC = 208
    """magic string = F1:00:74:7E"""
    CONFIGURATION_FILE = 209
    PATH_PREFIX = 210
    REBOOT_TIME = 211
    SIXRD = 212
    """IANA option 212, `OPTION_6RD` (RFC 5969)."""
    GRD = 212
    """An alias of `SIXRD`: `DHCPOptionCode(212).name` is `SIXRD`."""
    V4_ACCESS_DOMAIN = 213
    # 214-219
    SUBNET_ALLOCATION = 220
    VSS = 221
    # 222-223
    # 224-254 PRIVATE USE
    MSFT_CLASSLESS_STATIC_ROUTE = 249
    WPAD = 252
    ALL_VPNS = 254
    """All VPNs option"""
    END = 255
    """The end option marks the end of valid information in the vendor
   field.  Subsequent octets should be filled with pad options.

   The code for the end option is 255, and its length is 1 octet."""

ALL_SUBNETS_ARE_LOCAL = 27 class-attribute instance-attribute

This option specifies whether or not the client may assume that all subnets of the IP network to which the client is connected use the same MTU as the subnet of that network to which the client is directly connected. A value of 1 indicates that all subnets share the same MTU. A value of 0 means that the client should assume that some subnets of the directly connected network may have smaller MTUs.

The code for this option is 27, and its length is 1.

ALL_VPNS = 254 class-attribute instance-attribute

All VPNs option

ARP_TIMEOUT = 35 class-attribute instance-attribute

This option specifies the timeout in seconds for ARP cache entries. The time is specified as a 32-bit unsigned integer.

The code for this option is 35, and its length is 4.

ASSOCIATED_IP = 92 class-attribute instance-attribute

AUTHENTICATION = 90 class-attribute instance-attribute

AUTO_CONFIG = 116 class-attribute instance-attribute

BASE_TIME = 152 class-attribute instance-attribute

BCMCS_DOMAIN_NAME_LIST = 88 class-attribute instance-attribute

BCMCS_IPV4_ADDRESS = 89 class-attribute instance-attribute

BOOTFILE_NAME = 67 class-attribute instance-attribute

This option is used to identify a bootfile when the 'file' field in the DHCP header has been used for DHCP options.

The code for this option is 67, and its minimum length is 1.

BOOT_FILE_SIZE = 13 class-attribute instance-attribute

This option specifies the length in 512-octet blocks of the default boot image for the client. The file length is specified as an unsigned 16-bit integer.

The code for this option is 13, and its length is 2.

BROADCAST_ADDRESS = 28 class-attribute instance-attribute

This option specifies the broadcast address in use on the client's subnet. Legal values for broadcast addresses are specified in section 3.2.1.3 of [4].

The code for this option is 28, and its length is 4.

CAPWAP_AC_V4 = 138 class-attribute instance-attribute

CCC = 122 class-attribute instance-attribute

CLASSLESS_STATIC_ROUTE = 121 class-attribute instance-attribute

CLIENT_FQDN = 81 class-attribute instance-attribute

CLIENT_IDENTIFIER = 61 class-attribute instance-attribute

This option is used by DHCP clients to specify their unique identifier. DHCP servers use this value to index their database of address bindings. This value is expected to be unique for all clients in an administrative domain.

Identifiers SHOULD be treated as opaque objects by DHCP servers.

The client identifier MAY consist of type-value pairs similar to the 'htype'/'chaddr' fields defined in [3]. For instance, it MAY consist of a hardware type and hardware address. In this case the type field SHOULD be one of the ARP hardware types defined in STD2 [22]. A hardware type of 0 (zero) should be used when the value field contains an identifier other than a hardware address (e.g. a fully qualified domain name).

For correct identification of clients, each client's client- identifier MUST be unique among the client-identifiers used on the subnet to which the client is attached. Vendors and system administrators are responsible for choosing client-identifiers that meet this requirement for uniqueness.

The code for this option is 61, and its minimum length is 2.

CLIENT_LAST_TRANSACTION_TIME = 91 class-attribute instance-attribute

CLIENT_NDI = 94 class-attribute instance-attribute

CLIENT_SYSTEM_ARCHITECTURE = 93 class-attribute instance-attribute

CONFIGURATION_FILE = 209 class-attribute instance-attribute

COOKIE_SERVER = 8 class-attribute instance-attribute

The cookie server option specifies a list of RFC 865 [9] cookie servers available to the client. Servers SHOULD be listed in order of preference.

The code for the log server option is 8. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

DATA_SOURCE = 157 class-attribute instance-attribute

DHCP4_OVER_DHCP6_SOURCE_ADDRESS = 109 class-attribute instance-attribute

DHCP_CAPTIVE_PORTAL = 114 class-attribute instance-attribute

DHCP_MESSAGE = 56 class-attribute instance-attribute

This option is used by a DHCP server to provide an error message to a DHCP client in a DHCPNAK message in the event of a failure. A client may use this option in a DHCPDECLINE message to indicate the why the client declined the offered parameters. The message consists of n octets of NVT ASCII text, which the client may display on an available output device.

The code for this option is 56 and its minimum length is 1.

DHCP_MESSAGE_TYPE = 53 class-attribute instance-attribute

This option is used to convey the type of the DHCP message. The code for this option is 53, and its length is 1. Legal values for this option are:

    Value   Message Type
    -----   ------------
      1     DHCPDISCOVER
      2     DHCPOFFER
      3     DHCPREQUEST
      4     DHCPDECLINE
      5     DHCPACK
      6     DHCPNAK
      7     DHCPRELEASE
      8     DHCPINFORM

DHCP_STATE = 156 class-attribute instance-attribute

DIRECTORY_AGENT = 78 class-attribute instance-attribute

DNS = 6 class-attribute instance-attribute

The domain name server option specifies a list of Domain Name System (STD 13, RFC 1035 [8]) name servers available to the client. Servers SHOULD be listed in order of preference.

The code for the domain name server option is 6. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

DOMAIN_NAME = 15 class-attribute instance-attribute

This option specifies the domain name that client should use when resolving hostnames via the Domain Name System.

The code for this option is 15. Its minimum length is 1.

END = 255 class-attribute instance-attribute

The end option marks the end of valid information in the vendor field. Subsequent octets should be filled with pad options.

The code for the end option is 255, and its length is 1 octet.

ETHERBOOT = 175 class-attribute instance-attribute

ETHERNET_ENCAPSULATION = 36 class-attribute instance-attribute

This option specifies whether or not the client should use Ethernet Version 2 (RFC 894 [15]) or IEEE 802.3 (RFC 1042 [16]) encapsulation if the interface is an Ethernet. A value of 0 indicates that the client should use RFC 894 encapsulation. A value of 1 means that the client should use RFC 1042 encapsulation.

The code for this option is 36, and its length is 1.

EXTENSION_FILE = 18 class-attribute instance-attribute

A string to specify a file, retrievable via TFTP, which contains information which can be interpreted in the same way as the 64-octet vendor-extension field within the BOOTP response, with the following exceptions: - the length of the file is unconstrained; - all references to Tag 18 (i.e., instances of the BOOTP Extensions Path field) within the file are ignored.

The code for this option is 18. Its minimum length is 1.

FINGER_SERVER = 73 class-attribute instance-attribute

The Finger server option specifies a list of Finger available to the client. Servers SHOULD be listed in order of preference.

The code for the Finger server option is 73. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

FORCERENEW_NONCE_CAPABLE = 145 class-attribute instance-attribute

GEOCONF = 123 class-attribute instance-attribute

GEOCONF_CIVIC = 99 class-attribute instance-attribute

GEOLOC = 144 class-attribute instance-attribute

GRD = 212 class-attribute instance-attribute

An alias of SIXRD: DHCPOptionCode(212).name is SIXRD.

HOME_AGENT_ADDRESSES = 68 class-attribute instance-attribute

This option specifies a list of IP addresses indicating mobile IP home agents available to the client. Agents SHOULD be listed in order of preference.

The code for this option is 68. Its minimum length is 0 (indicating no home agents are available) and the length MUST be a multiple of 4. It is expected that the usual length will be four octets, containing a single home agent's address.

HOSTNAME = 12 class-attribute instance-attribute

This option specifies the name of the client. The name may or may not be qualified with the local domain name (see section 3.17 for the preferred way to retrieve the domain name). See RFC 1035 for character set restrictions.

The code for this option is 12, and its minimum length is 1.

IEN116_NAMESERVER = 5 class-attribute instance-attribute

The name server option specifies a list of IEN 116 [7] name servers available to the client. Servers SHOULD be listed in order of preference.

The code for the name server option is 5. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

IMPRESS_SERVER = 10 class-attribute instance-attribute

The Impress server option specifies a list of Imagen Impress servers available to the client. Servers SHOULD be listed in order of preference.

The code for the Impress server option is 10. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

INTERFACE_MTU = 26 class-attribute instance-attribute

This option specifies the MTU to use on this interface. The MTU is specified as a 16-bit unsigned integer. The minimum legal value for the MTU is 68.

The code for this option is 26, and its length is 2.

IPV4_ADDRESS_ANDSF = 142 class-attribute instance-attribute

IPV4_ADDRESS_MOS = 139 class-attribute instance-attribute

IPV4_FQDN_MOS = 140 class-attribute instance-attribute

IPV6_ONLY = 108 class-attribute instance-attribute

IP_ADDRESS_LEASE_TIME = 51 class-attribute instance-attribute

This option is used in a client request (DHCPDISCOVER or DHCPREQUEST) to allow the client to request a lease time for the IP address. In a server reply (DHCPOFFER), a DHCP server uses this option to specify the lease time it is willing to offer.

The time is in units of seconds, and is specified as a 32-bit unsigned integer.

The code for this option is 51, and its length is 4.

IP_FORWARDING = 19 class-attribute instance-attribute

This option specifies whether the client should configure its IP layer for packet forwarding. A value of 0 means disable IP forwarding, and a value of 1 means enable IP forwarding.

The code for this option is 19, and its length is 1.

IP_TELEPHONE = 176 class-attribute instance-attribute

IP_TTL = 23 class-attribute instance-attribute

This option specifies the default time-to-live that the client should use on outgoing datagrams. The TTL is specified as an octet with a value between 1 and 255.

The code for this option is 23, and its length is 1.

IRC_SERVER = 74 class-attribute instance-attribute

The IRC server option specifies a list of IRC available to the client. Servers SHOULD be listed in order of preference.

The code for the IRC server option is 74. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

ISNS = 83 class-attribute instance-attribute

LDAP = 95 class-attribute instance-attribute

IANA currently lists option 95 as unassigned/reclaimed per RFC 3679.

Interoperability with a standards-conformant peer should not be assumed.

LEGACY_CCC = 177 class-attribute instance-attribute

This option was historically used for PacketCable/CableHome CCC (Client Configuration for CableHome), later superseded by option 122 (CCC). IANA's registry separately lists 177 under a tentative 2005 Etherboot assignment as well; do not conflate the two.

LOG_SERVER = 7 class-attribute instance-attribute

The log server option specifies a list of MIT-LCS UDP log servers available to the client. Servers SHOULD be listed in order of preference.

The code for the log server option is 7. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

LPR_SERVER = 9 class-attribute instance-attribute

The LPR server option specifies a list of RFC 1179 [10] line printer servers available to the client. Servers SHOULD be listed in order of preference.

The code for the LPR server option is 9. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

MASK_DISCOVERY = 29 class-attribute instance-attribute

This option specifies whether or not the client should perform subnet mask discovery using ICMP. A value of 0 indicates that the client should not perform mask discovery. A value of 1 means that the client should perform mask discovery.

The code for this option is 29, and its length is 1.

MASK_SUPPLIER = 30 class-attribute instance-attribute

This option specifies whether or not the client should respond to subnet mask requests using ICMP. A value of 0 indicates that the client should not respond. A value of 1 means that the client should respond.

The code for this option is 30, and its length is 1.

MAXIMUM_DHCP_MESSAGE_SIZE = 57 class-attribute instance-attribute

This option specifies the maximum length DHCP message that it is willing to accept. The length is specified as an unsigned 16-bit integer. A client may use the maximum DHCP message size option in DHCPDISCOVER or DHCPREQUEST messages, but should not use the option in DHCPDECLINE messages.

The code for this option is 57, and its length is 2. The minimum legal value is 576 octets.

MAX_DATAGRAM_REASSEMBLY_SIZE = 22 class-attribute instance-attribute

This option specifies the maximum size datagram that the client should be prepared to reassemble. The size is specified as a 16-bit unsigned integer. The minimum value legal value is 576.

The code for this option is 22, and its length is 2.

MERIT_DUMP_FILE = 14 class-attribute instance-attribute

This option specifies the path-name of a file to which the client's core image should be dumped in the event the client crashes. The path is formatted as a character string consisting of characters from the NVT ASCII character set.

The code for this option is 14. Its minimum length is 1.

MSFT_CLASSLESS_STATIC_ROUTE = 249 class-attribute instance-attribute

MTU_PLATEAU = 25 class-attribute instance-attribute

This option specifies a table of MTU sizes to use when performing Path MTU Discovery as defined in RFC 1191. The table is formatted as a list of 16-bit unsigned integers, ordered from smallest to largest. The minimum MTU value cannot be smaller than 68.

The code for this option is 25. Its minimum length is 2, and the length MUST be a multiple of 2.

MTU_TIMEOUT = 24 class-attribute instance-attribute

This option specifies the timeout (in seconds) to use when aging Path MTU values discovered by the mechanism defined in RFC 1191 [12]. The timeout is specified as a 32-bit unsigned integer.

The code for this option is 24, and its length is 4.

MUD_URL_V4 = 161 class-attribute instance-attribute

NBDD_SERVERS = 45 class-attribute instance-attribute

The NetBIOS datagram distribution server (NBDD) option specifies a list of RFC 1001/1002 NBDD servers listed in order of preference. The code for this option is 45. The minimum length of the option is 4 octets, and the length must always be a multiple of 4.

NBNS_SERVERS = 44 class-attribute instance-attribute

The NetBIOS name server (NBNS) option specifies a list of RFC 1001/1002 [19] [20] NBNS name servers listed in order of preference.

The code for this option is 44. The minimum length of the option is 4 octets, and the length must always be a multiple of 4.

NDS_CONTEXT = 87 class-attribute instance-attribute

NDS_SERVERS = 85 class-attribute instance-attribute

NDS_TREE_NAME = 86 class-attribute instance-attribute

NETBIOS_NODE_TYPE = 46 class-attribute instance-attribute

The NetBIOS node type option allows NetBIOS over TCP/IP clients which are configurable to be configured as described in RFC 1001/1002. The value is specified as a single octet which identifies the client type as follows:

Value Node Type

----- ---------

0x1 B-node

0x2 P-node

0x4 M-node

0x8 H-node

In the above chart, the notation '0x' indicates a number in base-16 (hexadecimal).

The code for this option is 46. The length of this option is always 1.

NETBIOS_SCOPE = 47 class-attribute instance-attribute

The NetBIOS scope option specifies the NetBIOS over TCP/IP scope parameter for the client as specified in RFC 1001/1002. See [19], [20], and [8] for character-set restrictions.

The code for this option is 47. The minimum length of this option is 1.

NETINFO_ADDRESS = 112 class-attribute instance-attribute

NETINFO_TAG = 113 class-attribute instance-attribute

NETWARE_DOMAIN = 62 class-attribute instance-attribute

NETWARE_OPTION = 63 class-attribute instance-attribute

NIS_DOMAIN = 40 class-attribute instance-attribute

This option specifies the name of the client's NIS [17] domain. The domain is formatted as a character string consisting of characters from the NVT ASCII character set.

The code for this option is 40. Its minimum length is 1.

NIS_PLUS_DOMAIN = 64 class-attribute instance-attribute

This option specifies the name of the client's NIS+ [17] domain. The domain is formatted as a character string consisting of characters from the NVT ASCII character set.

The code for this option is 64. Its minimum length is 1.

NIS_PLUS_SERVERS = 65 class-attribute instance-attribute

This option specifies a list of IP addresses indicating NIS+ servers available to the client. Servers SHOULD be listed in order of preference.

The code for this option is 65. Its minimum length is 4, and the length MUST be a multiple of 4.

NIS_SERVERS = 41 class-attribute instance-attribute

This option specifies a list of IP addresses indicating NIS servers available to the client. Servers SHOULD be listed in order of preference.

The code for this option is 41. Its minimum length is 4, and the length MUST be a multiple of 4.

NNTP_SERVER = 71 class-attribute instance-attribute

The NNTP server option specifies a list of NNTP available to the client. Servers SHOULD be listed in order of preference.

The code for the NNTP server option is 71. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

NON_LOCAL_SOURCE_ROUTING = 20 class-attribute instance-attribute

This option specifies whether the client should configure its IP layer to allow forwarding of datagrams with non-local source routes (see Section 3.3.5 of [4] for a discussion of this topic). A value of 0 means disallow forwarding of such datagrams, and a value of 1 means allow forwarding.

The code for this option is 20, and its length is 1.

NTP_SERVERS = 42 class-attribute instance-attribute

This option specifies a list of IP addresses indicating NTP [18] servers available to the client. Servers SHOULD be listed in order of preference.

The code for this option is 42. Its minimum length is 4, and the length MUST be a multiple of 4.

OPTION_OVERLOAD = 52 class-attribute instance-attribute

This option is used to indicate that the DHCP 'sname' or 'file' fields are being overloaded by using them to carry DHCP options. A DHCP server inserts this option if the returned parameters will exceed the usual space allotted for options.

If this option is present, the client interprets the specified additional fields after it concludes interpretation of the standard option fields.

The code for this option is 52, and its length is 1. Legal values for this option are:

    Value   Meaning
    -----   --------
      1     the 'file' field is used to hold options
      2     the 'sname' field is used to hold options
      3     both fields are used to hold options

PAD = 0 class-attribute instance-attribute

The pad option can be used to cause subsequent fields to align on word boundaries.

The code for the pad option is 0, and its length is 1 octet.

PANA_AGENT = 136 class-attribute instance-attribute

PARAMETER_REQUEST_LIST = 55 class-attribute instance-attribute

This option is used by a DHCP client to request values for specified configuration parameters. The list of requested parameters is specified as n octets, where each octet is a valid DHCP option code as defined in this document.

The client MAY list the options in order of preference. The DHCP server is not required to return the options in the requested order, but MUST try to insert the requested options in the order requested by the client.

The code for this option is 55. Its minimum length is 1.

PATH_PREFIX = 210 class-attribute instance-attribute

PCODE = 100 class-attribute instance-attribute

POLICY_FILTER = 21 class-attribute instance-attribute

This option specifies policy filters for non-local source routing. The filters consist of a list of IP addresses and masks which specify destination/mask pairs with which to filter incoming source routes.

Any source routed datagram whose next-hop address does not match one of the filters should be discarded by the client.

The code for this option is 21. The minimum length of this option is 8, and the length MUST be a multiple of 8.

POP3_SERVER = 70 class-attribute instance-attribute

The POP3 server option specifies a list of POP3 available to the client. Servers SHOULD be listed in order of preference.

The code for the POP3 server option is 70. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

PXE_LINUX_MAGIC = 208 class-attribute instance-attribute

magic string = F1:00:74:7E

PXE_VENDOR_SPECIFIC_1 = 128 class-attribute instance-attribute

VENDOR SPECIFIC 128-135

PXE_VENDOR_SPECIFIC_2 = 129 class-attribute instance-attribute

PXE_VENDOR_SPECIFIC_3 = 130 class-attribute instance-attribute

PXE_VENDOR_SPECIFIC_4 = 131 class-attribute instance-attribute

PXE_VENDOR_SPECIFIC_5 = 132 class-attribute instance-attribute

PXE_VENDOR_SPECIFIC_6 = 133 class-attribute instance-attribute

PXE_VENDOR_SPECIFIC_7 = 134 class-attribute instance-attribute

PXE_VENDOR_SPECIFIC_8 = 135 class-attribute instance-attribute

QUERY_END_TIME = 155 class-attribute instance-attribute

QUERY_START_TIME = 154 class-attribute instance-attribute

RAPID_COMMIT = 80 class-attribute instance-attribute

RDNSS_SELECTION = 146 class-attribute instance-attribute

REBINDING_TIME = 59 class-attribute instance-attribute

This option specifies the time interval from address assignment until the client transitions to the REBINDING state.

The value is in units of seconds, and is specified as a 32-bit unsigned integer.

The code for this option is 59, and its length is 4.

REBOOT_TIME = 211 class-attribute instance-attribute

RELAY_AGENT_INFORMATION = 82 class-attribute instance-attribute

RENEWAL_TIME = 58 class-attribute instance-attribute

This option specifies the time interval from address assignment until the client transitions to the RENEWING state.

The value is in units of seconds, and is specified as a 32-bit unsigned integer.

The code for this option is 58, and its length is 4.

REQUESTED_IP = 50 class-attribute instance-attribute

This option is used in a client request (DHCPDISCOVER) to allow the client to request that a particular IP address be assigned.

The code for this option is 50, and its length is 4.

RFC868_TIMESERVER = 4 class-attribute instance-attribute

The time server option specifies a list of RFC 868 time servers available to the client. Servers SHOULD be listed in order of preference.

The code for the time server option is 4. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

RLP_SERVER = 11 class-attribute instance-attribute

his option specifies a list of RFC 887 [11] Resource Location servers available to the client. Servers SHOULD be listed in order of preference.

The code for this option is 11. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

ROOT_PATH = 17 class-attribute instance-attribute

This option specifies the path-name that contains the client's root disk. The path is formatted as a character string consisting of characters from the NVT ASCII character set.

The code for this option is 17. Its minimum length is 1.

ROUTER = 3 class-attribute instance-attribute

The router option specifies a list of IP addresses for routers on the client's subnet. Routers SHOULD be listed in order of preference.

The code for the router option is 3. The minimum length for the router option is 4 octets, and the length MUST always be a multiple of 4.

ROUTER_DISCOVERY = 31 class-attribute instance-attribute

This option specifies whether or not the client should solicit routers using the Router Discovery mechanism defined in RFC 1256 [13]. A value of 0 indicates that the client should not perform router discovery. A value of 1 means that the client should perform router discovery.

The code for this option is 31, and its length is 1.

ROUTER_SOLICITATION_ADDRESS = 32 class-attribute instance-attribute

This option specifies the address to which the client should transmit router solicitation requests.

The code for this option is 32, and its length is 4.

SERVER_IDENTIFIER = 54 class-attribute instance-attribute

This option is used in DHCPOFFER and DHCPREQUEST messages, and may optionally be included in the DHCPACK and DHCPNAK messages. DHCP servers include this option in the DHCPOFFER in order to allow the client to distinguish between lease offers. DHCP clients use the contents of the 'server identifier' field as the destination address for any DHCP messages unicast to the DHCP server. DHCP clients also indicate which of several lease offers is being accepted by including this option in a DHCPREQUEST message.

The identifier is the IP address of the selected server.

The code for this option is 54, and its length is 4.

SERVICE_SCOPE = 79 class-attribute instance-attribute

SIP_SERVERS = 120 class-attribute instance-attribute

SIP_UA_CONFIG_SERVICE_DOMAINS = 141 class-attribute instance-attribute

SIXRD = 212 class-attribute instance-attribute

IANA option 212, OPTION_6RD (RFC 5969).

SMTP_SERVER = 69 class-attribute instance-attribute

The SMTP server option specifies a list of SMTP servers available to the client. Servers SHOULD be listed in order of preference.

The code for the SMTP server option is 69. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

START_TIME_OF_STATE = 153 class-attribute instance-attribute

STATIC_ROUTE = 33 class-attribute instance-attribute

This option specifies a list of static routes that the client should install in its routing cache. If multiple routes to the same destination are specified, they are listed in descending order of priority.

The routes consist of a list of IP address pairs. The first address is the destination address, and the second address is the router for the destination.

The default route (0.0.0.0) is an illegal destination for a static route. See section 3.5 for information about the router option.

The code for this option is 33. The minimum length of this option is 8, and the length MUST be a multiple of 8.

STATUS_CODE = 151 class-attribute instance-attribute

STDA_SERVER = 76 class-attribute instance-attribute

The StreetTalk Directory Assistance (STDA) server option specifies a list of STDA servers available to the client. Servers SHOULD be listed in order of preference.

The code for the StreetTalk Directory Assistance server option is 76. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

STREETTALK_SERVER = 75 class-attribute instance-attribute

The StreetTalk server option specifies a list of StreetTalk servers available to the client. Servers SHOULD be listed in order of preference.

The code for the StreetTalk server option is 75. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

SUBNET_ALLOCATION = 220 class-attribute instance-attribute

SUBNET_MASK = 1 class-attribute instance-attribute

The subnet mask option specifies the client's subnet mask as per RFC 950. If both the subnet mask and the router option are specified in a DHCP reply, the subnet mask option MUST be first.

The code for the subnet mask option is 1, and its length is 4 octets.

SUBNET_SELECTION_OPTION = 118 class-attribute instance-attribute

SWAP_SERVER = 16 class-attribute instance-attribute

This specifies the IP address of the client's swap server.

The code for this option is 16 and its length is 4.

TCODE = 101 class-attribute instance-attribute

TCP_DEFAULT_TTL = 37 class-attribute instance-attribute

This option specifies the default TTL that the client should use when sending TCP segments. The value is represented as an 8-bit unsigned integer. The minimum value is 1.

The code for this option is 37, and its length is 1.

TCP_KEEPALIVE_GARBAGE = 39 class-attribute instance-attribute

This option specifies the whether or not the client should send TCP keepalive messages with a octet of garbage for compatibility with older implementations. A value of 0 indicates that a garbage octet should not be sent. A value of 1 indicates that a garbage octet should be sent.

The code for this option is 39, and its length is 1.

TCP_KEEPALIVE_INTERVAL = 38 class-attribute instance-attribute

This option specifies the interval (in seconds) that the client TCP should wait before sending a keepalive message on a TCP connection. The time is specified as a 32-bit unsigned integer. A value of zero indicates that the client should not generate keepalive messages on connections unless specifically requested by an application.

The code for this option is 38, and its length is 4.

TFTP_SERVER = 66 class-attribute instance-attribute

This option is used to identify a TFTP server when the 'sname' field in the DHCP header has been used for DHCP options.

The code for this option is 66, and its minimum length is 1.

TFTP_SERVER_ADDRESS = 150 class-attribute instance-attribute

Can replace siaddr and also provide multiple addresses instead of just one. Only IP Address

May also be use by ETHERBOOT

GRUB configuration path name

TIME_OFFSET = 2 class-attribute instance-attribute

The time offset field specifies the offset of the client's subnet in seconds from Coordinated Universal Time (UTC). The offset is expressed as a two's complement 32-bit integer. A positive offset indicates a location east of the zero meridian and a negative offset indicates a location west of the zero meridian.

The code for the time offset option is 2, and its length is 4 octets.

TRAILER_ENCAPSULATION = 34 class-attribute instance-attribute

This option specifies whether or not the client should negotiate the use of trailers (RFC 893 [14]) when using the ARP protocol. A value of 0 indicates that the client should not attempt to use trailers. A value of 1 means that the client should attempt to use trailers.

The code for this option is 34, and its length is 1.

USERAUTH = 98 class-attribute instance-attribute

USER_CLASS = 77 class-attribute instance-attribute

UUID = 97 class-attribute instance-attribute

V4_ACCESS_DOMAIN = 213 class-attribute instance-attribute

V4_DNR = 162 class-attribute instance-attribute

V4_DOTS_ADDRESS = 148 class-attribute instance-attribute

V4_DOTS_RI = 147 class-attribute instance-attribute

V4_LOST = 137 class-attribute instance-attribute

V4_PCP_SERVER = 158 class-attribute instance-attribute

V4_PORTPARAMS = 159 class-attribute instance-attribute

V4_SZTP_REDIRECT = 143 class-attribute instance-attribute

VENDOR_CLASS_IDENTIFIER = 60 class-attribute instance-attribute

This option is used by DHCP clients to optionally identify the vendor type and configuration of a DHCP client. The information is a string of n octets, interpreted by servers. Vendors may choose to define specific vendor class identifiers to convey particular configuration or other identification information about a client. For example, the identifier may encode the client's hardware configuration. Servers not equipped to interpret the class-specific information sent by a client MUST ignore it (although it may be reported). Servers that

respond SHOULD only use option 43 to return the vendor-specific information to the client.

The code for this option is 60, and its minimum length is 1.

VENDOR_SPECIFIC_INFORMATION = 43 class-attribute instance-attribute

This option is used by clients and servers to exchange vendor- specific information. The information is an opaque object of n octets, presumably interpreted by vendor-specific code on the clients and servers. The definition of this information is vendor specific. The vendor is indicated in the vendor class identifier option. Servers not equipped to interpret the vendor-specific information sent by a client MUST ignore it (although it may be reported). Clients which do not receive desired vendor-specific information SHOULD make an attempt to operate without it, although they may do so (and announce they are doing so) in a degraded mode.

If a vendor potentially encodes more than one item of information in this option, then the vendor SHOULD encode the option using "Encapsulated vendor-specific options" as described below:

The Encapsulated vendor-specific options field SHOULD be encoded as a sequence of code/length/value fields of identical syntax to the DHCP options field with the following exceptions:

1) There SHOULD NOT be a "magic cookie" field in the encapsulated vendor-specific extensions field.

2) Codes other than 0 or 255 MAY be redefined by the vendor within the encapsulated vendor-specific extensions field, but SHOULD conform to the tag-length-value syntax defined in section 2.

3) Code 255 (END), if present, signifies the end of the encapsulated vendor extensions, not the end of the vendor extensions field. If no code 255 is present, then the end of the enclosing vendor-specific information field is taken as the end of the encapsulated vendor-specific extensions field.

The code for this option is 43 and its minimum length is 1.

VI_VENDOR_CLASS = 124 class-attribute instance-attribute

VI_VENDOR_SPECIFIC_INFORMATION = 125 class-attribute instance-attribute

VSS = 221 class-attribute instance-attribute

WPAD = 252 class-attribute instance-attribute

WWW_SERVER = 72 class-attribute instance-attribute

The WWW server option specifies a list of WWW available to the client. Servers SHOULD be listed in order of preference.

The code for the WWW server option is 72. The minimum length for this option is 4 octets, and the length MUST always be a multiple of 4.

X_WINDOW_FONT_SERVER = 48 class-attribute instance-attribute

This option specifies a list of X Window System [21] Font servers available to the client. Servers SHOULD be listed in order of preference.

The code for this option is 48. The minimum length of this option is 4 octets, and the length MUST be a multiple of 4.

X_WINDOW_MANAGER = 49 class-attribute instance-attribute

This option specifies a list of IP addresses of systems that are running the X Window System Display Manager and are available to the client.

Addresses SHOULD be listed in order of preference.

The code for the this option is 49. The minimum length of this option is 4, and the length MUST be a multiple of 4.

BaseDHCPOptionCode

The base any custom code map subclasses; pass one to DHCPOptions(codemap=...) to serve a private or vendor option space instead of the IANA registry above.

pydhcp.options.BaseDHCPOptionCode

The base class of a code enum: the defaults of OptionCode.

A subclass that is an int or has an int value (an IntEnum) needs only to override get_type and label for the codes it names.

Source code in src/pydhcp/options/_codes.py
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
class BaseDHCPOptionCode:
    """The base class of a code enum: the defaults of `OptionCode`.

    A subclass that is an `int` or has an int `value` (an `IntEnum`) needs only
    to override `get_type` and `label` for the codes it names.
    """

    def get_type(self) -> "type[OptionCodec]":
        return Bytes

    def label(self) -> str:
        return "UNKNOWN"

    @classmethod
    def from_code(cls, code: int) -> "BaseDHCPOptionCode":
        return cls(code)  # type: ignore[call-arg]

    @classmethod
    def resolve(cls: type[_BaseCodeT], name: _ty.Union[str, int]) -> _BaseCodeT:
        """The code `name` names: a number, `option-<n>`, or a member name in
        any case with `-` and `_` alike (`bootfile-name`). `ValueError` for text
        that is none of these; see `pydhcp/options/AGENTS.md`."""
        return _ty.cast(_BaseCodeT, cls.from_code(_names.resolve_code(cls, name)))

    def __int__(self) -> int:
        """The option code as a byte value.

        A subclass that carries neither a `value` (the enum case) nor an
        integer identity of its own has no option code, and raises. Zero is
        not a neutral answer: it is PAD, the wire padding marker, so such a
        code would silently address option 0 and be stored, encoded and
        emitted as a PAD TLV.
        """
        if hasattr(self, "value"):
            return int(self.value)
        if isinstance(self, int):
            return int.__index__(self)
        raise TypeError(
            f"{type(self).__name__} has no option-code value: give the class an "
            "int `value` (an IntEnum member does) or make it an `int` subclass"
        )

    def to_json(self) -> int:
        return int(self)

    def __repr__(self) -> str:
        code_val = int(self) if isinstance(self, int) else 0
        return f"[{code_val:0>3}]{self.label()}"

    def __str__(self) -> str:
        return self.label()

    @classmethod
    def normalize(cls, code: int, value: object) -> DHCPOption:
        _code = cls.from_code(code)
        return DHCPOption(_code, _code.get_type()(value))

    @classmethod
    def decode(cls, code: int, value: bytearray) -> DHCPOption:
        _code = cls.from_code(code)
        return DHCPOption(_code, _code.get_type().unpack(value))

__int__()

The option code as a byte value.

A subclass that carries neither a value (the enum case) nor an integer identity of its own has no option code, and raises. Zero is not a neutral answer: it is PAD, the wire padding marker, so such a code would silently address option 0 and be stored, encoded and emitted as a PAD TLV.

Source code in src/pydhcp/options/_codes.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def __int__(self) -> int:
    """The option code as a byte value.

    A subclass that carries neither a `value` (the enum case) nor an
    integer identity of its own has no option code, and raises. Zero is
    not a neutral answer: it is PAD, the wire padding marker, so such a
    code would silently address option 0 and be stored, encoded and
    emitted as a PAD TLV.
    """
    if hasattr(self, "value"):
        return int(self.value)
    if isinstance(self, int):
        return int.__index__(self)
    raise TypeError(
        f"{type(self).__name__} has no option-code value: give the class an "
        "int `value` (an IntEnum member does) or make it an `int` subclass"
    )

__repr__()

Source code in src/pydhcp/options/_codes.py
86
87
88
def __repr__(self) -> str:
    code_val = int(self) if isinstance(self, int) else 0
    return f"[{code_val:0>3}]{self.label()}"

__str__()

Source code in src/pydhcp/options/_codes.py
90
91
def __str__(self) -> str:
    return self.label()

decode(code, value) classmethod

Source code in src/pydhcp/options/_codes.py
 98
 99
100
101
@classmethod
def decode(cls, code: int, value: bytearray) -> DHCPOption:
    _code = cls.from_code(code)
    return DHCPOption(_code, _code.get_type().unpack(value))

from_code(code) classmethod

Source code in src/pydhcp/options/_codes.py
54
55
56
@classmethod
def from_code(cls, code: int) -> "BaseDHCPOptionCode":
    return cls(code)  # type: ignore[call-arg]

get_type()

Source code in src/pydhcp/options/_codes.py
48
49
def get_type(self) -> "type[OptionCodec]":
    return Bytes

label()

Source code in src/pydhcp/options/_codes.py
51
52
def label(self) -> str:
    return "UNKNOWN"

normalize(code, value) classmethod

Source code in src/pydhcp/options/_codes.py
93
94
95
96
@classmethod
def normalize(cls, code: int, value: object) -> DHCPOption:
    _code = cls.from_code(code)
    return DHCPOption(_code, _code.get_type()(value))

resolve(name) classmethod

The code name names: a number, option-<n>, or a member name in any case with - and _ alike (bootfile-name). ValueError for text that is none of these; see pydhcp/options/AGENTS.md.

Source code in src/pydhcp/options/_codes.py
58
59
60
61
62
63
@classmethod
def resolve(cls: type[_BaseCodeT], name: _ty.Union[str, int]) -> _BaseCodeT:
    """The code `name` names: a number, `option-<n>`, or a member name in
    any case with `-` and `_` alike (`bootfile-name`). `ValueError` for text
    that is none of these; see `pydhcp/options/AGENTS.md`."""
    return _ty.cast(_BaseCodeT, cls.from_code(_names.resolve_code(cls, name)))

to_json()

Source code in src/pydhcp/options/_codes.py
83
84
def to_json(self) -> int:
    return int(self)

DHCPOption

pydhcp.options.DHCPOption

Bases: NamedTuple

Source code in src/pydhcp/options/_codes.py
104
105
106
class DHCPOption(_ty.NamedTuple):
    code: _ty.Union[int, "OptionCode"]
    value: "OptionCodec"

code instance-attribute

value instance-attribute

Option Types

pydhcp.options

BaseFixedLengthInteger

Bases: DHCPOptionType, int

Source code in src/pydhcp/options/_codecs/_scalar.py
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
class BaseFixedLengthInteger(DHCPOptionType, int):
    NUMBER_OF_BYTES: int
    SIGNED: bool = False

    @classmethod
    def unpack_from(
        cls: type[_BaseFixedLengthIntegerT], option: memoryview
    ) -> tuple[_BaseFixedLengthIntegerT, int]:
        option_part = option[: cls.NUMBER_OF_BYTES]
        if len(option_part) != cls.NUMBER_OF_BYTES:
            raise DHCPDecodeError(
                f"{cls.__name__} needs {cls.NUMBER_OF_BYTES} octets, "
                f"got {len(option_part)}"
            )
        return (
            cls(int.from_bytes(option_part, "big", signed=cls.SIGNED)),
            cls.NUMBER_OF_BYTES,
        )

    def pack_into(self, data: bytearray) -> int:
        self._validate()
        data.extend(self.to_bytes(self.NUMBER_OF_BYTES, "big", signed=self.SIGNED))
        return self.NUMBER_OF_BYTES

    @classmethod
    def fixed_size(cls) -> _ty.Optional[int]:
        return cls.NUMBER_OF_BYTES

    def _validate(self) -> None:
        bits = self.NUMBER_OF_BYTES * 8
        if self.SIGNED:
            low, high = -(1 << (bits - 1)), (1 << (bits - 1)) - 1
        else:
            low, high = 0, (1 << bits) - 1
        if low <= self <= high:
            return
        if self > high:
            what = "Number is too big"
        else:
            what = "Number is too small" if self.SIGNED else "Value must not be signed"
        raise DHCPValueError(
            f"{what}: {type(self).__name__} holds {low} to {high}, not {int(self)}"
        )

    def __repr__(self) -> str:
        return f"{type(self).__name__}({int(self)!r})"

    def to_json(self) -> int:
        """A plain `int`, not this subclass.

        The base `to_json` returns `self`, and `self` is a `U16`/`U32`. JSON
        tolerates an int subclass; YAML refuses to represent it and TOML writes
        something it cannot read back, so a packet carrying any integer option
        -- which is most real packets, via option 57 or 51 -- could not survive
        a round trip through either.
        """
        return int(self)

to_json()

A plain int, not this subclass.

The base to_json returns self, and self is a U16/U32. JSON tolerates an int subclass; YAML refuses to represent it and TOML writes something it cannot read back, so a packet carrying any integer option -- which is most real packets, via option 57 or 51 -- could not survive a round trip through either.

Source code in src/pydhcp/options/_codecs/_scalar.py
354
355
356
357
358
359
360
361
362
363
def to_json(self) -> int:
    """A plain `int`, not this subclass.

    The base `to_json` returns `self`, and `self` is a `U16`/`U32`. JSON
    tolerates an int subclass; YAML refuses to represent it and TOML writes
    something it cannot read back, so a packet carrying any integer option
    -- which is most real packets, via option 57 or 51 -- could not survive
    a round trip through either.
    """
    return int(self)

Boolean

Bases: DHCPOptionType, int

Boolean option encoded as a single octet.

Source code in src/pydhcp/options/_codecs/_scalar.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
class Boolean(DHCPOptionType, int):
    """Boolean option encoded as a single octet."""

    def __new__(cls: type[_BooleanT], val: _ty.Any) -> _BooleanT:
        if val:
            val = 1
        else:
            val = 0
        return super().__new__(cls, val)

    @classmethod
    def unpack_from(cls: type[_BooleanT], option: memoryview) -> tuple[_BooleanT, int]:
        return cls(option[0]), 1

    def pack_into(self, data: bytearray) -> int:
        data.append(self)
        return 1

    @classmethod
    def fixed_size(cls) -> _ty.Optional[int]:
        return 1

    def __repr__(self) -> str:
        return f"{type(self).__name__}({bool(self)!r})"

    def display_text(self) -> str:
        return f"Boolean({bool(self)!r})"

    def to_json(self) -> bool:
        return self.__bool__()

Bytes

Bases: DHCPOptionType, _TextForm, bytes

Opaque byte payload: built from bytes, written as hex text.

Source code in src/pydhcp/options/_codecs/_scalar.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class Bytes(DHCPOptionType, _TextForm, bytes):
    """Opaque byte payload: built from bytes, written as hex text."""

    def __new__(
        cls: type[_BytesT],
        value: _ty.Optional[_ty.Union[bytes, bytearray, memoryview]] = None,
    ) -> _BytesT:
        if isinstance(value, str):
            raise TypeError(
                f"{cls.__name__} is built from bytes, not text: "
                f"use {cls.__name__}.parse for hex text"
            )
        if isinstance(value, int):
            raise TypeError(
                f"{cls.__name__} is built from bytes, not a {type(value).__name__}"
            )
        if value is None:
            return super().__new__(cls)
        return super().__new__(cls, value)

    @classmethod
    def parse(cls: type[_BytesT], text: str) -> _BytesT:
        """Build from hex text; spaces and colons between octets are ignored.

        Raises `DHCPValueError` for anything that is not whole hex octets.
        """
        digits = _text_argument(cls, text).replace(":", "")
        try:
            return cls(bytes.fromhex(digits))
        except ValueError as exc:
            raise DHCPValueError(f"not hex octets, {text!r}: {exc}") from exc

    def __repr__(self) -> str:
        return f"{type(self).__name__}({bytes.__repr__(self)})"

    def display_text(self) -> str:
        return bytes.__repr__(self)

    def __str__(self) -> str:
        return self.hex().upper()

    @classmethod
    def unpack_from(cls: type[_BytesT], option: memoryview) -> tuple[_BytesT, int]:
        return cls(option), len(option)

    def pack_into(self, data: bytearray) -> int:
        data.extend(self)
        return len(self)

    def to_json(self) -> str:
        return self.hex()

    @classmethod
    def from_json(cls: type[_BytesT], data: _ty.Any) -> _BytesT:
        """Hex text, as `to_json` writes it; bytes-like as the constructor takes it.

        The constructor itself refuses text, so a `str` assigned to an opaque
        option is never silently read as hex.
        """
        return cls.parse(data) if isinstance(data, str) else cls(data)

from_json(data) classmethod

Hex text, as to_json writes it; bytes-like as the constructor takes it.

The constructor itself refuses text, so a str assigned to an opaque option is never silently read as hex.

Source code in src/pydhcp/options/_codecs/_scalar.py
67
68
69
70
71
72
73
74
@classmethod
def from_json(cls: type[_BytesT], data: _ty.Any) -> _BytesT:
    """Hex text, as `to_json` writes it; bytes-like as the constructor takes it.

    The constructor itself refuses text, so a `str` assigned to an opaque
    option is never silently read as hex.
    """
    return cls.parse(data) if isinstance(data, str) else cls(data)

parse(text) classmethod

Build from hex text; spaces and colons between octets are ignored.

Raises DHCPValueError for anything that is not whole hex octets.

Source code in src/pydhcp/options/_codecs/_scalar.py
35
36
37
38
39
40
41
42
43
44
45
@classmethod
def parse(cls: type[_BytesT], text: str) -> _BytesT:
    """Build from hex text; spaces and colons between octets are ignored.

    Raises `DHCPValueError` for anything that is not whole hex octets.
    """
    digits = _text_argument(cls, text).replace(":", "")
    try:
        return cls(bytes.fromhex(digits))
    except ValueError as exc:
        raise DHCPValueError(f"not hex octets, {text!r}: {exc}") from exc

CCCAPBackoffRetry

Bases: CCCASBackoffRetry

CCC sub-option 5 AP-REQ/AP-REP backoff and retry tuple.

Source code in src/pydhcp/options/_codecs/_ccc.py
254
255
class CCCAPBackoffRetry(CCCASBackoffRetry):
    """CCC sub-option 5 AP-REQ/AP-REP backoff and retry tuple."""

CCCASBackoffRetry

Bases: _Record

CCC sub-option 4 AS-REQ/AS-REP backoff and retry tuple.

Source code in src/pydhcp/options/_codecs/_ccc.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
class CCCASBackoffRetry(_Record):
    """CCC sub-option 4 AS-REQ/AS-REP backoff and retry tuple."""

    __slots__ = ("initial_timeout", "maximum_timeout", "maximum_retry_count")
    _FIELDS = __slots__

    initial_timeout: int
    maximum_timeout: int
    maximum_retry_count: int

    def __init__(
        self,
        initial_timeout: _ty.Any,
        maximum_timeout: _ty.Any,
        maximum_retry_count: _ty.Any,
    ) -> None:
        _set(self, "initial_timeout", int(initial_timeout))
        _set(self, "maximum_timeout", int(maximum_timeout))
        _set(self, "maximum_retry_count", int(maximum_retry_count))

    @classmethod
    def unpack_from(
        cls: type[_CCCASBackoffRetryT], option: memoryview
    ) -> tuple[_CCCASBackoffRetryT, int]:
        if len(option) != 12:
            raise DHCPDecodeError(f"{cls.__name__} option must contain 12 bytes")
        return (
            cls(
                int.from_bytes(option[0:4], "big"),
                int.from_bytes(option[4:8], "big"),
                int.from_bytes(option[8:12], "big"),
            ),
            12,
        )

    def pack_into(self, data: bytearray) -> int:
        data.extend(self.initial_timeout.to_bytes(4, "big"))
        data.extend(self.maximum_timeout.to_bytes(4, "big"))
        data.extend(self.maximum_retry_count.to_bytes(4, "big"))
        return 12

    def to_json(self) -> list[int]:
        return [self.initial_timeout, self.maximum_timeout, self.maximum_retry_count]

CCCKDCServerAddressList

Bases: List[IPv4AddressOption]

CCC sub-option 10 KDC server address list.

Source code in src/pydhcp/options/_codecs/_ccc.py
304
305
class CCCKDCServerAddressList(List[IPv4AddressOption]):
    """CCC sub-option 10 KDC server address list."""

CCCKerberosRealmName

Bases: _CCCDomainText

CCC Kerberos realm payload without DNS compression.

Source code in src/pydhcp/options/_codecs/_ccc.py
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
class CCCKerberosRealmName(_CCCDomainText):
    """CCC Kerberos realm payload without DNS compression."""

    def __new__(
        cls: type[_CCCKerberosRealmNameT], value: _ty.Any
    ) -> _CCCKerberosRealmNameT:
        return super().__new__(cls, str(value).upper())

    @classmethod
    def unpack_from(
        cls: type[_CCCKerberosRealmNameT], option: memoryview
    ) -> tuple[_CCCKerberosRealmNameT, int]:
        # The name as it arrived; only a value built by hand is upper-cased.
        text, read = _decode_no_compression_domain(option)
        return str.__new__(cls, text), read

CCCOption

Bases: RecordList[CCCSubOption]

CCC option container preserving unknown sub-options.

Source code in src/pydhcp/options/_codecs/_ccc.py
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
class CCCOption(RecordList[CCCSubOption]):
    """CCC option container preserving unknown sub-options."""

    @classmethod
    def _normalize(cls, item: _ty.Any) -> CCCSubOption:
        # Its own, because the record class is chosen by the sub-option code
        # rather than fixed for the container: an unknown code must still round
        # trip, as the plain `CCCSubOption` fallback.
        if isinstance(item, CCCSubOption):
            return item
        code, value = item
        record_type = _CCC_SUBOPTION_TYPES.get(int(code), CCCSubOption)
        return record_type(int(code), value)

    @classmethod
    def _read_record(cls, option: memoryview) -> tuple[CCCSubOption, int]:
        if len(option) < 2:
            raise DHCPDecodeError(f"{cls.__name__} option is truncated")
        code = option[0]
        length = option[1]
        if len(option) < 2 + length:
            raise DHCPDecodeError(f"{cls.__name__} option is truncated")
        payload = option[2 : 2 + length]
        record_type = _CCC_SUBOPTION_TYPES.get(code, CCCSubOption)
        return record_type._from_payload(code, payload), 2 + length

    @classmethod
    def unpack_from(
        cls: type[_CCCOptionT], option: memoryview
    ) -> tuple[_CCCOptionT, int]:
        # Its own, because each record's class comes from `_read_record`'s code
        # lookup, not from the container's item type.
        self = cls()
        idx = 0
        size = len(option)
        while idx < size:
            record, read = cls._read_record(option[idx:])
            self.append(record)
            idx += read
        return self, size

CCCPrimaryDHCPServerAddress

Bases: IPv4AddressOption

CCC sub-option 1 primary DHCP server address.

Source code in src/pydhcp/options/_codecs/_ccc.py
198
199
class CCCPrimaryDHCPServerAddress(IPv4AddressOption):
    """CCC sub-option 1 primary DHCP server address."""

CCCProvisioningServerAddress

Bases: _Record

CCC sub-option 3 tagged union for IPv4 address or FQDN.

Source code in src/pydhcp/options/_codecs/_ccc.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
class CCCProvisioningServerAddress(_Record):
    """CCC sub-option 3 tagged union for IPv4 address or FQDN."""

    __slots__ = ("kind", "value")
    _FIELDS = __slots__

    kind: str
    value: _ty.Any

    def __init__(self, value: _ty.Any) -> None:
        kind, payload = self._normalize(value)
        _set(self, "kind", kind)
        _set(self, "value", payload)

    def _args(self) -> tuple[_ty.Any, ...]:
        return ((self.kind, self.value),)

    def __repr__(self) -> str:
        return f"{type(self).__name__}(({self.kind!r}, {str(self.value)!r}))"

    @staticmethod
    def _normalize(value: _ty.Any) -> tuple[str, _ty.Any]:
        if isinstance(value, CCCProvisioningServerAddress):
            return value.kind, value.value
        if isinstance(value, (list, tuple)) and len(value) == 2:
            kind, payload = value
            kind_name = str(kind).lower()
            if kind_name in {"ipv4", "address"}:
                return "ipv4", IPv4AddressOption(payload)
            if kind_name in {"fqdn", "domain"}:
                return "fqdn", CCCProvisioningServerFQDN(payload)
            raise DHCPValueError(
                "CCC provisioning server address kind must be ipv4 or fqdn"
            )
        if isinstance(value, _ipaddress.IPv4Address):
            return "ipv4", IPv4AddressOption(value)
        if isinstance(value, str):
            try:
                return "ipv4", IPv4AddressOption(value)
            except ValueError:
                # `ValueError` only: `ipaddress.AddressValueError` (its
                # subclass) is what a non-address string raises -- measured
                # across 'not-an-ip', '', 'www.example.com', '1.2.3.4.5' and
                # '999.1.1.1'. A bare `except Exception` would also swallow a
                # genuine defect in the codec and silently reclassify the
                # value as an FQDN.
                return "fqdn", CCCProvisioningServerFQDN(value)
        if isinstance(value, CCCProvisioningServerFQDN):
            return "fqdn", value
        return "ipv4", IPv4AddressOption(value)

    @classmethod
    def _read_payload(cls, payload: memoryview) -> "CCCProvisioningServerAddress":
        if len(payload) < 1:
            raise DHCPDecodeError("CCC provisioning server address is truncated")
        kind = payload[0]
        if kind == 1:
            if len(payload) != 5:
                raise DHCPDecodeError(
                    "CCC provisioning server IPv4 payload must be 5 bytes"
                )
            return cls(("ipv4", IPv4AddressOption(payload[1:5].tobytes())))
        if kind == 0:
            text, read = _decode_no_compression_domain(payload, 1)
            if 1 + read != len(payload):
                raise DHCPDecodeError(
                    "CCC provisioning server FQDN payload is truncated"
                )
            return cls(("fqdn", CCCProvisioningServerFQDN(text)))
        raise DHCPDecodeError(
            f"CCC provisioning server address kind {kind} is unsupported"
        )

    @classmethod
    def unpack_from(
        cls, option: memoryview
    ) -> tuple["CCCProvisioningServerAddress", int]:
        return cls._read_payload(option), len(option)

    def pack_into(self, data: bytearray) -> int:
        data.append(1 if self.kind == "ipv4" else 0)
        if self.kind == "ipv4":
            ipv4_payload = _ty.cast(IPv4AddressOption, self.value)
            data.extend(ipv4_payload.packed)
            return 5
        fqdn_payload = _ty.cast(CCCProvisioningServerFQDN, self.value)
        encoded = fqdn_payload.pack()
        data.extend(encoded)
        return len(encoded) + 1

    def display_text(self) -> str:
        return (
            f"{type(self).__name__}(kind={self.kind!r}, "
            f"value={display_of(self.value)})"
        )

    def to_json(self) -> list[_ty.Any]:
        return [
            self.kind,
            (
                self.value.to_json()
                if isinstance(self.value, DHCPOptionType)
                else str(self.value)
            ),
        ]

CCCProvisioningServerFQDN

Bases: _CCCDomainText

CCC provisioning server FQDN payload without DNS compression.

Source code in src/pydhcp/options/_codecs/_ccc.py
65
66
class CCCProvisioningServerFQDN(_CCCDomainText):
    """CCC provisioning server FQDN payload without DNS compression."""

CCCProvisioningTimer

Bases: U8

CCC sub-option 8 provisioning timer value.

Source code in src/pydhcp/options/_codecs/_ccc.py
262
263
class CCCProvisioningTimer(U8):
    """CCC sub-option 8 provisioning timer value."""

CCCSecondaryDHCPServerAddress

Bases: IPv4AddressOption

CCC sub-option 2 secondary DHCP server address.

Source code in src/pydhcp/options/_codecs/_ccc.py
202
203
class CCCSecondaryDHCPServerAddress(IPv4AddressOption):
    """CCC sub-option 2 secondary DHCP server address."""

CCCSecurityTicketControl

Bases: DHCPOptionType, int

CCC sub-option 9 security ticket control mask.

Source code in src/pydhcp/options/_codecs/_ccc.py
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
class CCCSecurityTicketControl(DHCPOptionType, int):
    """CCC sub-option 9 security ticket control mask."""

    def __new__(
        cls: type[_CCCSecurityTicketControlT], value: _ty.Any
    ) -> _CCCSecurityTicketControlT:
        return int.__new__(cls, int(value))

    @classmethod
    def unpack_from(
        cls: type[_CCCSecurityTicketControlT], option: memoryview
    ) -> tuple[_CCCSecurityTicketControlT, int]:
        if len(option) != 2:
            raise DHCPDecodeError(f"{cls.__name__} option must contain 2 bytes")
        return cls(int.from_bytes(option, "big")), 2

    def pack_into(self, data: bytearray) -> int:
        if self < 0 or self > 0xFFFF:
            raise DHCPValueError(f"{type(self).__name__} mask must fit in 16 bits")
        if int(self) & ~0x0003:
            raise DHCPValueError(
                f"{type(self).__name__} reserved bits 2-15 must be zero"
            )
        data.extend(int(self).to_bytes(2, "big"))
        return 2

    def __repr__(self) -> str:
        return f"{type(self).__name__}({int(self)!r})"

    def to_json(self) -> int:
        return int(self)

CCCSubOption

Bases: _Record

Typed CCC sub-option record.

Source code in src/pydhcp/options/_codecs/_ccc.py
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
class CCCSubOption(_Record):
    """Typed CCC sub-option record."""

    __slots__ = ("code", "value")
    _FIELDS = __slots__

    code: int
    value: _ty.Any
    _PAYLOAD_TYPE: type[OptionCodec] = Bytes

    def __init__(self, code: int, value: _ty.Any) -> None:
        _set(self, "code", int(code))
        _set(self, "value", frozen(self._normalize_value(int(code), value)))

    @classmethod
    def _normalize_value(cls, code: int, value: _ty.Any) -> _ty.Any:
        if isinstance(value, DHCPOptionType):
            return value
        return _octets(value)

    @classmethod
    def _read_payload(cls, payload: memoryview) -> _ty.Any:
        return _octets(payload)

    def _write_payload(self, data: bytearray) -> int:
        payload = self.value
        if isinstance(payload, DHCPOptionType):
            return payload.pack_into(data)
        payload_bytes = _octets(payload)
        data.extend(payload_bytes)
        return len(payload_bytes)

    @classmethod
    def _from_payload(
        cls: type[_CCCSubOptionT], code: int, payload: memoryview
    ) -> _CCCSubOptionT:
        return cls(code, cls._read_payload(payload))

    def pack_into(self, data: bytearray) -> int:
        payload = bytearray()
        payload_len = self._write_payload(payload)
        if payload_len > 255:
            raise DHCPValueError(f"{type(self).__name__} entry exceeds 255 bytes")
        data.append(self.code)
        data.append(payload_len)
        data.extend(payload)
        return payload_len + 2

    def to_json(self) -> list[_ty.Any]:
        value = self.value
        if isinstance(value, DHCPOptionType):
            value = value.to_json()
        else:
            value = _octets(value).to_json()
        return [self.code, value]

CCCTicketGrantingServerUtilization

Bases: Boolean

CCC sub-option 7 ticket-granting server utilization toggle.

Source code in src/pydhcp/options/_codecs/_ccc.py
258
259
class CCCTicketGrantingServerUtilization(Boolean):
    """CCC sub-option 7 ticket-granting server utilization toggle."""

ClasslessRoute

Bases: _Record, _TextForm

RFC 3442 classless static route entry.

Accepts either ClasslessRoute(gateway, network) or a single (gateway, network) pair / existing instance, so that the option's list container can normalize routes coming from JSON, YAML or a config file.

Source code in src/pydhcp/options/_codecs/_addresses.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
class ClasslessRoute(_Record, _TextForm):
    """RFC 3442 classless static route entry.

    Accepts either ``ClasslessRoute(gateway, network)`` or a single
    ``(gateway, network)`` pair / existing instance, so that the option's list
    container can normalize routes coming from JSON, YAML or a config file.
    """

    __slots__ = ("gateway", "network")
    _FIELDS = __slots__

    # Declared here so the pair-accepting constructor below can read
    # `other.gateway` without mypy hitting a circular inference.
    gateway: _IP
    network: _Network

    def __init__(self, gateway: _ty.Any, network: _ty.Optional[_ty.Any] = None) -> None:
        gw: _ty.Any
        net: _ty.Any
        if network is not None:
            gw, net = gateway, network
        elif isinstance(gateway, ClasslessRoute):
            gw, net = gateway.gateway, gateway.network
        else:
            try:
                gw, net = gateway
            except (TypeError, ValueError):
                raise TypeError(
                    "ClasslessRoute takes (gateway, network) or a single "
                    f"(gateway, network) pair, got {gateway!r}"
                ) from None
        _set(self, "gateway", _IP(gw))
        _set(self, "network", _Network(net))

    def __str__(self) -> str:
        return f"{self.network} via {self.gateway}"

    @classmethod
    def parse(cls: type[_ClasslessRouteT], text: str) -> _ClasslessRouteT:
        """Build from ``"10.0.0.0/8 via 192.0.2.1"``, the text `str()` produces.

        Raises `DHCPValueError` for anything else, host bits set in the network
        included.
        """
        words = _text_argument(cls, text).split()
        if len(words) != 3 or words[1] != "via":
            raise DHCPValueError(
                f"not a route, {text!r}: write it as 'network/prefix via gateway'"
            )
        try:
            return cls(words[2], words[0])
        except ValueError as exc:
            raise DHCPValueError(f"not a route, {text!r}: {exc}") from exc

    @classmethod
    def unpack_from(
        cls: type[_ClasslessRouteT], option: memoryview
    ) -> tuple[_ClasslessRouteT, int]:
        if len(option) < 1:
            raise DHCPDecodeError(
                "ClasslessRoute option is truncated: missing prefix length"
            )
        cidr = option[0]
        if cidr > 32:
            raise DHCPDecodeError(f"ClasslessRoute prefix length {cidr} exceeds 32")
        last = 1 + (cidr + 7) // 8
        if len(option) < last + 4:
            raise DHCPDecodeError(
                f"ClasslessRoute option is truncated: needs {last + 4} bytes, got {len(option)}"
            )
        net_bytes = option[1:last].tobytes() + b"\x00\x00\x00\x00"
        # RFC 3442 s3: the client MUST zero the bits of the destination that
        # the mask zeroes, so a descriptor with host bits set is read, masked.
        try:
            network = _Network((net_bytes[:4], cidr), strict=False)
        except ValueError as exc:
            raise DHCPDecodeError(f"ClasslessRoute destination: {exc}") from exc
        gateway = _IP(option[last : last + 4].tobytes())
        return cls(gateway, network), last + 4

    def pack_into(self, data: bytearray) -> int:
        cidr = self.network.prefixlen
        last = (cidr + 7) // 8
        network = self.network.network_address.packed[:last]
        data.append(cidr)
        data.extend(network)
        data.extend(self.gateway.packed)
        return last + 5

    def to_json(self) -> list[_ty.Any]:
        return [str(self.gateway), str(self.network)]

parse(text) classmethod

Build from "10.0.0.0/8 via 192.0.2.1", the text str() produces.

Raises DHCPValueError for anything else, host bits set in the network included.

Source code in src/pydhcp/options/_codecs/_addresses.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
@classmethod
def parse(cls: type[_ClasslessRouteT], text: str) -> _ClasslessRouteT:
    """Build from ``"10.0.0.0/8 via 192.0.2.1"``, the text `str()` produces.

    Raises `DHCPValueError` for anything else, host bits set in the network
    included.
    """
    words = _text_argument(cls, text).split()
    if len(words) != 3 or words[1] != "via":
        raise DHCPValueError(
            f"not a route, {text!r}: write it as 'network/prefix via gateway'"
        )
    try:
        return cls(words[2], words[0])
    except ValueError as exc:
        raise DHCPValueError(f"not a route, {text!r}: {exc}") from exc

ClientArchitecture

Bases: DHCPOptionType, IntEnum

One entry of option 93: the IANA "Processor Architecture Types" registry.

RFC 4578 s2.1 started the list; IANA's registry, which RFC 5970 shares with DHCPv6, corrects it (7 is x64 UEFI and 9 is EBC) and extends it. A number the registry does not name is an unnamed member carrying it, never refused.

Source code in src/pydhcp/options/_codecs/_pxe.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
class ClientArchitecture(DHCPOptionType, _enum.IntEnum):
    """One entry of option 93: the IANA "Processor Architecture Types" registry.

    RFC 4578 s2.1 started the list; IANA's registry, which RFC 5970 shares with
    DHCPv6, corrects it (7 is x64 UEFI and 9 is EBC) and extends it. A number the
    registry does not name is an unnamed member carrying it, never refused.
    """

    X86_BIOS = 0x00
    NEC_PC98 = 0x01
    ITANIUM = 0x02
    DEC_ALPHA = 0x03
    ARC_X86 = 0x04
    INTEL_LEAN_CLIENT = 0x05
    X86_UEFI = 0x06
    X64_UEFI = 0x07
    EFI_XSCALE = 0x08
    EBC = 0x09
    ARM32_UEFI = 0x0A
    ARM64_UEFI = 0x0B
    POWERPC_OPEN_FIRMWARE = 0x0C
    POWERPC_EPAPR = 0x0D
    POWER_OPAL_V3 = 0x0E
    X86_UEFI_HTTP = 0x0F
    X64_UEFI_HTTP = 0x10
    EBC_HTTP = 0x11
    ARM32_UEFI_HTTP = 0x12
    ARM64_UEFI_HTTP = 0x13
    X86_BIOS_HTTP = 0x14
    ARM32_UBOOT = 0x15
    ARM64_UBOOT = 0x16
    ARM32_UBOOT_HTTP = 0x17
    ARM64_UBOOT_HTTP = 0x18
    RISCV32_UEFI = 0x19
    RISCV32_UEFI_HTTP = 0x1A
    RISCV64_UEFI = 0x1B
    RISCV64_UEFI_HTTP = 0x1C
    RISCV128_UEFI = 0x1D
    RISCV128_UEFI_HTTP = 0x1E
    S390_BASIC = 0x1F
    S390_EXTENDED = 0x20
    MIPS32_UEFI = 0x21
    MIPS64_UEFI = 0x22
    SUNWAY32_UEFI = 0x23
    SUNWAY64_UEFI = 0x24
    LOONGARCH32_UEFI = 0x25
    LOONGARCH32_UEFI_HTTP = 0x26
    LOONGARCH64_UEFI = 0x27
    LOONGARCH64_UEFI_HTTP = 0x28
    ARM_RPIBOOT = 0x29

    @classmethod
    def _missing_(cls, value: object) -> "_ty.Optional[ClientArchitecture]":
        """An unnamed member for a number the registry does not name (0 to 65535)."""
        if not isinstance(value, int) or isinstance(value, bool):
            return None
        if not 0 <= value <= 0xFFFF:
            return None
        pseudo = _UNNAMED.get(value)
        if pseudo is None:
            pseudo = int.__new__(cls, value)
            pseudo._name_ = None  # type: ignore[assignment]
            pseudo._value_ = value
            pseudo = _UNNAMED.setdefault(value, pseudo)
        return pseudo

    @classmethod
    def fixed_size(cls) -> _ty.Optional[int]:
        return 2

    @classmethod
    def unpack_from(
        cls: type[_ClientArchitectureT], option: memoryview
    ) -> tuple[_ClientArchitectureT, int]:
        if len(option) < 2:
            raise DHCPDecodeError("a client architecture is 2 octets (RFC 4578 s2.1)")
        return cls(int.from_bytes(option[:2], "big")), 2

    def pack_into(self, data: bytearray) -> int:
        data.extend(int(self).to_bytes(2, "big"))
        return 2

    def is_http(self) -> bool:
        """Whether this is one of the registry's "boot from http" architectures."""
        return self._name_ is not None and self._name_.endswith("_HTTP")

    def to_json(self) -> _ty.Any:
        """The member's name, or the number when it has none."""
        return self._name_ if self._name_ is not None else int(self)

    def label(self) -> str:
        """The member's name, or `ARCH_<n>` for a number that has none."""
        return self._name_ or f"ARCH_{self.value}"

    def __repr__(self) -> str:
        return f"{type(self).__name__}.{self.label()}"

    def __str__(self) -> str:
        return self.label()

    def display_text(self) -> str:
        return self.label()

_missing_(value) classmethod

An unnamed member for a number the registry does not name (0 to 65535).

Source code in src/pydhcp/options/_codecs/_pxe.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
@classmethod
def _missing_(cls, value: object) -> "_ty.Optional[ClientArchitecture]":
    """An unnamed member for a number the registry does not name (0 to 65535)."""
    if not isinstance(value, int) or isinstance(value, bool):
        return None
    if not 0 <= value <= 0xFFFF:
        return None
    pseudo = _UNNAMED.get(value)
    if pseudo is None:
        pseudo = int.__new__(cls, value)
        pseudo._name_ = None  # type: ignore[assignment]
        pseudo._value_ = value
        pseudo = _UNNAMED.setdefault(value, pseudo)
    return pseudo

is_http()

Whether this is one of the registry's "boot from http" architectures.

Source code in src/pydhcp/options/_codecs/_pxe.py
101
102
103
def is_http(self) -> bool:
    """Whether this is one of the registry's "boot from http" architectures."""
    return self._name_ is not None and self._name_.endswith("_HTTP")

label()

The member's name, or ARCH_<n> for a number that has none.

Source code in src/pydhcp/options/_codecs/_pxe.py
109
110
111
def label(self) -> str:
    """The member's name, or `ARCH_<n>` for a number that has none."""
    return self._name_ or f"ARCH_{self.value}"

to_json()

The member's name, or the number when it has none.

Source code in src/pydhcp/options/_codecs/_pxe.py
105
106
107
def to_json(self) -> _ty.Any:
    """The member's name, or the number when it has none."""
    return self._name_ if self._name_ is not None else int(self)

ClientFQDN

Bases: _Record, _TextForm

RFC 4702 Client FQDN: flags, RCODE1, RCODE2, then the name.

With the E bit the name is a fully qualified name (RFC 1035 labels and the terminating zero-length label), a partial name (the labels without the terminator) or empty (RFC 4702 s2.3). partial says which: it is true for a partial name and for an empty field, false for a qualified name and for the root (a lone terminator), so what is decoded is encoded to the octets it came from. The four reserved flag bits are ignored on receive and may not be set on send (s2.1).

Registering this as a plain String lost data silently rather than loudly: String splits at the first NUL, so the form every Windows client sends (00 00 00 then the name) decoded to the empty string, and a server reading it for DDNS saw no name at all. Emitting was the mirror image -- the first three characters of the name were read by the client as Flags/RCODE1/RCODE2.

Source code in src/pydhcp/options/_codecs/_fqdn.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
class ClientFQDN(_Record, _TextForm):
    """RFC 4702 Client FQDN: flags, RCODE1, RCODE2, then the name.

    With the E bit the name is a fully qualified name (RFC 1035 labels and the
    terminating zero-length label), a **partial** name (the labels without the
    terminator) or empty (RFC 4702 s2.3). `partial` says which: it is true for
    a partial name and for an empty field, false for a qualified name and for
    the root (a lone terminator), so what is decoded is encoded to the octets
    it came from. The four reserved flag bits are ignored on receive and may
    not be set on send (s2.1).

    Registering this as a plain `String` lost data silently rather than loudly:
    `String` splits at the first NUL, so the form every Windows client sends
    (`00 00 00` then the name) decoded to the empty string, and a server reading
    it for DDNS saw no name at all. Emitting was the mirror image -- the first
    three characters of the name were read by the client as Flags/RCODE1/RCODE2.
    """

    #: The client requests that the server update the A RR (RFC 4702 s2.1).
    FLAG_S = 0x01
    #: The server overrode the client's S bit.
    FLAG_O = 0x02
    #: The name is in DNS wire format rather than ASCII.
    FLAG_E = 0x04
    #: The server should not perform any DNS update.
    FLAG_N = 0x08

    #: The flag bits RFC 4702 s2.1 defines; the other four must be zero.
    FLAGS_MASK = 0x0F

    __slots__ = ("name", "flags", "rcode1", "rcode2", "partial")
    _FIELDS = __slots__

    # Declared so the instance-accepting constructor can read `other.name`
    # without mypy hitting a circular inference.
    name: str
    partial: bool
    flags: int
    rcode1: int
    rcode2: int

    def __init__(
        self,
        name: _ty.Any = "",
        flags: int = 0,
        rcode1: int = 0,
        rcode2: int = 0,
        partial: bool = False,
    ) -> None:
        if isinstance(name, ClientFQDN):
            name, flags, rcode1, rcode2, partial = (
                name.name,
                name.flags,
                name.rcode1,
                name.rcode2,
                name.partial,
            )
        elif isinstance(name, _ty.Mapping):
            mapping = name
            name = mapping.get("name", "")
            flags = int(mapping.get("flags", 0))
            rcode1 = int(mapping.get("rcode1", 0))
            rcode2 = int(mapping.get("rcode2", 0))
            partial = bool(mapping.get("partial", False))
        for label, value in (("flags", flags), ("rcode1", rcode1), ("rcode2", rcode2)):
            if not 0 <= int(value) <= 0xFF:
                raise DHCPValueError(f"ClientFQDN {label} must fit in one octet")
        if int(flags) & ~self.FLAGS_MASK:
            raise DHCPValueError(
                f"ClientFQDN flags {int(flags):#04x} set reserved bits; "
                "RFC 4702 s2.1 requires the senders to clear them"
            )
        if partial and not int(flags) & self.FLAG_E:
            raise DHCPValueError(
                "ClientFQDN partial applies to the DNS wire format: set the E bit"
            )
        _set(self, "name", str(name))
        _set(self, "flags", int(flags))
        _set(self, "rcode1", int(rcode1))
        _set(self, "rcode2", int(rcode2))
        _set(self, "partial", bool(partial))

    _TEXT = _re.compile(
        r"(?P<name>.*?)(?: \[flags=(?P<flags>0x[0-9a-fA-F]+|[0-9]+)"
        r" rcode1=(?P<rcode1>[0-9]+) rcode2=(?P<rcode2>[0-9]+)(?P<partial> partial)?\])?",
        _re.DOTALL,
    )

    def __str__(self) -> str:
        """The name; with ``[flags=0x05 rcode1=0 rcode2=0 partial]`` after it when those are set."""
        if not (self.flags or self.rcode1 or self.rcode2 or self.partial):
            return self.name
        return (
            f"{self.name} [flags={self.flags:#04x} rcode1={self.rcode1} "
            f"rcode2={self.rcode2}{' partial' if self.partial else ''}]"
        )

    @classmethod
    def parse(cls: type[_ClientFQDNT], text: str) -> _ClientFQDNT:
        """Build from the name, or from the text `str()` produces when flags are set.

        Raises `DHCPValueError` for a bracketed part that is malformed or that
        sets a reserved flag bit.
        """
        found = cls._TEXT.fullmatch(_text_argument(cls, text))
        if found is None or (found["flags"] is None and " [flags=" in text):
            raise DHCPValueError(f"not a client FQDN, {text!r}")
        if found["flags"] is None:
            return cls(found["name"])
        return cls(
            found["name"],
            int(found["flags"], 0),
            int(found["rcode1"]),
            int(found["rcode2"]),
            bool(found["partial"]),
        )

    @property
    def encoded(self) -> bool:
        """Whether the name is carried in DNS wire format (the E bit)."""
        return bool(self.flags & self.FLAG_E)

    @classmethod
    def unpack_from(
        cls: type[_ClientFQDNT], option: memoryview
    ) -> tuple[_ClientFQDNT, int]:
        if len(option) < 3:
            raise DHCPDecodeError(
                "ClientFQDN option is truncated: needs at least 3 octets"
            )
        # RFC 4702 s2.1: the reserved bits MUST be ignored.
        flags, rcode1, rcode2 = option[0] & cls.FLAGS_MASK, option[1], option[2]
        rest = option[3:]
        partial = False
        if flags & cls.FLAG_E:
            name, partial = cls._read_wire_name(rest)
        else:
            try:
                name = rest.tobytes().split(b"\x00", 1)[0].decode("utf-8")
            except UnicodeDecodeError as exc:
                raise DHCPDecodeError("ClientFQDN name is not valid UTF-8") from exc
        return cls(name, flags, rcode1, rcode2, partial), len(option)

    @staticmethod
    def _read_wire_name(field: memoryview) -> tuple[str, bool]:
        """The name of an E-bit field and whether it is partial (RFC 4702 s2.3)."""
        if not field:
            return "", True
        try:
            name, read = decode_domain_name(field, 0, "ClientFQDN name")
        except DHCPDecodeError:
            # Ran out before a terminating label: a partial name if what was
            # read is whole labels, an error if a label is cut short. Reading
            # the labels followed by a terminator tells the two apart.
            name, read = decode_domain_name(
                memoryview(field.tobytes() + b"\x00"), 0, "ClientFQDN name"
            )
            if read != len(field) + 1:
                raise DHCPDecodeError("ClientFQDN name is truncated") from None
            return name, True
        if read != len(field):
            raise DHCPDecodeError("ClientFQDN has trailing data after the name")
        return name, False

    def pack_into(self, data: bytearray) -> int:
        start = len(data)
        data.append(self.flags)
        data.append(self.rcode1)
        data.append(self.rcode2)
        if self.encoded:
            wire = encode_domain_name(self.name, "ClientFQDN name", allow_root=True)
            # A partial name has no terminating label; with no name at all the
            # field is empty (RFC 4702 s2.3).
            data.extend(wire[:-1] if self.partial else wire)
        else:
            data.extend(self.name.encode("utf-8"))
        return len(data) - start

    def display_text(self) -> str:
        return (
            f"ClientFQDN(name={self.name!r}, flags={self.flags:#04x}, "
            f"rcode1={self.rcode1}, rcode2={self.rcode2}"
            + (", partial=True)" if self.partial else ")")
        )

    def to_json(self) -> dict[str, _ty.Any]:
        return {
            "name": self.name,
            "flags": self.flags,
            "rcode1": self.rcode1,
            "rcode2": self.rcode2,
            "partial": self.partial,
        }

encoded property

Whether the name is carried in DNS wire format (the E bit).

__str__()

The name; with [flags=0x05 rcode1=0 rcode2=0 partial] after it when those are set.

Source code in src/pydhcp/options/_codecs/_fqdn.py
102
103
104
105
106
107
108
109
def __str__(self) -> str:
    """The name; with ``[flags=0x05 rcode1=0 rcode2=0 partial]`` after it when those are set."""
    if not (self.flags or self.rcode1 or self.rcode2 or self.partial):
        return self.name
    return (
        f"{self.name} [flags={self.flags:#04x} rcode1={self.rcode1} "
        f"rcode2={self.rcode2}{' partial' if self.partial else ''}]"
    )

_read_wire_name(field) staticmethod

The name of an E-bit field and whether it is partial (RFC 4702 s2.3).

Source code in src/pydhcp/options/_codecs/_fqdn.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
@staticmethod
def _read_wire_name(field: memoryview) -> tuple[str, bool]:
    """The name of an E-bit field and whether it is partial (RFC 4702 s2.3)."""
    if not field:
        return "", True
    try:
        name, read = decode_domain_name(field, 0, "ClientFQDN name")
    except DHCPDecodeError:
        # Ran out before a terminating label: a partial name if what was
        # read is whole labels, an error if a label is cut short. Reading
        # the labels followed by a terminator tells the two apart.
        name, read = decode_domain_name(
            memoryview(field.tobytes() + b"\x00"), 0, "ClientFQDN name"
        )
        if read != len(field) + 1:
            raise DHCPDecodeError("ClientFQDN name is truncated") from None
        return name, True
    if read != len(field):
        raise DHCPDecodeError("ClientFQDN has trailing data after the name")
    return name, False

parse(text) classmethod

Build from the name, or from the text str() produces when flags are set.

Raises DHCPValueError for a bracketed part that is malformed or that sets a reserved flag bit.

Source code in src/pydhcp/options/_codecs/_fqdn.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
@classmethod
def parse(cls: type[_ClientFQDNT], text: str) -> _ClientFQDNT:
    """Build from the name, or from the text `str()` produces when flags are set.

    Raises `DHCPValueError` for a bracketed part that is malformed or that
    sets a reserved flag bit.
    """
    found = cls._TEXT.fullmatch(_text_argument(cls, text))
    if found is None or (found["flags"] is None and " [flags=" in text):
        raise DHCPValueError(f"not a client FQDN, {text!r}")
    if found["flags"] is None:
        return cls(found["name"])
    return cls(
        found["name"],
        int(found["flags"], 0),
        int(found["rcode1"]),
        int(found["rcode2"]),
        bool(found["partial"]),
    )

ClientIdentifier

Bases: Bytes

RFC 2132 client identifier.

Source code in src/pydhcp/options/_codecs/_scalar.py
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
class ClientIdentifier(Bytes):
    """RFC 2132 client identifier."""

    @classmethod
    def unpack_from(
        cls: type[_ClientIdentifierT], option: memoryview
    ) -> tuple[_ClientIdentifierT, int]:
        if len(option) < 2:
            raise DHCPDecodeError(
                f"{cls.__name__} option is truncated: needs a type octet and an identifier"
            )
        return super().unpack_from(option)

    def pack_into(self, data: bytearray) -> int:
        if len(self) < 2:
            raise DHCPValueError(
                f"{type(self).__name__} needs at least 2 octets, a type and an "
                f"identifier (RFC 2132 section 9.14), got {len(self)}"
            )
        return super().pack_into(data)

    def display_text(self) -> str:
        if not self:
            return ""
        ty_val = self[0]
        addr = self[1:]
        ty_str = str(ty_val)
        try:
            ty_str = _HardwareAddressType(ty_val).label()
        except ValueError:
            ...
        maybe = f"{ty_str}({addr.hex(':').upper()})"

        return f"{maybe}|{self}"

    def __str__(self) -> str:
        return self.hex(":").upper()

ClientMachineID

Bases: _Record

Option 97 (RFC 4578 s2.3): a type octet, then the machine identifier.

Type 0, the only one defined, is a 16-octet GUID, which uuid reads in the order the octets arrived. Any other type or length is kept as received; sending a type 0 that is not 16 octets raises DHCPValueError.

Source code in src/pydhcp/options/_codecs/_pxe.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
class ClientMachineID(_Record):
    """Option 97 (RFC 4578 s2.3): a type octet, then the machine identifier.

    Type 0, the only one defined, is a 16-octet GUID, which `uuid` reads in the
    order the octets arrived. Any other type or length is kept as received;
    sending a type 0 that is not 16 octets raises `DHCPValueError`.
    """

    __slots__ = ("type", "ident")
    _FIELDS = __slots__

    type: int
    ident: Bytes

    GUID = 0

    def __init__(self, type: _ty.Any, ident: _ty.Any) -> None:
        _set(self, "type", int(U8(type)))
        _set(self, "ident", _octets(ident))

    @classmethod
    def from_uuid(cls, value: _ty.Union[_uuid.UUID, str]) -> "ClientMachineID":
        """A type 0 identifier holding `value`'s 16 octets."""
        return cls(cls.GUID, _uuid.UUID(str(value)).bytes)

    @property
    def uuid(self) -> _ty.Optional[_uuid.UUID]:
        """The GUID as a `uuid.UUID`, or None when this is not a 16-octet type 0."""
        if self.type != self.GUID or len(self.ident) != 16:
            return None
        return _uuid.UUID(bytes=bytes(self.ident))

    @classmethod
    def unpack_from(cls, option: memoryview) -> "tuple[ClientMachineID, int]":
        if not option:
            raise DHCPDecodeError("a client machine identifier needs a type octet")
        return cls(option[0], bytes(option[1:])), len(option)

    def pack_into(self, data: bytearray) -> int:
        if self.type == self.GUID and len(self.ident) != 16:
            raise DHCPValueError(
                f"a type 0 machine identifier is a 16-octet GUID, not {len(self.ident)} "
                "octets (RFC 4578 s2.3)"
            )
        data.append(self.type)
        data.extend(self.ident)
        return 1 + len(self.ident)

    def to_json(self) -> list[_ty.Any]:
        return [self.type, self.ident.to_json()]

    @classmethod
    def from_json(cls, data: _ty.Any) -> _ty.Any:
        """What `to_json` writes, or the hex text of a document written while
        this option had no codec of its own."""
        if isinstance(data, str):
            return cls.unpack(Bytes.parse(data))
        return super().from_json(data)

    def display_text(self) -> str:
        found = self.uuid
        return (
            f"GUID {found}"
            if found is not None
            else f"type {self.type} {self.ident.hex()}"
        )

uuid property

The GUID as a uuid.UUID, or None when this is not a 16-octet type 0.

from_json(data) classmethod

What to_json writes, or the hex text of a document written while this option had no codec of its own.

Source code in src/pydhcp/options/_codecs/_pxe.py
227
228
229
230
231
232
233
@classmethod
def from_json(cls, data: _ty.Any) -> _ty.Any:
    """What `to_json` writes, or the hex text of a document written while
    this option had no codec of its own."""
    if isinstance(data, str):
        return cls.unpack(Bytes.parse(data))
    return super().from_json(data)

from_uuid(value) classmethod

A type 0 identifier holding value's 16 octets.

Source code in src/pydhcp/options/_codecs/_pxe.py
196
197
198
199
@classmethod
def from_uuid(cls, value: _ty.Union[_uuid.UUID, str]) -> "ClientMachineID":
    """A type 0 identifier holding `value`'s 16 octets."""
    return cls(cls.GUID, _uuid.UUID(str(value)).bytes)

ClientNDI

Bases: _Record

Option 94 (RFC 4578 s2.2): the network interface type and its revision.

Type 1 is UNDI, the only one defined; major and minor are its revision (ClientNDI(1, 3, 16) is UNDI 3.16).

Source code in src/pydhcp/options/_codecs/_pxe.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
class ClientNDI(_Record):
    """Option 94 (RFC 4578 s2.2): the network interface type and its revision.

    Type 1 is UNDI, the only one defined; `major` and `minor` are its revision
    (`ClientNDI(1, 3, 16)` is UNDI 3.16).
    """

    __slots__ = ("type", "major", "minor")
    _FIELDS = __slots__

    type: int
    major: int
    minor: int

    UNDI = 1

    def __init__(self, type: _ty.Any, major: _ty.Any, minor: _ty.Any) -> None:
        _set(self, "type", int(U8(type)))
        _set(self, "major", int(U8(major)))
        _set(self, "minor", int(U8(minor)))

    @classmethod
    def fixed_size(cls) -> _ty.Optional[int]:
        return 3

    @classmethod
    def unpack_from(cls, option: memoryview) -> "tuple[ClientNDI, int]":
        return cls(option[0], option[1], option[2]), 3

    def pack_into(self, data: bytearray) -> int:
        data.extend((self.type, self.major, self.minor))
        return 3

    def to_json(self) -> list[int]:
        return [self.type, self.major, self.minor]

    @classmethod
    def from_json(cls, data: _ty.Any) -> _ty.Any:
        """What `to_json` writes, or the hex text of a document written while
        this option had no codec of its own."""
        if isinstance(data, str):
            return cls.unpack(Bytes.parse(data))
        return super().from_json(data)

    def display_text(self) -> str:
        kind = "UNDI" if self.type == self.UNDI else f"type {self.type}"
        return f"{kind} {self.major}.{self.minor}"

from_json(data) classmethod

What to_json writes, or the hex text of a document written while this option had no codec of its own.

Source code in src/pydhcp/options/_codecs/_pxe.py
163
164
165
166
167
168
169
@classmethod
def from_json(cls, data: _ty.Any) -> _ty.Any:
    """What `to_json` writes, or the hex text of a document written while
    this option had no codec of its own."""
    if isinstance(data, str):
        return cls.unpack(Bytes.parse(data))
    return super().from_json(data)

DHCPOptionCodes

Bases: List[_C]

List of option codes used by parameter-request-list style options.

Source code in src/pydhcp/options/_codecs/_lists.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
class DHCPOptionCodes(List[_C]):  # type: ignore[type-var]
    """List of option codes used by parameter-request-list style options."""

    @classmethod
    def _normalize(cls, item: _ty.Any) -> _ty.Any:
        ty = cls._args_[0]
        if isinstance(item, _ty.cast(_ty.Any, ty)):
            return item
        try:
            return _ty.cast(_ty.Any, ty)(item)
        except (TypeError, ValueError):
            ...
        item_int = int(item)
        if item_int > 255:
            raise DHCPValueError(
                f"DHCP option code {item_int} does not fit in one octet (0-255)"
            )
        return item_int

    @classmethod
    def unpack_from(
        cls: type[_DHCPOptionCodesT], option: memoryview
    ) -> tuple[_DHCPOptionCodesT, int]:
        return cls(option.tolist()), len(option)

    def pack_into(self, data: bytearray) -> int:
        data.extend(_ty.cast(_ty.Iterable[int], self))
        return len(self)

    def __repr__(self) -> str:
        return f"{type(self).__name__}({[int(code) for code in self]!r})"

DHCPOptionType

The base class of the option payload codecs: the defaults of OptionCodec.

A subclass implements unpack_from and pack_into (and its constructor), may set fixed_size, and may override to_json and display_text. unpack and pack are built on those: unpack refuses a payload of the wrong size or with octets left over, and pack collects what pack_into writes. The pair should round-trip the same Python value.

Source code in src/pydhcp/options/_codecs/_base.py
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
class DHCPOptionType:
    """The base class of the option payload codecs: the defaults of `OptionCodec`.

    A subclass implements `unpack_from` and `pack_into` (and its constructor),
    may set `fixed_size`, and may override `to_json` and `display_text`. `unpack`
    and `pack` are built on those: `unpack` refuses a payload of the wrong size
    or with octets left over, and `pack` collects what `pack_into` writes. The
    pair should round-trip the same Python value.
    """

    __slots__ = ()

    @classmethod
    def unpack_from(
        cls: type[_DHCPOptionTypeT], option: memoryview
    ) -> tuple[_DHCPOptionTypeT, int]:
        raise NotImplementedError()

    def pack_into(self, buffer: bytearray) -> int:
        raise NotImplementedError()

    def pack(self) -> bytes:
        encoded = bytearray()
        self.pack_into(encoded)
        return bytes(encoded)

    def to_json(self) -> _ty.Any:
        return self

    @classmethod
    def from_json(cls: type[_DHCPOptionTypeT], data: _ty.Any) -> _DHCPOptionTypeT:
        """The value `to_json` wrote: `cls(data)`, and an enum also by member name.

        A codec whose constructor does not take its own `to_json` form overrides
        this (`Bytes` reads hex text here, and only here).
        """
        if isinstance(data, str) and issubclass(cls, _enum.Enum):
            member = cls.__members__.get(data)
            if member is not None:
                return _ty.cast(_DHCPOptionTypeT, member)
        return cls(data)  # type: ignore[call-arg]

    def display_text(self) -> str:
        """The text the message display and the capture formats show for this value."""
        return repr(self)

    @classmethod
    def fixed_size(cls) -> _ty.Optional[int]:
        return None

    @classmethod
    def unpack(
        cls: type[_DHCPOptionTypeT], option: _ty.Union[memoryview, bytes, bytearray]
    ) -> _DHCPOptionTypeT:
        hint = cls.fixed_size()
        todecode = len(option)
        option = memoryview(option) if not isinstance(option, memoryview) else option
        # `is not None`, not truthiness: a hint of 0 is a real constraint (a
        # zero-length presence option such as RFC 4039 Rapid Commit must reject
        # any payload), and `if hint:` silently skipped it.
        if hint is not None:
            if todecode != hint:
                raise DHCPDecodeError(
                    f"{cls.__name__} payload must be exactly {hint} octets, got {todecode}"
                )
        try:
            decoded, read = cls.unpack_from(option)
        except DHCPValueError as exc:
            # A constructor refusing the octets it was built from.
            raise DHCPDecodeError(str(exc)) from exc
        if read != todecode:
            raise DHCPDecodeError(
                f"{cls.__name__} decoded only {read} of {todecode} octets; "
                "the payload carries trailing data the codec does not account for"
            )
        return decoded

display_text()

The text the message display and the capture formats show for this value.

Source code in src/pydhcp/options/_codecs/_base.py
108
109
110
def display_text(self) -> str:
    """The text the message display and the capture formats show for this value."""
    return repr(self)

from_json(data) classmethod

The value to_json wrote: cls(data), and an enum also by member name.

A codec whose constructor does not take its own to_json form overrides this (Bytes reads hex text here, and only here).

Source code in src/pydhcp/options/_codecs/_base.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
@classmethod
def from_json(cls: type[_DHCPOptionTypeT], data: _ty.Any) -> _DHCPOptionTypeT:
    """The value `to_json` wrote: `cls(data)`, and an enum also by member name.

    A codec whose constructor does not take its own `to_json` form overrides
    this (`Bytes` reads hex text here, and only here).
    """
    if isinstance(data, str) and issubclass(cls, _enum.Enum):
        member = cls.__members__.get(data)
        if member is not None:
            return _ty.cast(_DHCPOptionTypeT, member)
    return cls(data)  # type: ignore[call-arg]

DomainList

Bases: _NormalizedList[str]

RFC 1035 domain-name list with compression support.

Normalizes like List[T] does, and for the same reason. With no __init__ this inherited list's, where a str argument is an iterable of characters: DomainList("corp") was ["c", "o", "r", "p"] and options[119] = "corp" silently offered the client four single-letter search domains. A bare name is one entry; pass a list for several.

Source code in src/pydhcp/options/_codecs/_domains.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
class DomainList(_NormalizedList[str]):
    """RFC 1035 domain-name list with compression support.

    Normalizes like `List[T]` does, and for the same reason. With no
    `__init__` this inherited `list`'s, where a `str` argument is an
    *iterable of characters*: `DomainList("corp")` was `["c", "o", "r", "p"]`
    and `options[119] = "corp"` silently offered the client four
    single-letter search domains. A bare name is one entry; pass a list for
    several.
    """

    def __init__(self, *items: _ty.Any) -> None:
        # Same rule as `List.__init__`: a list/tuple argument is a sequence of
        # entries, anything else is a single entry.
        for group in items:
            self.extend(group if isinstance(group, (tuple, list)) else (group,))

    @classmethod
    def _normalize(cls, item: _ty.Any) -> str:
        if not isinstance(item, str):
            raise TypeError(
                f"domain-list entries must be str, not {type(item).__name__}"
            )
        # "." and "" are both the root name, which a list may hold (RFC 6731
        # s4.3 marks the default RDNSS with one).
        return "" if item == "." else item

    @classmethod
    def unpack_from(
        cls: type[_DomainListT], option: memoryview
    ) -> tuple[_DomainListT, int]:
        """Decode a compressed search list (RFC 1035 s4.1.4, RFC 3397 s3).

        Bounded as `read_names` says. A last name that ends, between labels,
        without a root label or a whole pointer is discarded (RFC 3397 s3), and
        the names before it are kept.
        """
        self = cls()
        if not option:
            return self, 0
        names, _ = read_names(memoryview(option))
        for name in names:
            self.append(name)
        return self, len(option)

    def pack_into(self, _data: bytearray) -> int:
        components: list[tuple[list[str], int]] = []
        data = bytearray()
        for domain_str in self:
            # Same rules as every other name in the package, even though the
            # encoding below is compressed and theirs is not: without this an
            # over-long label's length prefix sets the pointer flag bits and
            # this encoder emits bytes its own decoder rejects.
            domain = split_domain_name(domain_str, "search-list entry", allow_root=True)
            unique = domain
            parent: _ty.Optional[tuple[int, int]] = None
            for cn, cidx in components:
                pair = 1
                while pair < len(domain):
                    if domain[-pair:] != cn[-pair:]:
                        break
                    pair += 1
                pair -= 1
                if pair:
                    if parent:
                        _, _pair = parent
                        if pair <= _pair:
                            continue
                    for n in cn[:-pair]:
                        # Octets, not characters. A compression pointer is a
                        # byte offset into the option, so counting characters
                        # here aimed every pointer past a non-ASCII label at the
                        # wrong byte.
                        cidx += 1 + len(n.encode())

                    if cidx > _MAX_POINTER_OFFSET:
                        # A pointer holds 14 bits: a name this far in cannot be
                        # pointed at, so it is written out in full.
                        continue
                    parent = cidx, pair
                    unique = domain[:-pair]

            components.append((domain, len(data)))
            for comp in unique:
                # RFC 1035 3.1: a label's length octet counts OCTETS. This
                # counted characters, so `bücher.example` declared 6 for a
                # 7-octet label: the wire bytes would be corrupt and the decoder
                # would read the remainder as different labels entirely (measured:
                # ['éé.x.com', 'y.x.com'] came back as ['<0xef><0xbf><0xbd>',
                # 'x.com', 'y']). `split_domain_name` validates in octets, so the
                # length check and the length written agree.
                encoded = comp.encode()
                data.append(len(encoded))
                data.extend(encoded)
            if parent is None:
                data.append(0x00)
            else:
                data.extend((0xC000 | parent[0]).to_bytes(2, byteorder="big"))
        _data.extend(data)
        return len(data)

unpack_from(option) classmethod

Decode a compressed search list (RFC 1035 s4.1.4, RFC 3397 s3).

Bounded as read_names says. A last name that ends, between labels, without a root label or a whole pointer is discarded (RFC 3397 s3), and the names before it are kept.

Source code in src/pydhcp/options/_codecs/_domains.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
@classmethod
def unpack_from(
    cls: type[_DomainListT], option: memoryview
) -> tuple[_DomainListT, int]:
    """Decode a compressed search list (RFC 1035 s4.1.4, RFC 3397 s3).

    Bounded as `read_names` says. A last name that ends, between labels,
    without a root label or a whole pointer is discarded (RFC 3397 s3), and
    the names before it are kept.
    """
    self = cls()
    if not option:
        return self, 0
    names, _ = read_names(memoryview(option))
    for name in names:
        self.append(name)
    return self, len(option)

DomainName

Bases: DHCPOptionType, str

A single uncompressed RFC 1035 name, as an option payload.

Options 147 (RFC 8973 s5.2) and 213 (RFC 5986 s3.2) carry a label sequence, not dotted text. Registered as String they decoded to the raw label bytes rather than to example.com, and emitted dotted text that a conforming receiver cannot parse.

Source code in src/pydhcp/options/_codecs/_domains.py
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
class DomainName(DHCPOptionType, str):
    """A single uncompressed RFC 1035 name, as an option payload.

    Options 147 (RFC 8973 s5.2) and 213 (RFC 5986 s3.2) carry a label sequence,
    not dotted text. Registered as `String` they decoded to the raw label bytes
    rather than to `example.com`, and emitted dotted text that a conforming
    receiver cannot parse.
    """

    @classmethod
    def unpack_from(
        cls: type[_DomainNameT], option: memoryview
    ) -> tuple[_DomainNameT, int]:
        name, read = decode_domain_name(option, 0, cls.__name__)
        return cls(name), read

    def pack_into(self, data: bytearray) -> int:
        encoded = encode_domain_name(str(self), type(self).__name__, allow_root=True)
        data.extend(encoded)
        return len(encoded)

    def to_json(self) -> str:
        return str(self)

    def __repr__(self) -> str:
        return f"{type(self).__name__}({str.__repr__(self)})"

    def display_text(self) -> str:
        return str.__repr__(self)

EncapsulatedOptions

Bases: RecordList[TLVOption]

TLV container for building vendor-specific sub-option payloads.

Source code in src/pydhcp/options/_codecs/_vendor.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
class EncapsulatedOptions(RecordList[TLVOption]):
    """TLV container for building vendor-specific sub-option payloads."""

    # Only the *read* framing is its own: unlike a plain record list this one
    # honours the PAD (0) and END (255) markers that appear inside an
    # encapsulated options field, so it cannot share `List.unpack_from`.
    @classmethod
    def unpack_from(
        cls: type[_EncapsulatedOptionsT], option: memoryview
    ) -> tuple[_EncapsulatedOptionsT, int]:
        self = cls()
        idx = 0
        size = len(option)
        while idx < size:
            code = option[idx]
            idx += 1
            if code == 0:
                continue
            if code == 255:
                break
            if idx >= size:
                raise DHCPDecodeError(
                    f"{cls.__name__} option is truncated: missing length"
                )
            length = option[idx]
            idx += 1
            if idx + length > size:
                raise DHCPDecodeError(f"{cls.__name__} option is truncated")
            self.append(TLVOption(code, option[idx : idx + length]))
            idx += length
        return self, idx

Flag

Bases: DHCPOptionType

Zero-length presence option.

Some options carry their whole meaning in being present at all -- RFC 4039's Rapid Commit is "Code 80, Len 0". Encoding such an option as a one-octet Boolean both rejects conformant packets on decode and emits a malformed option on encode.

Source code in src/pydhcp/options/_codecs/_scalar.py
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
class Flag(DHCPOptionType):
    """Zero-length presence option.

    Some options carry their whole meaning in being present at all -- RFC 4039's
    Rapid Commit is "Code 80, Len 0". Encoding such an option as a one-octet
    Boolean both rejects conformant packets on decode and emits a malformed
    option on encode.
    """

    __slots__ = ()

    def __init__(self, value: _ty.Any = True) -> None:
        # A zero-length option says everything by being there, so there is no
        # false to encode. Rejecting a falsy value keeps `opts[code] = False`
        # from reading as "off" while actually setting the flag.
        if not value:
            raise DHCPValueError(
                f"{type(self).__name__} is a presence-only option; delete the "
                "option to express absence instead of assigning a false value"
            )

    @classmethod
    def unpack_from(cls: type[_FlagT], option: memoryview) -> tuple[_FlagT, int]:
        return cls(), 0

    def pack_into(self, data: bytearray) -> int:
        return 0

    @classmethod
    def fixed_size(cls) -> _ty.Optional[int]:
        return 0

    def __bool__(self) -> bool:
        return True

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Flag):
            return NotImplemented
        return True

    def __hash__(self) -> int:
        return hash(type(self))

    def to_json(self) -> bool:
        return True

    def __repr__(self) -> str:
        return "Flag()"

I32

Bases: FixedLengthInteger

Signed 32-bit integer.

Source code in src/pydhcp/options/_codecs/_scalar.py
407
408
409
410
411
class I32(FixedLengthInteger):
    """Signed 32-bit integer."""

    NUMBER_OF_BYTES = 4
    SIGNED = True

I8

Bases: FixedLengthInteger

Signed 8-bit integer.

Source code in src/pydhcp/options/_codecs/_scalar.py
414
415
416
417
418
class I8(FixedLengthInteger):
    """Signed 8-bit integer."""

    NUMBER_OF_BYTES = 1
    SIGNED = True

IPXEBusID

Bases: _Record

BUS_ID: the bus type octet, then what that bus identifies the NIC by.

For PCI (type 1) that is the vendor and device, two big-endian octets each, which vendor and device read; any other bus is kept as its octets.

Source code in src/pydhcp/options/_ipxe.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
class IPXEBusID(_Record):
    """`BUS_ID`: the bus type octet, then what that bus identifies the NIC by.

    For PCI (type 1) that is the vendor and device, two big-endian octets each,
    which `vendor` and `device` read; any other bus is kept as its octets.
    """

    __slots__ = ("bus_type", "ident")
    _FIELDS = __slots__

    bus_type: int
    ident: Bytes

    PCI = 1

    def __init__(self, bus_type: int, ident: _ty.Any) -> None:
        _set(self, "bus_type", int(U8(bus_type)))
        _set(self, "ident", _octets(ident))

    @classmethod
    def pci(cls, vendor: int, device: int) -> "IPXEBusID":
        return cls(
            cls.PCI, int(vendor).to_bytes(2, "big") + int(device).to_bytes(2, "big")
        )

    @property
    def vendor(self) -> _ty.Optional[int]:
        """The PCI vendor ID, or None for another bus."""
        if self.bus_type != self.PCI or len(self.ident) != 4:
            return None
        return int.from_bytes(self.ident[:2], "big")

    @property
    def device(self) -> _ty.Optional[int]:
        """The PCI device ID, or None for another bus."""
        if self.bus_type != self.PCI or len(self.ident) != 4:
            return None
        return int.from_bytes(self.ident[2:], "big")

    @classmethod
    def unpack_from(cls, option: memoryview) -> "tuple[IPXEBusID, int]":
        if not option:
            raise DHCPValueError("IPXEBusID needs a bus type octet")
        return cls(option[0], bytes(option[1:])), len(option)

    def pack_into(self, data: bytearray) -> int:
        data.append(self.bus_type)
        data.extend(self.ident)
        return 1 + len(self.ident)

    def to_json(self) -> list[_ty.Any]:
        return [self.bus_type, self.ident.to_json()]

    def display_text(self) -> str:
        if self.vendor is not None:
            return f"PCI {self.vendor:04x}:{self.device:04x}"
        return f"bus {self.bus_type} {self.ident.hex()}"

device property

The PCI device ID, or None for another bus.

vendor property

The PCI vendor ID, or None for another bus.

IPXEOption

Bases: OptionCodeEnum

The sub-options of option 175, named as iPXE's DHCP_EB_* constants are.

Source code in src/pydhcp/options/_ipxe.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class IPXEOption(OptionCodeEnum):
    """The sub-options of option 175, named as iPXE's `DHCP_EB_*` constants are."""

    PRIORITY = 0x01
    KEEP_SAN = 0x08
    SKIP_SAN_BOOT = 0x09
    # The features this build of iPXE has, each sent as a one-octet 1.
    PXE_EXT = 0x10
    ISCSI = 0x11
    AOE = 0x12
    HTTP = 0x13
    HTTPS = 0x14
    TFTP = 0x15
    FTP = 0x16
    DNS = 0x17
    BZIMAGE = 0x18
    MULTIBOOT = 0x19
    SLAM = 0x1A
    SRP = 0x1B
    NBI = 0x20
    PXE = 0x21
    ELF = 0x22
    COMBOOT = 0x23
    EFI = 0x24
    FCOE = 0x25
    VLAN = 0x26
    MENU = 0x27
    SDI = 0x28
    NFS = 0x29
    SCRIPTLET = 0x51
    SYSLOGS_SERVER = 0x55
    TRUST = 0x5A
    CERT = 0x5B
    KEY = 0x5C
    CROSS_CERT = 0x5D
    NO_PXEDHCP = 0xB0
    BUS_ID = 0xB1
    USE_CACHED = 0xB2
    SAN_RETRY = 0xBB
    SAN_FILENAME = 0xBC
    SAN_DRIVE = 0xBD
    USERNAME = 0xBE
    PASSWORD = 0xBF
    REVERSE_USERNAME = 0xC0
    REVERSE_PASSWORD = 0xC1
    UID = 0xC2
    GID = 0xC3
    VERSION = 0xEB

    def is_feature(self) -> bool:
        """Whether this sub-option is one of iPXE's feature flags (0x10 to 0x29)."""
        return 0x10 <= int(self) <= 0x29

is_feature()

Whether this sub-option is one of iPXE's feature flags (0x10 to 0x29).

Source code in src/pydhcp/options/_ipxe.py
71
72
73
def is_feature(self) -> bool:
    """Whether this sub-option is one of iPXE's feature flags (0x10 to 0x29)."""
    return 0x10 <= int(self) <= 0x29

IPXEOptions

Bases: EncapsulatedOptions

Option 175: iPXE's sub-options, kept as the octets they arrived as.

get reads one by its IPXEOption codec; to_json writes [name, value] pairs a document can hold, and reading takes them back. A sub-option is built from (code, value) where the code is a number or a name IPXEOption.resolve reads and the value octets, a codec value, or what its codec's from_json takes.

Source code in src/pydhcp/options/_ipxe.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
class IPXEOptions(EncapsulatedOptions):
    """Option 175: iPXE's sub-options, kept as the octets they arrived as.

    `get` reads one by its `IPXEOption` codec; `to_json` writes `[name, value]`
    pairs a document can hold, and reading takes them back. A sub-option is built
    from `(code, value)` where the code is a number or a name `IPXEOption.resolve`
    reads and the value octets, a codec value, or what its codec's `from_json` takes.
    """

    @classmethod
    def _normalize(cls, item: _ty.Any) -> TLVOption:
        if isinstance(item, TLVOption):
            return item
        first, second = item
        code = IPXEOption.resolve(first)
        if isinstance(second, (bytes, bytearray, memoryview)):
            return TLVOption(int(code), second)
        if isinstance(second, dict) and set(second) == {"hex"}:
            return TLVOption(int(code), bytes.fromhex(str(second["hex"])))
        codec = code.get_type()
        value = second if isinstance(second, codec) else _from_json(codec, second)
        return TLVOption(int(code), _ty.cast(OptionCodec, value).pack())

    def get(self, code: _ty.Union[str, int], default: _ty.Any = None) -> _ty.Any:
        """The first sub-option `code` names, decoded by its codec, or `default`.

        Octets its codec refuses come back as `Bytes`, never as an error.
        """
        wanted = IPXEOption.resolve(code)
        for option in self:
            if option.code == int(wanted):
                return _decoded(wanted, option.value)
        return default

    def features(self) -> "list[IPXEOption]":
        """The feature flags present, in the order they were sent."""
        found = []
        for option in self:
            code = IPXEOption(option.code)
            if code.is_feature():
                found.append(code)
        return found

    def to_json(self) -> list[_ty.Any]:
        written: list[_ty.Any] = []
        for option in self:
            code = IPXEOption(option.code)
            key: _ty.Any = code.label() if code.name is not None else option.code
            value = _decoded(code, option.value)
            readable = value.to_json()
            if isinstance(value, Bytes) or _repacked(code, readable) != bytes(
                option.value
            ):
                readable = {"hex": option.value.hex()}
            written.append([key, readable])
        return written

    @classmethod
    def from_json(cls, data: _ty.Any) -> _ty.Any:
        """What `to_json` writes, or the hex text of a document written while
        this option had no codec of its own."""
        if isinstance(data, str):
            return cls.unpack(Bytes.parse(data))
        return super().from_json(data)

    def _item_text(self, option: TLVOption) -> str:
        """One sub-option as the message display shows it: HTTPS=U8(1)."""
        code = IPXEOption(option.code)
        name = code.label() if code.name is not None else str(option.code)
        return f"{name}={_display(_decoded(code, option.value))}"

    def display_text(self) -> str:
        return ", ".join(self._item_text(option) for option in self)

_item_text(option)

One sub-option as the message display shows it: HTTPS=U8(1).

Source code in src/pydhcp/options/_ipxe.py
267
268
269
270
271
def _item_text(self, option: TLVOption) -> str:
    """One sub-option as the message display shows it: HTTPS=U8(1)."""
    code = IPXEOption(option.code)
    name = code.label() if code.name is not None else str(option.code)
    return f"{name}={_display(_decoded(code, option.value))}"

features()

The feature flags present, in the order they were sent.

Source code in src/pydhcp/options/_ipxe.py
236
237
238
239
240
241
242
243
def features(self) -> "list[IPXEOption]":
    """The feature flags present, in the order they were sent."""
    found = []
    for option in self:
        code = IPXEOption(option.code)
        if code.is_feature():
            found.append(code)
    return found

from_json(data) classmethod

What to_json writes, or the hex text of a document written while this option had no codec of its own.

Source code in src/pydhcp/options/_ipxe.py
259
260
261
262
263
264
265
@classmethod
def from_json(cls, data: _ty.Any) -> _ty.Any:
    """What `to_json` writes, or the hex text of a document written while
    this option had no codec of its own."""
    if isinstance(data, str):
        return cls.unpack(Bytes.parse(data))
    return super().from_json(data)

get(code, default=None)

The first sub-option code names, decoded by its codec, or default.

Octets its codec refuses come back as Bytes, never as an error.

Source code in src/pydhcp/options/_ipxe.py
225
226
227
228
229
230
231
232
233
234
def get(self, code: _ty.Union[str, int], default: _ty.Any = None) -> _ty.Any:
    """The first sub-option `code` names, decoded by its codec, or `default`.

    Octets its codec refuses come back as `Bytes`, never as an error.
    """
    wanted = IPXEOption.resolve(code)
    for option in self:
        if option.code == int(wanted):
            return _decoded(wanted, option.value)
    return default

IPXEVersion

Bases: _Record

VERSION: iPXE's major, minor and patch numbers, one octet each.

Source code in src/pydhcp/options/_ipxe.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
class IPXEVersion(_Record):
    """`VERSION`: iPXE's major, minor and patch numbers, one octet each."""

    __slots__ = ("major", "minor", "patch")
    _FIELDS = __slots__

    major: int
    minor: int
    patch: int

    def __init__(self, major: _ty.Any, minor: _ty.Any = 0, patch: _ty.Any = 0) -> None:
        if isinstance(major, str):
            parts = major.split(".")
            if len(parts) != 3:
                raise DHCPValueError(
                    f"an iPXE version is major.minor.patch, not {major!r}"
                )
            major, minor, patch = parts
        _set(self, "major", int(U8(major)))
        _set(self, "minor", int(U8(minor)))
        _set(self, "patch", int(U8(patch)))

    @classmethod
    def fixed_size(cls) -> int:
        return 3

    @classmethod
    def unpack_from(cls, option: memoryview) -> "tuple[IPXEVersion, int]":
        return cls(option[0], option[1], option[2]), 3

    def pack_into(self, data: bytearray) -> int:
        data.extend((self.major, self.minor, self.patch))
        return 3

    def to_json(self) -> str:
        return f"{self.major}.{self.minor}.{self.patch}"

    def display_text(self) -> str:
        return self.to_json()

IPv4AddressOption

Bases: DHCPOptionType, IPv4Address

A single IPv4 address carried in network byte order.

Source code in src/pydhcp/options/_codecs/_addresses.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class IPv4AddressOption(DHCPOptionType, _IP):
    """A single IPv4 address carried in network byte order."""

    @classmethod
    def unpack_from(
        cls: type[_IPv4AddressOptionT], option: memoryview
    ) -> tuple[_IPv4AddressOptionT, int]:
        if len(option) < 4:
            raise DHCPDecodeError(
                f"{cls.__name__} option is truncated: needs 4 octets, got {len(option)}"
            )
        return cls(option[:4].tobytes()), 4

    def pack_into(self, data: bytearray) -> int:
        data.extend(self.packed)
        return 4

    @classmethod
    def fixed_size(cls) -> _ty.Optional[int]:
        return 4

    def __repr__(self) -> str:
        return f"{type(self).__name__}({str(self)!r})"

    def display_text(self) -> str:
        return str(self)

    def to_json(self) -> str:
        return str(self)

LenientUserClass

Bases: UserClass

Option 77 in either form a client sends: RFC 3004's or one flat string.

RFC 3004 prefixes each class with its length; iPXE, and DHCP servers that predate the RFC, treat the option as one string. A payload is read as RFC 3004 when its length octets account for every octet and no class is empty, and as one class otherwise. That can misread only flat text of 33 octets or more whose first octet, and each length it leads to, land exactly on the end. Written back, the classes are always RFC 3004 framed.

Source code in src/pydhcp/options/_codecs/_vendor.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
class LenientUserClass(UserClass):
    """Option 77 in either form a client sends: RFC 3004's or one flat string.

    RFC 3004 prefixes each class with its length; iPXE, and DHCP servers that
    predate the RFC, treat the option as one string. A payload is read as RFC
    3004 when its length octets account for every octet and no class is empty,
    and as one class otherwise. That can misread only flat text of 33 octets or
    more whose first octet, and each length it leads to, land exactly on the
    end. Written back, the classes are always RFC 3004 framed.
    """

    @classmethod
    def unpack_from(
        cls: type[_LengthPrefixedOpaqueListT], option: memoryview
    ) -> tuple[_LengthPrefixedOpaqueListT, int]:
        if _framed(option):
            return super().unpack_from(option)
        self = cls()
        if option:
            self.append(_octets(option))
        return self, len(option)

List

Bases: _NormalizedList[_T]

Typed DHCP option list container.

Source code in src/pydhcp/options/_codecs/_lists.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
class List(_NormalizedList[_T], metaclass=GenericMeta):
    """Typed DHCP option list container."""

    _args_: _ty.ClassVar[tuple[_T]]

    def __init__(self, *items: _ty.Any):
        for _items in items:
            self.extend(_items if isinstance(_items, (tuple, list)) else (_items,))

    @classmethod
    def item_type(cls) -> type:
        """The codec of each item: `IPv4AddressOption` for `List[IPv4AddressOption]`.

        A subclass of a subscripted list answers its base's; `List` itself,
        unsubscripted, has none and raises `TypeError`.
        """
        args = getattr(cls, "_args_", None)
        if not args:
            raise TypeError(f"{cls.__name__} has no item type: subscript it")
        return _ty.cast(type, args[0])

    @classmethod
    def _normalize(cls, item: _ty.Any) -> _T:
        ty = cls._args_[0]
        if isinstance(item, _ty.cast(_ty.Any, ty)):
            return _ty.cast(_T, item)
        return _ty.cast(_T, _ty.cast(_ty.Any, ty)(item))

    @classmethod
    def unpack_from(cls: type[_ListT], option: memoryview) -> tuple[_ListT, int]:
        _l = len(option)
        self = cls()
        ty = self._args_[0]
        while option:
            item, l = ty.unpack_from(option)
            list.append(self, item)
            option = option[l:]
        return self, _l

    def pack_into(self, data: bytearray) -> int:
        written = 0
        for item in self:
            written += item.pack_into(data)
        return written

    def to_json(self) -> list[_ty.Any]:
        return [item.to_json() for item in self]

    @classmethod
    def from_json(cls: type[_ListT], data: _ty.Any) -> _ListT:
        """Each item read back by its own codec's `from_json`, as `to_json` wrote it."""
        if not isinstance(data, list):
            return cls(data)
        read = getattr(cls._args_[0], "from_json", None)
        return cls([read(item) if read is not None else item for item in data])

from_json(data) classmethod

Each item read back by its own codec's from_json, as to_json wrote it.

Source code in src/pydhcp/options/_codecs/_lists.py
69
70
71
72
73
74
75
@classmethod
def from_json(cls: type[_ListT], data: _ty.Any) -> _ListT:
    """Each item read back by its own codec's `from_json`, as `to_json` wrote it."""
    if not isinstance(data, list):
        return cls(data)
    read = getattr(cls._args_[0], "from_json", None)
    return cls([read(item) if read is not None else item for item in data])

item_type() classmethod

The codec of each item: IPv4AddressOption for List[IPv4AddressOption].

A subclass of a subscripted list answers its base's; List itself, unsubscripted, has none and raises TypeError.

Source code in src/pydhcp/options/_codecs/_lists.py
30
31
32
33
34
35
36
37
38
39
40
@classmethod
def item_type(cls) -> type:
    """The codec of each item: `IPv4AddressOption` for `List[IPv4AddressOption]`.

    A subclass of a subscripted list answers its base's; `List` itself,
    unsubscripted, has none and raises `TypeError`.
    """
    args = getattr(cls, "_args_", None)
    if not args:
        raise TypeError(f"{cls.__name__} has no item type: subscript it")
    return _ty.cast(type, args[0])

MoSFQDNList

Bases: RecordList[MoSFQDNRecord]

RFC 5678 MoS option carrying FQDN sub-options.

Source code in src/pydhcp/options/_codecs/_mos.py
221
222
class MoSFQDNList(RecordList[MoSFQDNRecord]):
    """RFC 5678 MoS option carrying FQDN sub-options."""

MoSFQDNRecord

Bases: _MoSFQDNSubOption

RFC 5678 MoS sub-option record carrying FQDN label sequences.

Source code in src/pydhcp/options/_codecs/_mos.py
213
214
class MoSFQDNRecord(_MoSFQDNSubOption):
    """RFC 5678 MoS sub-option record carrying FQDN label sequences."""

MoSIPv4AddressList

Bases: RecordList[MoSIPv4AddressRecord]

RFC 5678 MoS option carrying IPv4 address sub-options.

Source code in src/pydhcp/options/_codecs/_mos.py
217
218
class MoSIPv4AddressList(RecordList[MoSIPv4AddressRecord]):
    """RFC 5678 MoS option carrying IPv4 address sub-options."""

MoSIPv4AddressRecord

Bases: _MoSIPv4AddressSubOption

RFC 5678 MoS sub-option record carrying IPv4 addresses.

Source code in src/pydhcp/options/_codecs/_mos.py
209
210
class MoSIPv4AddressRecord(_MoSIPv4AddressSubOption):
    """RFC 5678 MoS sub-option record carrying IPv4 addresses."""

OctetString

Bases: String

Text that is the whole payload: no NUL terminator, no truncation.

RFC 2132 s9.13 defines option 60 as "a string of n octets" -- not a NUL-terminated NVT string. String partitions at the first NUL, so a vendor class identifier that is binary, as embedded and CPE firmware sends, decoded to the empty string and lost everything a server might have matched on. Octets that are not valid UTF-8 are preserved, as in String.

Source code in src/pydhcp/options/_codecs/_scalar.py
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
class OctetString(String):
    """Text that is the whole payload: no NUL terminator, no truncation.

    RFC 2132 s9.13 defines option 60 as "a string of n octets" -- not a
    NUL-terminated NVT string. `String` partitions at the first NUL, so a vendor
    class identifier that is binary, as embedded and CPE firmware sends, decoded
    to the empty string and lost everything a server might have matched on.
    Octets that are not valid UTF-8 are preserved, as in `String`.
    """

    @classmethod
    def unpack_from(
        cls: type[_OctetStringT], option: memoryview
    ) -> tuple[_OctetStringT, int]:
        return cls(_nvt.decode(option.tobytes(), "Option octet string")), len(option)

OptionCode

Bases: Protocol

What a code enum a DHCPOptions bag resolves option codes with provides.

A member names an option and knows its codec (get_type) and its name (label), and converts to the octet it is sent as (int()); the class builds a member from a number (from_code) and builds or decodes a whole option (normalize, decode). BaseDHCPOptionCode is the base class with the defaults for these; DHCPOptionCode is the IANA registry.

Source code in src/pydhcp/options/_codes.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@_ty.runtime_checkable
class OptionCode(_ty.Protocol):
    """What a code enum a `DHCPOptions` bag resolves option codes with provides.

    A member names an option and knows its codec (`get_type`) and its name
    (`label`), and converts to the octet it is sent as (`int()`); the class
    builds a member from a number (`from_code`) and builds or decodes a whole
    option (`normalize`, `decode`). `BaseDHCPOptionCode` is the base class with
    the defaults for these; `DHCPOptionCode` is the IANA registry.
    """

    def get_type(self) -> "type[OptionCodec]": ...

    def label(self) -> str: ...

    def __int__(self) -> int: ...

    @classmethod
    def from_code(cls, code: int) -> "OptionCode": ...

    @classmethod
    def normalize(cls, code: int, value: object) -> "DHCPOption": ...

    @classmethod
    def decode(cls, code: int, value: bytearray) -> "DHCPOption": ...

OptionCodeEnum

Bases: BaseDHCPOptionCode, IntEnum

The base of an option-code enum: a member per named code, the rest unnamed.

Each subclass has its own codec table and its own unnamed members, so one enum's registrations and lookups never reach another's.

Source code in src/pydhcp/options/_codes.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
class OptionCodeEnum(BaseDHCPOptionCode, _enum.IntEnum):
    """The base of an option-code enum: a member per named code, the rest unnamed.

    Each subclass has its own codec table and its own unnamed members, so one
    enum's registrations and lookups never reach another's.
    """

    def __init_subclass__(cls, **kwargs: _ty.Any) -> None:
        super().__init_subclass__(**kwargs)
        # Not annotated in the class body, where an enum would read them as members.
        setattr(cls, "_codecs", [Bytes] * 256)
        setattr(cls, "_unnamed", {})

    @classmethod
    def _missing_(
        cls: type[_OptionCodeEnumT], value: object
    ) -> _ty.Optional[_OptionCodeEnumT]:
        """Return an opaque pseudo-member for any byte value without one.

        Only 163 of codes 0-255 are members of `DHCPOptionCode`, and RFC 3942
        reserves 224-254 for site-specific use, so raising for the rest means a
        client sending any of them gets no service at all: the receive path
        resolves every option code before the packet reaches a handler. Such
        codes carry no known structure, so `get_type()` returns `Bytes` and
        `label()` reports "UNKNOWN" -- which is what the shipped API header
        documents.
        """
        if not isinstance(value, int) or isinstance(value, bool):
            return None
        if not 0 <= value <= 255:
            return None
        unnamed = _unnamed(cls)
        pseudo = unnamed.get(value)
        if pseudo is None:
            pseudo = int.__new__(cls, value)
            pseudo._name_ = None  # type: ignore[assignment]
            pseudo._value_ = value
            pseudo = unnamed.setdefault(value, pseudo)
        return _ty.cast(_OptionCodeEnumT, pseudo)

    def register_type(self, optiontype: type[OptionCodec]) -> None:
        """Bind `optiontype` as this code's codec in this enum, permanently.

        The built-in registry is loaded with this module, so a registration made
        after import is always the later write.
        """
        if not is_codec_class(optiontype):
            raise TypeError(
                "optiontype must be a DHCPOptionType subclass or have the methods "
                "of OptionCodec"
            )
        _codecs(type(self))[self] = optiontype

    def get_type(self) -> type[OptionCodec]:
        return _codecs(type(self))[self]

    def label(self) -> str:
        label = getattr(self, "name", None)
        if not isinstance(label, str):
            return BaseDHCPOptionCode.label(self)
        return label

_missing_(value) classmethod

Return an opaque pseudo-member for any byte value without one.

Only 163 of codes 0-255 are members of DHCPOptionCode, and RFC 3942 reserves 224-254 for site-specific use, so raising for the rest means a client sending any of them gets no service at all: the receive path resolves every option code before the packet reaches a handler. Such codes carry no known structure, so get_type() returns Bytes and label() reports "UNKNOWN" -- which is what the shipped API header documents.

Source code in src/pydhcp/options/_codes.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
@classmethod
def _missing_(
    cls: type[_OptionCodeEnumT], value: object
) -> _ty.Optional[_OptionCodeEnumT]:
    """Return an opaque pseudo-member for any byte value without one.

    Only 163 of codes 0-255 are members of `DHCPOptionCode`, and RFC 3942
    reserves 224-254 for site-specific use, so raising for the rest means a
    client sending any of them gets no service at all: the receive path
    resolves every option code before the packet reaches a handler. Such
    codes carry no known structure, so `get_type()` returns `Bytes` and
    `label()` reports "UNKNOWN" -- which is what the shipped API header
    documents.
    """
    if not isinstance(value, int) or isinstance(value, bool):
        return None
    if not 0 <= value <= 255:
        return None
    unnamed = _unnamed(cls)
    pseudo = unnamed.get(value)
    if pseudo is None:
        pseudo = int.__new__(cls, value)
        pseudo._name_ = None  # type: ignore[assignment]
        pseudo._value_ = value
        pseudo = unnamed.setdefault(value, pseudo)
    return _ty.cast(_OptionCodeEnumT, pseudo)

register_type(optiontype)

Bind optiontype as this code's codec in this enum, permanently.

The built-in registry is loaded with this module, so a registration made after import is always the later write.

Source code in src/pydhcp/options/_codes.py
162
163
164
165
166
167
168
169
170
171
172
173
def register_type(self, optiontype: type[OptionCodec]) -> None:
    """Bind `optiontype` as this code's codec in this enum, permanently.

    The built-in registry is loaded with this module, so a registration made
    after import is always the later write.
    """
    if not is_codec_class(optiontype):
        raise TypeError(
            "optiontype must be a DHCPOptionType subclass or have the methods "
            "of OptionCodec"
        )
    _codecs(type(self))[self] = optiontype

OptionCodec

Bases: Protocol

What an option payload codec is: a value that reads and writes its octets.

A codec is built from one value (Codec(value)), reads itself from the octets of an option (unpack, unpack_from), writes itself back (pack, pack_into) and renders itself for a structured document (to_json) and for the message display (display_text). DHCPOptionType is the base class that supplies everything but unpack_from, pack_into and the constructor; subclassing it is the way to get a codec the registry accepts. isinstance(x, OptionCodec) checks that the methods exist, not their signatures.

Source code in src/pydhcp/options/_codecs/_base.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@_ty.runtime_checkable
class OptionCodec(_ty.Protocol):
    """What an option payload codec is: a value that reads and writes its octets.

    A codec is built from one value (`Codec(value)`), reads itself from the
    octets of an option (`unpack`, `unpack_from`), writes itself back (`pack`,
    `pack_into`) and renders itself for a structured document (`to_json`) and
    for the message display (`display_text`). `DHCPOptionType` is the base class
    that supplies everything but `unpack_from`, `pack_into` and the
    constructor; subclassing it is the way to get a codec the registry accepts.
    `isinstance(x, OptionCodec)` checks that the methods exist, not their
    signatures.
    """

    def __init__(self, value: _ty.Any, /) -> None: ...

    @classmethod
    def unpack(
        cls: type[_OptionCodecT], data: _ty.Union[memoryview, bytes, bytearray]
    ) -> _OptionCodecT:
        """The value held by all of `data`; `DHCPDecodeError` if it is not one."""
        ...

    @classmethod
    def unpack_from(
        cls: type[_OptionCodecT], option: memoryview
    ) -> tuple[_OptionCodecT, int]:
        """The value at the start of `option`, and how many octets it took."""
        ...

    @classmethod
    def fixed_size(cls) -> _ty.Optional[int]:
        """The payload length every value has, or `None` when it varies."""
        ...

    def pack(self) -> bytes:
        """The octets of the payload."""
        ...

    def pack_into(self, buffer: bytearray) -> int:
        """Append the octets of the payload to `buffer`; how many were written."""
        ...

    def to_json(self) -> _ty.Any:
        """The value as plain data for a JSON, YAML, TOML or INI document."""
        ...

    def display_text(self) -> str:
        """The text the message display shows for the value."""
        ...

display_text()

The text the message display shows for the value.

Source code in src/pydhcp/options/_codecs/_base.py
61
62
63
def display_text(self) -> str:
    """The text the message display shows for the value."""
    ...

fixed_size() classmethod

The payload length every value has, or None when it varies.

Source code in src/pydhcp/options/_codecs/_base.py
44
45
46
47
@classmethod
def fixed_size(cls) -> _ty.Optional[int]:
    """The payload length every value has, or `None` when it varies."""
    ...

pack()

The octets of the payload.

Source code in src/pydhcp/options/_codecs/_base.py
49
50
51
def pack(self) -> bytes:
    """The octets of the payload."""
    ...

pack_into(buffer)

Append the octets of the payload to buffer; how many were written.

Source code in src/pydhcp/options/_codecs/_base.py
53
54
55
def pack_into(self, buffer: bytearray) -> int:
    """Append the octets of the payload to `buffer`; how many were written."""
    ...

to_json()

The value as plain data for a JSON, YAML, TOML or INI document.

Source code in src/pydhcp/options/_codecs/_base.py
57
58
59
def to_json(self) -> _ty.Any:
    """The value as plain data for a JSON, YAML, TOML or INI document."""
    ...

unpack(data) classmethod

The value held by all of data; DHCPDecodeError if it is not one.

Source code in src/pydhcp/options/_codecs/_base.py
30
31
32
33
34
35
@classmethod
def unpack(
    cls: type[_OptionCodecT], data: _ty.Union[memoryview, bytes, bytearray]
) -> _OptionCodecT:
    """The value held by all of `data`; `DHCPDecodeError` if it is not one."""
    ...

unpack_from(option) classmethod

The value at the start of option, and how many octets it took.

Source code in src/pydhcp/options/_codecs/_base.py
37
38
39
40
41
42
@classmethod
def unpack_from(
    cls: type[_OptionCodecT], option: memoryview
) -> tuple[_OptionCodecT, int]:
    """The value at the start of `option`, and how many octets it took."""
    ...

OptionOverload

Bases: DHCPOptionType, IntFlag

RFC 2132 option-overload selector.

Source code in src/pydhcp/options/_codecs/_scalar.py
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
class OptionOverload(DHCPOptionType, _enum.IntFlag):
    """RFC 2132 option-overload selector."""

    NONE = 0
    FILE = 1
    SNAME = 2
    BOTH = FILE | SNAME

    def __repr__(self) -> str:
        if self.name is None:
            return f"{type(self).__name__}({int(self)})"
        return f"{type(self).__name__}.{self.name}"

    def display_text(self) -> str:
        return _enum.IntFlag.__repr__(self)

    @classmethod
    def unpack_from(
        cls: type[_OptionOverloadT], option: memoryview
    ) -> tuple[_OptionOverloadT, int]:
        option_part = option[:1]
        if len(option_part) != 1:
            raise DHCPDecodeError(
                "OPTION_OVERLOAD needs 1 octet, got 0 (RFC 2132 s9.3)"
            )
        return cls(option_part[0]), 1

    def pack_into(self, data: bytearray) -> int:
        data.append(self.value)
        return 1

    @classmethod
    def fixed_size(cls) -> _ty.Optional[int]:
        return 1

PCPServerList

Bases: _NormalizedList[tuple[str, ...]]

RFC 7291 s4 PCP servers: one or more length-prefixed address lists.

Each entry is a List-Length octet giving the octet count, then that many octets of IPv4 addresses; separate entries are separate PCP servers. An entry is stored as a tuple of address texts, so it cannot be changed past the checks append makes: replace the entry instead. Registered as a flat List[IPv4AddressOption] the length octet was read as address data, so a conformant option raised and an emitted one carried no length octet at all.

Source code in src/pydhcp/options/_codecs/_servers.py
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
class PCPServerList(_NormalizedList[tuple[str, ...]]):
    """RFC 7291 s4 PCP servers: one or more length-prefixed address lists.

    Each entry is a List-Length octet giving the octet count, then that many
    octets of IPv4 addresses; separate entries are separate PCP servers. An
    entry is stored as a tuple of address texts, so it cannot be changed past
    the checks `append` makes: replace the entry instead.
    Registered as a flat `List[IPv4AddressOption]` the length octet was read as
    address data, so a conformant option raised and an emitted one carried no
    length octet at all.
    """

    def __init__(self, *items: _ty.Any):
        if len(items) == 1 and isinstance(items[0], (list, tuple)):
            entries = list(items[0])
            # PCPServerList(["192.0.2.1"]) means one server, not an entry of
            # nothing -- accept the flat spelling people will reach for.
            if entries and not isinstance(entries[0], (list, tuple)):
                entries = [entries]
        else:
            entries = list(items)
        for entry in entries:
            self.append(entry)

    @classmethod
    def _normalize(cls, entry: _ty.Any) -> tuple[str, ...]:
        if isinstance(entry, (str, _IP)):
            entry = [entry]
        addresses = tuple(str(_IP(address)) for address in entry)
        if not addresses:
            raise DHCPValueError("PCPServerList entry must hold at least one address")
        if len(addresses) > 63:
            raise DHCPValueError(
                f"PCPServerList entry holds {len(addresses)} addresses; "
                "its length octet allows at most 63"
            )
        return addresses

    @classmethod
    def unpack_from(
        cls: type[_PCPServerListT], option: memoryview
    ) -> tuple[_PCPServerListT, int]:
        self = cls()
        idx = 0
        while idx < len(option):
            length = option[idx]
            idx += 1
            if length == 0 or length % 4:
                raise DHCPDecodeError(
                    "PCPServerList entry length must be a non-zero multiple of 4, "
                    f"got {length}"
                )
            if idx + length > len(option):
                raise DHCPDecodeError("PCPServerList option is truncated")
            self.append(
                [
                    str(_IP(option[pos : pos + 4].tobytes()))
                    for pos in range(idx, idx + length, 4)
                ]
            )
            idx += length
        if not self:
            raise DHCPDecodeError("PCPServerList option is empty")
        return self, len(option)

    def pack_into(self, data: bytearray) -> int:
        written = 0
        for entry in self:
            data.append(len(entry) * 4)
            written += 1
            for address in entry:
                data.extend(_IP(address).packed)
                written += 4
        return written

    @staticmethod
    def _item_text(entry: tuple[str, ...]) -> str:
        """One entry as its addresses in brackets, the way a list of them reads."""
        return "[" + ", ".join(repr(address) for address in entry) + "]"

    def to_json(self) -> list[list[str]]:
        return [list(entry) for entry in self]

_item_text(entry) staticmethod

One entry as its addresses in brackets, the way a list of them reads.

Source code in src/pydhcp/options/_codecs/_servers.py
366
367
368
369
@staticmethod
def _item_text(entry: tuple[str, ...]) -> str:
    """One entry as its addresses in brackets, the way a list of them reads."""
    return "[" + ", ".join(repr(address) for address in entry) + "]"

PolicyFilter

Bases: _IPv4PairList

List of IPv4 destination/mask pairs for policy filtering.

Source code in src/pydhcp/options/_codecs/_addresses.py
190
191
class PolicyFilter(_IPv4PairList):
    """List of IPv4 destination/mask pairs for policy filtering."""

RDNSSSelection

Bases: _Record

RFC 6731 RDNSS selection payload.

Source code in src/pydhcp/options/_codecs/_servers.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
class RDNSSSelection(_Record):
    """RFC 6731 RDNSS selection payload."""

    __slots__ = ("flags", "primary", "secondary", "domains")
    _FIELDS = __slots__

    #: The two low bits, the preference; the other six are reserved.
    PREFERENCE_BITS = 0x03

    flags: int
    primary: _IP
    secondary: _IP
    domains: UncompressedDomainList

    def __init__(
        self,
        flags: _ty.Any,
        primary: _ty.Any = None,
        secondary: _ty.Any = None,
        domains: _ty.Any = None,
    ) -> None:
        if isinstance(flags, RDNSSSelection):
            flags, primary, secondary, domains = (
                flags.flags,
                flags.primary,
                flags.secondary,
                flags.domains,
            )
        elif isinstance(flags, (list, tuple)):
            # What `to_json` emits: [flags, primary, secondary, domains].
            if len(flags) not in (3, 4):
                raise TypeError(
                    "RDNSSSelection takes (flags, primary, secondary, domains) "
                    f"or one such sequence, got {len(flags)} items"
                )
            flags, primary, secondary, domains = (*flags, None)[:4]
        if primary is None or secondary is None:
            raise TypeError("RDNSSSelection needs a primary and a secondary address")
        _set(self, "flags", int(flags))
        _set(self, "primary", _IP(primary))
        _set(self, "secondary", _IP(secondary))
        _set(self, "domains", frozen(self._normalize_domains(domains or [])))

    @staticmethod
    def _normalize_domains(domains: _ty.Any) -> UncompressedDomainList:
        # Uncompressed: RFC 6731 §4.3 defers to RFC 3315 §8, which forbids the
        # compressed form. A bare `str` is one domain here, not one per
        # character -- see `DomainList`. The root name ("" or ".") stays: it
        # marks the default RDNSS.
        return UncompressedDomainList(domains)

    @classmethod
    def unpack_from(
        cls: type[_RDNSSSelectionT], option: memoryview
    ) -> tuple[_RDNSSSelectionT, int]:
        if len(option) < 9:
            raise DHCPDecodeError(f"{cls.__name__} option is truncated")
        # The six high bits are reserved and ignored on receipt (RFC 6731 s4.3).
        flags = option[0] & cls.PREFERENCE_BITS
        primary = _IP(option[1:5].tobytes())
        secondary = _IP(option[5:9].tobytes())
        domains, read = UncompressedDomainList.unpack_from(option[9:])
        return cls(flags, primary, secondary, domains), 9 + read

    def pack_into(self, data: bytearray) -> int:
        encoded = self.domains.pack()
        data.append(self.flags)
        data.extend(self.primary.packed)
        data.extend(self.secondary.packed)
        data.extend(encoded)
        return 9 + len(encoded)

    def to_json(self) -> list[_ty.Any]:
        return [
            self.flags,
            str(self.primary),
            str(self.secondary),
            self.domains.to_json(),
        ]

RecordList

Bases: List[_T]

Typed list of two-field records, normalized from (first, second) pairs.

Five containers repeated List's whole shape around a record type that takes two constructor arguments -- the MoS options, the two RFC 3925 VI* options, option 82's encapsulated TLVs and the CCC option. They differ from List in exactly one place: a record is itself a two-element sequence, so List.__init__'s rule that a tuple argument is a sequence of items would split one (code, value) record into two items. Here only a list spells "several records"; a tuple is one record.

Subclass a subscripted form (class X(RecordList[SomeRecord])) rather than setting a _RECORD_TYPE attribute -- _args_[0] is the same information and unpack_from/_normalize already read it.

Source code in src/pydhcp/options/_codecs/_lists.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
class RecordList(List[_T]):
    """Typed list of two-field records, normalized from `(first, second)` pairs.

    Five containers repeated `List`'s whole shape around a record type that
    takes two constructor arguments -- the MoS options, the two RFC 3925 `VI*`
    options, option 82's encapsulated TLVs and the CCC option. They differ from
    `List` in exactly one place: a record is *itself* a two-element sequence, so
    `List.__init__`'s rule that a tuple argument is a sequence of items would
    split one `(code, value)` record into two items. Here only a `list` spells
    "several records"; a tuple is one record.

    Subclass a subscripted form (`class X(RecordList[SomeRecord])`) rather than
    setting a `_RECORD_TYPE` attribute -- `_args_[0]` is the same information and
    `unpack_from`/`_normalize` already read it.
    """

    def __init__(self, *items: _ty.Any):
        if len(items) == 1 and isinstance(items[0], list):
            self.extend(items[0])
            return
        for item in items:
            self.append(item)

    @classmethod
    def _normalize(cls, item: _ty.Any) -> _T:
        ty = cls._args_[0]
        if isinstance(item, _ty.cast(_ty.Any, ty)):
            return _ty.cast(_T, item)
        first, second = item
        return _ty.cast(_T, _ty.cast(_ty.Any, ty)(first, second))

    @classmethod
    def from_json(cls: type[_ListT], data: _ty.Any) -> _ListT:
        """The `[first, second]` pairs `to_json` wrote, each normalized as a record."""
        return cls(data)

from_json(data) classmethod

The [first, second] pairs to_json wrote, each normalized as a record.

Source code in src/pydhcp/options/_codecs/_lists.py
109
110
111
112
@classmethod
def from_json(cls: type[_ListT], data: _ty.Any) -> _ListT:
    """The `[first, second]` pairs `to_json` wrote, each normalized as a record."""
    return cls(data)

RelayAgentInformation

Bases: EncapsulatedOptions

RFC 3046 relay-agent sub-options.

Plain code, length, value tuples: RFC 3046 s2.0 defines no pad sub-option and no terminating 255, so 0 and 255 are sub-option codes like any other.

Source code in src/pydhcp/options/_codecs/_vendor.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
class RelayAgentInformation(EncapsulatedOptions):
    """RFC 3046 relay-agent sub-options.

    Plain code, length, value tuples: RFC 3046 s2.0 defines no pad sub-option
    and no terminating 255, so 0 and 255 are sub-option codes like any other.
    """

    @classmethod
    def unpack_from(
        cls: type[_EncapsulatedOptionsT], option: memoryview
    ) -> tuple[_EncapsulatedOptionsT, int]:
        self = cls()
        idx = 0
        size = len(option)
        while idx < size:
            if idx + 2 > size:
                raise DHCPDecodeError(
                    f"{cls.__name__} option is truncated: missing length"
                )
            code = option[idx]
            length = option[idx + 1]
            idx += 2
            if idx + length > size:
                raise DHCPDecodeError(f"{cls.__name__} option is truncated")
            self.append(TLVOption(code, option[idx : idx + length]))
            idx += length
        return self, size

SIPServers

Bases: _Record

RFC 3361 SIP servers: an encoding octet, then names or addresses.

Encoding 0 is a list of RFC 1035 names, encoding 1 a list of IPv4 addresses. Registered as a bare address list, a conformant option could not be decoded at all, and an emitted one would have no encoding octet -- so a SIP phone would read the first address octet as the encoding and reject the option.

Source code in src/pydhcp/options/_codecs/_servers.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
class SIPServers(_Record):
    """RFC 3361 SIP servers: an encoding octet, then names or addresses.

    Encoding 0 is a list of RFC 1035 names, encoding 1 a list of IPv4 addresses.
    Registered as a bare address list, a conformant option could not be decoded
    at all, and an emitted one would have no encoding octet -- so a SIP phone
    would read the first address octet as the encoding and reject the option.
    """

    __slots__ = ("values", "encoding")
    _FIELDS = __slots__

    ENCODING_DOMAIN = 0
    ENCODING_ADDRESS = 1

    # See ClientFQDN: declared to keep mypy out of a circular inference.
    encoding: int
    values: tuple[str, ...]

    def __init__(
        self, values: _ty.Any = (), encoding: _ty.Optional[int] = None
    ) -> None:
        if isinstance(values, SIPServers):
            values, encoding = list(values.values), values.encoding
        elif isinstance(values, _ty.Mapping):
            mapping = values
            values = mapping.get("values", ())
            raw_encoding = mapping.get("encoding")
            if isinstance(raw_encoding, str):
                raw_encoding = (
                    self.ENCODING_ADDRESS
                    if raw_encoding == "address"
                    else self.ENCODING_DOMAIN
                )
            encoding = raw_encoding
        if isinstance(values, (str, bytes)):
            values = [values]
        items = [str(value) for value in values]
        if encoding is None:
            # Infer, so SIPServers(["192.0.2.1"]) does the obvious thing.
            encoding = self.ENCODING_ADDRESS
            for item in items:
                try:
                    _IP(item)
                except Exception:
                    encoding = self.ENCODING_DOMAIN
                    break
        if encoding not in (self.ENCODING_DOMAIN, self.ENCODING_ADDRESS):
            raise DHCPValueError(f"SIPServers encoding must be 0 or 1, got {encoding}")
        if encoding == self.ENCODING_ADDRESS:
            items = [str(_IP(item)) for item in items]
        _set(self, "encoding", int(encoding))
        _set(self, "values", tuple(items))

    @classmethod
    def unpack_from(
        cls: type[_SIPServersT], option: memoryview
    ) -> tuple[_SIPServersT, int]:
        if len(option) < 1:
            raise DHCPDecodeError(
                "SIPServers option is truncated: missing encoding octet"
            )
        encoding = option[0]
        body = option[1:]
        if encoding == cls.ENCODING_ADDRESS:
            if len(body) % 4:
                raise DHCPDecodeError(
                    "SIPServers address list length must be a multiple of 4 plus one"
                )
            values = [
                str(_IP(body[idx : idx + 4].tobytes()))
                for idx in range(0, len(body), 4)
            ]
        elif encoding == cls.ENCODING_DOMAIN:
            # Compression is read (RFC 3361 s3.1: clients MUST support it) and
            # never sent. A pointer counts from the encoding octet, the start
            # of the option data, as RFC 3397's counts from the start of its.
            values, complete = read_names(body, "SIPServers name list", base=1)
            if not complete:
                raise DHCPDecodeError("SIPServers name list is truncated")
        else:
            raise DHCPDecodeError(f"SIPServers encoding must be 0 or 1, got {encoding}")
        return cls(values, encoding), len(option)

    def pack_into(self, data: bytearray) -> int:
        if not self.values:
            raise DHCPValueError(
                "SIPServers needs at least one name or address (RFC 3361 section 3)"
            )
        start = len(data)
        data.append(self.encoding)
        if self.encoding == self.ENCODING_ADDRESS:
            for value in self.values:
                data.extend(_IP(value).packed)
        else:
            for value in self.values:
                data.extend(encode_domain_name(value, "SIPServers name"))
        return len(data) - start

    def display_text(self) -> str:
        kind = "address" if self.encoding == self.ENCODING_ADDRESS else "domain"
        return f"SIPServers({kind}, {list(self.values)!r})"

    def to_json(self) -> dict[str, _ty.Any]:
        return {
            "encoding": (
                "address" if self.encoding == self.ENCODING_ADDRESS else "domain"
            ),
            "values": list(self.values),
        }

StaticRoute

Bases: _IPv4PairList

List of IPv4 destination/router pairs for static routing.

Source code in src/pydhcp/options/_codecs/_addresses.py
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
class StaticRoute(_IPv4PairList):
    """List of IPv4 destination/router pairs for static routing."""

    @classmethod
    def _normalize(cls, item: _ty.Any) -> tuple[_IP, _IP]:
        left, right = super()._normalize(item)
        if left == _IP("0.0.0.0"):
            raise DHCPValueError(
                "StaticRoute does not allow a default-route destination"
            )
        return left, right

    @classmethod
    def unpack_from(
        cls: type[_IPv4PairListT], option: memoryview
    ) -> tuple[_IPv4PairListT, int]:
        """Read the pairs as they came, a 0.0.0.0 destination included.

        RFC 2132 s5.8 forbids that destination: a sender is refused it
        (`append`, `pack_into`) and a receiver keeps the route a peer sent.
        """
        if len(option) % 8:
            raise DHCPDecodeError(
                f"{cls.__name__} option is truncated: expected 8-byte records"
            )
        self = cls()
        for idx in range(0, len(option), 8):
            pair = (
                _IP(option[idx : idx + 4].tobytes()),
                _IP(option[idx + 4 : idx + 8].tobytes()),
            )
            list.append(self, pair)
        return self, len(option)

    def pack_into(self, data: bytearray) -> int:
        for destination, _ in self:
            if destination == _IP("0.0.0.0"):
                raise DHCPValueError(
                    "StaticRoute does not allow a default-route destination"
                )
        return super().pack_into(data)

unpack_from(option) classmethod

Read the pairs as they came, a 0.0.0.0 destination included.

RFC 2132 s5.8 forbids that destination: a sender is refused it (append, pack_into) and a receiver keeps the route a peer sent.

Source code in src/pydhcp/options/_codecs/_addresses.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
@classmethod
def unpack_from(
    cls: type[_IPv4PairListT], option: memoryview
) -> tuple[_IPv4PairListT, int]:
    """Read the pairs as they came, a 0.0.0.0 destination included.

    RFC 2132 s5.8 forbids that destination: a sender is refused it
    (`append`, `pack_into`) and a receiver keeps the route a peer sent.
    """
    if len(option) % 8:
        raise DHCPDecodeError(
            f"{cls.__name__} option is truncated: expected 8-byte records"
        )
    self = cls()
    for idx in range(0, len(option), 8):
        pair = (
            _IP(option[idx : idx + 4].tobytes()),
            _IP(option[idx + 4 : idx + 8].tobytes()),
        )
        list.append(self, pair)
    return self, len(option)

StatusCode

Bases: _Record, _TextForm

RFC 6926 s6.2.2 status: one code octet, then an optional UTF-8 message.

Registered as a bare U8 the message made the option the wrong size, so a DHCPLEASEQUERY reply carrying one could not be decoded at all.

Source code in src/pydhcp/options/_codecs/_servers.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
class StatusCode(_Record, _TextForm):
    """RFC 6926 s6.2.2 status: one code octet, then an optional UTF-8 message.

    Registered as a bare `U8` the message made the option the wrong size, so a
    DHCPLEASEQUERY reply carrying one could not be decoded at all.
    """

    __slots__ = ("code", "message")
    _FIELDS = __slots__

    code: int
    message: str

    def __init__(self, code: _ty.Any = 0, message: _ty.Any = "") -> None:
        if isinstance(code, StatusCode):
            code, message = code.code, code.message
        elif isinstance(code, _ty.Mapping):
            mapping = code
            code = mapping.get("code", 0)
            message = mapping.get("message", message)
        elif isinstance(code, (tuple, list)) and len(code) == 2:
            code, message = code
        value = int(code)
        if not 0 <= value <= 255:
            raise DHCPValueError(f"StatusCode code must fit one octet, got {value}")
        _set(self, "code", value)
        _set(self, "message", str(message or ""))

    def __str__(self) -> str:
        return f"{self.code} {self.message}" if self.message else str(self.code)

    @classmethod
    def parse(cls: type[_StatusCodeT], text: str) -> _StatusCodeT:
        """Build from ``"<code>"`` or ``"<code> <message>"``, the text `str()` produces.

        The message is everything after the first space. Raises `DHCPValueError`
        for a code that is not a number in 0-255.
        """
        digits, _, message = _text_argument(cls, text).partition(" ")
        if not (digits.isascii() and digits.isdigit()):
            raise DHCPValueError(
                f"not a status code, {text!r}: it starts with a number"
            )
        return cls(int(digits), message)

    @classmethod
    def unpack_from(
        cls: type[_StatusCodeT], option: memoryview
    ) -> tuple[_StatusCodeT, int]:
        if len(option) < 1:
            raise DHCPDecodeError("StatusCode option is truncated: missing code octet")
        message = _nvt.decode(option[1:].tobytes(), "StatusCode message")
        return cls(option[0], message), len(option)

    def pack_into(self, data: bytearray) -> int:
        encoded = _nvt.encode(self.message)
        data.append(self.code)
        data.extend(encoded)
        return 1 + len(encoded)

    def to_json(self) -> dict[str, _ty.Any]:
        return {"code": self.code, "message": _nvt.display(self.message)}

parse(text) classmethod

Build from "<code>" or "<code> <message>", the text str() produces.

The message is everything after the first space. Raises DHCPValueError for a code that is not a number in 0-255.

Source code in src/pydhcp/options/_codecs/_servers.py
255
256
257
258
259
260
261
262
263
264
265
266
267
@classmethod
def parse(cls: type[_StatusCodeT], text: str) -> _StatusCodeT:
    """Build from ``"<code>"`` or ``"<code> <message>"``, the text `str()` produces.

    The message is everything after the first space. Raises `DHCPValueError`
    for a code that is not a number in 0-255.
    """
    digits, _, message = _text_argument(cls, text).partition(" ")
    if not (digits.isascii() and digits.isdigit()):
        raise DHCPValueError(
            f"not a status code, {text!r}: it starts with a number"
        )
    return cls(int(digits), message)

String

Bases: DHCPOptionType, str

RFC 2132 NVT-ASCII string, not null-terminated on the wire.

A value read from the wire ends at the first NUL octet, so a trailing NUL and everything after an embedded one are dropped (RFC 2132 s2 asks a receiver to delete trailing NULs; OctetString keeps every octet).

Octets that are not valid UTF-8 are preserved rather than replaced, so a hostname or boot filename in another encoding survives a decode/encode round trip intact; to_json renders the display form. See pydhcp._nvt.

Source code in src/pydhcp/options/_codecs/_scalar.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
class String(DHCPOptionType, str):
    """RFC 2132 NVT-ASCII string, not null-terminated on the wire.

    A value read from the wire ends at the first NUL octet, so a trailing NUL
    and everything after an embedded one are dropped (RFC 2132 s2 asks a
    receiver to delete trailing NULs; `OctetString` keeps every octet).

    Octets that are not valid UTF-8 are preserved rather than replaced, so a
    hostname or boot filename in another encoding survives a decode/encode round
    trip intact; `to_json` renders the display form. See `pydhcp._nvt`.
    """

    def __new__(cls: type[_StringT], value: _ty.Any = "") -> _StringT:
        """Text, or octets read as text; anything else is a `TypeError`.

        `None` is not an empty string: an option with nothing to say is deleted
        from the bag, not stored.
        """
        if isinstance(value, (bytes, bytearray, memoryview)):
            value = _nvt.decode(bytes(value), "Option string")
        elif not isinstance(value, str):
            raise TypeError(
                f"{cls.__name__} is built from text, not {type(value).__name__}"
            )
        return str.__new__(cls, value)

    @classmethod
    def unpack_from(cls: type[_StringT], option: memoryview) -> tuple[_StringT, int]:
        text, _, _ = option.tobytes().partition(b"\x00")
        return cls(_nvt.decode(text, "Option string")), len(option)

    def pack_into(self, data: bytearray) -> int:
        text = _nvt.encode(self)
        data.extend(text)
        return len(text)

    def to_json(self) -> str:
        return _nvt.display(self)

    def __repr__(self) -> str:
        return f"{type(self).__name__}({str.__repr__(self)})"

    def display_text(self) -> str:
        return str.__repr__(self)

__new__(value='')

Text, or octets read as text; anything else is a TypeError.

None is not an empty string: an option with nothing to say is deleted from the bag, not stored.

Source code in src/pydhcp/options/_codecs/_scalar.py
160
161
162
163
164
165
166
167
168
169
170
171
172
def __new__(cls: type[_StringT], value: _ty.Any = "") -> _StringT:
    """Text, or octets read as text; anything else is a `TypeError`.

    `None` is not an empty string: an option with nothing to say is deleted
    from the bag, not stored.
    """
    if isinstance(value, (bytes, bytearray, memoryview)):
        value = _nvt.decode(bytes(value), "Option string")
    elif not isinstance(value, str):
        raise TypeError(
            f"{cls.__name__} is built from text, not {type(value).__name__}"
        )
    return str.__new__(cls, value)

U16

Bases: FixedLengthInteger

Unsigned 16-bit integer.

Source code in src/pydhcp/options/_codecs/_scalar.py
393
394
395
396
397
class U16(FixedLengthInteger):
    """Unsigned 16-bit integer."""

    NUMBER_OF_BYTES = 2
    SIGNED = False

U32

Bases: FixedLengthInteger

Unsigned 32-bit integer.

Source code in src/pydhcp/options/_codecs/_scalar.py
400
401
402
403
404
class U32(FixedLengthInteger):
    """Unsigned 32-bit integer."""

    NUMBER_OF_BYTES = 4
    SIGNED = False

U8

Bases: FixedLengthInteger

Unsigned 8-bit integer.

Source code in src/pydhcp/options/_codecs/_scalar.py
386
387
388
389
390
class U8(FixedLengthInteger):
    """Unsigned 8-bit integer."""

    NUMBER_OF_BYTES = 1
    SIGNED = False

URIList

Bases: _NormalizedList[str]

List of UTF-8 URIs encoded as repeated U16-length-prefixed entries.

Source code in src/pydhcp/options/_codecs/_scalar.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
class URIList(_NormalizedList[str]):
    """List of UTF-8 URIs encoded as repeated U16-length-prefixed entries."""

    def __init__(self, *items: _ty.Any):
        if len(items) == 1 and isinstance(items[0], list):
            self.extend(items[0])
            return
        for item in items:
            self.append(item)

    @classmethod
    def _normalize(cls, item: _ty.Any) -> str:
        if isinstance(item, str):
            return item
        return str(item)

    @classmethod
    def unpack_from(cls: type[_URIListT], option: memoryview) -> tuple[_URIListT, int]:
        self = cls()
        idx = 0
        size = len(option)
        while idx < size:
            if idx + 2 > size:
                raise DHCPDecodeError(f"{cls.__name__} option is truncated")
            length = int.from_bytes(option[idx : idx + 2], "big")
            idx += 2
            if idx + length > size:
                raise DHCPDecodeError(f"{cls.__name__} option is truncated")
            payload = option[idx : idx + length].tobytes()
            try:
                decoded = payload.decode("utf-8")
            except UnicodeDecodeError as exc:
                raise DHCPDecodeError(
                    f"{cls.__name__} option contains invalid UTF-8"
                ) from exc
            self.append(decoded)
            idx += length
        return self, size

    def pack_into(self, data: bytearray) -> int:
        written = 0
        for item in self:
            encoded = item.encode("utf-8")
            if len(encoded) > 0xFFFF:
                raise DHCPValueError(f"{type(self).__name__} entry exceeds 65535 bytes")
            data.extend(len(encoded).to_bytes(2, "big"))
            data.extend(encoded)
            written += len(encoded) + 2
        return written

    def to_json(self) -> list[str]:
        return list(self)

UncompressedDomainList

Bases: DomainList

A domain-name list for the options that forbid DNS name compression.

RFC 3397's search list (option 119) is compressed on purpose. Two other options carrying a name list are not, and both were registered as plain DomainList, so pydhcp emitted a 0xC0 pointer into payloads whose own RFCs forbid one:

  • option 88, BCMCS controller domain names -- RFC 4280 §4.6: "The domain names MUST be concatenated and encoded using the technique described in Section 3.3 of [RFC1035]. DNS name compression MUST NOT be used."
  • option 146, RDNSS selection -- RFC 6731 §4.3 defers to RFC 3315 §8: "A domain name, or list of domain names, in DHCP MUST NOT be stored in compressed form, as described in section 4.1.4 of RFC 1035."

Compressed, ["a.example.com", "b.example.com"] would encode as 01 61 07 example 03 com 00 01 62 c0 02 for option 88 -- the second name ending in a pointer a conforming receiver is not required to follow.

Encode only. Decoding stays DomainList's: a pointer that arrives resolves unambiguously inside the option, and the receive path in this package is deliberately liberal, so refusing one would turn a readable packet from a non-conforming peer into a decode failure. The RFCs constrain what is sent, and that is what this encoder constrains.

Source code in src/pydhcp/options/_codecs/_domains.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
class UncompressedDomainList(DomainList):
    """A domain-name list for the options that forbid DNS name compression.

    RFC 3397's search list (option 119) is compressed on purpose. Two other
    options carrying a name list are not, and both were registered as plain
    `DomainList`, so pydhcp emitted a `0xC0` pointer into payloads whose own
    RFCs forbid one:

    * option 88, BCMCS controller domain names -- RFC 4280 §4.6: "The domain
      names MUST be concatenated and encoded using the technique described in
      Section 3.3 of [RFC1035]. DNS name compression MUST NOT be used."
    * option 146, RDNSS selection -- RFC 6731 §4.3 defers to RFC 3315 §8: "A
      domain name, or list of domain names, in DHCP MUST NOT be stored in
      compressed form, as described in section 4.1.4 of RFC 1035."

    Compressed, `["a.example.com", "b.example.com"]` would encode as
    `01 61 07 example 03 com 00 01 62 c0 02` for option 88 -- the second name
    ending in a pointer a conforming receiver is not required to follow.

    **Encode only.** Decoding stays `DomainList`'s: a pointer that arrives
    resolves unambiguously inside the option, and the receive path in this
    package is deliberately liberal, so refusing one would turn a readable
    packet from a non-conforming peer into a decode failure. The RFCs
    constrain what is sent, and that is what this encoder constrains.
    """

    def pack_into(self, data: bytearray) -> int:
        written = 0
        for domain in self:
            # The shared encoder in `_codecs/_domain.py`, exactly as options 81,
            # 120, 122, 139 and 140 use it -- one set of name rules for the
            # whole package. `allow_root` keeps `DomainList`'s treatment of an
            # empty entry as the root name, a single zero octet.
            encoded = encode_domain_name(domain, "domain-list entry", allow_root=True)
            data.extend(encoded)
            written += len(encoded)
        return written

UserClass

Bases: _LengthPrefixedOpaqueList

RFC 3004 user-class opaque byte list.

Source code in src/pydhcp/options/_codecs/_vendor.py
88
89
90
91
92
93
94
class UserClass(_LengthPrefixedOpaqueList):
    """RFC 3004 user-class opaque byte list."""

    _UNFRAMED_HINT = (
        " (iPXE sends option 77 as one unframed string: read it with "
        "LenientUserClass)"
    )

VIVendorClass

Bases: RecordList[VIVendorClassRecord]

RFC 3925 vendor-identifying vendor class records.

Source code in src/pydhcp/options/_codecs/_vendor.py
344
345
class VIVendorClass(RecordList[VIVendorClassRecord]):
    """RFC 3925 vendor-identifying vendor class records."""

VIVendorSpecificInformation

Bases: RecordList[VIVendorSpecificInformationRecord]

RFC 3925 vendor-identifying vendor-specific information records.

Source code in src/pydhcp/options/_codecs/_vendor.py
284
285
class VIVendorSpecificInformation(RecordList[VIVendorSpecificInformationRecord]):
    """RFC 3925 vendor-identifying vendor-specific information records."""

VendorSpecificInformation

Bases: Bytes

Opaque vendor-specific payload for option 43.

Source code in src/pydhcp/options/_codecs/_vendor.py
203
204
class VendorSpecificInformation(Bytes):
    """Opaque vendor-specific payload for option 43."""

DHCPServer

pydhcp.server.DHCPServer

Bases: _ServerCore, DHCPListener

A DHCP server: a DHCPListener that allocates leases and sends the RFC 2131 replies.

Override any method or constant here: the handle_* hooks, acquire_lease, release_lease, get_inform_options, get_lease_seconds and the lease-policy constants. They run on the receive thread.

Source code in src/pydhcp/server/_sync.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class DHCPServer(_ServerCore, DHCPListener):
    """A DHCP server: a `DHCPListener` that allocates leases and sends the RFC 2131 replies.

    Override any method or constant here: the `handle_*` hooks, `acquire_lease`,
    `release_lease`, `get_inform_options`, `get_lease_seconds` and the lease-policy
    constants. They run on the receive thread.
    """

    def __init__(
        self,
        listen: ListenLike = None,
        *,
        poll_interval: _ty.Optional[float] = None,
        max_packet_size: _ty.Optional[int] = None,
        lease_backend: _ty.Optional[LeaseBackend] = None,
        per_interface: _ty.Optional[bool] = None,
        reuse_address: _ty.Optional[bool] = None,
        receive_buffer_size: _ty.Optional[int] = None,
    ) -> None:
        DHCPListener.__init__(
            self,
            listen=listen,
            poll_interval=poll_interval,
            max_packet_size=max_packet_size,
            per_interface=per_interface,
            reuse_address=reuse_address,
            receive_buffer_size=receive_buffer_size,
        )
        self._init_server_state(lease_backend)

    def close(self) -> None:
        """Shut down and release every socket, as `DHCPListener.close`.

        A lease backend the server was given is not closed: it is the caller's.
        One the server created itself is closed here.
        """
        DHCPListener.close(self)
        self._close_owned_backend()

bound_addresses property

The addresses this listener is currently bound to.

Empty before bind() and once the sockets are closed. Asking the socket rather than repeating the listen list is the point: binding port 0 gives an ephemeral port that only the socket knows, which is how a test or a tool discovers where to send.

acquire_lease(client_id, server_id, msg, *, commit=True)

Return a lease for a client message.

This is the extension point for address pools, reservations, policy checks and custom response options. It is an ordinary method of this server, called on the server itself from the handler thread (the one worker thread on the async server), so an attribute it keeps -- the next free host, a cache -- is the server's own.

commit says what kind of call this is. False: the server is deciding what to offer or whether to ACK (RFC 2131 s4.3.1 makes DHCPDISCOVER a probe), so the call must not extend an existing binding or add state that a client declining the offer would leave behind. True: the answer is being committed. A DHCPDISCOVER makes one call with commit=False; a DHCPREQUEST makes two, commit=False to decide and then commit=True if it is ACKed. An override honours commit itself, including in what it does to self.lease_backend, which is the real backend on both calls.

The base implementation is intentionally small. A client with a record gets it back: on commit=False as it stands, on commit=True an offered lease committed (LeaseBackend.commit) and a bound one renewed. Only a DHCPDISCOVER makes a record: from a client with none it holds the address in REQUESTED_IP (or ciaddr) as an offer for OFFER_HOLD_SECONDS on commit=False, and allocates it on commit=True. A DHCPREQUEST from a client with no record gets None. A message whose giaddr is outside the served network gets None: the stock allocator has the one network to give.

Source code in src/pydhcp/server/_policy.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def acquire_lease(
    self,
    client_id: str,
    server_id: _ipaddress.IPv4Address,
    msg: DHCPMessage,
    *,
    commit: bool = True,
) -> _ty.Optional[DHCPLease]:
    """Return a lease for a client message.

    This is the extension point for address pools, reservations, policy
    checks and custom response options. It is an ordinary method of this
    server, called on the server itself from the handler thread (the one
    worker thread on the async server), so an attribute it keeps -- the
    next free host, a cache -- is the server's own.

    `commit` says what kind of call this is. False: the server is deciding
    what to offer or whether to ACK (RFC 2131 s4.3.1 makes DHCPDISCOVER a
    probe), so the call must not extend an existing binding or add state
    that a client declining the offer would leave behind. True: the answer
    is being committed. A DHCPDISCOVER makes one call with `commit=False`; a
    DHCPREQUEST makes two, `commit=False` to decide and then `commit=True`
    if it is ACKed. An override honours `commit` itself, including in what
    it does to `self.lease_backend`, which is the real backend on both
    calls.

    The base implementation is intentionally small. A client with a record
    gets it back: on `commit=False` as it stands, on `commit=True` an
    offered lease committed (`LeaseBackend.commit`) and a bound one renewed.
    Only a DHCPDISCOVER makes a record: from a client with none it holds
    the address in `REQUESTED_IP` (or `ciaddr`) as an offer for
    `OFFER_HOLD_SECONDS` on `commit=False`, and allocates it on
    `commit=True`. A DHCPREQUEST from a client with no record gets `None`.
    A message whose `giaddr` is outside the served network gets `None`:
    the stock allocator has the one network to give.
    """
    _server = _servable_interface(server_id)
    if _server is None:
        return None

    if (
        msg.giaddr != _const.UNSPECIFIED_ADDRESS
        and msg.giaddr not in _server.network
    ):
        self.metrics.addresses_refused += 1
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            "refused relay",
            "[XID=%08x] Refusing %s: relay %s is outside the served network %s",
            msg.xid,
            _brief(client_id),
            msg.giaddr,
            _server.network,
            now=self._read_clock().monotonic,
        )
        return None

    backend = self.lease_backend
    ttl = self.get_lease_seconds(msg)
    existing = self.lookup_lease(client_id)
    if existing:
        if not commit:
            return existing
        if existing.offered:
            committed = backend.commit(client_id, ttl)
            if committed is not None:
                self.metrics.leases_allocated += 1
            return committed
        renewed = backend.renew(client_id, ttl)
        if renewed:
            self.metrics.leases_renewed += 1
            return renewed
        return existing

    if msg.message_type is not _enum.DHCPMessageType.DHCPDISCOVER:
        return None

    requested_ip = msg.options.get(
        DHCPOptionCode.REQUESTED_IP, decode=_type.IPv4AddressOption
    )

    ip: _ty.Optional[_ipaddress.IPv4Address] = None
    if requested_ip:
        ip = requested_ip
    elif msg.ciaddr != _const.UNSPECIFIED_ADDRESS:
        ip = msg.ciaddr

    if ip is None:
        return None

    refusal = self._address_refusal(ip, _server, client_id)
    if refusal is not None:
        self.metrics.addresses_refused += 1
        # No context reaches `acquire_lease`: the driver's reading stands in.
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            "refused address",
            "[XID=%08x] Refusing %s for %s: %s",
            msg.xid,
            ip,
            _brief(client_id),
            _brief(refusal),
            now=self._read_clock().monotonic,
        )
        return None

    options = DHCPOptions()
    options[DHCPOptionCode.SUBNET_MASK] = _server.network.netmask
    options[DHCPOptionCode.BROADCAST_ADDRESS] = _server.network.broadcast_address
    # No ROUTER and no DNS. This host's own address would be a guess and
    # usually a wrong one: running the server on an
    # ordinary machine then told every client to send all off-link traffic
    # and every name lookup to a host that routes and resolves nothing.
    # Omitting them leaves the client with whatever it already has -- a
    # statically configured resolver, another router on the segment -- which
    # is recoverable, where being pointed at a black hole is not.
    # Override `acquire_lease` to supply the real ones.

    if commit:
        LOGGER.debug(f"[XID={msg.xid:08x}] Allocating {ip} for {client_id}")
        lease = backend.allocate(client_id, ip, ttl, options)
        if lease is not None:
            self.metrics.leases_allocated += 1
        return lease
    LOGGER.debug(f"[XID={msg.xid:08x}] Offering {ip} to {client_id}")
    lease = backend.offer(client_id, ip, self.OFFER_HOLD_SECONDS, options)
    if lease is not None:
        self.metrics.leases_offered += 1
    return lease

bind()

Open the sockets the specification names; does nothing once they are open.

Raises what the bind raised, leaving none open, and RuntimeError once the listener is closed.

Source code in src/pydhcp/listener/_core.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def bind(self) -> None:
    """Open the sockets the specification names; does nothing once they are open.

    Raises what the bind raised, leaving none open, and `RuntimeError` once
    the listener is closed.
    """
    if self._closed:
        raise RuntimeError(f"{type(self).__name__} is closed")
    if self._sockets:
        return
    label = f" ({self._BIND_LABEL})" if self._BIND_LABEL else ""
    for entry in self._listen:
        LOGGER.info(
            f"Listening on{label}: {entry.address}:{entry.port}"
            + (
                f" ({len(entry.interfaces)} interface(s))"
                if entry.interfaces
                else ""
            )
        )
    endpoints = _netimps.bind_listen(
        self._listen,
        family=_socket.AF_INET,
        # `None` expands a wildcard where the host reports no packet info.
        per_address=True if self._per_interface else None,
        device_binding=self.USE_DEVICE_BINDING,
        allow_address_takeover=(
            self.REUSE_ADDRESS
            if self._reuse_address is None
            else self._reuse_address
        ),
        broadcast=True,
    )
    try:
        _prepare_endpoints(
            endpoints,
            self._BIND_LABEL,
            (
                self.RECEIVE_BUFFER_SIZE
                if self._receive_buffer_size is None
                else self._receive_buffer_size
            ),
        )
    except BaseException:
        for endpoint in endpoints:
            _close_socket(endpoint.socket, {endpoint.socket: endpoint})
        raise
    for endpoint in endpoints:
        self._sockets.append(endpoint.socket)
        self._endpoints[endpoint.socket] = endpoint

close()

Shut down and release every socket, as DHCPListener.close.

A lease backend the server was given is not closed: it is the caller's. One the server created itself is closed here.

Source code in src/pydhcp/server/_sync.py
43
44
45
46
47
48
49
50
def close(self) -> None:
    """Shut down and release every socket, as `DHCPListener.close`.

    A lease backend the server was given is not closed: it is the caller's.
    One the server created itself is closed here.
    """
    DHCPListener.close(self)
    self._close_owned_backend()

get_inform_options(server_id, msg)

Return configuration options for DHCPINFORM responses.

DHCPINFORM does not allocate an address. Override this method when clients should receive site-specific options without touching lease allocation.

Source code in src/pydhcp/server/_policy.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
def get_inform_options(
    self, server_id: _ipaddress.IPv4Address, msg: DHCPMessage
) -> DHCPOptions:
    """Return configuration options for DHCPINFORM responses.

    DHCPINFORM does not allocate an address. Override this method when clients
    should receive site-specific options without touching lease allocation.
    """
    options = DHCPOptions()
    _server = _servable_interface(server_id)
    if _server is not None:
        options[DHCPOptionCode.SUBNET_MASK] = _server.network.netmask
        options[DHCPOptionCode.BROADCAST_ADDRESS] = (
            _server.network.broadcast_address
        )
        # As in `acquire_lease`: this host is not known to be a router or a
        # resolver, so it does not claim to be either.
    return options

get_lease_seconds(msg)

Lease length to grant for msg, applying this server's policy.

RFC 2131 s4.3.1 lets the server honour a client's requested lease time only "if acceptable to local policy" -- a MAY, so there has to be a policy. There was none: the requested value went straight through, and any client could hold an address for 136 years by asking for one.

RFC 2132 s3.3 also makes 0xFFFFFFFF mean infinity, not 4294967295 seconds. Taken literally it became a finite expiry in 2162, and the reply then advertised 4294967294 -- a different number than the client asked for, one second short of the sentinel.

Override this, or set the class attributes, to change the policy.

Source code in src/pydhcp/server/_policy.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def get_lease_seconds(self, msg: DHCPMessage) -> float:
    """Lease length to grant for `msg`, applying this server's policy.

    RFC 2131 s4.3.1 lets the server honour a client's requested lease time
    only "if acceptable to local policy" -- a MAY, so there has to be a
    policy. There was none: the requested value went straight through, and
    any client could hold an address for 136 years by asking for one.

    RFC 2132 s3.3 also makes 0xFFFFFFFF mean *infinity*, not 4294967295
    seconds. Taken literally it became a finite expiry in 2162, and the
    reply then advertised 4294967294 -- a different number than the client
    asked for, one second short of the sentinel.

    Override this, or set the class attributes, to change the policy.
    """
    requested = msg.options.get(
        DHCPOptionCode.IP_ADDRESS_LEASE_TIME, decode=_type.U32
    )
    if requested is None:
        return float(self.DEFAULT_LEASE_SECONDS)
    value = int(requested)
    if value == _const.INFINITE_LEASE_TIME:
        return _inf if self.ALLOW_INFINITE_LEASE else float(self.MAX_LEASE_SECONDS)
    return float(max(self.MIN_LEASE_SECONDS, min(value, self.MAX_LEASE_SECONDS)))

handle_decline(msg, context)

Handle DHCPDECLINE: quarantine the address it names, and release the sender's own.

RFC 2131 s4.3.3: "The server MUST mark the network address as not available", whoever sent it. A DECLINE naming an address outside the served network, this host's own, or another server in option 54 changes nothing and is counted in declines_ignored; the quarantine is bounded (MAX_DECLINED_ADDRESSES). A record is released only when it is the sender's own, for the declined address. DECLINE_REQUIRES_LEASE marks only an address the sender holds, as a binding or an outstanding offer.

Source code in src/pydhcp/server/_handlers.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
def handle_decline(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Handle DHCPDECLINE: quarantine the address it names, and release the sender's own.

    RFC 2131 s4.3.3: "The server MUST mark the network address as not
    available", whoever sent it. A DECLINE naming an address outside the
    served network, this host's own, or another server in option 54 changes
    nothing and is counted in `declines_ignored`; the quarantine is bounded
    (`MAX_DECLINED_ADDRESSES`). A record is released only when it is the
    sender's own, for the declined address. `DECLINE_REQUIRES_LEASE` marks
    only an address the sender holds, as a binding or an outstanding offer.
    """
    client_id = msg.get_client_id()
    actual_server_id = _ty.cast(_ipaddress.IPv4Address, context.interface.ip)
    held = self.lookup_lease(client_id)
    declined: _ty.Optional[_ipaddress.IPv4Address] = msg.options.get(
        DHCPOptionCode.REQUESTED_IP, decode=_type.IPv4AddressOption
    )
    if declined is None and msg.ciaddr != _const.UNSPECIFIED_ADDRESS:
        declined = msg.ciaddr
    if declined is None and held is not None:
        declined = held.ip
    refusal = self._decline_refusal(msg, declined, held, actual_server_id)
    if refusal is not None or declined is None:
        self.metrics.declines_ignored += 1
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            "DHCPDECLINE ignored",
            "[XID=%08x] Ignoring DHCPDECLINE from %s|%s: %s",
            msg.xid,
            context.client,
            _brief(client_id),
            _brief(refusal),
            now=self._instant(context).monotonic,
        )
        return
    self._log_limit.log(
        LOGGER,
        _logging.WARNING,
        "DHCPDECLINE",
        "[XID=%08x] DHCPDECLINE of %s from %s|%s",
        msg.xid,
        declined,
        context.client,
        _brief(client_id),
        now=self._instant(context).monotonic,
    )
    self.quarantine_address(declined)
    # Counted as a decline, not a release: the client found the address
    # already in use, which is the opposite of an orderly hand-back.
    self.metrics.leases_declined += 1
    if held is not None and held.ip == declined:
        self.release_lease(client_id, actual_server_id, msg)

handle_discover(msg, context)

Handle DHCPDISCOVER by offering a lease returned from acquire_lease.

Source code in src/pydhcp/server/_handlers.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
def handle_discover(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Handle DHCPDISCOVER by offering a lease returned from `acquire_lease`."""
    client_id = msg.get_client_id()
    actual_server_id = _ty.cast(_ipaddress.IPv4Address, context.interface.ip)
    LOGGER.info(
        f"[XID={msg.xid:08x}] DHCPDISCOVER from {context.client}|{client_id}"
    )
    # A DISCOVER is a probe: it may hold an address as an offer but must
    # not extend an existing binding or commit one.
    lease = self._with_address(
        self.acquire_lease(client_id, actual_server_id, msg, commit=False),
        msg,
        context,
    )
    now = self._instant(context).utc
    if lease and self._refused_as_quarantined(msg, lease, client_id, context):
        return
    if not lease or not self._has_time_left(lease, now):
        LOGGER.info(
            f"[XID={msg.xid:08x}] No lease available for {context.client}|{client_id} at {actual_server_id} ignoring"
        )
        return
    resp = self._create_response(
        msg, lease, actual_server_id, _enum.DHCPMessageType.DHCPOFFER, now=now
    )
    self._filter_and_send(msg, resp, context, _enum.DHCPMessageType.DHCPOFFER)

handle_inform(msg, context)

Handle DHCPINFORM without requiring address allocation.

Source code in src/pydhcp/server/_handlers.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
def handle_inform(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Handle DHCPINFORM without requiring address allocation."""
    client_id = msg.get_client_id()
    actual_server_id = _ty.cast(_ipaddress.IPv4Address, context.interface.ip)
    LOGGER.info(f"[XID={msg.xid:08x}] DHCPINFORM from {context.client}|{client_id}")
    # RFC 2131 4.3.5: a DHCPINFORM client already has its address and is
    # asking only for configuration. Routing this through acquire_lease
    # created or renewed a binding for a client that never asked for one --
    # so an INFORM flood grew the lease store -- and bypassed the
    # allocation-free hook documented for exactly this path whenever a
    # binding happened to exist.
    refusal = self._inform_refusal(msg, context.client.ip, actual_server_id)
    if refusal is not None:
        self.metrics.informs_ignored += 1
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            "DHCPINFORM refused",
            "[XID=%08x] Ignoring DHCPINFORM from %s|%s: %s",
            msg.xid,
            context.client,
            _brief(client_id),
            _brief(refusal),
            now=self._instant(context).monotonic,
        )
        return
    resp = self._create_inform_response(
        msg, self.get_inform_options(actual_server_id, msg), actual_server_id
    )
    self._filter_and_send(msg, resp, context, _enum.DHCPMessageType.DHCPACK)

handle_release(msg, context)

Handle DHCPRELEASE, but only for the address the client actually holds.

Source code in src/pydhcp/server/_handlers.py
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
def handle_release(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Handle DHCPRELEASE, but only for the address the client actually holds."""
    client_id = msg.get_client_id()
    actual_server_id = _ty.cast(_ipaddress.IPv4Address, context.interface.ip)
    LOGGER.info(
        f"[XID={msg.xid:08x}] DHCPRELEASE from {context.client}|{client_id}"
    )
    # RFC 2131 4.4.6: the client puts the address being given up in ciaddr.
    # Releasing on client identifier alone would let a late or duplicated
    # RELEASE naming an *old* address delete whatever binding that client
    # holds at that moment, and the address would go to someone else while
    # the client was still using it.
    existing = self.lookup_lease(client_id)
    if existing is not None and msg.ciaddr != _const.UNSPECIFIED_ADDRESS:
        if existing.ip != msg.ciaddr:
            self._log_limit.log(
                LOGGER,
                _logging.WARNING,
                "DHCPRELEASE for an address not held",
                "[XID=%08x] Ignoring DHCPRELEASE from %s|%s for %s: it holds %s",
                msg.xid,
                context.client,
                _brief(client_id),
                msg.ciaddr,
                existing.ip,
                now=self._instant(context).monotonic,
            )
            self.metrics.releases_ignored += 1
            return
    if self.release_lease(client_id, actual_server_id, msg):
        self.metrics.leases_released += 1

handle_request(msg, context)

Handle DHCPREQUEST by ACKing or NAKing the lease returned from acquire_lease.

RFC 2131 s4.3.2 and Table 4 tell the four shapes apart, and each is answered from the record acquire_lease returns for the client:

  • SELECTING (option 54 names this server): a lease for the address asked for is ACKed; a request this server cannot satisfy is NAKed (s3.1 step 4), never left unanswered.
  • INIT-REBOOT (option 50, no option 54, ciaddr 0), RENEWING (ciaddr, unicast to this server) and REBINDING (ciaddr, broadcast): a lease for the address is ACKed, a lease for another address is NAKed, and no lease at all is silence, because the client may belong to another server on the same wire.
Source code in src/pydhcp/server/_handlers.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def handle_request(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Handle DHCPREQUEST by ACKing or NAKing the lease returned from `acquire_lease`.

    RFC 2131 s4.3.2 and Table 4 tell the four shapes apart, and each is
    answered from the record `acquire_lease` returns for the client:

    - SELECTING (option 54 names this server): a lease for the address asked
      for is ACKed; a request this server cannot satisfy is NAKed (s3.1
      step 4), never left unanswered.
    - INIT-REBOOT (option 50, no option 54, `ciaddr` 0), RENEWING (`ciaddr`,
      unicast to this server) and REBINDING (`ciaddr`, broadcast): a lease
      for the address is ACKed, a lease for another address is NAKed, and
      no lease at all is silence, because the client may belong to another
      server on the same wire.
    """
    client_id = msg.get_client_id()
    actual_server_id = _ty.cast(_ipaddress.IPv4Address, context.interface.ip)
    LOGGER.info(
        f"[XID={msg.xid:08x}] DHCPREQUEST from {context.client}|{client_id}"
    )
    server_id = msg.options.get(
        DHCPOptionCode.SERVER_IDENTIFIER, decode=_type.IPv4AddressOption
    )
    requested: _ty.Optional[_ipaddress.IPv4Address] = msg.options.get(
        DHCPOptionCode.REQUESTED_IP, decode=_type.IPv4AddressOption
    )
    if server_id is not None:
        shape = "SELECTING"
    elif msg.ciaddr != _const.UNSPECIFIED_ADDRESS:
        shape = "RENEWING" if context.is_unicast is not False else "REBINDING"
    elif requested is not None:
        shape = "INIT-REBOOT"
    else:
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            "REQUEST of no address",
            "[XID=%08x] DHCPREQUEST from %s|%s names no address: no "
            "server identifier, no requested address and no ciaddr",
            msg.xid,
            context.client,
            _brief(client_id),
            now=self._instant(context).monotonic,
        )
        return
    ip_req = requested if requested is not None else msg.ciaddr

    # Decide first, on a call that cannot extend the binding: a REQUEST for
    # the wrong address is about to be NAKed, and renewing the address it is
    # being refused was exactly backwards.
    lease = self._with_address(
        self.acquire_lease(client_id, actual_server_id, msg, commit=False),
        msg,
        context,
    )
    if not lease:
        if shape == "SELECTING":
            self._nak(msg, context, "the address is not available")
            return
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            f"{shape} from an unknown client",
            "[XID=%08x] %s from %s|%s with no record of this client, "
            "remaining silent",
            msg.xid,
            shape,
            context.client,
            _brief(client_id),
            now=self._instant(context).monotonic,
        )
        return
    now = self._instant(context).utc
    if self._refused_as_quarantined(msg, lease, client_id, context):
        self._nak(msg, context, "the address is not available")
        return
    if ip_req != lease.ip:
        self._nak(msg, context, "the requested address is not the client's")
        return
    if not self._has_time_left(lease, now):
        # A lease with no time left NAKs rather than ACKing nothing: the
        # client is told to start over, which is recoverable, instead of
        # being handed an ACK with no address in it.
        self._nak(msg, context, "the lease has expired")
        return
    # Only at this point is anything agreed, so this is where the lease time the ACK
    # advertises is actually committed.
    committed = self._with_address(
        self.acquire_lease(client_id, actual_server_id, msg, commit=True),
        msg,
        context,
    )
    if committed is not None:
        lease = committed
    elif lease.offered:
        # The hold lapsed between the decision and the commit.
        self._nak(msg, context, "the offer has lapsed")
        return
    resp = self._create_response(
        msg, lease, actual_server_id, _enum.DHCPMessageType.DHCPACK, now=now
    )
    self._filter_and_send(msg, resp, context, _enum.DHCPMessageType.DHCPACK)

is_quarantined(ip, *, now=None)

Whether ip is out of the pool after a DHCPDECLINE.

The server asks this of every lease a hook returns, so an acquire_lease override does not have to. now is time.monotonic() seconds; omitted, the driver's reading is used.

Source code in src/pydhcp/server/_policy.py
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
def is_quarantined(
    self, ip: _ipaddress.IPv4Address, *, now: _ty.Optional[float] = None
) -> bool:
    """Whether `ip` is out of the pool after a DHCPDECLINE.

    The server asks this of every lease a hook returns, so an
    `acquire_lease` override does not have to. `now` is `time.monotonic()`
    seconds; omitted, the driver's reading is used.
    """
    until = self._declined.get(ip)
    if until is None:
        return False
    if until > (self._read_clock().monotonic if now is None else now):
        return True
    del self._declined[ip]
    return False

lookup_lease(client_id)

The lease this server holds for client_id, in either state, or None.

What a DHCPDECLINE, a DHCPRELEASE and a REQUEST naming another server ask to learn what the sender holds. The default reads lease_backend; a server that keeps its leases elsewhere overrides this one method.

Source code in src/pydhcp/server/_policy.py
439
440
441
442
443
444
445
446
def lookup_lease(self, client_id: str) -> _ty.Optional[DHCPLease]:
    """The lease this server holds for `client_id`, in either state, or None.

    What a DHCPDECLINE, a DHCPRELEASE and a REQUEST naming another server
    ask to learn what the sender holds. The default reads `lease_backend`;
    a server that keeps its leases elsewhere overrides this one method.
    """
    return self.lease_backend.lookup(client_id)

quarantine_address(ip, *, now=None)

Stop offering ip for DECLINE_QUARANTINE_SECONDS.

RFC 2131 4.3.3: a DHCPDECLINE says the client found the address already in use, so the server MUST NOT hand it out again. Releasing the binding alone left it first in line to be offered to the next client, which would collide with whatever is really using it.

The map holds at most MAX_DECLINED_ADDRESSES. At the bound the entries that have run out are dropped first; if none has, a new address is refused and counted in quarantines_refused, because evicting the oldest would let a flood of reports push a genuine one out. An address already held is renewed for a full quarantine.

now is time.monotonic() seconds; omitted, the driver's reading is used, so an override that takes only ip keeps working.

Source code in src/pydhcp/server/_policy.py
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
def quarantine_address(
    self, ip: _ipaddress.IPv4Address, *, now: _ty.Optional[float] = None
) -> None:
    """Stop offering `ip` for `DECLINE_QUARANTINE_SECONDS`.

    RFC 2131 4.3.3: a DHCPDECLINE says the client found the address already
    in use, so the server MUST NOT hand it out again. Releasing the binding
    alone left it first in line to be offered to the next client, which
    would collide with whatever is really using it.

    The map holds at most `MAX_DECLINED_ADDRESSES`. At the bound the entries
    that have run out are dropped first; if none has, a new address is
    refused and counted in `quarantines_refused`, because evicting the
    oldest would let a flood of reports push a genuine one out. An address
    already held is renewed for a full quarantine.

    `now` is `time.monotonic()` seconds; omitted, the driver's reading is
    used, so an override that takes only `ip` keeps working.
    """
    if now is None:
        now = self._read_clock().monotonic
    if (
        ip not in self._declined
        and len(self._declined) >= self.MAX_DECLINED_ADDRESSES
    ):
        for held, until in list(self._declined.items()):
            if until <= now:
                del self._declined[held]
        if len(self._declined) >= self.MAX_DECLINED_ADDRESSES:
            self.metrics.quarantines_refused += 1
            self._log_limit.log(
                LOGGER,
                _logging.WARNING,
                "quarantine full",
                "The quarantine holds %d addresses; not adding %s",
                len(self._declined),
                ip,
                now=now,
            )
            return
    self._declined[ip] = now + self.DECLINE_QUARANTINE_SECONDS

release_lease(client_id, server_id, msg)

Release any lease associated with client_id; True if one went away.

Override this method when lease release needs to update an external store or emit custom audit records. Quarantining a declined address is handled by quarantine_address, which handle_decline calls before this.

Counting is the caller's job, not this method's, because only the caller knows why: an orderly DHCPRELEASE, a DHCPDECLINE reporting an address conflict, and a reclaim after the client chose another server are three different events that all arrive here.

Source code in src/pydhcp/server/_policy.py
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
def release_lease(
    self, client_id: str, server_id: _ipaddress.IPv4Address, msg: DHCPMessage
) -> bool:
    """Release any lease associated with `client_id`; True if one went away.

    Override this method when lease release needs to update an external store
    or emit custom audit records. Quarantining a declined address is handled
    by `quarantine_address`, which `handle_decline` calls before this.

    Counting is the *caller's* job, not this method's, because only the
    caller knows why: an orderly DHCPRELEASE, a DHCPDECLINE reporting an
    address conflict, and a reclaim after the client chose another server
    are three different events that all arrive here.
    """
    return self.lease_backend.release(client_id)

serve_forever()

Bind, then receive on the calling thread until shutdown().

Raises what bind() raised, and RuntimeError when already serving or closed. The sockets stay open on return: close() releases them.

Source code in src/pydhcp/listener/_sync.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def serve_forever(self) -> None:
    """Bind, then receive on the calling thread until `shutdown()`.

    Raises what `bind()` raised, and `RuntimeError` when already serving or
    closed. The sockets stay open on return: `close()` releases them.
    """
    token = object()
    try:
        self._claim(token)
        self._serve()
    finally:
        self._release(token)

shutdown()

Ask the receive loop to end. Returns at once, from any thread and from a handler.

Source code in src/pydhcp/listener/_sync.py
128
129
130
131
132
133
134
def shutdown(self) -> None:
    """Ask the receive loop to end. Returns at once, from any thread and from a handler."""
    with self._state_lock:
        if not self._serving:
            return
        self._stopping = True
    self._wake()

start()

Bind, then receive on a new daemon thread.

Binding happens here, on the caller's thread, so an address that cannot be bound raises what bind() raised and leaves the listener unstarted and nothing bound. Raises RuntimeError when already serving or closed.

Source code in src/pydhcp/listener/_sync.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def start(self) -> None:
    """Bind, then receive on a new daemon thread.

    Binding happens here, on the caller's thread, so an address that cannot
    be bound raises what `bind()` raised and leaves the listener unstarted
    and nothing bound. Raises `RuntimeError` when already serving or closed.
    """
    token = object()
    thread: _ty.Optional[_thread.Thread] = None
    try:
        self._claim(token)
        # Daemon: nothing in the loop ends on its own, so a non-daemon thread
        # keeps the interpreter alive after `main` returns.
        thread = _thread.Thread(
            target=self._serve, name="pydhcp-listener", daemon=True
        )
        self._receive_thread = thread
        thread.start()
    except BaseException:
        # A thread that began owns the release; one that did not leaves it here.
        if thread is None or thread.ident is None:
            self._release(token)
        raise

wait_closed(timeout=None)

Block until the receive loop has ended; False if timeout seconds passed first.

Raises RuntimeError on the receive thread, which would wait for itself.

Source code in src/pydhcp/listener/_sync.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def wait_closed(self, timeout: _ty.Optional[float] = None) -> bool:
    """Block until the receive loop has ended; `False` if `timeout` seconds passed first.

    Raises `RuntimeError` on the receive thread, which would wait for itself.
    """
    if self._serving_ident == _thread.get_ident():
        raise RuntimeError(
            "wait_closed() on the receive thread would wait for itself"
        )
    began = _time.monotonic()
    if not self._idle.wait(timeout):
        return False
    thread = self._receive_thread
    if thread is not None and thread is not _thread.current_thread():
        thread.join(
            None
            if timeout is None
            else max(0.0, timeout - (_time.monotonic() - began))
        )
        if thread.is_alive():
            return False
    return True

DHCPClient

pydhcp.client.DHCPClient

Bases: _ClientCore, DHCPListener

Small DHCPv4 packet client for tests and troubleshooting.

The base client builds and sends DHCP client messages, then queues matching replies. It does not configure operating-system network interfaces. Its receive thread must be running (start()) for an exchange to hear a reply.

Source code in src/pydhcp/client/_sync.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
class DHCPClient(_ClientCore, DHCPListener):
    """Small DHCPv4 packet client for tests and troubleshooting.

    The base client builds and sends DHCP client messages, then queues matching
    replies. It does not configure operating-system network interfaces. Its
    receive thread must be running (`start()`) for an exchange to hear a reply.
    """

    def __init__(
        self,
        listen: ListenLike = None,
        *,
        poll_interval: _ty.Optional[float] = None,
        max_packet_size: _ty.Optional[int] = None,
        per_interface: _ty.Optional[bool] = None,
        reuse_address: _ty.Optional[bool] = None,
        receive_buffer_size: _ty.Optional[int] = None,
    ) -> None:
        DHCPListener.__init__(
            self,
            listen=listen,
            poll_interval=poll_interval,
            max_packet_size=max_packet_size,
            per_interface=per_interface,
            reuse_address=reuse_address,
            receive_buffer_size=receive_buffer_size,
        )
        self._init_client_state()
        self._replies: _queue.Queue[Reply] = _queue.Queue(
            maxsize=self.MAX_QUEUED_REPLIES
        )
        #: Exchange key -> the queue the thread running that exchange is
        #: waiting on. A reply for a key in here is handed to its own exchange
        #: instead of the shared queue, which is what lets two exchanges run on
        #: one client without eating each other's replies.
        self._waiters: dict[tuple[int, bytes], _queue.Queue[Reply]] = {}
        self._waiters_lock = _threading.Lock()

    def send(
        self,
        message: DHCPMessage,
        *,
        dst: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
    ) -> int:
        if not self._sockets:
            self.bind()
        transport = UDPTransport(self._sockets[0])
        data = self._encode(message)
        sent = transport.send(
            data,
            _ipaddress.IPv4Address(dst),
            port=int(port),
            client_mac=message.chaddr,
        )
        self._note_sent()
        return sent

    def _monotonic(self) -> float:
        """Monotonic clock, as one override point for every timing decision.

        Retransmission intervals and `secs` are the only things here that read a
        clock, and a test that proved the backoff schedule by living through it
        would cost the suite the full 2+4+8 seconds of the default one. Stub
        this instead.
        """
        return _time.monotonic()

    def _exchange(
        self,
        message: DHCPMessage,
        msg_type: _enum.DHCPMessageType,
        *,
        destination: IPv4AddressLike,
        port: int,
        timeout: float,
        retries: int,
        started_at: float,
        server: _ty.Optional[_ipaddress.IPv4Address] = None,
        ends: _ty.Optional[float] = None,
    ) -> DHCPMessage:
        """Send `message`, retransmitting on RFC 2131 s4.1 backoff, until `msg_type`.

        `started_at` is when *acquisition* began, not when this message was
        built: see `_stamp_secs`. `server` is the server the exchange selected,
        `ends` the monotonic reading the call must be over by (`_deadline_end`).
        Raises `DHCPTimeoutError` when nothing usable arrives, and
        `DHCPRefusedError` for a DHCPNAK.
        """
        key = self._pending_key(message)
        try:
            # Registered before the first send, not inside the wait: a reply
            # that arrives in between is delivered by the receive thread, and
            # with no queue to route it to it would land in the shared one and
            # be invisible to the exchange that asked for it.
            with self._waiting_for(key) as waiter:
                self._pending_keys.add(key)
                attempts = 0
                for interval in self._retransmit_intervals(timeout, retries):
                    now = self._monotonic()
                    last = False
                    if ends is not None:
                        if ends - now <= 0:
                            break
                        # The deadline falls inside this wait, so it is the last:
                        # a timer may fire a clock tick early, and what the clock
                        # then says must not buy another send.
                        last = ends - now <= interval
                        interval = min(interval, ends - now)
                    self._stamp_secs(message, now - started_at)
                    attempts += 1
                    self.send(message, dst=destination, port=port)
                    reply = self._wait_for(waiter, msg_type, interval, server)
                    if reply is not None:
                        return reply
                    if last:
                        break
                raise self._timed_out(msg_type, attempts, ends)
        finally:
            # The exchange is over either way. Left in place, this set only ever
            # grew, and every later replay of a spent xid -- visible to anyone on
            # the segment, since these go out as broadcasts -- stayed acceptable
            # forever. dora() re-registers its own REQUEST.
            self._pending_keys.discard(key)

    @_contextlib.contextmanager
    def _waiting_for(self, key: tuple[int, bytes]) -> _ty.Iterator[_queue.Queue[Reply]]:
        """Claim the replies for one exchange for the duration of the block.

        This is what keeps two exchanges on one client apart: `handle()` routes
        a reply straight to the queue of the exchange it belongs to, so a
        waiting exchange never has to read -- and therefore never has to discard
        -- a reply that is someone else's. Two blocks open on the *same* key are
        one exchange by definition and share the queue; only the outer one
        removes it.
        """
        with self._waiters_lock:
            waiter = self._waiters.get(key)
            owned = waiter is None
            if waiter is None:
                waiter = self._waiters[key] = _queue.Queue(
                    maxsize=self.MAX_QUEUED_REPLIES
                )
        try:
            yield waiter
        finally:
            if owned:
                with self._waiters_lock:
                    if self._waiters.get(key) is waiter:
                        del self._waiters[key]

    def _wait_for(
        self,
        waiter: _queue.Queue[Reply],
        msg_type: _enum.DHCPMessageType,
        timeout: float,
        server: _ty.Optional[_ipaddress.IPv4Address] = None,
    ) -> _ty.Optional[DHCPMessage]:
        """Take the first usable reply of `msg_type` from one exchange's own queue.

        `None` when `timeout` runs out; see `_take` for what is skipped.
        """
        deadline = self._monotonic() + timeout
        while True:
            remaining = deadline - self._monotonic()
            if remaining <= 0:
                return None
            try:
                msg, _context = waiter.get(timeout=remaining)
            except _queue.Empty:
                return None
            taken = self._take(msg, msg_type, server, self._monotonic())
            if taken is not None:
                return taken

    def discover_offer(
        self,
        chaddr: bytes,
        *,
        timeout: float = 2.0,
        retries: int = 2,
        deadline: _ty.Optional[float] = None,
        destination: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
        xid: _ty.Optional[int] = None,
        client_identifier: _ty.Optional[ClientIdentifierLike] = None,
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
        broadcast: bool = True,
    ) -> DHCPMessage:
        """Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

        `timeout` is the *initial* retransmission interval, not a fixed one:
        each retransmission waits about twice as long as the last, jittered, up
        to `RETRANSMIT_MAX_INTERVAL` (RFC 2131 s4.1). With the defaults the
        whole call is bounded at roughly 2+4+8 seconds rather than 3x2;
        `deadline` bounds it outright, in seconds.

        An OFFER is usable when it offers an address and names its server;
        others are ignored. Raises `DHCPTimeoutError` when none arrives.
        """
        started_at = self._monotonic()
        return self._discover(
            chaddr,
            timeout=timeout,
            retries=retries,
            destination=destination,
            port=port,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
            started_at=started_at,
            ends=self._deadline_end(started_at, deadline),
        )

    def _discover(
        self,
        chaddr: bytes,
        *,
        timeout: float,
        retries: int,
        destination: IPv4AddressLike,
        port: int,
        xid: _ty.Optional[int],
        client_identifier: _ty.Optional[ClientIdentifierLike],
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]],
        broadcast: bool,
        started_at: float,
        ends: _ty.Optional[float],
    ) -> DHCPMessage:
        discover = self.build_discover(
            chaddr,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
        )
        return self._exchange(
            discover,
            _enum.DHCPMessageType.DHCPOFFER,
            destination=destination,
            port=port,
            timeout=timeout,
            retries=retries,
            started_at=started_at,
            ends=ends,
        )

    def dora(
        self,
        chaddr: bytes,
        *,
        timeout: float = 2.0,
        retries: int = 2,
        deadline: _ty.Optional[float] = None,
        destination: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
        xid: _ty.Optional[int] = None,
        client_identifier: _ty.Optional[ClientIdentifierLike] = None,
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
        broadcast: bool = True,
    ) -> DHCPMessage:
        """Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

        `timeout` is the initial retransmission interval for each half of the
        exchange -- see `discover_offer`; `deadline` bounds the two halves
        together. Raises `DHCPTimeoutError` when no usable OFFER or ACK arrives
        (an ACK is usable when it comes from the server the REQUEST selected) and
        `DHCPRefusedError` when the server answers the REQUEST with a DHCPNAK.
        """
        # RFC 2131 s2: `secs` counts from the start of *acquisition*, so the
        # REQUEST half keeps counting from the DISCOVER rather than resetting.
        started_at = self._monotonic()
        ends = self._deadline_end(started_at, deadline)
        offer = self._discover(
            chaddr,
            timeout=timeout,
            retries=retries,
            destination=destination,
            port=port,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
            started_at=started_at,
            ends=ends,
        )
        server = self._selected_server(offer)
        request = self._request_after(
            offer,
            server,
            chaddr,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
        )
        return self._exchange(
            request,
            _enum.DHCPMessageType.DHCPACK,
            destination=destination,
            port=port,
            timeout=timeout,
            retries=retries,
            started_at=started_at,
            server=server,
            ends=ends,
        )

    def _deliver(
        self, key: tuple[int, bytes], msg: DHCPMessage, context: DHCPRequestContext
    ) -> None:
        with self._waiters_lock:
            waiter = self._waiters.get(key)
        self._put_bounded(
            waiter if waiter is not None else self._replies, (msg, context)
        )

    def _put_bounded(self, replies: _queue.Queue[Reply], item: Reply) -> None:
        """Enqueue, discarding the oldest rather than growing without bound.

        Oldest rather than newest: a caller waiting on an exchange wants the
        recent ones. Without the cap, 50000 unsolicited BOOTREPLYs put 50000
        entries in the queue of a client nobody is draining.
        """
        while True:
            try:
                replies.put_nowait(item)
                return
            except _queue.Full:
                try:
                    replies.get_nowait()
                    self.metrics.replies_dropped_overflow += 1
                except _queue.Empty:  # pragma: no cover - drained concurrently
                    pass

    def next_reply(self, timeout: _ty.Optional[float] = None) -> _ty.Optional[Reply]:
        try:
            return self._replies.get(timeout=timeout)
        except _queue.Empty:
            return None

    def drain_replies(self) -> list[Reply]:
        replies: list[Reply] = []
        while True:
            try:
                replies.append(self._replies.get_nowait())
            except _queue.Empty:
                return replies

bound_addresses property

The addresses this listener is currently bound to.

Empty before bind() and once the sockets are closed. Asking the socket rather than repeating the listen list is the point: binding port 0 gives an ephemeral port that only the socket knows, which is how a test or a tool discovers where to send.

bind()

Open the sockets the specification names; does nothing once they are open.

Raises what the bind raised, leaving none open, and RuntimeError once the listener is closed.

Source code in src/pydhcp/listener/_core.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def bind(self) -> None:
    """Open the sockets the specification names; does nothing once they are open.

    Raises what the bind raised, leaving none open, and `RuntimeError` once
    the listener is closed.
    """
    if self._closed:
        raise RuntimeError(f"{type(self).__name__} is closed")
    if self._sockets:
        return
    label = f" ({self._BIND_LABEL})" if self._BIND_LABEL else ""
    for entry in self._listen:
        LOGGER.info(
            f"Listening on{label}: {entry.address}:{entry.port}"
            + (
                f" ({len(entry.interfaces)} interface(s))"
                if entry.interfaces
                else ""
            )
        )
    endpoints = _netimps.bind_listen(
        self._listen,
        family=_socket.AF_INET,
        # `None` expands a wildcard where the host reports no packet info.
        per_address=True if self._per_interface else None,
        device_binding=self.USE_DEVICE_BINDING,
        allow_address_takeover=(
            self.REUSE_ADDRESS
            if self._reuse_address is None
            else self._reuse_address
        ),
        broadcast=True,
    )
    try:
        _prepare_endpoints(
            endpoints,
            self._BIND_LABEL,
            (
                self.RECEIVE_BUFFER_SIZE
                if self._receive_buffer_size is None
                else self._receive_buffer_size
            ),
        )
    except BaseException:
        for endpoint in endpoints:
            _close_socket(endpoint.socket, {endpoint.socket: endpoint})
        raise
    for endpoint in endpoints:
        self._sockets.append(endpoint.socket)
        self._endpoints[endpoint.socket] = endpoint

close()

Shut down, wait for the receive loop, then release every socket. Final.

Called on the receive thread itself (from a handler) it shuts down and returns: the loop releases the sockets as it ends. Repeatable.

Source code in src/pydhcp/listener/_sync.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def close(self) -> None:
    """Shut down, wait for the receive loop, then release every socket. Final.

    Called on the receive thread itself (from a handler) it shuts down and
    returns: the loop releases the sockets as it ends. Repeatable.
    """
    if self._serving_ident == _thread.get_ident():
        with self._state_lock:
            self._closed = True
        self.shutdown()
        return
    with self._close_lock:
        with self._state_lock:
            self._closed = True
        self.shutdown()
        if not self.wait_closed(_CLOSE_WAIT):
            LOGGER.warning(
                f"The receive loop did not end within {_CLOSE_WAIT} s of close(); "
                "releasing the sockets under it."
            )
        self._close_sockets()

discover_offer(chaddr, *, timeout=2.0, retries=2, deadline=None, destination=BROADCAST_DESTINATION, port=_SERVER_PORT, xid=None, client_identifier=None, parameter_request_list=None, broadcast=True)

Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

timeout is the initial retransmission interval, not a fixed one: each retransmission waits about twice as long as the last, jittered, up to RETRANSMIT_MAX_INTERVAL (RFC 2131 s4.1). With the defaults the whole call is bounded at roughly 2+4+8 seconds rather than 3x2; deadline bounds it outright, in seconds.

An OFFER is usable when it offers an address and names its server; others are ignored. Raises DHCPTimeoutError when none arrives.

Source code in src/pydhcp/client/_sync.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def discover_offer(
    self,
    chaddr: bytes,
    *,
    timeout: float = 2.0,
    retries: int = 2,
    deadline: _ty.Optional[float] = None,
    destination: IPv4AddressLike = BROADCAST_DESTINATION,
    port: int = _SERVER_PORT,
    xid: _ty.Optional[int] = None,
    client_identifier: _ty.Optional[ClientIdentifierLike] = None,
    parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
    broadcast: bool = True,
) -> DHCPMessage:
    """Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

    `timeout` is the *initial* retransmission interval, not a fixed one:
    each retransmission waits about twice as long as the last, jittered, up
    to `RETRANSMIT_MAX_INTERVAL` (RFC 2131 s4.1). With the defaults the
    whole call is bounded at roughly 2+4+8 seconds rather than 3x2;
    `deadline` bounds it outright, in seconds.

    An OFFER is usable when it offers an address and names its server;
    others are ignored. Raises `DHCPTimeoutError` when none arrives.
    """
    started_at = self._monotonic()
    return self._discover(
        chaddr,
        timeout=timeout,
        retries=retries,
        destination=destination,
        port=port,
        xid=xid,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
        started_at=started_at,
        ends=self._deadline_end(started_at, deadline),
    )

dora(chaddr, *, timeout=2.0, retries=2, deadline=None, destination=BROADCAST_DESTINATION, port=_SERVER_PORT, xid=None, client_identifier=None, parameter_request_list=None, broadcast=True)

Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

timeout is the initial retransmission interval for each half of the exchange -- see discover_offer; deadline bounds the two halves together. Raises DHCPTimeoutError when no usable OFFER or ACK arrives (an ACK is usable when it comes from the server the REQUEST selected) and DHCPRefusedError when the server answers the REQUEST with a DHCPNAK.

Source code in src/pydhcp/client/_sync.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
def dora(
    self,
    chaddr: bytes,
    *,
    timeout: float = 2.0,
    retries: int = 2,
    deadline: _ty.Optional[float] = None,
    destination: IPv4AddressLike = BROADCAST_DESTINATION,
    port: int = _SERVER_PORT,
    xid: _ty.Optional[int] = None,
    client_identifier: _ty.Optional[ClientIdentifierLike] = None,
    parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
    broadcast: bool = True,
) -> DHCPMessage:
    """Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

    `timeout` is the initial retransmission interval for each half of the
    exchange -- see `discover_offer`; `deadline` bounds the two halves
    together. Raises `DHCPTimeoutError` when no usable OFFER or ACK arrives
    (an ACK is usable when it comes from the server the REQUEST selected) and
    `DHCPRefusedError` when the server answers the REQUEST with a DHCPNAK.
    """
    # RFC 2131 s2: `secs` counts from the start of *acquisition*, so the
    # REQUEST half keeps counting from the DISCOVER rather than resetting.
    started_at = self._monotonic()
    ends = self._deadline_end(started_at, deadline)
    offer = self._discover(
        chaddr,
        timeout=timeout,
        retries=retries,
        destination=destination,
        port=port,
        xid=xid,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
        started_at=started_at,
        ends=ends,
    )
    server = self._selected_server(offer)
    request = self._request_after(
        offer,
        server,
        chaddr,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
    )
    return self._exchange(
        request,
        _enum.DHCPMessageType.DHCPACK,
        destination=destination,
        port=port,
        timeout=timeout,
        retries=retries,
        started_at=started_at,
        server=server,
        ends=ends,
    )

on_reply(msg, context)

Hook called after a BOOTREPLY is accepted and queued.

Source code in src/pydhcp/client/_core.py
417
418
def on_reply(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Hook called after a BOOTREPLY is accepted and queued."""

serve_forever()

Bind, then receive on the calling thread until shutdown().

Raises what bind() raised, and RuntimeError when already serving or closed. The sockets stay open on return: close() releases them.

Source code in src/pydhcp/listener/_sync.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def serve_forever(self) -> None:
    """Bind, then receive on the calling thread until `shutdown()`.

    Raises what `bind()` raised, and `RuntimeError` when already serving or
    closed. The sockets stay open on return: `close()` releases them.
    """
    token = object()
    try:
        self._claim(token)
        self._serve()
    finally:
        self._release(token)

shutdown()

Ask the receive loop to end. Returns at once, from any thread and from a handler.

Source code in src/pydhcp/listener/_sync.py
128
129
130
131
132
133
134
def shutdown(self) -> None:
    """Ask the receive loop to end. Returns at once, from any thread and from a handler."""
    with self._state_lock:
        if not self._serving:
            return
        self._stopping = True
    self._wake()

start()

Bind, then receive on a new daemon thread.

Binding happens here, on the caller's thread, so an address that cannot be bound raises what bind() raised and leaves the listener unstarted and nothing bound. Raises RuntimeError when already serving or closed.

Source code in src/pydhcp/listener/_sync.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def start(self) -> None:
    """Bind, then receive on a new daemon thread.

    Binding happens here, on the caller's thread, so an address that cannot
    be bound raises what `bind()` raised and leaves the listener unstarted
    and nothing bound. Raises `RuntimeError` when already serving or closed.
    """
    token = object()
    thread: _ty.Optional[_thread.Thread] = None
    try:
        self._claim(token)
        # Daemon: nothing in the loop ends on its own, so a non-daemon thread
        # keeps the interpreter alive after `main` returns.
        thread = _thread.Thread(
            target=self._serve, name="pydhcp-listener", daemon=True
        )
        self._receive_thread = thread
        thread.start()
    except BaseException:
        # A thread that began owns the release; one that did not leaves it here.
        if thread is None or thread.ident is None:
            self._release(token)
        raise

wait_closed(timeout=None)

Block until the receive loop has ended; False if timeout seconds passed first.

Raises RuntimeError on the receive thread, which would wait for itself.

Source code in src/pydhcp/listener/_sync.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def wait_closed(self, timeout: _ty.Optional[float] = None) -> bool:
    """Block until the receive loop has ended; `False` if `timeout` seconds passed first.

    Raises `RuntimeError` on the receive thread, which would wait for itself.
    """
    if self._serving_ident == _thread.get_ident():
        raise RuntimeError(
            "wait_closed() on the receive thread would wait for itself"
        )
    began = _time.monotonic()
    if not self._idle.wait(timeout):
        return False
    thread = self._receive_thread
    if thread is not None and thread is not _thread.current_thread():
        thread.join(
            None
            if timeout is None
            else max(0.0, timeout - (_time.monotonic() - began))
        )
        if thread.is_alive():
            return False
    return True

AsyncDHCPClient

pydhcp.client.AsyncDHCPClient

Bases: _ClientCore, AsyncDHCPListener

DHCPClient on an event loop: the same messages, matching and backoff.

discover_offer, dora and send are coroutines taking the same keywords as the synchronous client's. The receive tasks must be running (start() or serve_forever()) for an exchange to hear a reply. handle() and on_reply() run on the event loop, not on a worker thread, so on_reply must not block. Any number of exchanges may be pending at once, each on its own transaction and client.

Source code in src/pydhcp/client/_asyncio.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
class AsyncDHCPClient(_ClientCore, AsyncDHCPListener):
    """`DHCPClient` on an event loop: the same messages, matching and backoff.

    `discover_offer`, `dora` and `send` are coroutines taking the same keywords
    as the synchronous client's. The receive tasks must be running (`start()` or
    `serve_forever()`) for an exchange to hear a reply. `handle()` and
    `on_reply()` run on the event loop, not on a worker thread, so `on_reply`
    must not block. Any number of exchanges may be pending at once, each on its
    own transaction and client.
    """

    #: A reply is only moved into a loop-owned queue, so it is handled where it
    #: arrives and no worker thread exists.
    _HANDLE_ON_WORKER = False

    def __init__(
        self,
        listen: ListenLike = None,
        *,
        max_packet_size: _ty.Optional[int] = None,
        per_interface: _ty.Optional[bool] = None,
        reuse_address: _ty.Optional[bool] = None,
        receive_buffer_size: _ty.Optional[int] = None,
    ) -> None:
        AsyncDHCPListener.__init__(
            self,
            listen=listen,
            max_packet_size=max_packet_size,
            per_interface=per_interface,
            reuse_address=reuse_address,
            receive_buffer_size=receive_buffer_size,
        )
        self._init_client_state()
        #: Created on first use, on the loop that serves: before Python 3.10 a
        #: queue binds to the current loop when it is constructed.
        self._replies: _ty.Optional[_asyncio.Queue[Reply]] = None
        #: Exchange key -> the queue the task running that exchange waits on.
        self._waiters: dict[tuple[int, bytes], _asyncio.Queue[Reply]] = {}

    async def send(
        self,
        message: DHCPMessage,
        *,
        dst: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
    ) -> int:
        if not self._sockets:
            self.bind()
        endpoint = self._endpoints[self._sockets[0]]
        data = self._encode(message)
        sent = await endpoint.asend(
            bytes(data),
            _dest_string(_ipaddress.IPv4Address(dst)),
            int(port),
        )
        self._note_sent()
        return int(sent)

    def _monotonic(self) -> float:
        """Monotonic clock, as one override point for every timing decision."""
        return _time.monotonic()

    async def _exchange(
        self,
        message: DHCPMessage,
        msg_type: _enum.DHCPMessageType,
        *,
        destination: IPv4AddressLike,
        port: int,
        timeout: float,
        retries: int,
        started_at: float,
        server: _ty.Optional[_ipaddress.IPv4Address] = None,
        ends: _ty.Optional[float] = None,
    ) -> DHCPMessage:
        """Send `message`, retransmitting on RFC 2131 s4.1 backoff, until `msg_type`.

        See `DHCPClient._exchange`. The key is accepted before the first send,
        because a reply can be handled on the loop turn between the datagram
        leaving and this coroutine resuming.
        """
        key = self._pending_key(message)
        try:
            with self._waiting_for(key) as waiter:
                self._pending_keys.add(key)
                attempts = 0
                for interval in self._retransmit_intervals(timeout, retries):
                    now = self._monotonic()
                    last = False
                    if ends is not None:
                        if ends - now <= 0:
                            break
                        # The deadline falls inside this wait, so it is the last:
                        # a timer may fire a clock tick early, and what the clock
                        # then says must not buy another send.
                        last = ends - now <= interval
                        interval = min(interval, ends - now)
                    self._stamp_secs(message, now - started_at)
                    attempts += 1
                    await self.send(message, dst=destination, port=port)
                    reply = await self._wait_for(waiter, msg_type, interval, server)
                    if reply is not None:
                        return reply
                    if last:
                        break
                raise self._timed_out(msg_type, attempts, ends)
        finally:
            # Also on cancellation: a spent transaction does not stay acceptable.
            self._pending_keys.discard(key)

    @_contextlib.contextmanager
    def _waiting_for(
        self, key: tuple[int, bytes]
    ) -> _ty.Iterator[_asyncio.Queue[Reply]]:
        """Claim the replies for one exchange for the duration of the block.

        Two blocks open on the *same* key are one exchange and share the queue;
        only the outer one removes it. Nothing awaits inside, so the table
        needs no lock: it is only touched on the loop.
        """
        waiter = self._waiters.get(key)
        owned = waiter is None
        if waiter is None:
            waiter = self._waiters[key] = _asyncio.Queue(
                maxsize=self.MAX_QUEUED_REPLIES
            )
        try:
            yield waiter
        finally:
            if owned and self._waiters.get(key) is waiter:
                del self._waiters[key]

    async def _wait_for(
        self,
        waiter: _asyncio.Queue[Reply],
        msg_type: _enum.DHCPMessageType,
        timeout: float,
        server: _ty.Optional[_ipaddress.IPv4Address] = None,
    ) -> _ty.Optional[DHCPMessage]:
        """Take the first usable reply of `msg_type` from one exchange's own queue."""
        deadline = self._monotonic() + timeout
        while True:
            remaining = deadline - self._monotonic()
            if remaining <= 0:
                return None
            try:
                msg, _context = await _asyncio.wait_for(waiter.get(), remaining)
            except _asyncio.TimeoutError:
                return None
            taken = self._take(msg, msg_type, server, self._monotonic())
            if taken is not None:
                return taken

    async def discover_offer(
        self,
        chaddr: bytes,
        *,
        timeout: float = 2.0,
        retries: int = 2,
        deadline: _ty.Optional[float] = None,
        destination: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
        xid: _ty.Optional[int] = None,
        client_identifier: _ty.Optional[ClientIdentifierLike] = None,
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
        broadcast: bool = True,
    ) -> DHCPMessage:
        """Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

        `timeout`, `deadline` and what raises are as in
        `DHCPClient.discover_offer`. Cancelling the call abandons the exchange.
        """
        started_at = self._monotonic()
        return await self._discover(
            chaddr,
            timeout=timeout,
            retries=retries,
            destination=destination,
            port=port,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
            started_at=started_at,
            ends=self._deadline_end(started_at, deadline),
        )

    async def _discover(
        self,
        chaddr: bytes,
        *,
        timeout: float,
        retries: int,
        destination: IPv4AddressLike,
        port: int,
        xid: _ty.Optional[int],
        client_identifier: _ty.Optional[ClientIdentifierLike],
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]],
        broadcast: bool,
        started_at: float,
        ends: _ty.Optional[float],
    ) -> DHCPMessage:
        discover = self.build_discover(
            chaddr,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
        )
        return await self._exchange(
            discover,
            _enum.DHCPMessageType.DHCPOFFER,
            destination=destination,
            port=port,
            timeout=timeout,
            retries=retries,
            started_at=started_at,
            ends=ends,
        )

    async def dora(
        self,
        chaddr: bytes,
        *,
        timeout: float = 2.0,
        retries: int = 2,
        deadline: _ty.Optional[float] = None,
        destination: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
        xid: _ty.Optional[int] = None,
        client_identifier: _ty.Optional[ClientIdentifierLike] = None,
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
        broadcast: bool = True,
    ) -> DHCPMessage:
        """Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

        Raises what `DHCPClient.dora` raises.
        """
        # RFC 2131 s2: `secs` counts from the start of *acquisition*.
        started_at = self._monotonic()
        ends = self._deadline_end(started_at, deadline)
        offer = await self._discover(
            chaddr,
            timeout=timeout,
            retries=retries,
            destination=destination,
            port=port,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
            started_at=started_at,
            ends=ends,
        )
        server = self._selected_server(offer)
        request = self._request_after(
            offer,
            server,
            chaddr,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
        )
        return await self._exchange(
            request,
            _enum.DHCPMessageType.DHCPACK,
            destination=destination,
            port=port,
            timeout=timeout,
            retries=retries,
            started_at=started_at,
            server=server,
            ends=ends,
        )

    def _shared_replies(self) -> _asyncio.Queue[Reply]:
        if self._replies is None:
            self._replies = _asyncio.Queue(maxsize=self.MAX_QUEUED_REPLIES)
        return self._replies

    def _deliver(
        self, key: tuple[int, bytes], msg: DHCPMessage, context: DHCPRequestContext
    ) -> None:
        waiter = self._waiters.get(key)
        self._put_bounded(
            waiter if waiter is not None else self._shared_replies(), (msg, context)
        )

    def _put_bounded(self, replies: _asyncio.Queue[Reply], item: Reply) -> None:
        """Enqueue, discarding the oldest rather than growing without bound."""
        while True:
            try:
                replies.put_nowait(item)
                return
            except _asyncio.QueueFull:
                replies.get_nowait()
                self.metrics.replies_dropped_overflow += 1

    async def next_reply(
        self, timeout: _ty.Optional[float] = None
    ) -> _ty.Optional[Reply]:
        """The oldest queued reply, waiting up to `timeout` seconds; `None` if none came."""
        replies = self._shared_replies()
        try:
            return replies.get_nowait()
        except _asyncio.QueueEmpty:
            pass
        if timeout is not None and timeout <= 0:
            return None
        try:
            return await _asyncio.wait_for(replies.get(), timeout)
        except _asyncio.TimeoutError:
            return None

    def drain_replies(self) -> list[Reply]:
        replies: list[Reply] = []
        queue = self._replies
        while queue is not None:
            try:
                replies.append(queue.get_nowait())
            except _asyncio.QueueEmpty:
                break
        return replies

bound_addresses property

The addresses this listener is currently bound to.

Empty before bind() and once the sockets are closed. Asking the socket rather than repeating the listen list is the point: binding port 0 gives an ephemeral port that only the socket knows, which is how a test or a tool discovers where to send.

aclose() async

Shut down, wait for serving to end, then release every socket. Final and repeatable.

Source code in src/pydhcp/listener/_asyncio.py
350
351
352
353
354
async def aclose(self) -> None:
    """Shut down, wait for serving to end, then release every socket. Final and repeatable."""
    if self._close_task is None:
        self._close_task = _asyncio.ensure_future(self._close())
    await _asyncio.shield(self._close_task)

bind()

Open the sockets the specification names; does nothing once they are open.

Raises what the bind raised, leaving none open, and RuntimeError once the listener is closed.

Source code in src/pydhcp/listener/_core.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def bind(self) -> None:
    """Open the sockets the specification names; does nothing once they are open.

    Raises what the bind raised, leaving none open, and `RuntimeError` once
    the listener is closed.
    """
    if self._closed:
        raise RuntimeError(f"{type(self).__name__} is closed")
    if self._sockets:
        return
    label = f" ({self._BIND_LABEL})" if self._BIND_LABEL else ""
    for entry in self._listen:
        LOGGER.info(
            f"Listening on{label}: {entry.address}:{entry.port}"
            + (
                f" ({len(entry.interfaces)} interface(s))"
                if entry.interfaces
                else ""
            )
        )
    endpoints = _netimps.bind_listen(
        self._listen,
        family=_socket.AF_INET,
        # `None` expands a wildcard where the host reports no packet info.
        per_address=True if self._per_interface else None,
        device_binding=self.USE_DEVICE_BINDING,
        allow_address_takeover=(
            self.REUSE_ADDRESS
            if self._reuse_address is None
            else self._reuse_address
        ),
        broadcast=True,
    )
    try:
        _prepare_endpoints(
            endpoints,
            self._BIND_LABEL,
            (
                self.RECEIVE_BUFFER_SIZE
                if self._receive_buffer_size is None
                else self._receive_buffer_size
            ),
        )
    except BaseException:
        for endpoint in endpoints:
            _close_socket(endpoint.socket, {endpoint.socket: endpoint})
        raise
    for endpoint in endpoints:
        self._sockets.append(endpoint.socket)
        self._endpoints[endpoint.socket] = endpoint

discover_offer(chaddr, *, timeout=2.0, retries=2, deadline=None, destination=BROADCAST_DESTINATION, port=_SERVER_PORT, xid=None, client_identifier=None, parameter_request_list=None, broadcast=True) async

Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

timeout, deadline and what raises are as in DHCPClient.discover_offer. Cancelling the call abandons the exchange.

Source code in src/pydhcp/client/_asyncio.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
async def discover_offer(
    self,
    chaddr: bytes,
    *,
    timeout: float = 2.0,
    retries: int = 2,
    deadline: _ty.Optional[float] = None,
    destination: IPv4AddressLike = BROADCAST_DESTINATION,
    port: int = _SERVER_PORT,
    xid: _ty.Optional[int] = None,
    client_identifier: _ty.Optional[ClientIdentifierLike] = None,
    parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
    broadcast: bool = True,
) -> DHCPMessage:
    """Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

    `timeout`, `deadline` and what raises are as in
    `DHCPClient.discover_offer`. Cancelling the call abandons the exchange.
    """
    started_at = self._monotonic()
    return await self._discover(
        chaddr,
        timeout=timeout,
        retries=retries,
        destination=destination,
        port=port,
        xid=xid,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
        started_at=started_at,
        ends=self._deadline_end(started_at, deadline),
    )

dora(chaddr, *, timeout=2.0, retries=2, deadline=None, destination=BROADCAST_DESTINATION, port=_SERVER_PORT, xid=None, client_identifier=None, parameter_request_list=None, broadcast=True) async

Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

Raises what DHCPClient.dora raises.

Source code in src/pydhcp/client/_asyncio.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
async def dora(
    self,
    chaddr: bytes,
    *,
    timeout: float = 2.0,
    retries: int = 2,
    deadline: _ty.Optional[float] = None,
    destination: IPv4AddressLike = BROADCAST_DESTINATION,
    port: int = _SERVER_PORT,
    xid: _ty.Optional[int] = None,
    client_identifier: _ty.Optional[ClientIdentifierLike] = None,
    parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
    broadcast: bool = True,
) -> DHCPMessage:
    """Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

    Raises what `DHCPClient.dora` raises.
    """
    # RFC 2131 s2: `secs` counts from the start of *acquisition*.
    started_at = self._monotonic()
    ends = self._deadline_end(started_at, deadline)
    offer = await self._discover(
        chaddr,
        timeout=timeout,
        retries=retries,
        destination=destination,
        port=port,
        xid=xid,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
        started_at=started_at,
        ends=ends,
    )
    server = self._selected_server(offer)
    request = self._request_after(
        offer,
        server,
        chaddr,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
    )
    return await self._exchange(
        request,
        _enum.DHCPMessageType.DHCPACK,
        destination=destination,
        port=port,
        timeout=timeout,
        retries=retries,
        started_at=started_at,
        server=server,
        ends=ends,
    )

next_reply(timeout=None) async

The oldest queued reply, waiting up to timeout seconds; None if none came.

Source code in src/pydhcp/client/_asyncio.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
async def next_reply(
    self, timeout: _ty.Optional[float] = None
) -> _ty.Optional[Reply]:
    """The oldest queued reply, waiting up to `timeout` seconds; `None` if none came."""
    replies = self._shared_replies()
    try:
        return replies.get_nowait()
    except _asyncio.QueueEmpty:
        pass
    if timeout is not None and timeout <= 0:
        return None
    try:
        return await _asyncio.wait_for(replies.get(), timeout)
    except _asyncio.TimeoutError:
        return None

on_reply(msg, context)

Hook called after a BOOTREPLY is accepted and queued.

Source code in src/pydhcp/client/_core.py
417
418
def on_reply(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Hook called after a BOOTREPLY is accepted and queued."""

serve_forever() async

Bind, then receive in the calling task until shutdown().

Raises what bind() raised, and RuntimeError when already serving or closed. The sockets stay open on return: aclose() releases them.

Source code in src/pydhcp/listener/_asyncio.py
231
232
233
234
235
236
237
238
239
240
241
242
243
async def serve_forever(self) -> None:
    """Bind, then receive in the calling task until `shutdown()`.

    Raises what `bind()` raised, and `RuntimeError` when already serving or
    closed. The sockets stay open on return: `aclose()` releases them.
    """
    loop = self._claim()
    try:
        self._begin_receiving(loop)
    except BaseException:
        self._abort_claim()
        raise
    await self._serve()

shutdown()

Ask serving to end. Returns at once, from any thread and from a handler.

Nothing is closed here: the receive tasks end on the event loop's next turn and aclose() releases the sockets. A no-op when nothing serves.

Source code in src/pydhcp/listener/_asyncio.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
def shutdown(self) -> None:
    """Ask serving to end. Returns at once, from any thread and from a handler.

    Nothing is closed here: the receive tasks end on the event loop's next
    turn and `aclose()` releases the sockets. A no-op when nothing serves.
    """
    loop, stopped = self._loop, self._stopped
    if not self._serving or loop is None or stopped is None:
        return
    try:
        # `Event.set` finishes through `loop.call_soon`, which queues a
        # callback without waking a selector loop from another thread.
        loop.call_soon_threadsafe(stopped.set)
    except RuntimeError:  # pragma: no cover - the loop closed since
        pass

start() async

Bind, then receive in background tasks, and return once receiving.

Raises what bind() raised, and RuntimeError when already serving or closed; nothing is left open or running in either case.

Source code in src/pydhcp/listener/_asyncio.py
217
218
219
220
221
222
223
224
225
226
227
228
229
async def start(self) -> None:
    """Bind, then receive in background tasks, and return once receiving.

    Raises what `bind()` raised, and `RuntimeError` when already serving or
    closed; nothing is left open or running in either case.
    """
    loop = self._claim()
    try:
        self._begin_receiving(loop)
    except BaseException:
        self._abort_claim()
        raise
    self._serving_task = loop.create_task(self._serve())

wait_closed(timeout=None) async

Wait until serving has ended; False if timeout seconds passed first.

Source code in src/pydhcp/listener/_asyncio.py
337
338
339
340
341
342
343
344
345
346
347
348
async def wait_closed(self, timeout: _ty.Optional[float] = None) -> bool:
    """Wait until serving has ended; `False` if `timeout` seconds passed first."""
    ended = self._serve_ended
    if ended is not None and not ended.done():
        try:
            await _asyncio.wait_for(_asyncio.shield(ended), timeout)
        except _asyncio.TimeoutError:
            return False
    task, self._serving_task = self._serving_task, None
    if task is not None and not task.done():
        await task
    return True

DHCPRelay

pydhcp.relay.DHCPRelay

Bases: _RelayCore, DHCPListener

RFC 1542 / RFC 2131 4.1 / RFC 3046 DHCP relay agent on a receive thread.

Forwards client requests to the configured upstream servers and their replies back; see _RelayCore for the rules.

Source code in src/pydhcp/relay/_sync.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class DHCPRelay(_RelayCore, DHCPListener):
    """RFC 1542 / RFC 2131 4.1 / RFC 3046 DHCP relay agent on a receive thread.

    Forwards client requests to the configured upstream servers and their
    replies back; see `_RelayCore` for the rules.
    """

    def __init__(
        self,
        listen: ListenLike = None,
        server_addresses: _ty.Sequence[ServerAddressLike] = (),
        *,
        max_hops: int = DEFAULT_MAX_HOPS,
        insert_relay_agent_info: bool = False,
        circuit_id: _ty.Optional[bytes] = None,
        remote_id: _ty.Optional[bytes] = None,
        trust_client_relay_agent_info: bool = False,
        poll_interval: _ty.Optional[float] = None,
        max_packet_size: _ty.Optional[int] = None,
        per_interface: _ty.Optional[bool] = None,
        reuse_address: _ty.Optional[bool] = None,
        receive_buffer_size: _ty.Optional[int] = None,
    ) -> None:
        DHCPListener.__init__(
            self,
            listen=listen,
            poll_interval=poll_interval,
            max_packet_size=max_packet_size,
            per_interface=per_interface,
            reuse_address=reuse_address,
            receive_buffer_size=receive_buffer_size,
        )
        self._init_relay_state(
            server_addresses,
            max_hops=max_hops,
            insert_relay_agent_info=insert_relay_agent_info,
            circuit_id=circuit_id,
            remote_id=remote_id,
            trust_client_relay_agent_info=trust_client_relay_agent_info,
        )

AsyncDHCPServer

pydhcp.server.AsyncDHCPServer

Bases: _ServerCore, AsyncDHCPListener

A DHCP server on an event loop.

The same rules and hooks as DHCPServer; the hooks are ordinary blocking methods and run on the one worker thread the listener hands datagrams to, never on the loop.

Source code in src/pydhcp/server/_asyncio.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class AsyncDHCPServer(_ServerCore, AsyncDHCPListener):
    """A DHCP server on an event loop.

    The same rules and hooks as `DHCPServer`; the hooks are ordinary blocking
    methods and run on the one worker thread the listener hands datagrams to,
    never on the loop.
    """

    def __init__(
        self,
        listen: ListenLike = None,
        *,
        max_packet_size: _ty.Optional[int] = None,
        lease_backend: _ty.Optional[LeaseBackend] = None,
        per_interface: _ty.Optional[bool] = None,
        reuse_address: _ty.Optional[bool] = None,
        receive_buffer_size: _ty.Optional[int] = None,
        max_queued: _ty.Optional[int] = None,
    ) -> None:
        AsyncDHCPListener.__init__(
            self,
            listen=listen,
            max_packet_size=max_packet_size,
            per_interface=per_interface,
            reuse_address=reuse_address,
            receive_buffer_size=receive_buffer_size,
            max_queued=max_queued,
        )
        self._init_server_state(lease_backend)

    async def _close(self) -> None:
        # A lease backend the server was given is not closed: it is the caller's.
        # One the server created itself is closed here.
        try:
            await AsyncDHCPListener._close(self)
        finally:
            self._close_owned_backend()

bound_addresses property

The addresses this listener is currently bound to.

Empty before bind() and once the sockets are closed. Asking the socket rather than repeating the listen list is the point: binding port 0 gives an ephemeral port that only the socket knows, which is how a test or a tool discovers where to send.

aclose() async

Shut down, wait for serving to end, then release every socket. Final and repeatable.

Source code in src/pydhcp/listener/_asyncio.py
350
351
352
353
354
async def aclose(self) -> None:
    """Shut down, wait for serving to end, then release every socket. Final and repeatable."""
    if self._close_task is None:
        self._close_task = _asyncio.ensure_future(self._close())
    await _asyncio.shield(self._close_task)

acquire_lease(client_id, server_id, msg, *, commit=True)

Return a lease for a client message.

This is the extension point for address pools, reservations, policy checks and custom response options. It is an ordinary method of this server, called on the server itself from the handler thread (the one worker thread on the async server), so an attribute it keeps -- the next free host, a cache -- is the server's own.

commit says what kind of call this is. False: the server is deciding what to offer or whether to ACK (RFC 2131 s4.3.1 makes DHCPDISCOVER a probe), so the call must not extend an existing binding or add state that a client declining the offer would leave behind. True: the answer is being committed. A DHCPDISCOVER makes one call with commit=False; a DHCPREQUEST makes two, commit=False to decide and then commit=True if it is ACKed. An override honours commit itself, including in what it does to self.lease_backend, which is the real backend on both calls.

The base implementation is intentionally small. A client with a record gets it back: on commit=False as it stands, on commit=True an offered lease committed (LeaseBackend.commit) and a bound one renewed. Only a DHCPDISCOVER makes a record: from a client with none it holds the address in REQUESTED_IP (or ciaddr) as an offer for OFFER_HOLD_SECONDS on commit=False, and allocates it on commit=True. A DHCPREQUEST from a client with no record gets None. A message whose giaddr is outside the served network gets None: the stock allocator has the one network to give.

Source code in src/pydhcp/server/_policy.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def acquire_lease(
    self,
    client_id: str,
    server_id: _ipaddress.IPv4Address,
    msg: DHCPMessage,
    *,
    commit: bool = True,
) -> _ty.Optional[DHCPLease]:
    """Return a lease for a client message.

    This is the extension point for address pools, reservations, policy
    checks and custom response options. It is an ordinary method of this
    server, called on the server itself from the handler thread (the one
    worker thread on the async server), so an attribute it keeps -- the
    next free host, a cache -- is the server's own.

    `commit` says what kind of call this is. False: the server is deciding
    what to offer or whether to ACK (RFC 2131 s4.3.1 makes DHCPDISCOVER a
    probe), so the call must not extend an existing binding or add state
    that a client declining the offer would leave behind. True: the answer
    is being committed. A DHCPDISCOVER makes one call with `commit=False`; a
    DHCPREQUEST makes two, `commit=False` to decide and then `commit=True`
    if it is ACKed. An override honours `commit` itself, including in what
    it does to `self.lease_backend`, which is the real backend on both
    calls.

    The base implementation is intentionally small. A client with a record
    gets it back: on `commit=False` as it stands, on `commit=True` an
    offered lease committed (`LeaseBackend.commit`) and a bound one renewed.
    Only a DHCPDISCOVER makes a record: from a client with none it holds
    the address in `REQUESTED_IP` (or `ciaddr`) as an offer for
    `OFFER_HOLD_SECONDS` on `commit=False`, and allocates it on
    `commit=True`. A DHCPREQUEST from a client with no record gets `None`.
    A message whose `giaddr` is outside the served network gets `None`:
    the stock allocator has the one network to give.
    """
    _server = _servable_interface(server_id)
    if _server is None:
        return None

    if (
        msg.giaddr != _const.UNSPECIFIED_ADDRESS
        and msg.giaddr not in _server.network
    ):
        self.metrics.addresses_refused += 1
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            "refused relay",
            "[XID=%08x] Refusing %s: relay %s is outside the served network %s",
            msg.xid,
            _brief(client_id),
            msg.giaddr,
            _server.network,
            now=self._read_clock().monotonic,
        )
        return None

    backend = self.lease_backend
    ttl = self.get_lease_seconds(msg)
    existing = self.lookup_lease(client_id)
    if existing:
        if not commit:
            return existing
        if existing.offered:
            committed = backend.commit(client_id, ttl)
            if committed is not None:
                self.metrics.leases_allocated += 1
            return committed
        renewed = backend.renew(client_id, ttl)
        if renewed:
            self.metrics.leases_renewed += 1
            return renewed
        return existing

    if msg.message_type is not _enum.DHCPMessageType.DHCPDISCOVER:
        return None

    requested_ip = msg.options.get(
        DHCPOptionCode.REQUESTED_IP, decode=_type.IPv4AddressOption
    )

    ip: _ty.Optional[_ipaddress.IPv4Address] = None
    if requested_ip:
        ip = requested_ip
    elif msg.ciaddr != _const.UNSPECIFIED_ADDRESS:
        ip = msg.ciaddr

    if ip is None:
        return None

    refusal = self._address_refusal(ip, _server, client_id)
    if refusal is not None:
        self.metrics.addresses_refused += 1
        # No context reaches `acquire_lease`: the driver's reading stands in.
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            "refused address",
            "[XID=%08x] Refusing %s for %s: %s",
            msg.xid,
            ip,
            _brief(client_id),
            _brief(refusal),
            now=self._read_clock().monotonic,
        )
        return None

    options = DHCPOptions()
    options[DHCPOptionCode.SUBNET_MASK] = _server.network.netmask
    options[DHCPOptionCode.BROADCAST_ADDRESS] = _server.network.broadcast_address
    # No ROUTER and no DNS. This host's own address would be a guess and
    # usually a wrong one: running the server on an
    # ordinary machine then told every client to send all off-link traffic
    # and every name lookup to a host that routes and resolves nothing.
    # Omitting them leaves the client with whatever it already has -- a
    # statically configured resolver, another router on the segment -- which
    # is recoverable, where being pointed at a black hole is not.
    # Override `acquire_lease` to supply the real ones.

    if commit:
        LOGGER.debug(f"[XID={msg.xid:08x}] Allocating {ip} for {client_id}")
        lease = backend.allocate(client_id, ip, ttl, options)
        if lease is not None:
            self.metrics.leases_allocated += 1
        return lease
    LOGGER.debug(f"[XID={msg.xid:08x}] Offering {ip} to {client_id}")
    lease = backend.offer(client_id, ip, self.OFFER_HOLD_SECONDS, options)
    if lease is not None:
        self.metrics.leases_offered += 1
    return lease

bind()

Open the sockets the specification names; does nothing once they are open.

Raises what the bind raised, leaving none open, and RuntimeError once the listener is closed.

Source code in src/pydhcp/listener/_core.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def bind(self) -> None:
    """Open the sockets the specification names; does nothing once they are open.

    Raises what the bind raised, leaving none open, and `RuntimeError` once
    the listener is closed.
    """
    if self._closed:
        raise RuntimeError(f"{type(self).__name__} is closed")
    if self._sockets:
        return
    label = f" ({self._BIND_LABEL})" if self._BIND_LABEL else ""
    for entry in self._listen:
        LOGGER.info(
            f"Listening on{label}: {entry.address}:{entry.port}"
            + (
                f" ({len(entry.interfaces)} interface(s))"
                if entry.interfaces
                else ""
            )
        )
    endpoints = _netimps.bind_listen(
        self._listen,
        family=_socket.AF_INET,
        # `None` expands a wildcard where the host reports no packet info.
        per_address=True if self._per_interface else None,
        device_binding=self.USE_DEVICE_BINDING,
        allow_address_takeover=(
            self.REUSE_ADDRESS
            if self._reuse_address is None
            else self._reuse_address
        ),
        broadcast=True,
    )
    try:
        _prepare_endpoints(
            endpoints,
            self._BIND_LABEL,
            (
                self.RECEIVE_BUFFER_SIZE
                if self._receive_buffer_size is None
                else self._receive_buffer_size
            ),
        )
    except BaseException:
        for endpoint in endpoints:
            _close_socket(endpoint.socket, {endpoint.socket: endpoint})
        raise
    for endpoint in endpoints:
        self._sockets.append(endpoint.socket)
        self._endpoints[endpoint.socket] = endpoint

get_inform_options(server_id, msg)

Return configuration options for DHCPINFORM responses.

DHCPINFORM does not allocate an address. Override this method when clients should receive site-specific options without touching lease allocation.

Source code in src/pydhcp/server/_policy.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
def get_inform_options(
    self, server_id: _ipaddress.IPv4Address, msg: DHCPMessage
) -> DHCPOptions:
    """Return configuration options for DHCPINFORM responses.

    DHCPINFORM does not allocate an address. Override this method when clients
    should receive site-specific options without touching lease allocation.
    """
    options = DHCPOptions()
    _server = _servable_interface(server_id)
    if _server is not None:
        options[DHCPOptionCode.SUBNET_MASK] = _server.network.netmask
        options[DHCPOptionCode.BROADCAST_ADDRESS] = (
            _server.network.broadcast_address
        )
        # As in `acquire_lease`: this host is not known to be a router or a
        # resolver, so it does not claim to be either.
    return options

get_lease_seconds(msg)

Lease length to grant for msg, applying this server's policy.

RFC 2131 s4.3.1 lets the server honour a client's requested lease time only "if acceptable to local policy" -- a MAY, so there has to be a policy. There was none: the requested value went straight through, and any client could hold an address for 136 years by asking for one.

RFC 2132 s3.3 also makes 0xFFFFFFFF mean infinity, not 4294967295 seconds. Taken literally it became a finite expiry in 2162, and the reply then advertised 4294967294 -- a different number than the client asked for, one second short of the sentinel.

Override this, or set the class attributes, to change the policy.

Source code in src/pydhcp/server/_policy.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def get_lease_seconds(self, msg: DHCPMessage) -> float:
    """Lease length to grant for `msg`, applying this server's policy.

    RFC 2131 s4.3.1 lets the server honour a client's requested lease time
    only "if acceptable to local policy" -- a MAY, so there has to be a
    policy. There was none: the requested value went straight through, and
    any client could hold an address for 136 years by asking for one.

    RFC 2132 s3.3 also makes 0xFFFFFFFF mean *infinity*, not 4294967295
    seconds. Taken literally it became a finite expiry in 2162, and the
    reply then advertised 4294967294 -- a different number than the client
    asked for, one second short of the sentinel.

    Override this, or set the class attributes, to change the policy.
    """
    requested = msg.options.get(
        DHCPOptionCode.IP_ADDRESS_LEASE_TIME, decode=_type.U32
    )
    if requested is None:
        return float(self.DEFAULT_LEASE_SECONDS)
    value = int(requested)
    if value == _const.INFINITE_LEASE_TIME:
        return _inf if self.ALLOW_INFINITE_LEASE else float(self.MAX_LEASE_SECONDS)
    return float(max(self.MIN_LEASE_SECONDS, min(value, self.MAX_LEASE_SECONDS)))

handle_decline(msg, context)

Handle DHCPDECLINE: quarantine the address it names, and release the sender's own.

RFC 2131 s4.3.3: "The server MUST mark the network address as not available", whoever sent it. A DECLINE naming an address outside the served network, this host's own, or another server in option 54 changes nothing and is counted in declines_ignored; the quarantine is bounded (MAX_DECLINED_ADDRESSES). A record is released only when it is the sender's own, for the declined address. DECLINE_REQUIRES_LEASE marks only an address the sender holds, as a binding or an outstanding offer.

Source code in src/pydhcp/server/_handlers.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
def handle_decline(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Handle DHCPDECLINE: quarantine the address it names, and release the sender's own.

    RFC 2131 s4.3.3: "The server MUST mark the network address as not
    available", whoever sent it. A DECLINE naming an address outside the
    served network, this host's own, or another server in option 54 changes
    nothing and is counted in `declines_ignored`; the quarantine is bounded
    (`MAX_DECLINED_ADDRESSES`). A record is released only when it is the
    sender's own, for the declined address. `DECLINE_REQUIRES_LEASE` marks
    only an address the sender holds, as a binding or an outstanding offer.
    """
    client_id = msg.get_client_id()
    actual_server_id = _ty.cast(_ipaddress.IPv4Address, context.interface.ip)
    held = self.lookup_lease(client_id)
    declined: _ty.Optional[_ipaddress.IPv4Address] = msg.options.get(
        DHCPOptionCode.REQUESTED_IP, decode=_type.IPv4AddressOption
    )
    if declined is None and msg.ciaddr != _const.UNSPECIFIED_ADDRESS:
        declined = msg.ciaddr
    if declined is None and held is not None:
        declined = held.ip
    refusal = self._decline_refusal(msg, declined, held, actual_server_id)
    if refusal is not None or declined is None:
        self.metrics.declines_ignored += 1
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            "DHCPDECLINE ignored",
            "[XID=%08x] Ignoring DHCPDECLINE from %s|%s: %s",
            msg.xid,
            context.client,
            _brief(client_id),
            _brief(refusal),
            now=self._instant(context).monotonic,
        )
        return
    self._log_limit.log(
        LOGGER,
        _logging.WARNING,
        "DHCPDECLINE",
        "[XID=%08x] DHCPDECLINE of %s from %s|%s",
        msg.xid,
        declined,
        context.client,
        _brief(client_id),
        now=self._instant(context).monotonic,
    )
    self.quarantine_address(declined)
    # Counted as a decline, not a release: the client found the address
    # already in use, which is the opposite of an orderly hand-back.
    self.metrics.leases_declined += 1
    if held is not None and held.ip == declined:
        self.release_lease(client_id, actual_server_id, msg)

handle_discover(msg, context)

Handle DHCPDISCOVER by offering a lease returned from acquire_lease.

Source code in src/pydhcp/server/_handlers.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
def handle_discover(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Handle DHCPDISCOVER by offering a lease returned from `acquire_lease`."""
    client_id = msg.get_client_id()
    actual_server_id = _ty.cast(_ipaddress.IPv4Address, context.interface.ip)
    LOGGER.info(
        f"[XID={msg.xid:08x}] DHCPDISCOVER from {context.client}|{client_id}"
    )
    # A DISCOVER is a probe: it may hold an address as an offer but must
    # not extend an existing binding or commit one.
    lease = self._with_address(
        self.acquire_lease(client_id, actual_server_id, msg, commit=False),
        msg,
        context,
    )
    now = self._instant(context).utc
    if lease and self._refused_as_quarantined(msg, lease, client_id, context):
        return
    if not lease or not self._has_time_left(lease, now):
        LOGGER.info(
            f"[XID={msg.xid:08x}] No lease available for {context.client}|{client_id} at {actual_server_id} ignoring"
        )
        return
    resp = self._create_response(
        msg, lease, actual_server_id, _enum.DHCPMessageType.DHCPOFFER, now=now
    )
    self._filter_and_send(msg, resp, context, _enum.DHCPMessageType.DHCPOFFER)

handle_inform(msg, context)

Handle DHCPINFORM without requiring address allocation.

Source code in src/pydhcp/server/_handlers.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
def handle_inform(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Handle DHCPINFORM without requiring address allocation."""
    client_id = msg.get_client_id()
    actual_server_id = _ty.cast(_ipaddress.IPv4Address, context.interface.ip)
    LOGGER.info(f"[XID={msg.xid:08x}] DHCPINFORM from {context.client}|{client_id}")
    # RFC 2131 4.3.5: a DHCPINFORM client already has its address and is
    # asking only for configuration. Routing this through acquire_lease
    # created or renewed a binding for a client that never asked for one --
    # so an INFORM flood grew the lease store -- and bypassed the
    # allocation-free hook documented for exactly this path whenever a
    # binding happened to exist.
    refusal = self._inform_refusal(msg, context.client.ip, actual_server_id)
    if refusal is not None:
        self.metrics.informs_ignored += 1
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            "DHCPINFORM refused",
            "[XID=%08x] Ignoring DHCPINFORM from %s|%s: %s",
            msg.xid,
            context.client,
            _brief(client_id),
            _brief(refusal),
            now=self._instant(context).monotonic,
        )
        return
    resp = self._create_inform_response(
        msg, self.get_inform_options(actual_server_id, msg), actual_server_id
    )
    self._filter_and_send(msg, resp, context, _enum.DHCPMessageType.DHCPACK)

handle_release(msg, context)

Handle DHCPRELEASE, but only for the address the client actually holds.

Source code in src/pydhcp/server/_handlers.py
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
def handle_release(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Handle DHCPRELEASE, but only for the address the client actually holds."""
    client_id = msg.get_client_id()
    actual_server_id = _ty.cast(_ipaddress.IPv4Address, context.interface.ip)
    LOGGER.info(
        f"[XID={msg.xid:08x}] DHCPRELEASE from {context.client}|{client_id}"
    )
    # RFC 2131 4.4.6: the client puts the address being given up in ciaddr.
    # Releasing on client identifier alone would let a late or duplicated
    # RELEASE naming an *old* address delete whatever binding that client
    # holds at that moment, and the address would go to someone else while
    # the client was still using it.
    existing = self.lookup_lease(client_id)
    if existing is not None and msg.ciaddr != _const.UNSPECIFIED_ADDRESS:
        if existing.ip != msg.ciaddr:
            self._log_limit.log(
                LOGGER,
                _logging.WARNING,
                "DHCPRELEASE for an address not held",
                "[XID=%08x] Ignoring DHCPRELEASE from %s|%s for %s: it holds %s",
                msg.xid,
                context.client,
                _brief(client_id),
                msg.ciaddr,
                existing.ip,
                now=self._instant(context).monotonic,
            )
            self.metrics.releases_ignored += 1
            return
    if self.release_lease(client_id, actual_server_id, msg):
        self.metrics.leases_released += 1

handle_request(msg, context)

Handle DHCPREQUEST by ACKing or NAKing the lease returned from acquire_lease.

RFC 2131 s4.3.2 and Table 4 tell the four shapes apart, and each is answered from the record acquire_lease returns for the client:

  • SELECTING (option 54 names this server): a lease for the address asked for is ACKed; a request this server cannot satisfy is NAKed (s3.1 step 4), never left unanswered.
  • INIT-REBOOT (option 50, no option 54, ciaddr 0), RENEWING (ciaddr, unicast to this server) and REBINDING (ciaddr, broadcast): a lease for the address is ACKed, a lease for another address is NAKed, and no lease at all is silence, because the client may belong to another server on the same wire.
Source code in src/pydhcp/server/_handlers.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def handle_request(self, msg: DHCPMessage, context: DHCPRequestContext) -> None:
    """Handle DHCPREQUEST by ACKing or NAKing the lease returned from `acquire_lease`.

    RFC 2131 s4.3.2 and Table 4 tell the four shapes apart, and each is
    answered from the record `acquire_lease` returns for the client:

    - SELECTING (option 54 names this server): a lease for the address asked
      for is ACKed; a request this server cannot satisfy is NAKed (s3.1
      step 4), never left unanswered.
    - INIT-REBOOT (option 50, no option 54, `ciaddr` 0), RENEWING (`ciaddr`,
      unicast to this server) and REBINDING (`ciaddr`, broadcast): a lease
      for the address is ACKed, a lease for another address is NAKed, and
      no lease at all is silence, because the client may belong to another
      server on the same wire.
    """
    client_id = msg.get_client_id()
    actual_server_id = _ty.cast(_ipaddress.IPv4Address, context.interface.ip)
    LOGGER.info(
        f"[XID={msg.xid:08x}] DHCPREQUEST from {context.client}|{client_id}"
    )
    server_id = msg.options.get(
        DHCPOptionCode.SERVER_IDENTIFIER, decode=_type.IPv4AddressOption
    )
    requested: _ty.Optional[_ipaddress.IPv4Address] = msg.options.get(
        DHCPOptionCode.REQUESTED_IP, decode=_type.IPv4AddressOption
    )
    if server_id is not None:
        shape = "SELECTING"
    elif msg.ciaddr != _const.UNSPECIFIED_ADDRESS:
        shape = "RENEWING" if context.is_unicast is not False else "REBINDING"
    elif requested is not None:
        shape = "INIT-REBOOT"
    else:
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            "REQUEST of no address",
            "[XID=%08x] DHCPREQUEST from %s|%s names no address: no "
            "server identifier, no requested address and no ciaddr",
            msg.xid,
            context.client,
            _brief(client_id),
            now=self._instant(context).monotonic,
        )
        return
    ip_req = requested if requested is not None else msg.ciaddr

    # Decide first, on a call that cannot extend the binding: a REQUEST for
    # the wrong address is about to be NAKed, and renewing the address it is
    # being refused was exactly backwards.
    lease = self._with_address(
        self.acquire_lease(client_id, actual_server_id, msg, commit=False),
        msg,
        context,
    )
    if not lease:
        if shape == "SELECTING":
            self._nak(msg, context, "the address is not available")
            return
        self._log_limit.log(
            LOGGER,
            _logging.WARNING,
            f"{shape} from an unknown client",
            "[XID=%08x] %s from %s|%s with no record of this client, "
            "remaining silent",
            msg.xid,
            shape,
            context.client,
            _brief(client_id),
            now=self._instant(context).monotonic,
        )
        return
    now = self._instant(context).utc
    if self._refused_as_quarantined(msg, lease, client_id, context):
        self._nak(msg, context, "the address is not available")
        return
    if ip_req != lease.ip:
        self._nak(msg, context, "the requested address is not the client's")
        return
    if not self._has_time_left(lease, now):
        # A lease with no time left NAKs rather than ACKing nothing: the
        # client is told to start over, which is recoverable, instead of
        # being handed an ACK with no address in it.
        self._nak(msg, context, "the lease has expired")
        return
    # Only at this point is anything agreed, so this is where the lease time the ACK
    # advertises is actually committed.
    committed = self._with_address(
        self.acquire_lease(client_id, actual_server_id, msg, commit=True),
        msg,
        context,
    )
    if committed is not None:
        lease = committed
    elif lease.offered:
        # The hold lapsed between the decision and the commit.
        self._nak(msg, context, "the offer has lapsed")
        return
    resp = self._create_response(
        msg, lease, actual_server_id, _enum.DHCPMessageType.DHCPACK, now=now
    )
    self._filter_and_send(msg, resp, context, _enum.DHCPMessageType.DHCPACK)

is_quarantined(ip, *, now=None)

Whether ip is out of the pool after a DHCPDECLINE.

The server asks this of every lease a hook returns, so an acquire_lease override does not have to. now is time.monotonic() seconds; omitted, the driver's reading is used.

Source code in src/pydhcp/server/_policy.py
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
def is_quarantined(
    self, ip: _ipaddress.IPv4Address, *, now: _ty.Optional[float] = None
) -> bool:
    """Whether `ip` is out of the pool after a DHCPDECLINE.

    The server asks this of every lease a hook returns, so an
    `acquire_lease` override does not have to. `now` is `time.monotonic()`
    seconds; omitted, the driver's reading is used.
    """
    until = self._declined.get(ip)
    if until is None:
        return False
    if until > (self._read_clock().monotonic if now is None else now):
        return True
    del self._declined[ip]
    return False

lookup_lease(client_id)

The lease this server holds for client_id, in either state, or None.

What a DHCPDECLINE, a DHCPRELEASE and a REQUEST naming another server ask to learn what the sender holds. The default reads lease_backend; a server that keeps its leases elsewhere overrides this one method.

Source code in src/pydhcp/server/_policy.py
439
440
441
442
443
444
445
446
def lookup_lease(self, client_id: str) -> _ty.Optional[DHCPLease]:
    """The lease this server holds for `client_id`, in either state, or None.

    What a DHCPDECLINE, a DHCPRELEASE and a REQUEST naming another server
    ask to learn what the sender holds. The default reads `lease_backend`;
    a server that keeps its leases elsewhere overrides this one method.
    """
    return self.lease_backend.lookup(client_id)

quarantine_address(ip, *, now=None)

Stop offering ip for DECLINE_QUARANTINE_SECONDS.

RFC 2131 4.3.3: a DHCPDECLINE says the client found the address already in use, so the server MUST NOT hand it out again. Releasing the binding alone left it first in line to be offered to the next client, which would collide with whatever is really using it.

The map holds at most MAX_DECLINED_ADDRESSES. At the bound the entries that have run out are dropped first; if none has, a new address is refused and counted in quarantines_refused, because evicting the oldest would let a flood of reports push a genuine one out. An address already held is renewed for a full quarantine.

now is time.monotonic() seconds; omitted, the driver's reading is used, so an override that takes only ip keeps working.

Source code in src/pydhcp/server/_policy.py
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
def quarantine_address(
    self, ip: _ipaddress.IPv4Address, *, now: _ty.Optional[float] = None
) -> None:
    """Stop offering `ip` for `DECLINE_QUARANTINE_SECONDS`.

    RFC 2131 4.3.3: a DHCPDECLINE says the client found the address already
    in use, so the server MUST NOT hand it out again. Releasing the binding
    alone left it first in line to be offered to the next client, which
    would collide with whatever is really using it.

    The map holds at most `MAX_DECLINED_ADDRESSES`. At the bound the entries
    that have run out are dropped first; if none has, a new address is
    refused and counted in `quarantines_refused`, because evicting the
    oldest would let a flood of reports push a genuine one out. An address
    already held is renewed for a full quarantine.

    `now` is `time.monotonic()` seconds; omitted, the driver's reading is
    used, so an override that takes only `ip` keeps working.
    """
    if now is None:
        now = self._read_clock().monotonic
    if (
        ip not in self._declined
        and len(self._declined) >= self.MAX_DECLINED_ADDRESSES
    ):
        for held, until in list(self._declined.items()):
            if until <= now:
                del self._declined[held]
        if len(self._declined) >= self.MAX_DECLINED_ADDRESSES:
            self.metrics.quarantines_refused += 1
            self._log_limit.log(
                LOGGER,
                _logging.WARNING,
                "quarantine full",
                "The quarantine holds %d addresses; not adding %s",
                len(self._declined),
                ip,
                now=now,
            )
            return
    self._declined[ip] = now + self.DECLINE_QUARANTINE_SECONDS

release_lease(client_id, server_id, msg)

Release any lease associated with client_id; True if one went away.

Override this method when lease release needs to update an external store or emit custom audit records. Quarantining a declined address is handled by quarantine_address, which handle_decline calls before this.

Counting is the caller's job, not this method's, because only the caller knows why: an orderly DHCPRELEASE, a DHCPDECLINE reporting an address conflict, and a reclaim after the client chose another server are three different events that all arrive here.

Source code in src/pydhcp/server/_policy.py
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
def release_lease(
    self, client_id: str, server_id: _ipaddress.IPv4Address, msg: DHCPMessage
) -> bool:
    """Release any lease associated with `client_id`; True if one went away.

    Override this method when lease release needs to update an external store
    or emit custom audit records. Quarantining a declined address is handled
    by `quarantine_address`, which `handle_decline` calls before this.

    Counting is the *caller's* job, not this method's, because only the
    caller knows why: an orderly DHCPRELEASE, a DHCPDECLINE reporting an
    address conflict, and a reclaim after the client chose another server
    are three different events that all arrive here.
    """
    return self.lease_backend.release(client_id)

serve_forever() async

Bind, then receive in the calling task until shutdown().

Raises what bind() raised, and RuntimeError when already serving or closed. The sockets stay open on return: aclose() releases them.

Source code in src/pydhcp/listener/_asyncio.py
231
232
233
234
235
236
237
238
239
240
241
242
243
async def serve_forever(self) -> None:
    """Bind, then receive in the calling task until `shutdown()`.

    Raises what `bind()` raised, and `RuntimeError` when already serving or
    closed. The sockets stay open on return: `aclose()` releases them.
    """
    loop = self._claim()
    try:
        self._begin_receiving(loop)
    except BaseException:
        self._abort_claim()
        raise
    await self._serve()

shutdown()

Ask serving to end. Returns at once, from any thread and from a handler.

Nothing is closed here: the receive tasks end on the event loop's next turn and aclose() releases the sockets. A no-op when nothing serves.

Source code in src/pydhcp/listener/_asyncio.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
def shutdown(self) -> None:
    """Ask serving to end. Returns at once, from any thread and from a handler.

    Nothing is closed here: the receive tasks end on the event loop's next
    turn and `aclose()` releases the sockets. A no-op when nothing serves.
    """
    loop, stopped = self._loop, self._stopped
    if not self._serving or loop is None or stopped is None:
        return
    try:
        # `Event.set` finishes through `loop.call_soon`, which queues a
        # callback without waking a selector loop from another thread.
        loop.call_soon_threadsafe(stopped.set)
    except RuntimeError:  # pragma: no cover - the loop closed since
        pass

start() async

Bind, then receive in background tasks, and return once receiving.

Raises what bind() raised, and RuntimeError when already serving or closed; nothing is left open or running in either case.

Source code in src/pydhcp/listener/_asyncio.py
217
218
219
220
221
222
223
224
225
226
227
228
229
async def start(self) -> None:
    """Bind, then receive in background tasks, and return once receiving.

    Raises what `bind()` raised, and `RuntimeError` when already serving or
    closed; nothing is left open or running in either case.
    """
    loop = self._claim()
    try:
        self._begin_receiving(loop)
    except BaseException:
        self._abort_claim()
        raise
    self._serving_task = loop.create_task(self._serve())

wait_closed(timeout=None) async

Wait until serving has ended; False if timeout seconds passed first.

Source code in src/pydhcp/listener/_asyncio.py
337
338
339
340
341
342
343
344
345
346
347
348
async def wait_closed(self, timeout: _ty.Optional[float] = None) -> bool:
    """Wait until serving has ended; `False` if `timeout` seconds passed first."""
    ended = self._serve_ended
    if ended is not None and not ended.done():
        try:
            await _asyncio.wait_for(_asyncio.shield(ended), timeout)
        except _asyncio.TimeoutError:
            return False
    task, self._serving_task = self._serving_task, None
    if task is not None and not task.done():
        await task
    return True

AsyncDHCPRelay

pydhcp.relay.AsyncDHCPRelay

Bases: _RelayCore, AsyncDHCPListener

The relay on an event loop.

The same rules as DHCPRelay. The pending-client table is unguarded, as there: what keeps it safe is that AsyncDHCPListener runs handlers on a single worker thread, so handle() is serialised and in arrival order.

Source code in src/pydhcp/relay/_asyncio.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class AsyncDHCPRelay(_RelayCore, AsyncDHCPListener):
    """The relay on an event loop.

    The same rules as `DHCPRelay`. The pending-client table is unguarded, as
    there: what keeps it safe is that `AsyncDHCPListener` runs handlers on a
    single worker thread, so `handle()` is serialised and in arrival order.
    """

    def __init__(
        self,
        listen: ListenLike = None,
        server_addresses: _ty.Sequence[ServerAddressLike] = (),
        *,
        max_hops: int = DEFAULT_MAX_HOPS,
        insert_relay_agent_info: bool = False,
        circuit_id: _ty.Optional[bytes] = None,
        remote_id: _ty.Optional[bytes] = None,
        trust_client_relay_agent_info: bool = False,
        max_packet_size: _ty.Optional[int] = None,
        per_interface: _ty.Optional[bool] = None,
        reuse_address: _ty.Optional[bool] = None,
        receive_buffer_size: _ty.Optional[int] = None,
        max_queued: _ty.Optional[int] = None,
    ) -> None:
        AsyncDHCPListener.__init__(
            self,
            listen=listen,
            max_packet_size=max_packet_size,
            per_interface=per_interface,
            reuse_address=reuse_address,
            receive_buffer_size=receive_buffer_size,
            max_queued=max_queued,
        )
        self._init_relay_state(
            server_addresses,
            max_hops=max_hops,
            insert_relay_agent_info=insert_relay_agent_info,
            circuit_id=circuit_id,
            remote_id=remote_id,
            trust_client_relay_agent_info=trust_client_relay_agent_info,
        )

DHCPListener

pydhcp.listener.DHCPListener

Bases: _ListenerCore

Receives DHCP datagrams on a thread and hands each to handle().

serve_forever() receives on the calling thread, start() on a daemon thread. shutdown() ends the receive loop and never blocks, so a handler may call it; wait_closed() blocks until the loop has ended; close() is both, then releases the sockets, and is final. The library installs no signal handler: Ctrl-C reaches serve_forever() as KeyboardInterrupt.

Source code in src/pydhcp/listener/_sync.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
class DHCPListener(_ListenerCore):
    """Receives DHCP datagrams on a thread and hands each to `handle()`.

    `serve_forever()` receives on the calling thread, `start()` on a daemon
    thread. `shutdown()` ends the receive loop and never blocks, so a handler
    may call it; `wait_closed()` blocks until the loop has ended; `close()` is
    both, then releases the sockets, and is final. The library installs no
    signal handler: Ctrl-C reaches `serve_forever()` as `KeyboardInterrupt`.
    """

    def __init__(
        self,
        listen: ListenLike = None,
        *,
        poll_interval: _ty.Optional[float] = None,
        max_packet_size: _ty.Optional[int] = None,
        per_interface: _ty.Optional[bool] = None,
        reuse_address: _ty.Optional[bool] = None,
        receive_buffer_size: _ty.Optional[int] = None,
    ) -> None:
        super().__init__(
            listen=listen,
            max_packet_size=max_packet_size,
            per_interface=per_interface,
            reuse_address=reuse_address,
            receive_buffer_size=receive_buffer_size,
        )
        #: Seconds between looks at the shutdown flag when no datagram arrives:
        #: Windows delivers a `KeyboardInterrupt` to the main thread between
        #: waits, not during one.
        self._poll_interval = poll_interval or 1
        self._state_lock = _thread.Lock()
        self._close_lock = _thread.Lock()
        self._serving = False
        #: Who made the claim that has not been released; each attempt holds its
        #: own object, so one that is refused never releases another's.
        self._claim_token: _ty.Optional[object] = None
        self._stopping = False
        self._idle = _thread.Event()
        self._idle.set()
        self._serving_ident: _ty.Optional[int] = None
        self._receive_thread: _ty.Optional[_thread.Thread] = None
        self._wake_read: _ty.Optional[_socket.socket] = None
        self._wake_write: _ty.Optional[_socket.socket] = None

    # -- lifecycle ---------------------------------------------------------

    def _claim(self, token: object) -> None:
        """Bind, then mark this caller as the one that serves."""
        self.bind()
        with self._state_lock:
            if self._closed:
                raise RuntimeError(f"{type(self).__name__} is closed")
            if self._serving:
                raise RuntimeError(f"{type(self).__name__} is already serving")
            self._claim_token = token
            # Held by the listener from the statement that makes them, so a
            # release after an interrupt anywhere below closes them.
            self._wake_read, self._wake_write = _socket.socketpair()
            self._wake_read.setblocking(False)
            self._wake_write.setblocking(False)
            self._serving = True
            self._stopping = False
            self._idle.clear()

    def serve_forever(self) -> None:
        """Bind, then receive on the calling thread until `shutdown()`.

        Raises what `bind()` raised, and `RuntimeError` when already serving or
        closed. The sockets stay open on return: `close()` releases them.
        """
        token = object()
        try:
            self._claim(token)
            self._serve()
        finally:
            self._release(token)

    def start(self) -> None:
        """Bind, then receive on a new daemon thread.

        Binding happens here, on the caller's thread, so an address that cannot
        be bound raises what `bind()` raised and leaves the listener unstarted
        and nothing bound. Raises `RuntimeError` when already serving or closed.
        """
        token = object()
        thread: _ty.Optional[_thread.Thread] = None
        try:
            self._claim(token)
            # Daemon: nothing in the loop ends on its own, so a non-daemon thread
            # keeps the interpreter alive after `main` returns.
            thread = _thread.Thread(
                target=self._serve, name="pydhcp-listener", daemon=True
            )
            self._receive_thread = thread
            thread.start()
        except BaseException:
            # A thread that began owns the release; one that did not leaves it here.
            if thread is None or thread.ident is None:
                self._release(token)
            raise

    def shutdown(self) -> None:
        """Ask the receive loop to end. Returns at once, from any thread and from a handler."""
        with self._state_lock:
            if not self._serving:
                return
            self._stopping = True
        self._wake()

    def wait_closed(self, timeout: _ty.Optional[float] = None) -> bool:
        """Block until the receive loop has ended; `False` if `timeout` seconds passed first.

        Raises `RuntimeError` on the receive thread, which would wait for itself.
        """
        if self._serving_ident == _thread.get_ident():
            raise RuntimeError(
                "wait_closed() on the receive thread would wait for itself"
            )
        began = _time.monotonic()
        if not self._idle.wait(timeout):
            return False
        thread = self._receive_thread
        if thread is not None and thread is not _thread.current_thread():
            thread.join(
                None
                if timeout is None
                else max(0.0, timeout - (_time.monotonic() - began))
            )
            if thread.is_alive():
                return False
        return True

    def close(self) -> None:
        """Shut down, wait for the receive loop, then release every socket. Final.

        Called on the receive thread itself (from a handler) it shuts down and
        returns: the loop releases the sockets as it ends. Repeatable.
        """
        if self._serving_ident == _thread.get_ident():
            with self._state_lock:
                self._closed = True
            self.shutdown()
            return
        with self._close_lock:
            with self._state_lock:
                self._closed = True
            self.shutdown()
            if not self.wait_closed(_CLOSE_WAIT):
                LOGGER.warning(
                    f"The receive loop did not end within {_CLOSE_WAIT} s of close(); "
                    "releasing the sockets under it."
                )
            self._close_sockets()

    def __enter__(self) -> "DHCPListener":
        self.bind()
        return self

    def __exit__(self, *_exc: _ty.Any) -> None:
        self.close()

    def _wake(self) -> None:
        """End a wait for datagrams from any thread."""
        wake = self._wake_write
        if wake is not None:
            try:
                wake.send(b"\0")
            except OSError:  # pragma: no cover - full or closed: already woken
                pass

    def _release(self, token: object) -> None:
        """Undo the claim made with `token`, if it is still held; otherwise nothing."""
        if self._claim_token is token:
            self._finish_serving()

    def _finish_serving(self) -> None:
        """Release what serving held and let `wait_closed()` return."""
        for sock in (self._wake_read, self._wake_write):
            if sock is not None:
                sock.close()
        self._wake_read = self._wake_write = None
        if self._closed:
            self._close_sockets()
        with self._state_lock:
            self._serving_ident = None
            self._serving = False
            self._claim_token = None
        self._idle.set()

    def _serve(self) -> None:
        self._serving_ident = _thread.get_ident()
        try:
            self._loop()
        finally:
            self._finish_serving()

    def _loop(self) -> None:
        wake = self._wake_read
        assert wake is not None
        while not self._stopping:
            try:
                rlist, _, _ = _select.select(
                    [*self._sockets, wake], [], [], self._poll_interval
                )
            except (OSError, ValueError):
                if self._stopping:  # a close() that gave up waiting
                    break
                raise
            for sock in rlist:
                if sock is wake:
                    self._drain_wake(wake)
                    continue
                if self._stopping:
                    # A handler can shut the listener down, and the rest of the ready
                    # set must not be handled after that.
                    break
                self._receive_one(sock)

    @staticmethod
    def _drain_wake(wake: _socket.socket) -> None:
        try:
            while wake.recv(64):
                pass
        except OSError:
            pass

    def _receive_one(self, sock: _socket.socket) -> None:
        """Receive, decode and dispatch exactly one datagram.

        Split into three steps because they fail for three unrelated reasons and
        need three different reports: a malformed packet from the segment
        (routine, the peer's doing), a socket error (ours), and a bug inside a
        `handle()` override, which keeps its traceback.
        """
        try:
            # One octet over the limit, so a datagram that does not fit can be
            # told apart from one that exactly fills it. netimps reports the
            # cut on every platform, Windows included, as `truncated`.
            endpoint = self._endpoints.get(sock)
            if endpoint is None:  # pragma: no cover - not bound through bind()
                endpoint = self._endpoints[sock] = _netimps.UDPEndpoint(
                    sock, pktinfo=False
                )
            arrival = _arrival(
                endpoint.recv(self._max_packet_size + 1),
                self._max_packet_size,
                self._control_truncated,
            )
        except _TruncatedDatagram as e:
            self._note_truncated(e)
            return
        except OSError as e:
            self._note_receive_error(self._describe(sock), e)
            return

        if self._admits(arrival, sock):
            self._dispatch_arrival(arrival, sock)

    @staticmethod
    def _describe(sock: _socket.socket) -> str:
        try:
            return str(_net.SocketAddress.from_socket(sock))
        except OSError:  # pragma: no cover - closed underneath us
            return "a closed socket"

close()

Shut down, wait for the receive loop, then release every socket. Final.

Called on the receive thread itself (from a handler) it shuts down and returns: the loop releases the sockets as it ends. Repeatable.

Source code in src/pydhcp/listener/_sync.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def close(self) -> None:
    """Shut down, wait for the receive loop, then release every socket. Final.

    Called on the receive thread itself (from a handler) it shuts down and
    returns: the loop releases the sockets as it ends. Repeatable.
    """
    if self._serving_ident == _thread.get_ident():
        with self._state_lock:
            self._closed = True
        self.shutdown()
        return
    with self._close_lock:
        with self._state_lock:
            self._closed = True
        self.shutdown()
        if not self.wait_closed(_CLOSE_WAIT):
            LOGGER.warning(
                f"The receive loop did not end within {_CLOSE_WAIT} s of close(); "
                "releasing the sockets under it."
            )
        self._close_sockets()

serve_forever()

Bind, then receive on the calling thread until shutdown().

Raises what bind() raised, and RuntimeError when already serving or closed. The sockets stay open on return: close() releases them.

Source code in src/pydhcp/listener/_sync.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def serve_forever(self) -> None:
    """Bind, then receive on the calling thread until `shutdown()`.

    Raises what `bind()` raised, and `RuntimeError` when already serving or
    closed. The sockets stay open on return: `close()` releases them.
    """
    token = object()
    try:
        self._claim(token)
        self._serve()
    finally:
        self._release(token)

shutdown()

Ask the receive loop to end. Returns at once, from any thread and from a handler.

Source code in src/pydhcp/listener/_sync.py
128
129
130
131
132
133
134
def shutdown(self) -> None:
    """Ask the receive loop to end. Returns at once, from any thread and from a handler."""
    with self._state_lock:
        if not self._serving:
            return
        self._stopping = True
    self._wake()

start()

Bind, then receive on a new daemon thread.

Binding happens here, on the caller's thread, so an address that cannot be bound raises what bind() raised and leaves the listener unstarted and nothing bound. Raises RuntimeError when already serving or closed.

Source code in src/pydhcp/listener/_sync.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def start(self) -> None:
    """Bind, then receive on a new daemon thread.

    Binding happens here, on the caller's thread, so an address that cannot
    be bound raises what `bind()` raised and leaves the listener unstarted
    and nothing bound. Raises `RuntimeError` when already serving or closed.
    """
    token = object()
    thread: _ty.Optional[_thread.Thread] = None
    try:
        self._claim(token)
        # Daemon: nothing in the loop ends on its own, so a non-daemon thread
        # keeps the interpreter alive after `main` returns.
        thread = _thread.Thread(
            target=self._serve, name="pydhcp-listener", daemon=True
        )
        self._receive_thread = thread
        thread.start()
    except BaseException:
        # A thread that began owns the release; one that did not leaves it here.
        if thread is None or thread.ident is None:
            self._release(token)
        raise

wait_closed(timeout=None)

Block until the receive loop has ended; False if timeout seconds passed first.

Raises RuntimeError on the receive thread, which would wait for itself.

Source code in src/pydhcp/listener/_sync.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def wait_closed(self, timeout: _ty.Optional[float] = None) -> bool:
    """Block until the receive loop has ended; `False` if `timeout` seconds passed first.

    Raises `RuntimeError` on the receive thread, which would wait for itself.
    """
    if self._serving_ident == _thread.get_ident():
        raise RuntimeError(
            "wait_closed() on the receive thread would wait for itself"
        )
    began = _time.monotonic()
    if not self._idle.wait(timeout):
        return False
    thread = self._receive_thread
    if thread is not None and thread is not _thread.current_thread():
        thread.join(
            None
            if timeout is None
            else max(0.0, timeout - (_time.monotonic() - began))
        )
        if thread.is_alive():
            return False
    return True

AsyncDHCPListener

pydhcp.listener.AsyncDHCPListener

Bases: _ListenerCore

The listener on an event loop: the receive path of DHCPListener, one task per socket.

handle() runs on a single worker thread, in arrival order, so it may block; at most max_queued datagrams wait for it and the next is dropped and counted. Drive it with await serve_forever() or await start(), then shutdown(), await wait_closed() and await aclose(); async with binds on entry.

Source code in src/pydhcp/listener/_asyncio.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
class AsyncDHCPListener(_ListenerCore):
    """The listener on an event loop: the receive path of `DHCPListener`, one task per socket.

    `handle()` runs on a single worker thread, in arrival order, so it may block;
    at most `max_queued` datagrams wait for it and the next is dropped and counted.
    Drive it with `await serve_forever()` or `await start()`, then `shutdown()`,
    `await wait_closed()` and `await aclose()`; `async with` binds on entry.
    """

    _BIND_LABEL = "async"

    #: Datagrams that may wait for, or be in, the handler at once when
    #: `max_queued` is not given: about 3 MiB of queued 300-octet
    #: datagrams. The datagram that finds the backlog at the bound is
    #: dropped.
    MAX_QUEUED_DATAGRAMS: int = 1024

    #: Whether `handle()` runs on the one worker thread (a handler may block) or
    #: on the event loop, where it must not. A role whose handler only moves a
    #: reply into a loop-owned queue sets this to false and has no worker thread.
    _HANDLE_ON_WORKER: bool = True

    #: Seconds between reports of dropped datagrams. A flood reaches the
    #: bound once per datagram, so logging each hands the sender the log.
    BACKLOG_LOG_INTERVAL_SECONDS: float = 60.0

    def __init__(
        self,
        listen: ListenLike = None,
        *,
        max_packet_size: _ty.Optional[int] = None,
        per_interface: _ty.Optional[bool] = None,
        reuse_address: _ty.Optional[bool] = None,
        receive_buffer_size: _ty.Optional[int] = None,
        max_queued: _ty.Optional[int] = None,
    ) -> None:
        _ListenerCore.__init__(
            self,
            listen=listen,
            max_packet_size=max_packet_size,
            per_interface=per_interface,
            reuse_address=reuse_address,
            receive_buffer_size=receive_buffer_size,
        )
        if max_queued is None:
            max_queued = self.MAX_QUEUED_DATAGRAMS
        if isinstance(max_queued, bool) or not isinstance(max_queued, int):
            raise ValueError(
                f"max_queued must be a positive integer, not {max_queued!r}"
            )
        if max_queued < 1:
            raise ValueError(
                f"max_queued must be a positive integer, not {max_queued!r}"
            )
        self._max_queued = max_queued
        #: Datagrams handed to the worker and not yet finished, the one
        #: running included; guarded, as the worker thread finishes them.
        self._pending = 0
        self._pending_lock = _thread.Lock()
        self._last_backlog_report = float("-inf")
        #: Set while the worker is aborted: a queued datagram is discarded.
        self._closing = False
        #: One receive task per socket; see `_receive`.
        self._tasks: "list[_asyncio.Task[None]]" = []
        self._loop: _ty.Optional[_asyncio.AbstractEventLoop] = None
        self._stopped: _ty.Optional[_asyncio.Event] = None
        self._serve_ended: "_ty.Optional[_asyncio.Future[None]]" = None
        self._serving = False
        self._serving_task: "_ty.Optional[_asyncio.Task[None]]" = None
        self._close_task: "_ty.Optional[_asyncio.Future[None]]" = None
        self._worker: _ty.Optional[_futures.ThreadPoolExecutor] = None

    async def _receive(
        self, sock: _socket.socket, endpoint: _netimps.UDPEndpoint
    ) -> None:
        """Receive from one socket until cancelled, on the event loop.

        Only the read happens here; the handler runs on the worker. netimps'
        `arecv` keeps packet info on every loop type, Windows' default proactor
        included -- which `add_reader` (absent there) and a `DatagramTransport`
        (no slot for control messages) cannot, so every loop type learns which
        interface a broadcast arrived on.

        An `arecv` loop rather than `datagrams()`: an async iterator ends at its
        first exception, and a server has to survive a datagram it cannot read.
        """
        while True:
            try:
                arrival = _arrival(
                    await endpoint.arecv(self._max_packet_size + 1),
                    self._max_packet_size,
                    self._control_truncated,
                )
            except (BlockingIOError, InterruptedError):  # spurious readability
                continue
            except RuntimeError:
                # netimps ends a wait on a closed endpoint this way. Closing is
                # how a socket that is no longer listened on is retired.
                if sock.fileno() == -1:
                    return
                raise
            except _TruncatedDatagram as e:
                self._note_truncated(e)
                continue
            except OSError as e:
                if sock.fileno() == -1:
                    return  # closed underneath us; nothing more will arrive
                self._note_receive_error("an async socket", e)
                continue
            if self._admits(arrival, sock):
                self._dispatch_received(arrival, sock)

    def _dispatch_received(self, arrival: _Arrival, sock: _socket.socket) -> None:
        """Run one datagram's handling off the event loop.

        Exactly one worker thread, so handlers still run one at a time and in
        arrival order. That matters: the lease backends are not thread-safe, so a
        pool here would trade a blocked event loop for a data race.

        At most `max_queued` datagrams wait for or are in the handler. The one
        that finds the backlog full is dropped, counted in
        `metrics.packets_dropped_backlog` and reported at most once per
        `BACKLOG_LOG_INTERVAL_SECONDS`.
        """
        worker = self._worker
        if worker is None:  # on the loop: not started, or `_HANDLE_ON_WORKER` is off
            self._handle_datagram(arrival, sock)
            return
        with self._pending_lock:
            full = self._pending >= self._max_queued
            if not full:
                self._pending += 1
        if full:
            self.metrics.packets_dropped_backlog += 1
            self._report_backlog()
            return
        try:
            future = worker.submit(self._handle_datagram, arrival, sock)
        except RuntimeError:  # the worker was shut down since it was read
            self._finished()
            return
        future.add_done_callback(self._report_worker_result)

    def _finished(self) -> None:
        with self._pending_lock:
            self._pending -= 1

    def _report_backlog(self) -> None:
        now = _time.monotonic()
        if now - self._last_backlog_report < self.BACKLOG_LOG_INTERVAL_SECONDS:
            return
        self._last_backlog_report = now
        LOGGER.warning(
            f"The handler backlog is full ({self._max_queued} datagrams, max_queued): "
            f"newly arriving datagrams are dropped. "
            f"{self.metrics.packets_dropped_backlog} dropped so far."
        )

    def _report_worker_result(self, future: "_futures.Future[None]") -> None:
        self._finished()
        if future.cancelled():  # discarded at shutdown, still queued
            self.metrics.packets_dropped_backlog += 1
            return
        error = future.exception()
        if error is not None:  # pragma: no cover - _handle_datagram catches
            LOGGER.error(
                f"Unhandled error in async handler: "
                f"{error.__class__.__name__} | {error}"
            )

    def _handle_datagram(self, arrival: _Arrival, sock: _socket.socket) -> None:
        if self._closing:  # shut down after this was queued
            self.metrics.packets_dropped_backlog += 1
            return
        self._dispatch_arrival(arrival, sock)

    # -- lifecycle ---------------------------------------------------------

    def _claim(self) -> "_asyncio.AbstractEventLoop":
        """Bind, then mark this task as the one that serves."""
        self.bind()
        if self._serving:
            raise RuntimeError(f"{type(self).__name__} is already serving")
        loop = _asyncio.get_running_loop()
        if self._worker is None and self._HANDLE_ON_WORKER:
            self._worker = _futures.ThreadPoolExecutor(
                max_workers=1, thread_name_prefix="pydhcp-async-handler"
            )
        self._loop = loop
        self._closing = False
        self._stopped = _asyncio.Event()
        self._serve_ended = loop.create_future()
        self._serving = True
        return loop

    async def start(self) -> None:
        """Bind, then receive in background tasks, and return once receiving.

        Raises what `bind()` raised, and `RuntimeError` when already serving or
        closed; nothing is left open or running in either case.
        """
        loop = self._claim()
        try:
            self._begin_receiving(loop)
        except BaseException:
            self._abort_claim()
            raise
        self._serving_task = loop.create_task(self._serve())

    async def serve_forever(self) -> None:
        """Bind, then receive in the calling task until `shutdown()`.

        Raises what `bind()` raised, and `RuntimeError` when already serving or
        closed. The sockets stay open on return: `aclose()` releases them.
        """
        loop = self._claim()
        try:
            self._begin_receiving(loop)
        except BaseException:
            self._abort_claim()
            raise
        await self._serve()

    def _begin_receiving(self, loop: "_asyncio.AbstractEventLoop") -> None:
        for sock in self._sockets:
            sock.setblocking(False)
            task = loop.create_task(self._receive(sock, self._endpoints[sock]))
            task.add_done_callback(self._receive_ended)
            self._tasks.append(task)

    def _abort_claim(self) -> None:
        for task in self._tasks:
            task.cancel()
        self._tasks = []
        worker, self._worker = self._worker, None
        if worker is not None:
            worker.shutdown(wait=False, cancel_futures=True)
        self._release_serving()

    def _receive_ended(self, task: "_asyncio.Task[None]") -> None:
        if task.cancelled():
            return
        error = task.exception()
        if error is not None:
            LOGGER.error(
                f"A receive task ended on an unexpected error: "
                f"{error.__class__.__name__} | {error}",
                exc_info=error,
            )

    async def _serve(self) -> None:
        stopped = self._stopped
        assert stopped is not None
        try:
            await stopped.wait()
        finally:
            await self._end_receiving()

    async def _end_receiving(self) -> None:
        """Cancel the receive tasks, abort the worker and let `wait_closed()` return.

        The tasks end *before* the sockets are closed, the order netimps
        documents as a clean shutdown: a cancelled `arecv` unregisters its
        reader, so the loop is not left polling a socket about to close.
        Datagrams still queued for the handler are discarded and counted in
        `metrics.packets_dropped_backlog`; the handler already running
        finishes, and nothing waits for it, since a handler in flight may be
        doing exactly the blocking work the worker exists to keep off the loop.
        """
        tasks, self._tasks = self._tasks, []
        for task in tasks:
            task.cancel()
        worker, self._worker = self._worker, None
        if worker is not None:
            self._closing = True
            discarded_before = self.metrics.packets_dropped_backlog
            worker.shutdown(wait=False, cancel_futures=True)
            discarded = self.metrics.packets_dropped_backlog - discarded_before
            if discarded:
                LOGGER.info(
                    f"Stopped with {discarded} datagrams still queued; "
                    f"discarded {discarded} unhandled."
                )
        try:
            await _asyncio.gather(*tasks, return_exceptions=True)
        finally:
            self._release_serving()

    def _release_serving(self) -> None:
        ended = self._serve_ended
        self._serving = False
        self._loop = None
        self._stopped = None
        self._serve_ended = None
        if self._closed:
            self._close_sockets()
        if ended is not None and not ended.done():
            ended.set_result(None)

    def shutdown(self) -> None:
        """Ask serving to end. Returns at once, from any thread and from a handler.

        Nothing is closed here: the receive tasks end on the event loop's next
        turn and `aclose()` releases the sockets. A no-op when nothing serves.
        """
        loop, stopped = self._loop, self._stopped
        if not self._serving or loop is None or stopped is None:
            return
        try:
            # `Event.set` finishes through `loop.call_soon`, which queues a
            # callback without waking a selector loop from another thread.
            loop.call_soon_threadsafe(stopped.set)
        except RuntimeError:  # pragma: no cover - the loop closed since
            pass

    async def wait_closed(self, timeout: _ty.Optional[float] = None) -> bool:
        """Wait until serving has ended; `False` if `timeout` seconds passed first."""
        ended = self._serve_ended
        if ended is not None and not ended.done():
            try:
                await _asyncio.wait_for(_asyncio.shield(ended), timeout)
            except _asyncio.TimeoutError:
                return False
        task, self._serving_task = self._serving_task, None
        if task is not None and not task.done():
            await task
        return True

    async def aclose(self) -> None:
        """Shut down, wait for serving to end, then release every socket. Final and repeatable."""
        if self._close_task is None:
            self._close_task = _asyncio.ensure_future(self._close())
        await _asyncio.shield(self._close_task)

    async def _close(self) -> None:
        self._closed = True
        self.shutdown()
        try:
            await self.wait_closed()
        finally:
            self._close_sockets()

    async def __aenter__(self) -> "AsyncDHCPListener":
        self.bind()
        return self

    async def __aexit__(self, *_exc: _ty.Any) -> None:
        await self.aclose()

aclose() async

Shut down, wait for serving to end, then release every socket. Final and repeatable.

Source code in src/pydhcp/listener/_asyncio.py
350
351
352
353
354
async def aclose(self) -> None:
    """Shut down, wait for serving to end, then release every socket. Final and repeatable."""
    if self._close_task is None:
        self._close_task = _asyncio.ensure_future(self._close())
    await _asyncio.shield(self._close_task)

serve_forever() async

Bind, then receive in the calling task until shutdown().

Raises what bind() raised, and RuntimeError when already serving or closed. The sockets stay open on return: aclose() releases them.

Source code in src/pydhcp/listener/_asyncio.py
231
232
233
234
235
236
237
238
239
240
241
242
243
async def serve_forever(self) -> None:
    """Bind, then receive in the calling task until `shutdown()`.

    Raises what `bind()` raised, and `RuntimeError` when already serving or
    closed. The sockets stay open on return: `aclose()` releases them.
    """
    loop = self._claim()
    try:
        self._begin_receiving(loop)
    except BaseException:
        self._abort_claim()
        raise
    await self._serve()

shutdown()

Ask serving to end. Returns at once, from any thread and from a handler.

Nothing is closed here: the receive tasks end on the event loop's next turn and aclose() releases the sockets. A no-op when nothing serves.

Source code in src/pydhcp/listener/_asyncio.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
def shutdown(self) -> None:
    """Ask serving to end. Returns at once, from any thread and from a handler.

    Nothing is closed here: the receive tasks end on the event loop's next
    turn and `aclose()` releases the sockets. A no-op when nothing serves.
    """
    loop, stopped = self._loop, self._stopped
    if not self._serving or loop is None or stopped is None:
        return
    try:
        # `Event.set` finishes through `loop.call_soon`, which queues a
        # callback without waking a selector loop from another thread.
        loop.call_soon_threadsafe(stopped.set)
    except RuntimeError:  # pragma: no cover - the loop closed since
        pass

start() async

Bind, then receive in background tasks, and return once receiving.

Raises what bind() raised, and RuntimeError when already serving or closed; nothing is left open or running in either case.

Source code in src/pydhcp/listener/_asyncio.py
217
218
219
220
221
222
223
224
225
226
227
228
229
async def start(self) -> None:
    """Bind, then receive in background tasks, and return once receiving.

    Raises what `bind()` raised, and `RuntimeError` when already serving or
    closed; nothing is left open or running in either case.
    """
    loop = self._claim()
    try:
        self._begin_receiving(loop)
    except BaseException:
        self._abort_claim()
        raise
    self._serving_task = loop.create_task(self._serve())

wait_closed(timeout=None) async

Wait until serving has ended; False if timeout seconds passed first.

Source code in src/pydhcp/listener/_asyncio.py
337
338
339
340
341
342
343
344
345
346
347
348
async def wait_closed(self, timeout: _ty.Optional[float] = None) -> bool:
    """Wait until serving has ended; `False` if `timeout` seconds passed first."""
    ended = self._serve_ended
    if ended is not None and not ended.done():
        try:
            await _asyncio.wait_for(_asyncio.shield(ended), timeout)
        except _asyncio.TimeoutError:
            return False
    task, self._serving_task = self._serving_task, None
    if task is not None and not task.done():
        await task
    return True

DHCPMetrics

Every DHCPListener (and therefore DHCPServer, DHCPClient) owns its own metrics: DHCPMetrics instance — counters are per-instance, not global, so running multiple listeners in one process (e.g. in tests) never cross-contaminates counts. Call .snapshot() for a plain dict[str, int].

pydhcp.listener.DHCPMetrics

Per-instance counters. Every listener/server/client/relay owns one.

The field list lives in FIELDS rather than being repeated by __init__, reset and snapshot: three copies of the same names is how a counter ends up incremented but absent from a snapshot, or reset everywhere but one.

Source code in src/pydhcp/_metrics.py
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
class DHCPMetrics:
    """Per-instance counters. Every listener/server/client/relay owns one.

    The field list lives in `FIELDS` rather than being repeated by `__init__`,
    `reset` and `snapshot`: three copies of the same names is how a counter ends
    up incremented but absent from a snapshot, or reset everywhere but one.
    """

    #: Every counter, in the order `snapshot()` reports them.
    FIELDS: _ty.ClassVar[_ty.Tuple[str, ...]] = (
        "packets_received",
        "packets_sent",
        "leases_offered",
        "leases_allocated",
        "leases_renewed",
        "leases_released",
        "leases_declined",
        "declines_ignored",
        "quarantines_refused",
        "offers_withdrawn",
        "releases_ignored",
        "informs_ignored",
        "relay_info_omitted",
        "packets_dropped_hop_limit",
        "packets_dropped_untrusted",
        "packets_dropped_truncated",
        "packets_dropped_error",
        "replies_dropped_overflow",
        "packets_dropped_backlog",
        "packets_decoded_leniently",
        "packets_dropped_no_client_id",
        "packets_dropped_other_server",
        "packets_dropped_malformed_option",
        "options_ignored_malformed",
        "addresses_refused",
        "replies_dropped_pin",
        "packets_dropped_other_interface",
        "packets_dropped_relay_loop",
        "packets_dropped_unknown_giaddr",
        "packets_dropped_reused_transaction",
    )

    packets_received: int
    packets_sent: int
    #: Addresses held for a client by a DHCPOFFER; none of them is a lease
    #: until the client's REQUEST commits it.
    leases_offered: int
    #: Leases the server committed: an offer the client accepted, or an
    #: address allocated and committed in one step.
    leases_allocated: int
    leases_renewed: int
    leases_released: int
    #: DHCPDECLINEs. Counted apart from releases because they mean the
    #: opposite: the client found the address already in use, which is an
    #: address-conflict signal an operator needs to see, and folding it into
    #: `leases_released` made a conflict storm look like orderly shutdowns.
    leases_declined: int
    #: DHCPDECLINEs that quarantined nothing: the sender holds no lease for
    #: the address named, it is outside the served network, or the message
    #: names another server.
    declines_ignored: int
    #: Addresses the quarantine refused to take because it was full.
    quarantines_refused: int
    #: Offers dropped because the client's REQUEST chose another server.
    offers_withdrawn: int
    #: DHCPRELEASEs refused because the address named did not match the stored
    #: binding. Visible so "nobody is releasing" reads differently from
    #: "somebody is releasing addresses they do not hold".
    releases_ignored: int
    #: DHCPINFORMs not answered because `ciaddr` was neither the sender's
    #: address nor in the served network: the ACK goes to `ciaddr`, so
    #: answering would send a reply to an address the sender chose.
    informs_ignored: int
    #: Messages sent without the relay agent information option because
    #: carrying it would have needed the overloaded `sname` or `file`
    #: field (RFC 3046 s2.1 and s2.2 forbid the option there): a reply the
    #: server echoed it into, or a request the relay would have added it to.
    relay_info_omitted: int
    packets_dropped_hop_limit: int
    packets_dropped_untrusted: int
    #: Datagrams dropped before decoding because they did not fit
    #: `max_packet_size`. A sender that keeps exceeding it is a
    #: misconfiguration an operator can act on, which is why it is counted
    #: rather than only logged.
    packets_dropped_truncated: int
    #: Datagrams lost to an error anywhere in receive, decode or handle.
    packets_dropped_error: int
    #: Replies discarded because the client's reply queue was full.
    replies_dropped_overflow: int
    #: Datagrams the async listener's hand-off dropped unhandled: the
    #: backlog behind the handler was at its bound, or the listener
    #: was stopping and discarded what was queued.
    packets_dropped_backlog: int
    #: Datagrams that decoded because the decoder forgave something: an option
    #: cut short (what arrived is kept) or text that is not UTF-8 (its octets
    #: are kept). One per datagram, however many places in it. Option text is
    #: read when a handler asks for it, so a bad option string is not counted.
    packets_decoded_leniently: int
    #: Messages a server dropped because they carry neither a client
    #: identifier nor a hardware address.
    packets_dropped_no_client_id: int
    #: Messages other than a REQUEST that name another server's identifier.
    packets_dropped_other_server: int
    #: Messages dropped because option 50 or 54 could not be read (wrong length).
    packets_dropped_malformed_option: int
    #: Options 51 or 57 of the wrong length, treated as absent.
    options_ignored_malformed: int
    #: Requested addresses the server refused to lease (off the served
    #: network, its own, in use, quarantined).
    addresses_refused: int
    #: Broadcast replies dropped because they could not be pinned to the
    #: interface the request arrived on, with or without its index. Sent
    #: unpinned they could have left by another interface.
    replies_dropped_pin: int
    #: Datagrams dropped before decoding because they arrived on an interface
    #: other than the one(s) the listener was told to serve.
    packets_dropped_other_interface: int
    #: Requests a relay dropped because their `giaddr` is one of its own
    #: addresses (RFC 3046 s2.1.1): forwarded, they would loop.
    packets_dropped_relay_loop: int
    #: Replies a relay dropped because their `giaddr` is not one of its own
    #: addresses (RFC 1542 s4.1.2).
    packets_dropped_unknown_giaddr: int
    #: Requests a relay dropped because they reuse the transaction of a
    #: pending request from another source address.
    packets_dropped_reused_transaction: int

    def __init__(self) -> None:
        self.reset()

    def reset(self) -> None:
        """Set every counter to zero."""
        for field in self.FIELDS:
            setattr(self, field, 0)

    def snapshot(self) -> _ty.Dict[str, int]:
        """A plain dict of every counter's name and value, in `FIELDS` order."""
        return {field: int(getattr(self, field)) for field in self.FIELDS}

reset()

Set every counter to zero.

Source code in src/pydhcp/_metrics.py
136
137
138
139
def reset(self) -> None:
    """Set every counter to zero."""
    for field in self.FIELDS:
        setattr(self, field, 0)

snapshot()

A plain dict of every counter's name and value, in FIELDS order.

Source code in src/pydhcp/_metrics.py
141
142
143
def snapshot(self) -> _ty.Dict[str, int]:
    """A plain dict of every counter's name and value, in `FIELDS` order."""
    return {field: int(getattr(self, field)) for field in self.FIELDS}

pydhcp.client

pydhcp.client

The DHCPv4 client: DHCPClient on threads and AsyncDHCPClient on an event loop.

AsyncDHCPClient

Bases: _ClientCore, AsyncDHCPListener

DHCPClient on an event loop: the same messages, matching and backoff.

discover_offer, dora and send are coroutines taking the same keywords as the synchronous client's. The receive tasks must be running (start() or serve_forever()) for an exchange to hear a reply. handle() and on_reply() run on the event loop, not on a worker thread, so on_reply must not block. Any number of exchanges may be pending at once, each on its own transaction and client.

Source code in src/pydhcp/client/_asyncio.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
class AsyncDHCPClient(_ClientCore, AsyncDHCPListener):
    """`DHCPClient` on an event loop: the same messages, matching and backoff.

    `discover_offer`, `dora` and `send` are coroutines taking the same keywords
    as the synchronous client's. The receive tasks must be running (`start()` or
    `serve_forever()`) for an exchange to hear a reply. `handle()` and
    `on_reply()` run on the event loop, not on a worker thread, so `on_reply`
    must not block. Any number of exchanges may be pending at once, each on its
    own transaction and client.
    """

    #: A reply is only moved into a loop-owned queue, so it is handled where it
    #: arrives and no worker thread exists.
    _HANDLE_ON_WORKER = False

    def __init__(
        self,
        listen: ListenLike = None,
        *,
        max_packet_size: _ty.Optional[int] = None,
        per_interface: _ty.Optional[bool] = None,
        reuse_address: _ty.Optional[bool] = None,
        receive_buffer_size: _ty.Optional[int] = None,
    ) -> None:
        AsyncDHCPListener.__init__(
            self,
            listen=listen,
            max_packet_size=max_packet_size,
            per_interface=per_interface,
            reuse_address=reuse_address,
            receive_buffer_size=receive_buffer_size,
        )
        self._init_client_state()
        #: Created on first use, on the loop that serves: before Python 3.10 a
        #: queue binds to the current loop when it is constructed.
        self._replies: _ty.Optional[_asyncio.Queue[Reply]] = None
        #: Exchange key -> the queue the task running that exchange waits on.
        self._waiters: dict[tuple[int, bytes], _asyncio.Queue[Reply]] = {}

    async def send(
        self,
        message: DHCPMessage,
        *,
        dst: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
    ) -> int:
        if not self._sockets:
            self.bind()
        endpoint = self._endpoints[self._sockets[0]]
        data = self._encode(message)
        sent = await endpoint.asend(
            bytes(data),
            _dest_string(_ipaddress.IPv4Address(dst)),
            int(port),
        )
        self._note_sent()
        return int(sent)

    def _monotonic(self) -> float:
        """Monotonic clock, as one override point for every timing decision."""
        return _time.monotonic()

    async def _exchange(
        self,
        message: DHCPMessage,
        msg_type: _enum.DHCPMessageType,
        *,
        destination: IPv4AddressLike,
        port: int,
        timeout: float,
        retries: int,
        started_at: float,
        server: _ty.Optional[_ipaddress.IPv4Address] = None,
        ends: _ty.Optional[float] = None,
    ) -> DHCPMessage:
        """Send `message`, retransmitting on RFC 2131 s4.1 backoff, until `msg_type`.

        See `DHCPClient._exchange`. The key is accepted before the first send,
        because a reply can be handled on the loop turn between the datagram
        leaving and this coroutine resuming.
        """
        key = self._pending_key(message)
        try:
            with self._waiting_for(key) as waiter:
                self._pending_keys.add(key)
                attempts = 0
                for interval in self._retransmit_intervals(timeout, retries):
                    now = self._monotonic()
                    last = False
                    if ends is not None:
                        if ends - now <= 0:
                            break
                        # The deadline falls inside this wait, so it is the last:
                        # a timer may fire a clock tick early, and what the clock
                        # then says must not buy another send.
                        last = ends - now <= interval
                        interval = min(interval, ends - now)
                    self._stamp_secs(message, now - started_at)
                    attempts += 1
                    await self.send(message, dst=destination, port=port)
                    reply = await self._wait_for(waiter, msg_type, interval, server)
                    if reply is not None:
                        return reply
                    if last:
                        break
                raise self._timed_out(msg_type, attempts, ends)
        finally:
            # Also on cancellation: a spent transaction does not stay acceptable.
            self._pending_keys.discard(key)

    @_contextlib.contextmanager
    def _waiting_for(
        self, key: tuple[int, bytes]
    ) -> _ty.Iterator[_asyncio.Queue[Reply]]:
        """Claim the replies for one exchange for the duration of the block.

        Two blocks open on the *same* key are one exchange and share the queue;
        only the outer one removes it. Nothing awaits inside, so the table
        needs no lock: it is only touched on the loop.
        """
        waiter = self._waiters.get(key)
        owned = waiter is None
        if waiter is None:
            waiter = self._waiters[key] = _asyncio.Queue(
                maxsize=self.MAX_QUEUED_REPLIES
            )
        try:
            yield waiter
        finally:
            if owned and self._waiters.get(key) is waiter:
                del self._waiters[key]

    async def _wait_for(
        self,
        waiter: _asyncio.Queue[Reply],
        msg_type: _enum.DHCPMessageType,
        timeout: float,
        server: _ty.Optional[_ipaddress.IPv4Address] = None,
    ) -> _ty.Optional[DHCPMessage]:
        """Take the first usable reply of `msg_type` from one exchange's own queue."""
        deadline = self._monotonic() + timeout
        while True:
            remaining = deadline - self._monotonic()
            if remaining <= 0:
                return None
            try:
                msg, _context = await _asyncio.wait_for(waiter.get(), remaining)
            except _asyncio.TimeoutError:
                return None
            taken = self._take(msg, msg_type, server, self._monotonic())
            if taken is not None:
                return taken

    async def discover_offer(
        self,
        chaddr: bytes,
        *,
        timeout: float = 2.0,
        retries: int = 2,
        deadline: _ty.Optional[float] = None,
        destination: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
        xid: _ty.Optional[int] = None,
        client_identifier: _ty.Optional[ClientIdentifierLike] = None,
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
        broadcast: bool = True,
    ) -> DHCPMessage:
        """Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

        `timeout`, `deadline` and what raises are as in
        `DHCPClient.discover_offer`. Cancelling the call abandons the exchange.
        """
        started_at = self._monotonic()
        return await self._discover(
            chaddr,
            timeout=timeout,
            retries=retries,
            destination=destination,
            port=port,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
            started_at=started_at,
            ends=self._deadline_end(started_at, deadline),
        )

    async def _discover(
        self,
        chaddr: bytes,
        *,
        timeout: float,
        retries: int,
        destination: IPv4AddressLike,
        port: int,
        xid: _ty.Optional[int],
        client_identifier: _ty.Optional[ClientIdentifierLike],
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]],
        broadcast: bool,
        started_at: float,
        ends: _ty.Optional[float],
    ) -> DHCPMessage:
        discover = self.build_discover(
            chaddr,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
        )
        return await self._exchange(
            discover,
            _enum.DHCPMessageType.DHCPOFFER,
            destination=destination,
            port=port,
            timeout=timeout,
            retries=retries,
            started_at=started_at,
            ends=ends,
        )

    async def dora(
        self,
        chaddr: bytes,
        *,
        timeout: float = 2.0,
        retries: int = 2,
        deadline: _ty.Optional[float] = None,
        destination: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
        xid: _ty.Optional[int] = None,
        client_identifier: _ty.Optional[ClientIdentifierLike] = None,
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
        broadcast: bool = True,
    ) -> DHCPMessage:
        """Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

        Raises what `DHCPClient.dora` raises.
        """
        # RFC 2131 s2: `secs` counts from the start of *acquisition*.
        started_at = self._monotonic()
        ends = self._deadline_end(started_at, deadline)
        offer = await self._discover(
            chaddr,
            timeout=timeout,
            retries=retries,
            destination=destination,
            port=port,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
            started_at=started_at,
            ends=ends,
        )
        server = self._selected_server(offer)
        request = self._request_after(
            offer,
            server,
            chaddr,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
        )
        return await self._exchange(
            request,
            _enum.DHCPMessageType.DHCPACK,
            destination=destination,
            port=port,
            timeout=timeout,
            retries=retries,
            started_at=started_at,
            server=server,
            ends=ends,
        )

    def _shared_replies(self) -> _asyncio.Queue[Reply]:
        if self._replies is None:
            self._replies = _asyncio.Queue(maxsize=self.MAX_QUEUED_REPLIES)
        return self._replies

    def _deliver(
        self, key: tuple[int, bytes], msg: DHCPMessage, context: DHCPRequestContext
    ) -> None:
        waiter = self._waiters.get(key)
        self._put_bounded(
            waiter if waiter is not None else self._shared_replies(), (msg, context)
        )

    def _put_bounded(self, replies: _asyncio.Queue[Reply], item: Reply) -> None:
        """Enqueue, discarding the oldest rather than growing without bound."""
        while True:
            try:
                replies.put_nowait(item)
                return
            except _asyncio.QueueFull:
                replies.get_nowait()
                self.metrics.replies_dropped_overflow += 1

    async def next_reply(
        self, timeout: _ty.Optional[float] = None
    ) -> _ty.Optional[Reply]:
        """The oldest queued reply, waiting up to `timeout` seconds; `None` if none came."""
        replies = self._shared_replies()
        try:
            return replies.get_nowait()
        except _asyncio.QueueEmpty:
            pass
        if timeout is not None and timeout <= 0:
            return None
        try:
            return await _asyncio.wait_for(replies.get(), timeout)
        except _asyncio.TimeoutError:
            return None

    def drain_replies(self) -> list[Reply]:
        replies: list[Reply] = []
        queue = self._replies
        while queue is not None:
            try:
                replies.append(queue.get_nowait())
            except _asyncio.QueueEmpty:
                break
        return replies

discover_offer(chaddr, *, timeout=2.0, retries=2, deadline=None, destination=BROADCAST_DESTINATION, port=_SERVER_PORT, xid=None, client_identifier=None, parameter_request_list=None, broadcast=True) async

Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

timeout, deadline and what raises are as in DHCPClient.discover_offer. Cancelling the call abandons the exchange.

Source code in src/pydhcp/client/_asyncio.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
async def discover_offer(
    self,
    chaddr: bytes,
    *,
    timeout: float = 2.0,
    retries: int = 2,
    deadline: _ty.Optional[float] = None,
    destination: IPv4AddressLike = BROADCAST_DESTINATION,
    port: int = _SERVER_PORT,
    xid: _ty.Optional[int] = None,
    client_identifier: _ty.Optional[ClientIdentifierLike] = None,
    parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
    broadcast: bool = True,
) -> DHCPMessage:
    """Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

    `timeout`, `deadline` and what raises are as in
    `DHCPClient.discover_offer`. Cancelling the call abandons the exchange.
    """
    started_at = self._monotonic()
    return await self._discover(
        chaddr,
        timeout=timeout,
        retries=retries,
        destination=destination,
        port=port,
        xid=xid,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
        started_at=started_at,
        ends=self._deadline_end(started_at, deadline),
    )

dora(chaddr, *, timeout=2.0, retries=2, deadline=None, destination=BROADCAST_DESTINATION, port=_SERVER_PORT, xid=None, client_identifier=None, parameter_request_list=None, broadcast=True) async

Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

Raises what DHCPClient.dora raises.

Source code in src/pydhcp/client/_asyncio.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
async def dora(
    self,
    chaddr: bytes,
    *,
    timeout: float = 2.0,
    retries: int = 2,
    deadline: _ty.Optional[float] = None,
    destination: IPv4AddressLike = BROADCAST_DESTINATION,
    port: int = _SERVER_PORT,
    xid: _ty.Optional[int] = None,
    client_identifier: _ty.Optional[ClientIdentifierLike] = None,
    parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
    broadcast: bool = True,
) -> DHCPMessage:
    """Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

    Raises what `DHCPClient.dora` raises.
    """
    # RFC 2131 s2: `secs` counts from the start of *acquisition*.
    started_at = self._monotonic()
    ends = self._deadline_end(started_at, deadline)
    offer = await self._discover(
        chaddr,
        timeout=timeout,
        retries=retries,
        destination=destination,
        port=port,
        xid=xid,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
        started_at=started_at,
        ends=ends,
    )
    server = self._selected_server(offer)
    request = self._request_after(
        offer,
        server,
        chaddr,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
    )
    return await self._exchange(
        request,
        _enum.DHCPMessageType.DHCPACK,
        destination=destination,
        port=port,
        timeout=timeout,
        retries=retries,
        started_at=started_at,
        server=server,
        ends=ends,
    )

next_reply(timeout=None) async

The oldest queued reply, waiting up to timeout seconds; None if none came.

Source code in src/pydhcp/client/_asyncio.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
async def next_reply(
    self, timeout: _ty.Optional[float] = None
) -> _ty.Optional[Reply]:
    """The oldest queued reply, waiting up to `timeout` seconds; `None` if none came."""
    replies = self._shared_replies()
    try:
        return replies.get_nowait()
    except _asyncio.QueueEmpty:
        pass
    if timeout is not None and timeout <= 0:
        return None
    try:
        return await _asyncio.wait_for(replies.get(), timeout)
    except _asyncio.TimeoutError:
        return None

DHCPClient

Bases: _ClientCore, DHCPListener

Small DHCPv4 packet client for tests and troubleshooting.

The base client builds and sends DHCP client messages, then queues matching replies. It does not configure operating-system network interfaces. Its receive thread must be running (start()) for an exchange to hear a reply.

Source code in src/pydhcp/client/_sync.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
class DHCPClient(_ClientCore, DHCPListener):
    """Small DHCPv4 packet client for tests and troubleshooting.

    The base client builds and sends DHCP client messages, then queues matching
    replies. It does not configure operating-system network interfaces. Its
    receive thread must be running (`start()`) for an exchange to hear a reply.
    """

    def __init__(
        self,
        listen: ListenLike = None,
        *,
        poll_interval: _ty.Optional[float] = None,
        max_packet_size: _ty.Optional[int] = None,
        per_interface: _ty.Optional[bool] = None,
        reuse_address: _ty.Optional[bool] = None,
        receive_buffer_size: _ty.Optional[int] = None,
    ) -> None:
        DHCPListener.__init__(
            self,
            listen=listen,
            poll_interval=poll_interval,
            max_packet_size=max_packet_size,
            per_interface=per_interface,
            reuse_address=reuse_address,
            receive_buffer_size=receive_buffer_size,
        )
        self._init_client_state()
        self._replies: _queue.Queue[Reply] = _queue.Queue(
            maxsize=self.MAX_QUEUED_REPLIES
        )
        #: Exchange key -> the queue the thread running that exchange is
        #: waiting on. A reply for a key in here is handed to its own exchange
        #: instead of the shared queue, which is what lets two exchanges run on
        #: one client without eating each other's replies.
        self._waiters: dict[tuple[int, bytes], _queue.Queue[Reply]] = {}
        self._waiters_lock = _threading.Lock()

    def send(
        self,
        message: DHCPMessage,
        *,
        dst: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
    ) -> int:
        if not self._sockets:
            self.bind()
        transport = UDPTransport(self._sockets[0])
        data = self._encode(message)
        sent = transport.send(
            data,
            _ipaddress.IPv4Address(dst),
            port=int(port),
            client_mac=message.chaddr,
        )
        self._note_sent()
        return sent

    def _monotonic(self) -> float:
        """Monotonic clock, as one override point for every timing decision.

        Retransmission intervals and `secs` are the only things here that read a
        clock, and a test that proved the backoff schedule by living through it
        would cost the suite the full 2+4+8 seconds of the default one. Stub
        this instead.
        """
        return _time.monotonic()

    def _exchange(
        self,
        message: DHCPMessage,
        msg_type: _enum.DHCPMessageType,
        *,
        destination: IPv4AddressLike,
        port: int,
        timeout: float,
        retries: int,
        started_at: float,
        server: _ty.Optional[_ipaddress.IPv4Address] = None,
        ends: _ty.Optional[float] = None,
    ) -> DHCPMessage:
        """Send `message`, retransmitting on RFC 2131 s4.1 backoff, until `msg_type`.

        `started_at` is when *acquisition* began, not when this message was
        built: see `_stamp_secs`. `server` is the server the exchange selected,
        `ends` the monotonic reading the call must be over by (`_deadline_end`).
        Raises `DHCPTimeoutError` when nothing usable arrives, and
        `DHCPRefusedError` for a DHCPNAK.
        """
        key = self._pending_key(message)
        try:
            # Registered before the first send, not inside the wait: a reply
            # that arrives in between is delivered by the receive thread, and
            # with no queue to route it to it would land in the shared one and
            # be invisible to the exchange that asked for it.
            with self._waiting_for(key) as waiter:
                self._pending_keys.add(key)
                attempts = 0
                for interval in self._retransmit_intervals(timeout, retries):
                    now = self._monotonic()
                    last = False
                    if ends is not None:
                        if ends - now <= 0:
                            break
                        # The deadline falls inside this wait, so it is the last:
                        # a timer may fire a clock tick early, and what the clock
                        # then says must not buy another send.
                        last = ends - now <= interval
                        interval = min(interval, ends - now)
                    self._stamp_secs(message, now - started_at)
                    attempts += 1
                    self.send(message, dst=destination, port=port)
                    reply = self._wait_for(waiter, msg_type, interval, server)
                    if reply is not None:
                        return reply
                    if last:
                        break
                raise self._timed_out(msg_type, attempts, ends)
        finally:
            # The exchange is over either way. Left in place, this set only ever
            # grew, and every later replay of a spent xid -- visible to anyone on
            # the segment, since these go out as broadcasts -- stayed acceptable
            # forever. dora() re-registers its own REQUEST.
            self._pending_keys.discard(key)

    @_contextlib.contextmanager
    def _waiting_for(self, key: tuple[int, bytes]) -> _ty.Iterator[_queue.Queue[Reply]]:
        """Claim the replies for one exchange for the duration of the block.

        This is what keeps two exchanges on one client apart: `handle()` routes
        a reply straight to the queue of the exchange it belongs to, so a
        waiting exchange never has to read -- and therefore never has to discard
        -- a reply that is someone else's. Two blocks open on the *same* key are
        one exchange by definition and share the queue; only the outer one
        removes it.
        """
        with self._waiters_lock:
            waiter = self._waiters.get(key)
            owned = waiter is None
            if waiter is None:
                waiter = self._waiters[key] = _queue.Queue(
                    maxsize=self.MAX_QUEUED_REPLIES
                )
        try:
            yield waiter
        finally:
            if owned:
                with self._waiters_lock:
                    if self._waiters.get(key) is waiter:
                        del self._waiters[key]

    def _wait_for(
        self,
        waiter: _queue.Queue[Reply],
        msg_type: _enum.DHCPMessageType,
        timeout: float,
        server: _ty.Optional[_ipaddress.IPv4Address] = None,
    ) -> _ty.Optional[DHCPMessage]:
        """Take the first usable reply of `msg_type` from one exchange's own queue.

        `None` when `timeout` runs out; see `_take` for what is skipped.
        """
        deadline = self._monotonic() + timeout
        while True:
            remaining = deadline - self._monotonic()
            if remaining <= 0:
                return None
            try:
                msg, _context = waiter.get(timeout=remaining)
            except _queue.Empty:
                return None
            taken = self._take(msg, msg_type, server, self._monotonic())
            if taken is not None:
                return taken

    def discover_offer(
        self,
        chaddr: bytes,
        *,
        timeout: float = 2.0,
        retries: int = 2,
        deadline: _ty.Optional[float] = None,
        destination: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
        xid: _ty.Optional[int] = None,
        client_identifier: _ty.Optional[ClientIdentifierLike] = None,
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
        broadcast: bool = True,
    ) -> DHCPMessage:
        """Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

        `timeout` is the *initial* retransmission interval, not a fixed one:
        each retransmission waits about twice as long as the last, jittered, up
        to `RETRANSMIT_MAX_INTERVAL` (RFC 2131 s4.1). With the defaults the
        whole call is bounded at roughly 2+4+8 seconds rather than 3x2;
        `deadline` bounds it outright, in seconds.

        An OFFER is usable when it offers an address and names its server;
        others are ignored. Raises `DHCPTimeoutError` when none arrives.
        """
        started_at = self._monotonic()
        return self._discover(
            chaddr,
            timeout=timeout,
            retries=retries,
            destination=destination,
            port=port,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
            started_at=started_at,
            ends=self._deadline_end(started_at, deadline),
        )

    def _discover(
        self,
        chaddr: bytes,
        *,
        timeout: float,
        retries: int,
        destination: IPv4AddressLike,
        port: int,
        xid: _ty.Optional[int],
        client_identifier: _ty.Optional[ClientIdentifierLike],
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]],
        broadcast: bool,
        started_at: float,
        ends: _ty.Optional[float],
    ) -> DHCPMessage:
        discover = self.build_discover(
            chaddr,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
        )
        return self._exchange(
            discover,
            _enum.DHCPMessageType.DHCPOFFER,
            destination=destination,
            port=port,
            timeout=timeout,
            retries=retries,
            started_at=started_at,
            ends=ends,
        )

    def dora(
        self,
        chaddr: bytes,
        *,
        timeout: float = 2.0,
        retries: int = 2,
        deadline: _ty.Optional[float] = None,
        destination: IPv4AddressLike = BROADCAST_DESTINATION,
        port: int = _SERVER_PORT,
        xid: _ty.Optional[int] = None,
        client_identifier: _ty.Optional[ClientIdentifierLike] = None,
        parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
        broadcast: bool = True,
    ) -> DHCPMessage:
        """Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

        `timeout` is the initial retransmission interval for each half of the
        exchange -- see `discover_offer`; `deadline` bounds the two halves
        together. Raises `DHCPTimeoutError` when no usable OFFER or ACK arrives
        (an ACK is usable when it comes from the server the REQUEST selected) and
        `DHCPRefusedError` when the server answers the REQUEST with a DHCPNAK.
        """
        # RFC 2131 s2: `secs` counts from the start of *acquisition*, so the
        # REQUEST half keeps counting from the DISCOVER rather than resetting.
        started_at = self._monotonic()
        ends = self._deadline_end(started_at, deadline)
        offer = self._discover(
            chaddr,
            timeout=timeout,
            retries=retries,
            destination=destination,
            port=port,
            xid=xid,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
            started_at=started_at,
            ends=ends,
        )
        server = self._selected_server(offer)
        request = self._request_after(
            offer,
            server,
            chaddr,
            client_identifier=client_identifier,
            parameter_request_list=parameter_request_list,
            broadcast=broadcast,
        )
        return self._exchange(
            request,
            _enum.DHCPMessageType.DHCPACK,
            destination=destination,
            port=port,
            timeout=timeout,
            retries=retries,
            started_at=started_at,
            server=server,
            ends=ends,
        )

    def _deliver(
        self, key: tuple[int, bytes], msg: DHCPMessage, context: DHCPRequestContext
    ) -> None:
        with self._waiters_lock:
            waiter = self._waiters.get(key)
        self._put_bounded(
            waiter if waiter is not None else self._replies, (msg, context)
        )

    def _put_bounded(self, replies: _queue.Queue[Reply], item: Reply) -> None:
        """Enqueue, discarding the oldest rather than growing without bound.

        Oldest rather than newest: a caller waiting on an exchange wants the
        recent ones. Without the cap, 50000 unsolicited BOOTREPLYs put 50000
        entries in the queue of a client nobody is draining.
        """
        while True:
            try:
                replies.put_nowait(item)
                return
            except _queue.Full:
                try:
                    replies.get_nowait()
                    self.metrics.replies_dropped_overflow += 1
                except _queue.Empty:  # pragma: no cover - drained concurrently
                    pass

    def next_reply(self, timeout: _ty.Optional[float] = None) -> _ty.Optional[Reply]:
        try:
            return self._replies.get(timeout=timeout)
        except _queue.Empty:
            return None

    def drain_replies(self) -> list[Reply]:
        replies: list[Reply] = []
        while True:
            try:
                replies.append(self._replies.get_nowait())
            except _queue.Empty:
                return replies

discover_offer(chaddr, *, timeout=2.0, retries=2, deadline=None, destination=BROADCAST_DESTINATION, port=_SERVER_PORT, xid=None, client_identifier=None, parameter_request_list=None, broadcast=True)

Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

timeout is the initial retransmission interval, not a fixed one: each retransmission waits about twice as long as the last, jittered, up to RETRANSMIT_MAX_INTERVAL (RFC 2131 s4.1). With the defaults the whole call is bounded at roughly 2+4+8 seconds rather than 3x2; deadline bounds it outright, in seconds.

An OFFER is usable when it offers an address and names its server; others are ignored. Raises DHCPTimeoutError when none arrives.

Source code in src/pydhcp/client/_sync.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def discover_offer(
    self,
    chaddr: bytes,
    *,
    timeout: float = 2.0,
    retries: int = 2,
    deadline: _ty.Optional[float] = None,
    destination: IPv4AddressLike = BROADCAST_DESTINATION,
    port: int = _SERVER_PORT,
    xid: _ty.Optional[int] = None,
    client_identifier: _ty.Optional[ClientIdentifierLike] = None,
    parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
    broadcast: bool = True,
) -> DHCPMessage:
    """Broadcast DHCPDISCOVER and return the first usable DHCPOFFER.

    `timeout` is the *initial* retransmission interval, not a fixed one:
    each retransmission waits about twice as long as the last, jittered, up
    to `RETRANSMIT_MAX_INTERVAL` (RFC 2131 s4.1). With the defaults the
    whole call is bounded at roughly 2+4+8 seconds rather than 3x2;
    `deadline` bounds it outright, in seconds.

    An OFFER is usable when it offers an address and names its server;
    others are ignored. Raises `DHCPTimeoutError` when none arrives.
    """
    started_at = self._monotonic()
    return self._discover(
        chaddr,
        timeout=timeout,
        retries=retries,
        destination=destination,
        port=port,
        xid=xid,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
        started_at=started_at,
        ends=self._deadline_end(started_at, deadline),
    )

dora(chaddr, *, timeout=2.0, retries=2, deadline=None, destination=BROADCAST_DESTINATION, port=_SERVER_PORT, xid=None, client_identifier=None, parameter_request_list=None, broadcast=True)

Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

timeout is the initial retransmission interval for each half of the exchange -- see discover_offer; deadline bounds the two halves together. Raises DHCPTimeoutError when no usable OFFER or ACK arrives (an ACK is usable when it comes from the server the REQUEST selected) and DHCPRefusedError when the server answers the REQUEST with a DHCPNAK.

Source code in src/pydhcp/client/_sync.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
def dora(
    self,
    chaddr: bytes,
    *,
    timeout: float = 2.0,
    retries: int = 2,
    deadline: _ty.Optional[float] = None,
    destination: IPv4AddressLike = BROADCAST_DESTINATION,
    port: int = _SERVER_PORT,
    xid: _ty.Optional[int] = None,
    client_identifier: _ty.Optional[ClientIdentifierLike] = None,
    parameter_request_list: _ty.Optional[_ty.Iterable[DHCPOptionCode]] = None,
    broadcast: bool = True,
) -> DHCPMessage:
    """Run a full DISCOVER/OFFER/REQUEST/ACK exchange and return the DHCPACK.

    `timeout` is the initial retransmission interval for each half of the
    exchange -- see `discover_offer`; `deadline` bounds the two halves
    together. Raises `DHCPTimeoutError` when no usable OFFER or ACK arrives
    (an ACK is usable when it comes from the server the REQUEST selected) and
    `DHCPRefusedError` when the server answers the REQUEST with a DHCPNAK.
    """
    # RFC 2131 s2: `secs` counts from the start of *acquisition*, so the
    # REQUEST half keeps counting from the DISCOVER rather than resetting.
    started_at = self._monotonic()
    ends = self._deadline_end(started_at, deadline)
    offer = self._discover(
        chaddr,
        timeout=timeout,
        retries=retries,
        destination=destination,
        port=port,
        xid=xid,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
        started_at=started_at,
        ends=ends,
    )
    server = self._selected_server(offer)
    request = self._request_after(
        offer,
        server,
        chaddr,
        client_identifier=client_identifier,
        parameter_request_list=parameter_request_list,
        broadcast=broadcast,
    )
    return self._exchange(
        request,
        _enum.DHCPMessageType.DHCPACK,
        destination=destination,
        port=port,
        timeout=timeout,
        retries=retries,
        started_at=started_at,
        server=server,
        ends=ends,
    )

__getattr__(name)

Bind an asyncio class on first use, so importing this package does not import asyncio.

Source code in src/pydhcp/client/__init__.py
24
25
26
def __getattr__(name: str) -> _ty.Any:
    """Bind an asyncio class on first use, so importing this package does not import asyncio."""
    return _bind(__name__, globals(), _ASYNCIO, name)

pydhcp.capture

pktcap_plugin below holds the filter keys of a DHCP message, which pydhcp capture --filter takes beside pktcap's own.

pydhcp.capture

DHCP for pktcap: a dissector and a plugin, and the sockets DHCP messages arrive at.

DHCPLayer

Bases: NamedTuple

The DHCP message a datagram carries, as plain values.

op is the opcode's name (BOOTREQUEST), xid the transaction id as a number, message_type the name of option 53 (DHCPDISCOVER, UNKNOWN without one), client_id the client identifier as colon-separated upper-case hex (option 61, else the hardware type and address; UNKNOWN when there is neither) and message the whole message as DHCPMessage.to_mapping(). A mapping is not hashable, so neither is the layer.

Source code in src/pydhcp/capture/_dissector.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class DHCPLayer(_ty.NamedTuple):
    """The DHCP message a datagram carries, as plain values.

    `op` is the opcode's name (`BOOTREQUEST`), `xid` the transaction id as a number,
    `message_type` the name of option 53 (`DHCPDISCOVER`, `UNKNOWN` without one),
    `client_id` the client identifier as colon-separated upper-case hex (option 61,
    else the hardware type and address; `UNKNOWN` when there is neither) and `message` the whole message as
    `DHCPMessage.to_mapping()`. A mapping is not hashable, so neither is the layer.
    """

    op: str
    xid: int
    message_type: str
    client_id: str
    message: _ty.Dict[str, _ty.Any]

    def summary(self) -> str:
        """One line for a person: the type, the transaction id, the client, and
        `yiaddr` and `giaddr` when they are not zero.

        `DHCPOFFER xid=1A52B52E client=01:02:00:00:99:00:02 yiaddr=10.99.0.100`.
        This is the text pktcap's `text` format shows for a frame whose innermost
        layer is this one.
        """
        parts = [f"{self.message_type} xid={self.xid:08X} client={self.client_id}"]
        for key in ("yiaddr", "giaddr"):
            value = self.message.get(key)
            if value and value != "0.0.0.0":
                parts.append(f"{key}={value}")
        return " ".join(parts)

summary()

One line for a person: the type, the transaction id, the client, and yiaddr and giaddr when they are not zero.

DHCPOFFER xid=1A52B52E client=01:02:00:00:99:00:02 yiaddr=10.99.0.100. This is the text pktcap's text format shows for a frame whose innermost layer is this one.

Source code in src/pydhcp/capture/_dissector.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def summary(self) -> str:
    """One line for a person: the type, the transaction id, the client, and
    `yiaddr` and `giaddr` when they are not zero.

    `DHCPOFFER xid=1A52B52E client=01:02:00:00:99:00:02 yiaddr=10.99.0.100`.
    This is the text pktcap's `text` format shows for a frame whose innermost
    layer is this one.
    """
    parts = [f"{self.message_type} xid={self.xid:08X} client={self.client_id}"]
    for key in ("yiaddr", "giaddr"):
        value = self.message.get(key)
        if value and value != "0.0.0.0":
            parts.append(f"{key}={value}")
    return " ".join(parts)

asniff_dhcp(listen=None, *, per_interface=None, dissector=None)

sniff_dhcp for an event loop: an asynchronous iterator of dissected frames.

The sockets are bound when this is called. When the iterator ends, fails or is cancelled, every reader task is cancelled and every socket closed; a loop that stops early awaits the iterator's aclose(). It has no stop: break and aclose(), or cancel.

Source code in src/pydhcp/capture/_source.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def asniff_dhcp(
    listen: "_netimps.ListenLike" = None,
    *,
    per_interface: _ty.Optional[bool] = None,
    dissector: "_ty.Optional[_pktcap.FrameDissector]" = None,
) -> "_ty.AsyncIterator[_pktcap.DissectedFrame]":
    """`sniff_dhcp` for an event loop: an asynchronous iterator of dissected frames.

    The sockets are bound when this is called. When the iterator ends, fails or is
    cancelled, every reader task is cancelled and every socket closed; a loop that
    stops early awaits the iterator's `aclose()`. It has no `stop`: break and
    `aclose()`, or cancel.
    """
    _extras.pktcap_module()
    endpoints = bound_endpoints(listen, per_interface)
    try:
        reader = dissector if dissector is not None else _reader(endpoints)
        return _pktcap.asniff_udp(endpoints, dissector=reader)
    except BaseException:
        _close(endpoints)
        raise

capture_dissector(ports=DHCP_PORTS)

A frame dissector that reads DHCP on ports, over a copy of pktcap's registry.

The registry also declares :class:DHCPLayer and its filter keys (msg_type, xid, client_id, chaddr, op, option.NAME), so proto=dhcp and msg_type=DHCPOFFER compile against dissector.registry. The process-wide registry is not changed, and whatever else is registered there is read as well. Its stats say what could not be read.

Source code in src/pydhcp/capture/_dissector.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
def capture_dissector(
    ports: _ty.Iterable[int] = DHCP_PORTS,
) -> "_pktcap.FrameDissector":
    """A frame dissector that reads DHCP on `ports`, over a copy of pktcap's registry.

    The registry also declares :class:`DHCPLayer` and its filter keys (`msg_type`,
    `xid`, `client_id`, `chaddr`, `op`, `option.NAME`), so `proto=dhcp` and
    `msg_type=DHCPOFFER` compile against `dissector.registry`. The process-wide
    registry is not changed, and whatever else is registered there is read as well.
    Its `stats` say what could not be read.
    """
    from ._plugin import declare_dhcp_layer

    registry = _pktcap.default_registry().copy()
    declare_dhcp_layer(registry, replace=True)
    for port in ports:
        registry.register("udp", port, dissect_dhcp, replace=True)
    return _pktcap.FrameDissector(registry)

dissect_dhcp(data)

Read the DHCP message data holds: its DHCPLayer, and no payload after it.

Raises pktcap.DissectError (a ValueError) for octets that are not a message; its text is fixed and quotes none of them. Keeps the contract pktcap.check_dissector checks.

Source code in src/pydhcp/capture/_dissector.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def dissect_dhcp(data: bytes) -> "_pktcap.Dissected":
    """Read the DHCP message `data` holds: its `DHCPLayer`, and no payload after it.

    Raises `pktcap.DissectError` (a `ValueError`) for octets that are not a message;
    its text is fixed and quotes none of them. Keeps the contract
    `pktcap.check_dissector` checks.
    """
    try:
        message = DHCPMessage.decode(data)
    except ValueError:
        raise _pktcap.DissectError("not a DHCP message") from None
    layer = DHCPLayer(
        message.op.name or str(int(message.op)),
        message.xid,
        message_type_text(message),
        client_id_text(message),
        message.to_mapping(),
    )
    return _pktcap.Dissected(layer, b"")

pktcap_plugin(registry)

Declare :class:DHCPLayer and its filter keys in registry, then register the dissector.

The keys are op, msg_type, xid, client_id, chaddr and option.NAME_OR_CODE, each refusing at compile time a value no message could match, and the DHCPLayer fields as dhcp.FIELD. A call that raises leaves registry as it found it: ValueError for a layer name or a port that is taken.

Source code in src/pydhcp/capture/_plugin.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def pktcap_plugin(registry: "_pktcap.DissectorRegistry") -> None:
    """Declare :class:`DHCPLayer` and its filter keys in `registry`, then register the dissector.

    The keys are `op`, `msg_type`, `xid`, `client_id`, `chaddr` and `option.NAME_OR_CODE`,
    each refusing at compile time a value no message could match, and the `DHCPLayer` fields
    as `dhcp.FIELD`.
    A call that raises leaves `registry` as it found it: `ValueError` for a layer name or a
    port that is taken.
    """
    declare_dhcp_layer(registry)
    try:
        register_dhcp_dissector(registry)
    except Exception:
        registry.unregister_layer("dhcp")
        raise

register_dhcp_dissector(registry=None)

Have registry (default: pktcap's process-wide one) dissect UDP ports 67 and 68.

Nothing registers on import. ValueError, with nothing registered by this call, when a port already has a dissector (pktcap's rule).

Source code in src/pydhcp/capture/_dissector.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def register_dhcp_dissector(
    registry: "_ty.Optional[_pktcap.DissectorRegistry]" = None,
) -> None:
    """Have `registry` (default: pktcap's process-wide one) dissect UDP ports 67 and 68.

    Nothing registers on import. `ValueError`, with nothing registered by this call,
    when a port already has a dissector (pktcap's rule).
    """
    target = registry if registry is not None else _pktcap.default_registry()
    done: "_ty.List[int]" = []
    try:
        for port in DHCP_PORTS:
            target.register("udp", port, dissect_dhcp)
            done.append(port)
    except ValueError:
        for port in done:
            target.unregister("udp", port)
        raise

sniff_dhcp(listen=None, *, per_interface=None, stop=None, dissector=None)

The DHCP messages that reach listen, as pktcap's dissected frames, as they arrive.

listen is netimps' listen specification (netimps.parse_listen) read with the default ports 67 and 68 and IPv4: None is every address, "127.0.0.1:6767" one socket, an adapter name or a MAC the sockets of that adapter. The sockets are bound when this is called, and a part of the specification that cannot be bound is an error before any is opened (ValueError for an adapter that matches nothing, OSError for a port taken). per_interface=True binds one socket for each address of the host instead of a wildcard; on Linux such sockets hear no broadcast.

The iterator closes every socket when it ends, fails, is closed or is dropped. stop() is called between datagrams, and at least once a second on a quiet socket; true ends the iteration. dissector is a pktcap.FrameDissector: omitted, it is capture_dissector() reading DHCP on 67, 68 and the ports the sockets are bound to, so the last layer of a frame that holds a message is a DHCPLayer. The IP header of a frame is made up and no other host's traffic is seen: see pktcap's sniff_udp. ImportError naming the pktcap extra when it is not installed.

Source code in src/pydhcp/capture/_source.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def sniff_dhcp(
    listen: "_netimps.ListenLike" = None,
    *,
    per_interface: _ty.Optional[bool] = None,
    stop: _ty.Optional[_ty.Callable[[], bool]] = None,
    dissector: "_ty.Optional[_pktcap.FrameDissector]" = None,
) -> "_ty.Iterator[_pktcap.DissectedFrame]":
    """The DHCP messages that reach `listen`, as pktcap's dissected frames, as they arrive.

    `listen` is netimps' listen specification (`netimps.parse_listen`) read with the
    default ports 67 and 68 and IPv4: `None` is every address, `"127.0.0.1:6767"` one
    socket, an adapter name or a MAC the sockets of that adapter. The sockets are bound
    when this is called, and a part of the specification that cannot be bound is an
    error before any is opened (`ValueError` for an adapter that matches nothing,
    `OSError` for a port taken). `per_interface=True` binds one socket for each
    address of the host instead of a wildcard; on Linux such sockets hear no broadcast.

    The iterator closes every socket when it ends, fails, is closed or is dropped.
    `stop()` is called between datagrams, and at least once a second on a quiet socket;
    true ends the iteration. `dissector` is a `pktcap.FrameDissector`: omitted, it is
    `capture_dissector()` reading DHCP on 67, 68 and the ports the sockets are bound to,
    so the last layer of a frame that holds a message is a `DHCPLayer`. The IP header
    of a frame is made up and no other host's traffic is seen: see pktcap's `sniff_udp`.
    `ImportError` naming the `pktcap` extra when it is not installed.
    """
    _extras.pktcap_module()
    endpoints = bound_endpoints(listen, per_interface)
    try:
        reader = dissector if dissector is not None else _reader(endpoints)
        return _pktcap.sniff_udp(endpoints, stop=stop, dissector=reader)
    except BaseException:
        _close(endpoints)  # the source turned them away: they are still ours
        raise

pydhcp.packet

pydhcp.packet

DHCPFlags

Bases: IntFlag

The 16-bit flags field of the header (RFC 2131 s2).

Only the broadcast bit is assigned; the other fifteen are reserved and a decoded value keeps them, so a relay forwards the field it received. Test the bit with DHCPMessage.broadcast or flags & DHCPFlags.BROADCAST, not by comparing the whole value.

Source code in src/pydhcp/packet/_enums.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class DHCPFlags(_enum.IntFlag):
    """The 16-bit flags field of the header (RFC 2131 s2).

    Only the broadcast bit is assigned; the other fifteen are reserved and a
    decoded value keeps them, so a relay forwards the field it received. Test
    the bit with `DHCPMessage.broadcast` or `flags & DHCPFlags.BROADCAST`, not
    by comparing the whole value.
    """

    UNICAST = 0
    BROADCAST = 1 << 15
    """Set by client that cant listen to unicast response as it doesnt have an ip yet"""

    def label(self) -> str:
        """`UNICAST` or `BROADCAST`, then `|0x....` for the reserved bits set."""
        value = int(self)
        named = "BROADCAST" if value & 0x8000 else "UNICAST"
        reserved = value & 0x7FFF
        return f"{named}|0x{reserved:04X}" if reserved else named

BROADCAST = 1 << 15 class-attribute instance-attribute

Set by client that cant listen to unicast response as it doesnt have an ip yet

label()

UNICAST or BROADCAST, then |0x.... for the reserved bits set.

Source code in src/pydhcp/packet/_enums.py
47
48
49
50
51
52
def label(self) -> str:
    """`UNICAST` or `BROADCAST`, then `|0x....` for the reserved bits set."""
    value = int(self)
    named = "BROADCAST" if value & 0x8000 else "UNICAST"
    reserved = value & 0x7FFF
    return f"{named}|0x{reserved:04X}" if reserved else named

DHCPMessage dataclass

Bases: _MessageDisplay

A DHCPv4 message (RFC 2131 s2): the fixed BOOTP header, then options.

The fields, decode/encode, to_mapping/from_mapping, client_id and the display helpers are inherited from the layers in pydhcp.packet; the dataclass __init__, __repr__ and __eq__ are the base layer's, and report this class.

Source code in src/pydhcp/packet/_message.py
12
13
14
15
16
17
18
19
class DHCPMessage(_MessageDisplay):
    """A DHCPv4 message (RFC 2131 s2): the fixed BOOTP header, then options.

    The fields, `decode`/`encode`, `to_mapping`/`from_mapping`, `client_id` and
    the display helpers are inherited from the layers in `pydhcp.packet`; the
    dataclass `__init__`, `__repr__` and `__eq__` are the base layer's, and
    report this class.
    """

DHCPMessageType

Bases: DHCPOptionType, IntEnum

DHCP message types

Source code in src/pydhcp/options/_codecs/_message_type.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
class DHCPMessageType(DHCPOptionType, _enum.IntEnum):
    """DHCP message types"""

    @classmethod
    def unpack_from(
        cls: type[_DHCPMessageTypeT], option: memoryview
    ) -> tuple[_DHCPMessageTypeT, int]:
        option_part = option[:1]
        if len(option_part) != 1:
            raise DHCPDecodeError(
                "DHCP_MESSAGE_TYPE needs 1 octet, got 0 (RFC 2132 s9.6)"
            )
        try:
            return cls(option_part[0]), 1
        except ValueError as exc:
            raise DHCPDecodeError(str(exc)) from exc

    def pack_into(self, data: bytearray) -> int:
        data.append(self.value)
        return 1

    @classmethod
    def fixed_size(cls) -> _ty.Optional[int]:
        return 1

    @classmethod
    def _missing_(cls, value: object) -> "_ty.Optional[DHCPMessageType]":
        """An unnamed member for any octet that no message type names.

        A relay forwards the type it received and a server reports it, so an
        assigned-by-someone-else number is carried, never refused or rewritten.
        """
        if not isinstance(value, int) or isinstance(value, bool):
            return None
        if not 0 <= value <= 255:
            return None
        pseudo = _PSEUDO_MEMBERS.get(value)
        if pseudo is None:
            pseudo = int.__new__(cls, value)
            pseudo._name_ = None  # type: ignore[assignment]
            pseudo._value_ = value
            _PSEUDO_MEMBERS[value] = pseudo
        return pseudo

    def label(self) -> str:
        """The member's name, or `TYPE_<n>` for a number that has none."""
        return self._name_ or f"TYPE_{self.value}"

    def __repr__(self) -> str:
        return f"{type(self).__name__}.{self.label()}"

    def __str__(self) -> str:
        return self.label()

    def display_text(self) -> str:
        return self.label()

    DHCPDISCOVER = 1
    """ Client broadcast to locate available servers."""
    DHCPOFFER = 2
    """ Server to client in response to DHCPDISCOVER with offer of configuration parameters."""
    DHCPREQUEST = 3
    """Client message to servers either (a) requesting
    offered parameters from one server and implicitly
    declining offers from all others, (b) confirming
    correctness of previously allocated address after,
    e.g., system reboot, or (c) extending the lease on a
    particular network address."""
    DHCPDECLINE = 4
    """Client to server indicating network address is already
    in use."""
    DHCPACK = 5
    """Server to client with configuration parameters,
    including committed network address."""
    DHCPNAK = 6
    """Server to client indicating client's notion of network
    address is incorrect (e.g., client has moved to new
    subnet) or client's lease as expired"""
    DHCPRELEASE = 7
    """Client to server relinquishing network address and
    cancelling remaining lease."""
    DHCPINFORM = 8
    """ Client to server, asking only for local configuration
    parameters; client already has externally configured
    network address."""

    DHCPFORCERENEW = 9
    """Forces the client to the RENEW state. """
    DHCPLEASEQUERY = 10
    """The DHCPLEASEQUERY message is a new DHCP message type transmitted
   from a DHCP relay agent to a DHCP server.  A DHCPLEASEQUERY-aware
   relay agent sends the DHCPLEASEQUERY message when it needs to know
   the location of an IP endpoint.  The DHCPLEASEQUERY-aware DHCP server
   replies with a DHCPLEASEUNASSIGNED, DHCPLEASEACTIVE, or
   DHCPLEASEUNKNOWN message. """
    DHCPLEASEUNASSIGNED = 11
    """The DHCPLEASEUNASSIGNED is similar to a DHCPLEASEACTIVE message, but
   indicates that there is no currently active lease on the resultant IP
   address but that this DHCP server is authoritative for this IP
   address."""
    DHCPLEASEUNKNOWN = 12
    """The DHCPLEASEUNKNOWN message indicates that the DHCP server
   has no knowledge of the information specified in the query (e.g., IP
   address, MAC address, or Client-identifier option)."""
    DHCPLEASEACTIVE = 13
    """The DHCPLEASEACTIVE response to a
   DHCPLEASEQUERY message allows the relay agent to determine the IP
   endpoint location and the remaining duration of the IP address lease."""
    DHCPBULKLEASEQUERY = 14
    DHCPLEASEQUERYDONE = 15
    DHCPACTIVELEASEQUERY = 16
    DHCPLEASEQUERYSTATUS = 17
    DHCPTLS = 18

DHCPACK = 5 class-attribute instance-attribute

Server to client with configuration parameters, including committed network address.

DHCPDECLINE = 4 class-attribute instance-attribute

Client to server indicating network address is already in use.

DHCPDISCOVER = 1 class-attribute instance-attribute

Client broadcast to locate available servers.

DHCPFORCERENEW = 9 class-attribute instance-attribute

Forces the client to the RENEW state.

DHCPINFORM = 8 class-attribute instance-attribute

Client to server, asking only for local configuration parameters; client already has externally configured network address.

DHCPLEASEACTIVE = 13 class-attribute instance-attribute

The DHCPLEASEACTIVE response to a DHCPLEASEQUERY message allows the relay agent to determine the IP endpoint location and the remaining duration of the IP address lease.

DHCPLEASEQUERY = 10 class-attribute instance-attribute

The DHCPLEASEQUERY message is a new DHCP message type transmitted from a DHCP relay agent to a DHCP server. A DHCPLEASEQUERY-aware relay agent sends the DHCPLEASEQUERY message when it needs to know the location of an IP endpoint. The DHCPLEASEQUERY-aware DHCP server replies with a DHCPLEASEUNASSIGNED, DHCPLEASEACTIVE, or DHCPLEASEUNKNOWN message.

DHCPLEASEUNASSIGNED = 11 class-attribute instance-attribute

The DHCPLEASEUNASSIGNED is similar to a DHCPLEASEACTIVE message, but indicates that there is no currently active lease on the resultant IP address but that this DHCP server is authoritative for this IP address.

DHCPLEASEUNKNOWN = 12 class-attribute instance-attribute

The DHCPLEASEUNKNOWN message indicates that the DHCP server has no knowledge of the information specified in the query (e.g., IP address, MAC address, or Client-identifier option).

DHCPNAK = 6 class-attribute instance-attribute

Server to client indicating client's notion of network address is incorrect (e.g., client has moved to new subnet) or client's lease as expired

DHCPOFFER = 2 class-attribute instance-attribute

Server to client in response to DHCPDISCOVER with offer of configuration parameters.

DHCPRELEASE = 7 class-attribute instance-attribute

Client to server relinquishing network address and cancelling remaining lease.

DHCPREQUEST = 3 class-attribute instance-attribute

Client message to servers either (a) requesting offered parameters from one server and implicitly declining offers from all others, (b) confirming correctness of previously allocated address after, e.g., system reboot, or (c) extending the lease on a particular network address.

label()

The member's name, or TYPE_<n> for a number that has none.

Source code in src/pydhcp/options/_codecs/_message_type.py
69
70
71
def label(self) -> str:
    """The member's name, or `TYPE_<n>` for a number that has none."""
    return self._name_ or f"TYPE_{self.value}"

DHCPOpcode

Bases: IntEnum

Specifies if the message originates from a server or client

Source code in src/pydhcp/packet/_enums.py
20
21
22
23
24
25
26
class DHCPOpcode(_enum.IntEnum):
    """Specifies if the message originates from a server or client"""

    BOOTREQUEST = 1
    """DHCP message sent from a client to a server."""
    BOOTREPLY = 2
    """DHCP message sent from a server to a client."""

BOOTREPLY = 2 class-attribute instance-attribute

DHCP message sent from a server to a client.

BOOTREQUEST = 1 class-attribute instance-attribute

DHCP message sent from a client to a server.

HardwareAddressType

Bases: IntEnum

IANA ARP hardware types, as used by the BOOTP htype field.

Values 0-37 of the IANA "Number Hardware Type (hrd)" registry that are in use for DHCP; anything else in an octet becomes an unnamed pseudo-member rather than being rewritten (see _missing_).

Defined here rather than in packet/_enums.py (which re-exports it, and is where the rest of the message-header enums live) because it is what closes the options -> packet -> options import cycle: ClientIdentifier names its leading type octet with this enum, and reaching packet._enums for it meant a function-local import re-executed on every __repr__. This module imports nothing from the package.

Source code in src/pydhcp/_network/__init__.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
class HardwareAddressType(_enum.IntEnum):
    """IANA ARP hardware types, as used by the BOOTP `htype` field.

    Values 0-37 of the IANA "Number Hardware Type (hrd)" registry that are in
    use for DHCP; anything else in an octet becomes an unnamed pseudo-member
    rather than being rewritten (see `_missing_`).

    Defined here rather than in `packet/_enums.py` (which re-exports it, and is
    where the rest of the message-header enums live) because it is what closes
    the `options` -> `packet` -> `options` import cycle: `ClientIdentifier`
    names its leading type octet with this enum, and reaching `packet._enums`
    for it meant a function-local import re-executed on every `__repr__`. This
    module imports nothing from the package.
    """

    NONE = 0
    ETHERNET = 1
    EXPERIMENTAL_ETHERNET = 2
    AX25 = 3
    PRONET = 4
    CHAOS = 5
    IEEE_802 = 6
    ARCNET = 7
    HYPERCHANNEL = 8
    LANSTAR = 9
    AUTONET = 10
    LOCALTALK = 11
    LOCALNET = 12
    ULTRA_LINK = 13
    SMDS = 14
    FRAME_RELAY = 15
    ATM_16 = 16
    HDLC = 17
    FIBRE_CHANNEL = 18
    ATM_19 = 19
    SERIAL_LINE = 20
    ATM_21 = 21
    MIL_STD_188_220 = 22
    METRICOM = 23
    IEEE_1394 = 24
    MAPOS = 25
    TWINAXIAL = 26
    EUI_64 = 27
    HIPARP = 28
    IP_ARP_OVER_ISO_7816_3 = 29
    ARPSEC = 30
    IPSEC_TUNNEL = 31
    INFINIBAND = 32
    CAI = 33
    WIEGAND = 34
    PURE_IP = 35
    HW_EXP1 = 36
    HFI = 37

    @classmethod
    def _missing_(cls, value: object) -> "_ty.Optional[HardwareAddressType]":
        """Return an unnamed pseudo-member for any octet without one.

        Rewriting an unknown type to ETHERNET was destructive in exactly the way
        a relay must not be: RFC 1542 s4.1.2 has a relay alter giaddr and hops
        and nothing else, so forwarding an IPoIB request (RFC 4390, htype 32)
        handed the server a different htype than the client sent. The derived
        client identifier is built from this value too, so it changed as well.
        """
        if isinstance(value, str):
            # The round-trip form emitted by `label()`, so a message that went
            # out through to_mapping()/JSON comes back with its type intact.
            if value.startswith("HTYPE_") and value[6:].isdigit():
                value = int(value[6:])
            else:
                return None
        if not isinstance(value, int) or isinstance(value, bool):
            return None
        if not 0 <= value <= 255:
            return None
        pseudo = _HTYPE_PSEUDO_MEMBERS.get(value)
        if pseudo is None:
            pseudo = int.__new__(cls, value)
            pseudo._name_ = None  # type: ignore[assignment]
            pseudo._value_ = value
            _HTYPE_PSEUDO_MEMBERS[value] = pseudo
        return pseudo

    def label(self) -> str:
        """Name of this hardware type, or `HTYPE_<n>` when it has none."""
        return self._name_ or f"HTYPE_{self.value}"

    def __repr__(self) -> str:
        # Without this an unnamed member reports `<HardwareAddressType.None: 32>`,
        # which reads as the NONE member (value 0) rather than "no name".
        return f"<{type(self).__name__}.{self.label()}: {self.value}>"

    def format_address(self, address: bytes) -> str:
        """`address` as text: colon-separated hex for Ethernet, else its `repr`."""
        if self is HardwareAddressType.ETHERNET:
            return address.hex(":", 1).upper()
        return repr(address)

format_address(address)

address as text: colon-separated hex for Ethernet, else its repr.

Source code in src/pydhcp/_network/__init__.py
108
109
110
111
112
def format_address(self, address: bytes) -> str:
    """`address` as text: colon-separated hex for Ethernet, else its `repr`."""
    if self is HardwareAddressType.ETHERNET:
        return address.hex(":", 1).upper()
    return repr(address)

label()

Name of this hardware type, or HTYPE_<n> when it has none.

Source code in src/pydhcp/_network/__init__.py
 99
100
101
def label(self) -> str:
    """Name of this hardware type, or `HTYPE_<n>` when it has none."""
    return self._name_ or f"HTYPE_{self.value}"

pydhcp.packet.structured

A message as a mapping in one of four text formats; DHCPMessage.to_text and from_text are these two around to_mapping and from_mapping.

pydhcp.packet.structured

dumps(data, format)

The text of a mapping in a structured format, with json.dumps's meaning.

format is json, yaml, toml or ini in any letter case (ValueError otherwise). JSON is written by the standard library, indented; the others by pktcap.dumps_record, so they need the pktcap extra (ImportError naming pip install "pydhcp[pktcap]") and yaml and toml the format library of pktcap (ImportError naming pip install "pktcap[yaml]" or "pktcap[toml]"). A value the format cannot hold is a TypeError or ValueError from pktcap that does not quote the record, and so is a message whose options TOML would write in another order (an option held as a table before a plain one). The INI form is [record] with the header fields and one section for options, each value one line of JSON, and keeps the case of every name.

Source code in src/pydhcp/packet/structured.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def dumps(data: dict[str, _ty.Any], format: str) -> str:
    """The text of a mapping in a structured format, with `json.dumps`'s meaning.

    `format` is `json`, `yaml`, `toml` or `ini` in any letter case (`ValueError`
    otherwise). JSON is written by the standard library, indented; the others by
    `pktcap.dumps_record`, so they need the `pktcap` extra (`ImportError` naming
    `pip install "pydhcp[pktcap]"`) and `yaml` and `toml` the format library of pktcap
    (`ImportError` naming `pip install "pktcap[yaml]"` or `"pktcap[toml]"`). A value
    the format cannot hold is a `TypeError` or `ValueError` from pktcap that does not
    quote the record, and so is a message whose options TOML would write in another
    order (an option held as a table before a plain one). The INI form is `[record]`
    with the header fields and one section for `options`, each value one line of JSON,
    and keeps the case of every name.
    """
    normalized = _normalize_format(format)
    if normalized == "json":
        return _json.dumps(data, indent=2) + "\n"
    pktcap = pktcap_module("a message or a lease as YAML, TOML or INI text")
    if normalized == "toml":
        _refuse_a_reordering(data)
    return _ty.cast(str, pktcap.dumps_record(_plain(data), normalized))

loads(text, format)

The mapping a structured document holds, with json.loads's meaning.

text is the content, never a file name. format is json, yaml, toml or ini in any letter case (ValueError otherwise). JSON is read by the standard library; the others by pktcap.loads_record, so they need the pktcap extra (ImportError naming pip install "pydhcp[pktcap]"), and yaml and toml the format library of pktcap (ImportError naming pip install "pktcap[yaml]" or "pktcap[toml]"). A document pktcap does not take as one record is a ValueError (pktcap.RecordFormatError) that never quotes the text. An INI document is the sections [record] and [options] that dumps writes, or the [message] and [options] a message was written with before. A document that is not a mapping is a DHCPDecodeError.

Source code in src/pydhcp/packet/structured.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def loads(text: str, format: str) -> dict[str, _ty.Any]:
    """The mapping a structured document holds, with `json.loads`'s meaning.

    `text` is the content, never a file name. `format` is `json`, `yaml`, `toml` or
    `ini` in any letter case (`ValueError` otherwise). JSON is read by the standard
    library; the others by `pktcap.loads_record`, so they need the `pktcap` extra
    (`ImportError` naming `pip install "pydhcp[pktcap]"`), and `yaml` and `toml` the
    format library of pktcap (`ImportError` naming `pip install "pktcap[yaml]"` or
    `"pktcap[toml]"`). A document pktcap does not take as one record is a `ValueError`
    (`pktcap.RecordFormatError`) that never quotes the text. An INI document is the
    sections `[record]` and `[options]` that `dumps` writes, or the `[message]` and
    `[options]` a message was written with before. A document that is not a mapping is
    a `DHCPDecodeError`.
    """
    normalized = _normalize_format(format)
    if normalized == "json":
        return _ensure_mapping(_json.loads(text))
    pktcap = pktcap_module("a message or a lease as YAML, TOML or INI text")
    data = _ensure_mapping(pktcap.loads_record(text, normalized))
    return _from_the_old_ini(data) if normalized == "ini" else data

Configuration files

The commands read the root's named settings file with load_config, which is not importable from a public module; its failures are DHCPConfigError.

pydhcp._config.load_config(source, format=None)

Read one configuration file into a mapping of sections.

The format is format (json, toml, yaml or ini, any case) or else the file name's ending (.json, .toml, .yaml, .yml, .ini, .cfg); any other ending is refused. The reader is duho.config: its INI puts the keys of [DEFAULT] at the top level, and a TOML file before Python 3.11 and a YAML file need its config and yaml extras.

A file that cannot be read raises the OSError. A document that does not parse, whose top level is not a mapping, whose format cannot be told, or whose reader is not installed raises DHCPConfigError with the path and the position when duho.config gave them. Without the cli extra, which installs duho, the ImportError names it.

Source code in src/pydhcp/_config.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def load_config(
    source: "_ty.Union[str, _os.PathLike[str]]", format: _ty.Optional[str] = None
) -> _ty.Dict[str, _ty.Any]:
    """Read one configuration file into a mapping of sections.

    The format is `format` (`json`, `toml`, `yaml` or `ini`, any case) or else the
    file name's ending (`.json`, `.toml`, `.yaml`, `.yml`, `.ini`, `.cfg`); any
    other ending is refused. The reader is `duho.config`: its INI puts the keys of
    `[DEFAULT]` at the top level, and a TOML file before Python 3.11 and a YAML
    file need its `config` and `yaml` extras.

    A file that cannot be read raises the `OSError`. A document that does not
    parse, whose top level is not a mapping, whose format cannot be told, or
    whose reader is not installed raises `DHCPConfigError` with the path and the
    position when `duho.config` gave them. Without the `cli` extra, which
    installs `duho`, the `ImportError` names it.
    """
    config = _extras.duho_config()
    path = _os.fspath(source)
    try:
        parsed = config.load(path, format, backends=BACKENDS)
    except config.ConfigError as error:
        raise DHCPConfigError(
            _problem(str(error), path), path, error.lineno, error.colno
        ) from None
    if not isinstance(parsed, dict):
        raise DHCPConfigError(
            f"the top level must be a mapping, not {type(parsed).__name__}", path
        )
    return _ty.cast(_ty.Dict[str, _ty.Any], parsed)

pydhcp.exceptions

pydhcp.exceptions

Every exception the package raises on its own account.

DHCPError is the one base: a caller who wants "anything pydhcp reported" catches it. Each class also inherits the builtin a caller would already catch for that kind of failure, so an except ValueError keeps working.

A caller's own mistake, such as a wrong argument type or a call made out of order, is not here: it raises plain ValueError or TypeError.

DHCPConfigError

Bases: DHCPError, ValueError

A configuration file that cannot be used.

Raised for a document that does not parse, whose top level is not a mapping, that names a section or key the command does not have, or whose format cannot be told. path is the file as it was named (<stdin> for standard input, None when unknown), lineno and colno are 1-based and None when the parser gave none, and msg is the problem in the words of this package. The message, the attributes and the exception chain carry no text from the document, which may hold secrets. Also a ValueError. str() is one line: path:line:col: msg.

Source code in src/pydhcp/exceptions.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class DHCPConfigError(DHCPError, ValueError):
    """A configuration file that cannot be used.

    Raised for a document that does not parse, whose top level is not a
    mapping, that names a section or key the command does not have, or whose
    format cannot be told. `path` is the file as it was named (`<stdin>` for
    standard input, `None` when unknown), `lineno` and `colno` are 1-based and
    `None` when the parser gave none, and `msg` is the problem in the words of
    this package. The message, the attributes and the exception chain carry no
    text from the document, which may hold secrets. Also a `ValueError`.
    `str()` is one line: `path:line:col: msg`.
    """

    def __init__(
        self,
        msg: str,
        path: _ty.Optional[str] = None,
        lineno: _ty.Optional[int] = None,
        colno: _ty.Optional[int] = None,
    ) -> None:
        super().__init__(msg)
        self.msg = msg
        self.path = path
        self.lineno = lineno
        self.colno = colno

    def __str__(self) -> str:
        where = "" if self.path is None else self.path
        if self.lineno is not None:
            where += f":{self.lineno}"
            if self.colno is not None:
                where += f":{self.colno}"
        return f"{where}: {self.msg}" if where else self.msg

    def __reduce__(self) -> _ty.Any:
        return (type(self), (self.msg, self.path, self.lineno, self.colno))

DHCPDecodeError

Bases: DHCPError, ValueError

Octets that are not the message, option or name they were read as.

Raised by every decoder for a truncated, oversized, looping or otherwise malformed input. Also a ValueError.

Source code in src/pydhcp/exceptions.py
30
31
32
33
34
35
class DHCPDecodeError(DHCPError, ValueError):
    """Octets that are not the message, option or name they were read as.

    Raised by every decoder for a truncated, oversized, looping or otherwise
    malformed input. Also a `ValueError`.
    """

DHCPError

Bases: Exception

The base of every exception pydhcp raises on its own account.

Source code in src/pydhcp/exceptions.py
26
27
class DHCPError(Exception):
    """The base of every exception pydhcp raises on its own account."""

DHCPRefusedError

Bases: DHCPError

The server answered a DHCPREQUEST with a DHCPNAK.

The client's configuration process starts over (RFC 2131 section 3.1), so the exchange ends at once instead of retransmitting. nak is the DHCPNAK message; its DHCP_MESSAGE option, when present, says why.

Source code in src/pydhcp/exceptions.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
class DHCPRefusedError(DHCPError):
    """The server answered a DHCPREQUEST with a DHCPNAK.

    The client's configuration process starts over (RFC 2131 section 3.1), so the
    exchange ends at once instead of retransmitting. `nak` is the DHCPNAK
    message; its `DHCP_MESSAGE` option, when present, says why.
    """

    #: The DHCPNAK `DHCPMessage`. Typed `Any` because this module is a leaf
    #: that the message decoder itself imports.
    nak: _ty.Any

    def __init__(self, message: str, nak: _ty.Any) -> None:
        super().__init__(message, nak)
        self.nak = nak

    def __str__(self) -> str:
        return str(self.args[0])

DHCPTimeoutError

Bases: DHCPError, TimeoutError

A client exchange ran out of time.

Raised by DHCPClient and AsyncDHCPClient when the retransmissions, or the deadline of the call, ran out before an acceptable DHCPOFFER or DHCPACK arrived. Also a TimeoutError.

Source code in src/pydhcp/exceptions.py
93
94
95
96
97
98
99
class DHCPTimeoutError(DHCPError, TimeoutError):
    """A client exchange ran out of time.

    Raised by `DHCPClient` and `AsyncDHCPClient` when the retransmissions, or the
    `deadline` of the call, ran out before an acceptable DHCPOFFER or DHCPACK
    arrived. Also a `TimeoutError`.
    """

DHCPValueError

Bases: DHCPError, ValueError

A value an option codec or message field cannot represent.

Raised when a constructor rejects what it was given: an entry past one octet's length, an empty list where the RFC requires an entry, a prefix past 32. Also a ValueError.

Source code in src/pydhcp/exceptions.py
38
39
40
41
42
43
44
class DHCPValueError(DHCPError, ValueError):
    """A value an option codec or message field cannot represent.

    Raised when a constructor rejects what it was given: an entry past one
    octet's length, an empty list where the RFC requires an entry, a prefix
    past 32. Also a `ValueError`.
    """

NoClientIdentityError

Bases: DHCPError, ValueError

A message carries nothing that identifies its client.

Neither a client identifier option nor a hardware address. Also a ValueError; a server drops the message rather than failing.

Source code in src/pydhcp/exceptions.py
85
86
87
88
89
90
class NoClientIdentityError(DHCPError, ValueError):
    """A message carries nothing that identifies its client.

    Neither a client identifier option nor a hardware address. Also a
    `ValueError`; a server drops the message rather than failing.
    """

SocketAddress

pydhcp.SocketAddress

Bases: _SocketAddress

Source code in src/pydhcp/_network/__init__.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
class SocketAddress(_SocketAddress):
    def __new__(cls, ip: _ty.Union[str, _ip.IPv4Address], port: int) -> "SocketAddress":
        if not 0 <= int(port) <= 65535:
            raise _DHCPValueError(f"port out of range: {port} (must be 0-65535)")
        return super(SocketAddress, cls).__new__(cls, _ip.IPv4Address(ip), int(port))

    @classmethod
    def from_socket(cls, sock: _socket.socket) -> "SocketAddress":
        """The local address `sock` is bound to; asks the socket (`getsockname()`)."""
        ip, port = sock.getsockname()[:2]
        return cls(ip, port)

    def to_tuple(self) -> tuple[str, int]:
        """The plain `(host, port)` pair the standard `socket` calls take."""
        return (str(self.ip), self.port)

    @classmethod
    def parse(cls, text: str) -> "SocketAddress":
        """Build from ``"host:port"``, the text `str()` produces.

        Raises `DHCPValueError` when the text is not an IPv4 address and a port
        in 0-65535, and `TypeError` when it is not text.
        """
        if not isinstance(text, str):
            raise TypeError(
                f"SocketAddress.parse takes text, not {type(text).__name__}"
            )
        try:
            host, port = _netimps.split_host(text)
            if port is None:
                raise ValueError("it has no port")
            return cls(host, int(port))
        except (ValueError, _netimps.NetimpsValueError) as exc:
            raise _DHCPValueError(f"not a SocketAddress, {text!r}: {exc}") from exc

    @classmethod
    def try_parse(cls, text: str, default: _ty.Any = None) -> _ty.Any:
        """`parse`, or `default` for text that does not parse; still `TypeError` for a non-text."""
        try:
            return cls.parse(text)
        except _DHCPValueError:
            return default

    def __str__(self) -> str:
        return _netimps.join_host(self.ip, self.port)

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(ip={self.ip}, port={self.port})"

from_socket(sock) classmethod

The local address sock is bound to; asks the socket (getsockname()).

Source code in src/pydhcp/_network/__init__.py
130
131
132
133
134
@classmethod
def from_socket(cls, sock: _socket.socket) -> "SocketAddress":
    """The local address `sock` is bound to; asks the socket (`getsockname()`)."""
    ip, port = sock.getsockname()[:2]
    return cls(ip, port)

parse(text) classmethod

Build from "host:port", the text str() produces.

Raises DHCPValueError when the text is not an IPv4 address and a port in 0-65535, and TypeError when it is not text.

Source code in src/pydhcp/_network/__init__.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
@classmethod
def parse(cls, text: str) -> "SocketAddress":
    """Build from ``"host:port"``, the text `str()` produces.

    Raises `DHCPValueError` when the text is not an IPv4 address and a port
    in 0-65535, and `TypeError` when it is not text.
    """
    if not isinstance(text, str):
        raise TypeError(
            f"SocketAddress.parse takes text, not {type(text).__name__}"
        )
    try:
        host, port = _netimps.split_host(text)
        if port is None:
            raise ValueError("it has no port")
        return cls(host, int(port))
    except (ValueError, _netimps.NetimpsValueError) as exc:
        raise _DHCPValueError(f"not a SocketAddress, {text!r}: {exc}") from exc

to_tuple()

The plain (host, port) pair the standard socket calls take.

Source code in src/pydhcp/_network/__init__.py
136
137
138
def to_tuple(self) -> tuple[str, int]:
    """The plain `(host, port)` pair the standard `socket` calls take."""
    return (str(self.ip), self.port)

try_parse(text, default=None) classmethod

parse, or default for text that does not parse; still TypeError for a non-text.

Source code in src/pydhcp/_network/__init__.py
159
160
161
162
163
164
165
@classmethod
def try_parse(cls, text: str, default: _ty.Any = None) -> _ty.Any:
    """`parse`, or `default` for text that does not parse; still `TypeError` for a non-text."""
    try:
        return cls.parse(text)
    except _DHCPValueError:
        return default

NetworkInterface

pydhcp.NetworkInterface

Bases: NamedTuple

Source code in src/pydhcp/_network/__init__.py
174
175
176
177
178
179
180
181
182
183
184
185
class NetworkInterface(_ty.NamedTuple):
    name: str
    ip_interface: _ty.Union[_ip.IPv4Interface, _ip.IPv6Interface]
    mac: _ty.Optional[_netimps.MACAddress] = None

    @property
    def ip(self) -> _ty.Union[_ip.IPv4Address, _ip.IPv6Address]:
        return self.ip_interface.ip

    @property
    def network(self) -> _ty.Union[_ip.IPv4Network, _ip.IPv6Network]:
        return self.ip_interface.network

pydhcp.lease

pydhcp.lease

Lease storage: the DHCPLease value, the LeaseBackend contract and its two stores.

DHCPLease

One address held by one client: a value, built once and never changed.

ip is the address: never None, and never 0.0.0.0 unless the lease is built with strict=False, for a reservation that has no address yet; whoever takes such a lease checks it with validate(). expires is the instant the lease ends, a timezone-aware datetime held in UTC, or None for a lease that never ends; a naive datetime raises DHCPValueError and any other type TypeError. options is the configuration the lease carries: it is copied in, so the bag the caller holds is not the lease's, and it is read-only; lease.options.copy() is an ordinary bag to change. Two leases with the same address, expiry, option payloads and state are equal and hash equal.

offered is the state: True for an address held for a client that has been offered it and has not yet accepted (expires is then the end of the hold), False, the default, for a binding the client has accepted.

siaddr, sname and file are the BOOTP header fields a reply to the client carries (the next server, its name and the boot file, RFC 2131 Table 3); unset, the reply's are blank. They take part in equality and hash.

Source code in src/pydhcp/lease/_value.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
class DHCPLease:
    """One address held by one client: a value, built once and never changed.

    `ip` is the address: never `None`, and never `0.0.0.0` unless the lease is
    built with `strict=False`, for a reservation that has no address yet; whoever
    takes such a lease checks it with `validate()`. `expires` is the
    instant the lease ends, a timezone-aware `datetime` held in UTC, or `None`
    for a lease that never ends; a naive `datetime` raises `DHCPValueError` and
    any other type `TypeError`. `options` is the configuration the lease carries:
    it is copied in, so the bag the caller holds is not the lease's, and it is
    read-only; `lease.options.copy()` is an ordinary bag to change. Two leases
    with the same address, expiry, option payloads and state are equal and hash
    equal.

    `offered` is the state: True for an address held for a client that has been
    offered it and has not yet accepted (`expires` is then the end of the hold),
    False, the default, for a binding the client has accepted.

    `siaddr`, `sname` and `file` are the BOOTP header fields a reply to the
    client carries (the next server, its name and the boot file, RFC 2131 Table
    3); unset, the reply's are blank. They take part in equality and hash.
    """

    __slots__ = ("ip", "expires", "options", "offered", "siaddr", "sname", "file")

    ip: _ipaddress.IPv4Address
    expires: _ty.Optional[_dt.datetime]
    options: DHCPOptions
    offered: bool
    siaddr: _ipaddress.IPv4Address
    sname: str
    file: str

    def __init__(
        self,
        ip: _Address,
        expires: _ty.Optional[_dt.datetime] = None,
        options: _ty.Optional[DHCPOptions] = None,
        *,
        offered: bool = False,
        siaddr: _Address = _const.UNSPECIFIED_ADDRESS,
        sname: str = "",
        file: str = "",
        strict: bool = True,
    ) -> None:
        if ip is None:
            raise TypeError("a lease needs an address")
        address = _address("ip", ip)
        if strict and address.is_unspecified:
            raise DHCPValueError(
                "a lease needs an address, not 0.0.0.0 (strict=False builds one "
                "without, to be checked with validate())"
            )
        if expires is not None:
            if not isinstance(expires, _dt.datetime):
                raise TypeError(
                    "a lease expires at a timezone-aware datetime or never (None), "
                    f"not {type(expires).__name__}"
                )
            if expires.utcoffset() is None:
                raise DHCPValueError(
                    "a lease expires at a timezone-aware instant, not a naive "
                    "datetime; use datetime.now(timezone.utc)"
                )
            if expires.tzinfo is not _dt.timezone.utc:
                expires = expires.astimezone(_dt.timezone.utc)
        if options is None:
            options = _NO_OPTIONS
        elif not isinstance(options, FrozenDHCPOptions):
            options = FrozenDHCPOptions(options)
        _set = object.__setattr__
        _set(self, "ip", address)
        _set(self, "expires", expires)
        _set(self, "options", options)
        _set(self, "offered", bool(offered))
        _set(self, "siaddr", _address("siaddr", siaddr))
        _set(self, "sname", _bootp_text("sname", sname, _SNAME_FIELD_SIZE))
        _set(self, "file", _bootp_text("file", file, _FILE_FIELD_SIZE))

    def validate(self) -> "DHCPLease":
        """This lease, or `DHCPValueError` when it has no address to give.

        A lease built with `strict=False` may hold `0.0.0.0`; call this before
        using one as a binding. A strict lease always passes.
        """
        if self.ip.is_unspecified:
            raise DHCPValueError("the lease has no address (0.0.0.0)")
        return self

    def replace(
        self,
        *,
        ip: _ty.Union[str, int, bytes, _ipaddress.IPv4Address, _Missing] = _MISSING,
        expires: _ty.Union[_dt.datetime, None, _Missing] = _MISSING,
        options: _ty.Union[DHCPOptions, None, _Missing] = _MISSING,
        offered: _ty.Union[bool, _Missing] = _MISSING,
        siaddr: _ty.Union[_Address, _Missing] = _MISSING,
        sname: _ty.Union[str, _Missing] = _MISSING,
        file: _ty.Union[str, _Missing] = _MISSING,
        strict: bool = True,
    ) -> "DHCPLease":
        """A new lease with the given fields changed and the others carried over.

        An omitted field keeps this lease's value, `options` as the same
        read-only object; a given one is checked as `DHCPLease(...)` checks it,
        so `expires=None` is a lease that never ends. `strict` applies to a new
        `ip`: an address carried over was accepted already. This lease is not
        changed.
        """
        return DHCPLease(
            self.ip if isinstance(ip, _Missing) else ip,
            self.expires if isinstance(expires, _Missing) else expires,
            self.options if isinstance(options, _Missing) else options,
            offered=self.offered if isinstance(offered, _Missing) else offered,
            siaddr=self.siaddr if isinstance(siaddr, _Missing) else siaddr,
            sname=self.sname if isinstance(sname, _Missing) else sname,
            file=self.file if isinstance(file, _Missing) else file,
            strict=strict and not isinstance(ip, _Missing),
        )

    def to_mapping(self) -> _ty.Dict[str, _ty.Any]:
        """The lease as plain data: `ip`, `expires`, `offered`, the boot fields, `options`.

        `expires` is ISO 8601 text in UTC, and is left out for a lease that never
        ends (TOML has no null); `siaddr`, `sname` and `file` are written only
        when set, `sname` and `file` as text or, when not UTF-8, ``{"hex": ...}``;
        `options` is `DHCPOptions.to_mapping`'s form.
        """
        mapping: _ty.Dict[str, _ty.Any] = {"ip": str(self.ip)}
        if self.expires is not None:
            mapping["expires"] = self.expires.isoformat()
        mapping["offered"] = self.offered
        if not self.siaddr.is_unspecified:
            mapping["siaddr"] = str(self.siaddr)
        if self.sname:
            mapping["sname"] = _text_value(self.sname)
        if self.file:
            mapping["file"] = _text_value(self.file)
        mapping["options"] = self.options.to_mapping()
        return mapping

    @classmethod
    def from_mapping(
        cls,
        data: _ty.Mapping[str, _ty.Any],
        *,
        codemap: _ty.Optional[_ty.Type[OptionCode]] = None,
        strict: bool = True,
    ) -> "DHCPLease":
        """Build a lease from a mapping in the shape `to_mapping` produces.

        Only `ip` is required, and with `strict=False` not even that: it is then
        `0.0.0.0` when left out, for `validate()` to catch. `expires` left out or
        null is a lease that never ends, `offered` defaults to False, the boot
        fields to blank and `options` to none. `expires` is ISO 8601 text with an
        offset (`Z` included) or an aware `datetime`; `sname` and `file` are text,
        null or ``{"hex": ...}``; `options` is read by `DHCPOptions.from_mapping`
        with `codemap`. Each value is then checked as `DHCPLease(...)` checks it.
        Other keys are ignored.

        Raises:
            TypeError: a field has the wrong type.
            ValueError: `ip` is missing, or a field cannot be read.
        """
        if "ip" not in data and strict:
            raise ValueError("the lease has no 'ip' field")
        offered = data.get("offered", False)
        if not isinstance(offered, bool):
            raise TypeError(f"offered must be true or false, not {offered!r}")
        return cls(
            data.get("ip", _const.UNSPECIFIED_ADDRESS),
            _instant(data.get("expires")),
            DHCPOptions.from_mapping(data.get("options"), codemap=codemap),
            offered=offered,
            siaddr=data.get("siaddr", _const.UNSPECIFIED_ADDRESS),
            sname=_coerce_bootp_text(data.get("sname"), "sname"),
            file=_coerce_bootp_text(data.get("file"), "file"),
            strict=strict,
        )

    def to_text(self, format: str) -> str:
        """The lease as a document: ``"json"``, ``"yaml"``, ``"toml"`` or ``"ini"``.

        `to_mapping` written out by `pydhcp.packet.structured.dumps`.
        """
        return _structured.dumps(self.to_mapping(), format)

    @classmethod
    def from_text(
        cls,
        text: str,
        format: str,
        *,
        codemap: _ty.Optional[_ty.Type[OptionCode]] = None,
        strict: bool = True,
    ) -> "DHCPLease":
        """`pydhcp.packet.structured.loads` then `from_mapping`, with its errors."""
        return cls.from_mapping(
            _structured.loads(text, format), codemap=codemap, strict=strict
        )

    def __setattr__(self, name: str, value: _ty.Any) -> _ty.NoReturn:
        raise AttributeError("DHCPLease is read-only")

    def __delattr__(self, name: str) -> _ty.NoReturn:
        raise AttributeError("DHCPLease is read-only")

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, DHCPLease):
            return NotImplemented
        return self._key() == other._key()

    def __hash__(self) -> int:
        return hash(self._key())

    def _key(self) -> tuple[_ty.Any, ...]:
        return (
            self.ip,
            self.expires,
            self.options,
            self.offered,
            self.siaddr,
            self.sname,
            self.file,
        )

    def __repr__(self) -> str:
        extra = ", offered=True" if self.offered else ""
        if not self.siaddr.is_unspecified:
            extra += f", siaddr={self.siaddr!r}"
        if self.sname:
            extra += f", sname={self.sname!r}"
        if self.file:
            extra += f", file={self.file!r}"
        if self.ip.is_unspecified:
            extra += ", strict=False"
        return (
            f"DHCPLease(ip={self.ip!r}, expires={self.expires!r}, "
            f"options={self.options!r}{extra})"
        )

    def __reduce__(self) -> tuple[_ty.Any, ...]:
        return (_rebuild, self._key())

    def __copy__(self) -> "DHCPLease":
        return self

    def __deepcopy__(self, memo: _ty.Dict[int, _ty.Any]) -> "DHCPLease":
        return self

from_mapping(data, *, codemap=None, strict=True) classmethod

Build a lease from a mapping in the shape to_mapping produces.

Only ip is required, and with strict=False not even that: it is then 0.0.0.0 when left out, for validate() to catch. expires left out or null is a lease that never ends, offered defaults to False, the boot fields to blank and options to none. expires is ISO 8601 text with an offset (Z included) or an aware datetime; sname and file are text, null or {"hex": ...}; options is read by DHCPOptions.from_mapping with codemap. Each value is then checked as DHCPLease(...) checks it. Other keys are ignored.

Raises:

Type Description
TypeError

a field has the wrong type.

ValueError

ip is missing, or a field cannot be read.

Source code in src/pydhcp/lease/_value.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
@classmethod
def from_mapping(
    cls,
    data: _ty.Mapping[str, _ty.Any],
    *,
    codemap: _ty.Optional[_ty.Type[OptionCode]] = None,
    strict: bool = True,
) -> "DHCPLease":
    """Build a lease from a mapping in the shape `to_mapping` produces.

    Only `ip` is required, and with `strict=False` not even that: it is then
    `0.0.0.0` when left out, for `validate()` to catch. `expires` left out or
    null is a lease that never ends, `offered` defaults to False, the boot
    fields to blank and `options` to none. `expires` is ISO 8601 text with an
    offset (`Z` included) or an aware `datetime`; `sname` and `file` are text,
    null or ``{"hex": ...}``; `options` is read by `DHCPOptions.from_mapping`
    with `codemap`. Each value is then checked as `DHCPLease(...)` checks it.
    Other keys are ignored.

    Raises:
        TypeError: a field has the wrong type.
        ValueError: `ip` is missing, or a field cannot be read.
    """
    if "ip" not in data and strict:
        raise ValueError("the lease has no 'ip' field")
    offered = data.get("offered", False)
    if not isinstance(offered, bool):
        raise TypeError(f"offered must be true or false, not {offered!r}")
    return cls(
        data.get("ip", _const.UNSPECIFIED_ADDRESS),
        _instant(data.get("expires")),
        DHCPOptions.from_mapping(data.get("options"), codemap=codemap),
        offered=offered,
        siaddr=data.get("siaddr", _const.UNSPECIFIED_ADDRESS),
        sname=_coerce_bootp_text(data.get("sname"), "sname"),
        file=_coerce_bootp_text(data.get("file"), "file"),
        strict=strict,
    )

from_text(text, format, *, codemap=None, strict=True) classmethod

pydhcp.packet.structured.loads then from_mapping, with its errors.

Source code in src/pydhcp/lease/_value.py
214
215
216
217
218
219
220
221
222
223
224
225
226
@classmethod
def from_text(
    cls,
    text: str,
    format: str,
    *,
    codemap: _ty.Optional[_ty.Type[OptionCode]] = None,
    strict: bool = True,
) -> "DHCPLease":
    """`pydhcp.packet.structured.loads` then `from_mapping`, with its errors."""
    return cls.from_mapping(
        _structured.loads(text, format), codemap=codemap, strict=strict
    )

replace(*, ip=_MISSING, expires=_MISSING, options=_MISSING, offered=_MISSING, siaddr=_MISSING, sname=_MISSING, file=_MISSING, strict=True)

A new lease with the given fields changed and the others carried over.

An omitted field keeps this lease's value, options as the same read-only object; a given one is checked as DHCPLease(...) checks it, so expires=None is a lease that never ends. strict applies to a new ip: an address carried over was accepted already. This lease is not changed.

Source code in src/pydhcp/lease/_value.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def replace(
    self,
    *,
    ip: _ty.Union[str, int, bytes, _ipaddress.IPv4Address, _Missing] = _MISSING,
    expires: _ty.Union[_dt.datetime, None, _Missing] = _MISSING,
    options: _ty.Union[DHCPOptions, None, _Missing] = _MISSING,
    offered: _ty.Union[bool, _Missing] = _MISSING,
    siaddr: _ty.Union[_Address, _Missing] = _MISSING,
    sname: _ty.Union[str, _Missing] = _MISSING,
    file: _ty.Union[str, _Missing] = _MISSING,
    strict: bool = True,
) -> "DHCPLease":
    """A new lease with the given fields changed and the others carried over.

    An omitted field keeps this lease's value, `options` as the same
    read-only object; a given one is checked as `DHCPLease(...)` checks it,
    so `expires=None` is a lease that never ends. `strict` applies to a new
    `ip`: an address carried over was accepted already. This lease is not
    changed.
    """
    return DHCPLease(
        self.ip if isinstance(ip, _Missing) else ip,
        self.expires if isinstance(expires, _Missing) else expires,
        self.options if isinstance(options, _Missing) else options,
        offered=self.offered if isinstance(offered, _Missing) else offered,
        siaddr=self.siaddr if isinstance(siaddr, _Missing) else siaddr,
        sname=self.sname if isinstance(sname, _Missing) else sname,
        file=self.file if isinstance(file, _Missing) else file,
        strict=strict and not isinstance(ip, _Missing),
    )

to_mapping()

The lease as plain data: ip, expires, offered, the boot fields, options.

expires is ISO 8601 text in UTC, and is left out for a lease that never ends (TOML has no null); siaddr, sname and file are written only when set, sname and file as text or, when not UTF-8, {"hex": ...}; options is DHCPOptions.to_mapping's form.

Source code in src/pydhcp/lease/_value.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def to_mapping(self) -> _ty.Dict[str, _ty.Any]:
    """The lease as plain data: `ip`, `expires`, `offered`, the boot fields, `options`.

    `expires` is ISO 8601 text in UTC, and is left out for a lease that never
    ends (TOML has no null); `siaddr`, `sname` and `file` are written only
    when set, `sname` and `file` as text or, when not UTF-8, ``{"hex": ...}``;
    `options` is `DHCPOptions.to_mapping`'s form.
    """
    mapping: _ty.Dict[str, _ty.Any] = {"ip": str(self.ip)}
    if self.expires is not None:
        mapping["expires"] = self.expires.isoformat()
    mapping["offered"] = self.offered
    if not self.siaddr.is_unspecified:
        mapping["siaddr"] = str(self.siaddr)
    if self.sname:
        mapping["sname"] = _text_value(self.sname)
    if self.file:
        mapping["file"] = _text_value(self.file)
    mapping["options"] = self.options.to_mapping()
    return mapping

to_text(format)

The lease as a document: "json", "yaml", "toml" or "ini".

to_mapping written out by pydhcp.packet.structured.dumps.

Source code in src/pydhcp/lease/_value.py
207
208
209
210
211
212
def to_text(self, format: str) -> str:
    """The lease as a document: ``"json"``, ``"yaml"``, ``"toml"`` or ``"ini"``.

    `to_mapping` written out by `pydhcp.packet.structured.dumps`.
    """
    return _structured.dumps(self.to_mapping(), format)

validate()

This lease, or DHCPValueError when it has no address to give.

A lease built with strict=False may hold 0.0.0.0; call this before using one as a binding. A strict lease always passes.

Source code in src/pydhcp/lease/_value.py
106
107
108
109
110
111
112
113
114
def validate(self) -> "DHCPLease":
    """This lease, or `DHCPValueError` when it has no address to give.

    A lease built with `strict=False` may hold `0.0.0.0`; call this before
    using one as a binding. A strict lease always passes.
    """
    if self.ip.is_unspecified:
        raise DHCPValueError("the lease has no address (0.0.0.0)")
    return self

FileLeaseBackend

Bases: InMemoryLeaseBackend

Leases persisted to JSON.

Every mutation writes the whole file by default, which is O(store) each time. SAVE_INTERVAL_SECONDS above zero coalesces instead: the file is rewritten at most that often. A change after a quiet interval is written at once; one that comes sooner is written by a daemon timer thread at the end of the interval, so a change reaches the disk within SAVE_INTERVAL_SECONDS of being made and no later. flush() and close() write at once and cancel the timer.

Off by default deliberately. Coalescing trades a property the operator cannot see going wrong -- up to an interval of leases lost on a crash -- for one they can already measure and which MAX_LEASES already bounds. A default that silently gives up durability for throughput is the wrong way round; a deployment that wants the trade can ask for it.

Source code in src/pydhcp/lease/_file.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
class FileLeaseBackend(InMemoryLeaseBackend):
    """Leases persisted to JSON.

    Every mutation writes the whole file by default, which is O(store) each
    time. `SAVE_INTERVAL_SECONDS` above zero coalesces instead: the file is
    rewritten at most that often. A change after a quiet interval is written at
    once; one that comes sooner is written by a daemon timer thread at the end of
    the interval, so a change reaches the disk within `SAVE_INTERVAL_SECONDS` of
    being made and no later. `flush()` and `close()` write at once and cancel the
    timer.

    Off by default deliberately. Coalescing trades a property the operator
    cannot see going wrong -- up to an interval of leases lost on a crash -- for
    one they can already measure and which `MAX_LEASES` already bounds. A
    default that silently gives up durability for throughput is the wrong way
    round; a deployment that wants the trade can ask for it.
    """

    #: Seconds to coalesce writes over. 0 writes on every mutation.
    SAVE_INTERVAL_SECONDS: float = 0.0

    def __init__(self, filepath: str) -> None:
        # Constructing reads nothing: the file is read by `open()`, which the
        # first lease operation (and `with`) calls.
        self._loaded = False
        super().__init__()
        self.filepath = filepath
        self._dirty = False
        self._last_save = float("-inf")
        #: The pending write of a coalescing store, if one is armed.
        self._timer: _ty.Optional[_threading.Timer] = None

    @property
    def _leases(self) -> _ty.Dict[str, DHCPLease]:
        if not self._loaded:
            self.open()
        return self._store

    @_leases.setter
    def _leases(self, value: _ty.Dict[str, DHCPLease]) -> None:
        self._store = value

    def open(self) -> None:
        """Read the lease file, once. A missing file is an empty store.

        The file is read whole or not at all: a bad entry, a duplicated address
        or more live leases than `MAX_LEASES` leaves the store empty and moves the
        file aside as `<path>.corrupt` (`.corrupt.1`, `.corrupt.2`, ... when that
        name is taken). Called by the first lease operation if the caller has
        not, and by `with`.
        """
        with self._lock:
            if self._loaded:
                return
            self._loaded = True
            self._load()

    def flush(self) -> None:
        """Write if anything is pending. Safe to call when nothing is."""
        with self._lock:
            if self._dirty:
                self._write_now()

    def close(self) -> None:
        """Flush before going away, so a clean shutdown loses nothing."""
        self.flush()

    def __enter__(self) -> "FileLeaseBackend":
        self.open()
        return self

    def __exit__(self, *_exc: _ty.Any) -> None:
        self.close()

    def _load(self) -> None:
        """Read the file whole or not at all.

        Every entry is parsed into a dict of its own first; only when all of
        them are good, and there are no more than `MAX_LEASES`, is the store
        filled. Anything else leaves the store empty and the file set aside.
        """
        if not _os.path.exists(self.filepath):
            return
        try:
            with open(self.filepath, "r", encoding="utf-8") as f:
                data = _json.load(f)
            loaded = self._parse(data)
        except Exception as e:
            # Swallowing this started the server with an empty store and then
            # overwrote the file on the next save, so a truncated lease file
            # destroyed every lease with nothing said. Keep the bad file: it is
            # the only copy of that state, and it is recoverable by hand.
            LOGGER.error(
                f"Could not read lease file {self.filepath}: "
                f"{e.__class__.__name__} | {e}"
            )
            self._quarantine_unreadable_file()
            return
        for client_id, lease in loaded.items():
            self._put(client_id, lease)

    def _parse(self, data: _ty.Any) -> _ty.Dict[str, DHCPLease]:
        """The live leases `data` holds; `ValueError` or the like for a bad one."""
        now = self._now()
        loaded: _ty.Dict[str, DHCPLease] = {}
        holders: _ty.Dict[_ipaddress.IPv4Address, str] = {}
        for client_id, lease_data in data.items():
            ip_str = lease_data.get("ip")
            if not ip_str:
                LOGGER.warning(
                    f"Skipping the lease of {client_id} in {self.filepath}: "
                    "it has no address"
                )
                continue
            state = lease_data.get("state", "bound")
            if state not in ("offered", "bound"):
                raise ValueError(f"unknown lease state {state!r}")
            exp_str = lease_data.get("expires")
            expires: _ty.Optional[_dt.datetime] = None
            if exp_str and exp_str != "inf":
                expires = _dt.datetime.fromisoformat(exp_str)
                if expires.utcoffset() is None:
                    # Written as naive local time, once, by an older version:
                    # read as the local time it was, the same instant.
                    expires = expires.astimezone()

            opts = DHCPOptions()
            for code_str, val_hex in lease_data.get("options", {}).items():
                opts[int(code_str)] = bytearray.fromhex(val_hex)
            address = _ipaddress.IPv4Address(ip_str)
            if address in holders:
                raise ValueError(
                    f"{address} is held by both {holders[address]} and {client_id}"
                )
            lease = DHCPLease(
                ip=address,
                expires=expires,
                options=opts,
                offered=state == "offered",
                siaddr=lease_data.get("siaddr", "0.0.0.0"),
                sname=_nvt.decode(bytes.fromhex(lease_data.get("sname", ""))),
                file=_nvt.decode(bytes.fromhex(lease_data.get("file", ""))),
            )
            holders[address] = client_id
            # A lease that has run out is absent by contract, so it neither
            # loads nor counts toward the bound.
            if lease.expires is None or lease.expires > now:
                loaded[client_id] = lease
        if len(loaded) > self.MAX_LEASES:
            raise ValueError(
                f"the file holds {len(loaded)} leases, more than MAX_LEASES "
                f"({self.MAX_LEASES})"
            )
        return loaded

    def _quarantine_unreadable_file(self) -> None:
        """Move the file to `<path>.corrupt`, or `.corrupt.1`, `.corrupt.2`, ... when taken.

        An earlier set-aside copy is never overwritten: each is the only copy
        of the state it held.
        """
        damaged = f"{self.filepath}.corrupt"
        attempt = 0
        while _os.path.exists(damaged):
            attempt += 1
            damaged = f"{self.filepath}.corrupt.{attempt}"
        try:
            _os.replace(self.filepath, damaged)
        except Exception as e:  # pragma: no cover - unreadable and unmovable
            LOGGER.error(
                f"Could not set aside the unreadable lease file: "
                f"{e.__class__.__name__} | {e}"
            )
            return
        LOGGER.error(
            f"Moved the unreadable lease file to {damaged}; starting with no "
            "leases. Recover it by hand rather than losing the bindings."
        )

    def _save(self) -> None:
        """Record that the store changed, and write at once or arm the timer."""
        with self._lock:
            self._dirty = True
            if self.SAVE_INTERVAL_SECONDS <= 0:
                self._write_now()
                return
            wait = self._last_save + self.SAVE_INTERVAL_SECONDS - _time.monotonic()
            if wait <= 0:
                self._write_now()
            elif self._timer is None:
                timer = _threading.Timer(wait, self._write_when_due)
                timer.daemon = True
                timer.name = "pydhcp-lease-save"
                self._timer = timer
                timer.start()

    def _write_when_due(self) -> None:
        """The timer's call: write what changed since the last write."""
        with self._lock:
            self._timer = None
            if self._dirty:
                self._write_now()

    def _write_now(self) -> None:
        with self._lock:
            if self._timer is not None:
                self._timer.cancel()
                self._timer = None
            self._dirty = False
            self._last_save = _time.monotonic()
            # First clean up expired leases
            for client_id in list(self._leases.keys()):
                self.lookup(client_id)

            data = {}
            for client_id, lease in self._leases.items():
                exp_str = "inf" if lease.expires is None else lease.expires.isoformat()
                opts_data = {}
                for code, option in lease.options.items(decoded=False):
                    opts_data[str(int(code))] = option.hex()
                entry: _ty.Dict[str, _ty.Any] = {
                    "ip": str(lease.ip),
                    "expires": exp_str,
                    "state": "offered" if lease.offered else "bound",
                    "options": opts_data,
                }
                # Written only when set: an entry without them is a lease without them.
                if not lease.siaddr.is_unspecified:
                    entry["siaddr"] = str(lease.siaddr)
                if lease.sname:
                    entry["sname"] = _nvt.encode(lease.sname).hex()
                if lease.file:
                    entry["file"] = _nvt.encode(lease.file).hex()
                data[client_id] = entry
            self._write_atomically(data)

    #: Attempts at the final rename, and the pause between them.
    REPLACE_ATTEMPTS = 5
    REPLACE_BACKOFF_SECONDS = 0.02

    def _replace_with_retry(self, temp_path: str) -> None:
        """Rename the finished file over the target, retrying a held lock.

        POSIX renames over an open file happily. Windows refuses while any
        handle is open, and a search indexer, a backup agent or an antivirus
        scanner opening a file it just saw written is ordinary there -- so the
        first attempt can fail for a reason that is gone milliseconds later.
        """
        for attempt in range(self.REPLACE_ATTEMPTS):
            try:
                _os.replace(temp_path, self.filepath)
                return
            except PermissionError:
                if attempt == self.REPLACE_ATTEMPTS - 1:
                    raise
                _time.sleep(self.REPLACE_BACKOFF_SECONDS * (attempt + 1))

    def _write_atomically(self, data: _ty.Dict[str, _ty.Any]) -> None:
        """Write the whole file or none of it.

        `open(path, "w")` truncates first, so an interrupted write -- a crash, a
        full disk, two threads saving at once -- left a half-written file that
        `_load` then rejected, silently discarding every lease. Writing a
        temporary file in the same directory and renaming it over the target
        makes the replacement atomic for readers on POSIX and Windows alike, and
        leaves the previous contents intact if anything fails.
        """
        directory = _os.path.dirname(_os.path.abspath(self.filepath))
        handle = None
        temp_path = None
        try:
            fd, temp_path = _tempfile.mkstemp(
                dir=directory, prefix=".leases-", suffix=".tmp"
            )
            handle = _os.fdopen(fd, "w", encoding="utf-8", newline="\n")
            _json.dump(data, handle, indent=2)
            handle.flush()
            _os.fsync(handle.fileno())
            handle.close()
            handle = None
            self._replace_with_retry(temp_path)
            temp_path = None
        except Exception as e:
            # Not raised: a lease store that cannot be written must not take the
            # server down mid-exchange. It is logged, because the caller was
            # handed a lease it believes is persisted.
            LOGGER.error(
                f"Could not persist leases to {self.filepath}: "
                f"{e.__class__.__name__} | {e}"
            )
        finally:
            if handle is not None:
                try:
                    handle.close()
                except Exception:  # pragma: no cover - already failing
                    pass
            if temp_path is not None and _os.path.exists(temp_path):
                try:
                    _os.remove(temp_path)
                except Exception:  # pragma: no cover - best effort
                    pass

    def allocate(
        self,
        client_id: str,
        ip: _ipaddress.IPv4Address,
        ttl: float,
        options: _ty.Optional[DHCPOptions] = None,
    ) -> _ty.Optional[DHCPLease]:
        lease = super().allocate(client_id, ip, ttl, options)
        if lease:
            self._save()
        return lease

    def offer(
        self,
        client_id: str,
        ip: _ipaddress.IPv4Address,
        hold_seconds: float,
        options: _ty.Optional[DHCPOptions] = None,
    ) -> _ty.Optional[DHCPLease]:
        # Not saved: an offer is short and means nothing after a restart, and a
        # whole-file rewrite per forged DISCOVER would be a cost the sender
        # chooses. A later save writes whichever offers are still held.
        return super().offer(client_id, ip, hold_seconds, options)

    def commit(self, client_id: str, ttl: float) -> _ty.Optional[DHCPLease]:
        lease = super().commit(client_id, ttl)
        if lease:
            self._save()
        return lease

    def release(self, client_id: str) -> bool:
        dropped = self._release(client_id)
        if dropped is not None and not dropped.offered:
            self._save()
        return dropped is not None

    def renew(self, client_id: str, ttl: float) -> _ty.Optional[DHCPLease]:
        lease = super().renew(client_id, ttl)
        if lease:
            self._save()
        return lease

close()

Flush before going away, so a clean shutdown loses nothing.

Source code in src/pydhcp/lease/_file.py
88
89
90
def close(self) -> None:
    """Flush before going away, so a clean shutdown loses nothing."""
    self.flush()

flush()

Write if anything is pending. Safe to call when nothing is.

Source code in src/pydhcp/lease/_file.py
82
83
84
85
86
def flush(self) -> None:
    """Write if anything is pending. Safe to call when nothing is."""
    with self._lock:
        if self._dirty:
            self._write_now()

open()

Read the lease file, once. A missing file is an empty store.

The file is read whole or not at all: a bad entry, a duplicated address or more live leases than MAX_LEASES leaves the store empty and moves the file aside as <path>.corrupt (.corrupt.1, .corrupt.2, ... when that name is taken). Called by the first lease operation if the caller has not, and by with.

Source code in src/pydhcp/lease/_file.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def open(self) -> None:
    """Read the lease file, once. A missing file is an empty store.

    The file is read whole or not at all: a bad entry, a duplicated address
    or more live leases than `MAX_LEASES` leaves the store empty and moves the
    file aside as `<path>.corrupt` (`.corrupt.1`, `.corrupt.2`, ... when that
    name is taken). Called by the first lease operation if the caller has
    not, and by `with`.
    """
    with self._lock:
        if self._loaded:
            return
        self._loaded = True
        self._load()

InMemoryLeaseBackend

Leases in a dict, guarded by a re-entrant lock.

The lock makes each operation atomic against the others: a server started with start() handles packets on a background thread, the async server on a worker thread, and one backend can be shared by several of them. It does not make a caller's compound operation atomic -- "is this address free, and if so allocate it" is two calls, and two threads can still interleave across them. Hold self._lock around such a sequence if that matters.

The store is bounded by MAX_LEASES. Nothing about a DHCP client is authenticated, so a flood of forged client identifiers is otherwise an unbounded allocation -- and, for FileLeaseBackend, a whole-file rewrite per forged identity, which is quadratic.

Source code in src/pydhcp/lease/_store.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
class InMemoryLeaseBackend:
    """Leases in a dict, guarded by a re-entrant lock.

    The lock makes each operation atomic against the others: a server started
    with `start()` handles packets on a background thread, the async server on a
    worker thread, and one backend can be shared by several of them. It does
    **not** make a caller's compound operation atomic -- "is this address free,
    and if so allocate it" is two calls, and two threads can still interleave
    across them. Hold `self._lock` around such a sequence if that matters.

    The store is bounded by `MAX_LEASES`. Nothing about a DHCP client is
    authenticated, so a flood of forged client identifiers is otherwise an
    unbounded allocation -- and, for `FileLeaseBackend`, a whole-file rewrite
    per forged identity, which is quadratic.
    """

    #: Upper bound on stored leases. At the cap, expired entries are reclaimed
    #: first and a *new* client is then refused; established bindings are never
    #: evicted to make room. Evicting the least-recently-used would be exactly
    #: backwards under a flood -- the forged identities are the newest, so LRU
    #: would drop the long-lived real clients and keep the attacker's.
    MAX_LEASES = 10_000

    def __init__(self) -> None:
        self._leases: _ty.Dict[str, DHCPLease] = {}
        #: The client holding each address, in either state: `lookup_by_ip`
        #: answers from it instead of walking the store.
        self._by_ip: _ty.Dict[_ipaddress.IPv4Address, str] = {}
        #: Re-entrant: `lookup_by_ip` and `renew` call `lookup` while holding it.
        self._lock = _threading.RLock()
        #: New clients turned away because the store was full. Visible so an
        #: operator can tell "nobody is asking" from "everybody is refused".
        self.refused_while_full = 0
        self._last_full_log = float("-inf")

    def _now(self) -> _dt.datetime:
        """The instant every expiry is read on: aware, UTC."""
        return _dt.datetime.now(_dt.timezone.utc)

    def _put(self, client_id: str, lease: DHCPLease) -> None:
        """Store `lease` for `client_id` and keep the address index true."""
        leases = self._leases
        old = leases.get(client_id)
        if old is not None and self._by_ip.get(old.ip) == client_id:
            del self._by_ip[old.ip]
        leases[client_id] = lease
        self._by_ip[lease.ip] = client_id

    def _drop(self, client_id: str) -> _ty.Optional[DHCPLease]:
        """Remove `client_id`'s record and its index entry; the record, if any."""
        old = self._leases.pop(client_id, None)
        if old is not None and self._by_ip.get(old.ip) == client_id:
            del self._by_ip[old.ip]
        return old

    def _other_holder(
        self, client_id: str, ip: _ipaddress.IPv4Address
    ) -> _ty.Optional[str]:
        """The client other than `client_id` holding `ip`, in either state."""
        holder = self.lookup_by_ip(ip)
        return holder if holder is not None and holder != client_id else None

    def allocate(
        self,
        client_id: str,
        ip: _ipaddress.IPv4Address,
        ttl: float,
        options: _ty.Optional[DHCPOptions] = None,
    ) -> _ty.Optional[DHCPLease]:
        with self._lock:
            if self._other_holder(client_id, ip) is not None:
                return None
            if self.lookup(client_id) is None and not self._make_room():
                self._report_full()
                return None
            lease = DHCPLease(ip=ip, expires=self._expiry(ttl), options=options)
            self._put(client_id, lease)
            return lease

    def offer(
        self,
        client_id: str,
        ip: _ipaddress.IPv4Address,
        hold_seconds: float,
        options: _ty.Optional[DHCPOptions] = None,
    ) -> _ty.Optional[DHCPLease]:
        if not 0 < hold_seconds < _inf:
            raise ValueError(
                f"an offer is held for a finite positive time, not {hold_seconds!r}"
            )
        with self._lock:
            if self._other_holder(client_id, ip) is not None:
                return None
            existing = self.lookup(client_id)
            if existing is not None and not existing.offered:
                return existing if existing.ip == ip else None
            if existing is None and not self._make_room():
                self._report_full()
                return None
            lease = DHCPLease(
                ip=ip,
                expires=self._expiry(hold_seconds),
                options=options,
                offered=True,
            )
            self._put(client_id, lease)
            return lease

    def commit(self, client_id: str, ttl: float) -> _ty.Optional[DHCPLease]:
        with self._lock:
            lease = self.lookup(client_id)
            if lease is None or not lease.offered:
                return None
            bound = lease.replace(expires=self._expiry(ttl), offered=False)
            self._put(client_id, bound)
            return bound

    def _expiry(self, ttl: float) -> _ty.Optional[_dt.datetime]:
        """The instant `ttl` seconds from now, or `None` for no expiry."""
        if ttl == _inf:
            return None
        return self._now() + _dt.timedelta(seconds=ttl)

    #: Seconds between "store is full" reports. The condition is reached once
    #: per refused packet, and the thing that fills the store is a flood -- so
    #: logging each one hands the attacker the log as a second target, which is
    #: the trap the per-packet unknown-htype warning fell into.
    FULL_LOG_INTERVAL_SECONDS = 60.0

    def _report_full(self) -> None:
        """Report a full store at most once per interval. Caller holds the lock."""
        self.refused_while_full += 1
        now = _time.monotonic()
        if now - self._last_full_log < self.FULL_LOG_INTERVAL_SECONDS:
            return
        self._last_full_log = now
        LOGGER.warning(
            f"Lease store is full ({len(self._leases)}/{self.MAX_LEASES}); refusing "
            f"new clients. {self.refused_while_full} refused so far. Established "
            "bindings are kept; raise MAX_LEASES if this is legitimate demand."
        )

    def _make_room(self) -> bool:
        """Whether there is room for one more client. Caller holds the lock.

        Reclaims expired leases before answering: under a flood most of the
        store is forged entries that expire on their own, so a sweep usually
        makes room without touching anyone real.
        """
        if len(self._leases) < self.MAX_LEASES:
            return True
        now = self._now()
        for client_id in list(self._leases):
            expires = self._leases[client_id].expires
            if expires is not None and expires <= now:
                self._drop(client_id)
        return len(self._leases) < self.MAX_LEASES

    def lookup(self, client_id: str) -> _ty.Optional[DHCPLease]:
        with self._lock:
            lease = self._leases.get(client_id)
            if lease is None:
                return None
            # `<=`, not `<`: a lease whose expiry instant has arrived is over,
            # not live for one more tick. With `<`, a zero-second TTL survived
            # whenever both clock reads landed in the same tick, which the
            # coarse clock of the 3.9 floor makes routine.
            if lease.expires is not None and lease.expires <= self._now():
                self._drop(client_id)
                return None
            return lease

    def lookup_by_ip(self, ip: _ipaddress.IPv4Address) -> _ty.Optional[str]:
        """Return the client currently holding `ip`, offered or bound, if any.

        Optional backend extension, not part of the `LeaseBackend` Protocol: a
        backend that does not provide it simply skips the allocator's
        already-in-use check. Answered from an index, so the cost does not grow
        with the number of leases.
        """
        with self._lock:
            self._leases  # a file-backed store loads here, before the index is read
            client_id = self._by_ip.get(ip)
            if client_id is None:
                return None
            lease = self.lookup(client_id)
            if lease is not None and lease.ip == ip:
                return client_id
            return None

    def release(self, client_id: str) -> bool:
        return self._release(client_id) is not None

    def _release(self, client_id: str) -> _ty.Optional[DHCPLease]:
        with self._lock:
            return self._drop(client_id)

    def renew(self, client_id: str, ttl: float) -> _ty.Optional[DHCPLease]:
        with self._lock:
            lease = self.lookup(client_id)
            if lease is None or lease.offered:
                return None
            renewed = lease.replace(expires=self._expiry(ttl))
            self._put(client_id, renewed)
            return renewed

lookup_by_ip(ip)

Return the client currently holding ip, offered or bound, if any.

Optional backend extension, not part of the LeaseBackend Protocol: a backend that does not provide it simply skips the allocator's already-in-use check. Answered from an index, so the cost does not grow with the number of leases.

Source code in src/pydhcp/lease/_store.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
def lookup_by_ip(self, ip: _ipaddress.IPv4Address) -> _ty.Optional[str]:
    """Return the client currently holding `ip`, offered or bound, if any.

    Optional backend extension, not part of the `LeaseBackend` Protocol: a
    backend that does not provide it simply skips the allocator's
    already-in-use check. Answered from an index, so the cost does not grow
    with the number of leases.
    """
    with self._lock:
        self._leases  # a file-backed store loads here, before the index is read
        client_id = self._by_ip.get(ip)
        if client_id is None:
            return None
        lease = self.lookup(client_id)
        if lease is not None and lease.ip == ip:
            return client_id
        return None

LeaseBackend

Bases: Protocol

Where leases live. ttl is seconds, or math.inf for no expiry (a lease whose expires is None).

A client's record is in one of two states, told apart by DHCPLease.offered. Offered: the address is held for that client for hold_seconds after a DHCPDISCOVER, and expires is the end of the hold. Bound: the client accepted it, and expires is the end of the lease. An offer that is not committed in time is an expired record: lookup returns None for it and the address is free for another client. Every method is atomic against the others, and each treats a record whose expires has passed as absent.

ttl is typed float rather than int because math.inf is a float and the implementations have always accepted it. An int is still accepted: every int is a float to the type system.

Source code in src/pydhcp/lease/_store.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class LeaseBackend(_ty.Protocol):
    """Where leases live. `ttl` is seconds, or `math.inf` for no expiry (a lease
    whose `expires` is `None`).

    A client's record is in one of two states, told apart by `DHCPLease.offered`.
    **Offered**: the address is held for that client for `hold_seconds` after a
    DHCPDISCOVER, and `expires` is the end of the hold. **Bound**: the client
    accepted it, and `expires` is the end of the lease. An offer that is not
    committed in time is an expired record: `lookup` returns `None` for it and
    the address is free for another client. Every method is atomic against the
    others, and each treats a record whose `expires` has passed as absent.

    `ttl` is typed `float` rather than `int` because `math.inf` is a float and
    the implementations have always accepted it. An `int` is still accepted:
    every int is a float to the type system.
    """

    def allocate(
        self,
        client_id: str,
        ip: _ipaddress.IPv4Address,
        ttl: float,
        options: _ty.Optional[DHCPOptions] = None,
    ) -> _ty.Optional[DHCPLease]:
        """An offer and its commit in one call: a bound lease for `ttl`.

        Replaces whatever record `client_id` has. `None` when another client
        holds `ip`, or when the store is full and `client_id` is new.
        """
        ...

    def offer(
        self,
        client_id: str,
        ip: _ipaddress.IPv4Address,
        hold_seconds: float,
        options: _ty.Optional[DHCPOptions] = None,
    ) -> _ty.Optional[DHCPLease]:
        """Hold `ip` for `client_id` for `hold_seconds` and return the offered lease.

        `None` when another client holds `ip` (offered or bound), when the store
        is full and `client_id` is new, or when `client_id` holds a bound lease
        on another address (an offer never replaces a binding). A client that
        holds a bound lease on `ip` is returned that lease unchanged. A client
        with an outstanding offer has it replaced.
        """
        ...

    def commit(self, client_id: str, ttl: float) -> _ty.Optional[DHCPLease]:
        """Turn the client's offered lease into a bound one lasting `ttl`.

        `None` when the client has no offered lease (none, expired, or already
        bound).
        """
        ...

    def lookup(self, client_id: str) -> _ty.Optional[DHCPLease]:
        """The client's lease, offered or bound, or `None`."""
        ...

    def release(self, client_id: str) -> bool:
        """Drop the client's record, in either state; whether there was one."""
        ...

    def renew(self, client_id: str, ttl: float) -> _ty.Optional[DHCPLease]:
        """Extend a **bound** lease to `ttl` from now; `None` for no lease or an offer."""
        ...

allocate(client_id, ip, ttl, options=None)

An offer and its commit in one call: a bound lease for ttl.

Replaces whatever record client_id has. None when another client holds ip, or when the store is full and client_id is new.

Source code in src/pydhcp/lease/_store.py
38
39
40
41
42
43
44
45
46
47
48
49
50
def allocate(
    self,
    client_id: str,
    ip: _ipaddress.IPv4Address,
    ttl: float,
    options: _ty.Optional[DHCPOptions] = None,
) -> _ty.Optional[DHCPLease]:
    """An offer and its commit in one call: a bound lease for `ttl`.

    Replaces whatever record `client_id` has. `None` when another client
    holds `ip`, or when the store is full and `client_id` is new.
    """
    ...

commit(client_id, ttl)

Turn the client's offered lease into a bound one lasting ttl.

None when the client has no offered lease (none, expired, or already bound).

Source code in src/pydhcp/lease/_store.py
69
70
71
72
73
74
75
def commit(self, client_id: str, ttl: float) -> _ty.Optional[DHCPLease]:
    """Turn the client's offered lease into a bound one lasting `ttl`.

    `None` when the client has no offered lease (none, expired, or already
    bound).
    """
    ...

lookup(client_id)

The client's lease, offered or bound, or None.

Source code in src/pydhcp/lease/_store.py
77
78
79
def lookup(self, client_id: str) -> _ty.Optional[DHCPLease]:
    """The client's lease, offered or bound, or `None`."""
    ...

offer(client_id, ip, hold_seconds, options=None)

Hold ip for client_id for hold_seconds and return the offered lease.

None when another client holds ip (offered or bound), when the store is full and client_id is new, or when client_id holds a bound lease on another address (an offer never replaces a binding). A client that holds a bound lease on ip is returned that lease unchanged. A client with an outstanding offer has it replaced.

Source code in src/pydhcp/lease/_store.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def offer(
    self,
    client_id: str,
    ip: _ipaddress.IPv4Address,
    hold_seconds: float,
    options: _ty.Optional[DHCPOptions] = None,
) -> _ty.Optional[DHCPLease]:
    """Hold `ip` for `client_id` for `hold_seconds` and return the offered lease.

    `None` when another client holds `ip` (offered or bound), when the store
    is full and `client_id` is new, or when `client_id` holds a bound lease
    on another address (an offer never replaces a binding). A client that
    holds a bound lease on `ip` is returned that lease unchanged. A client
    with an outstanding offer has it replaced.
    """
    ...

release(client_id)

Drop the client's record, in either state; whether there was one.

Source code in src/pydhcp/lease/_store.py
81
82
83
def release(self, client_id: str) -> bool:
    """Drop the client's record, in either state; whether there was one."""
    ...

renew(client_id, ttl)

Extend a bound lease to ttl from now; None for no lease or an offer.

Source code in src/pydhcp/lease/_store.py
85
86
87
def renew(self, client_id: str, ttl: float) -> _ty.Optional[DHCPLease]:
    """Extend a **bound** lease to `ttl` from now; `None` for no lease or an offer."""
    ...