Skip to content

API Reference

Every public name, generated from the package's own docstrings.

For the contracts and gotchas in prose form — including the platform differences that are easy to get wrong — see src/netimps/AGENTS.md in the repository, which lists the per-package headers beside the code (_dns, _sockets, _udp and the others).

netimps -- small, self-contained network utilities.

A thin, typed convenience layer over the standard library's :mod:ipaddress plus a handful of host helpers (DNS lookup, ping, interface discovery). One flat import surface; no hard runtime dependencies. :func:resolve chains dnspython/OS-resolver/nslookup backends, using whichever is available -- dnspython (install with the dns extra) is optional but gives the fullest one.

::

import netimps

netimps.parse("10.0.0.5")                           # -> IPv4Address
netimps.MACAddress("AA:BB:CC:DD:EE:FF").format("-")
netimps.resolve("example.com", "aaaa")
for iface in netimps.get_interfaces():
    print(iface.name, iface.mac, iface.ips)

Types and parsing

IPAddress/IPInterface/IPNetwork are the v4/v6 unions you annotate with, reading the way :class:ipaddress.IPv4Address does::

def get_route(dst: netimps.IPAddress, via: netimps.IPNetwork) -> None: ...

The same names are what you parse into, via one entry point::

parse(value, IPNetwork)              # raises on bad input
try_parse(value, IPNetwork)          # None instead
is_valid(value, IPNetwork)           # bool convertibility check

All IP/network values are the concrete :mod:ipaddress classes, so .exploded, .network_address, .netmask and addr in network membership all behave exactly as the stdlib does.

AddressInUseError

Bases: NetimpsError, OSError

The address is taken -- one stable type, whatever the platform called it.

"The port is already bound" surfaces as three different shapes depending on the flags and the interpreter. Measured on Windows 11 ARM64 against an exclusive holder:

========================== ================== ======== ========== call type errno winerror ========================== ================== ======== ========== 3.14, allow_takeover PermissionError 13 10013 3.9, allow_takeover OSError 10013 10013 either, plain OSError 10048 10048 ========================== ================== ======== ==========

The 3.14 row is the harmful one: PermissionError says "privilege problem", and on Windows there is no such thing for a port -- the address is simply held. A caller branching on the type then sends its user after an elevation problem that cannot exist -- and the alternative is a wrapper that re-derives the fact by string-matching :func:bind_error_hint's message, which is worse.

So :func:bind raises this instead, with errno normalised to EADDRINUSE, the hint as the message, and the original exception chained as __cause__ -- so winerror and the platform's own code stay reachable for anyone who wants them.

Subclasses :class:OSError and deliberately not :class:PermissionError: every existing except OSError keeps working, while except PermissionError stops catching a case that was never about permission. A genuine privilege failure -- POSIX EACCES on a port below 1024 -- is left exactly as it was.

DeviceBindingUnsupportedError

Bases: NetimpsError, OSError

bind(device=...) on a platform with no way to restrict a socket to a device.

Raised before any socket is opened, with errno set to ENOPROTOOPT and a message naming :func:has_device_binding, which says in advance whether a platform can. An :class:OSError, so an except OSError around a bind keeps catching it.

DNSDecodeError

Bases: NetimpsValueError

Bytes or a name that the DNS message codec cannot read or write: a truncated or looping reply, a reply for another query, an empty or over-long label, a record type the codec does not handle.

NetimpsError

Bases: Exception

The base of every exception netimps raises on its own account.

NetimpsValueError

Bases: NetimpsError, ValueError

Text that is not the value it was asked to become: a malformed address, MAC, domain name or host:port.

Also a :class:ValueError, so except ValueError keeps catching it.

NoAnswerError

Bases: ResolutionError

The lookup completed and there is no such name or no such record.

What check=True on :class:~netimps.Host and :class:~netimps.FQDN raises for an empty answer, as opposed to a plain :class:ResolutionError for a resolver that could not be asked. The difference matters to :func:netimps.retry: an outage is worth another attempt, a name that does not exist is not.

ResolutionError

Bases: NetimpsError, OSError

A backend could not even attempt the query (missing binary, unsupported rdtype, a server that did not answer, a transport or setup failure). Distinct from a definitive DNS answer of "no such record", which is [], not an exception.

An :class:OSError, as :class:socket.gaierror is: a caller who writes except OSError around a connect already expects name failures in it, and :func:netimps.retry retries it by default.

ResolutionTimeoutError

Bases: ResolutionError, TimeoutError

A resolution backend's deadline expired before it had an answer.

Caught by except ResolutionError (so the :func:netimps.resolve chain moves on) and by except TimeoutError.

Host(value)

A host named by either an address or a hostname.

Config files and URLs hold "the host" as a string that may be either, and the useful operations differ. This keeps the original text and resolves on demand::

host = Host("db.internal")
host.ip()                  # IPv4Address(...) once DNS answers
str(host)                  # 'db.internal' -- always the original

Host("10.0.0.5").is_address    # True, no DNS involved

The point is that str(host) is always what was given, so a URL can still be rebuilt when resolution fails -- the case a bare lookup handles badly, since it returns None and loses the name.

Source code in src/netimps/_ip/_host.py
109
110
111
112
113
114
115
116
117
118
def __init__(self, value: "Optional[HostLike]") -> None:
    if isinstance(value, Host):
        value = value.value
    elif value is not None:
        # An interface is its address and a network is no host, as for
        # every `dst` parameter.
        value = _dst_argument(value)
    object.__setattr__(self, "value", "" if value is None else str(value).strip())
    object.__setattr__(self, "_resolved", None)
    object.__setattr__(self, "_attempted", False)

is_address property

True if the value is already an IP literal -- no DNS needed.

parse(text) classmethod

Build a Host from text naming an address or a hostname.

A Host keeps what it was given, so no spelling is rejected for its form; text with nothing in it is, since there is no host to name. The constructor is the lenient entry that takes None and "".

:raises NetimpsValueError: for empty or blank text. It is a ValueError. :raises TypeError: for anything that is not str.

Source code in src/netimps/_ip/_host.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
@classmethod
def parse(cls: "Type[_H]", text: str) -> "_H":
    """Build a ``Host`` from text naming an address or a hostname.

    A ``Host`` keeps what it was given, so no spelling is rejected for its
    form; text with nothing in it is, since there is no host to name.
    The constructor is the lenient entry that takes ``None`` and ``""``.

    :raises NetimpsValueError: for empty or blank text. It is a
        ``ValueError``.
    :raises TypeError: for anything that is not ``str``.
    """
    if not isinstance(text, str):
        raise TypeError("Host.parse takes text, not %r" % (type(text).__name__,))
    if not text.strip():
        raise NetimpsValueError("a host cannot be empty")
    return cls(text)

try_parse(text, default=None) classmethod

try_parse(text: str) -> 'Optional[_H]'
try_parse(text: str, default: _D) -> 'Union[_H, _D]'

Return parse(text), or default for empty or blank text.

:raises TypeError: for anything that is not str; only bad text is answered with default. :func:netimps.try_parse is the entry that answers default for any object.

Source code in src/netimps/_ip/_host.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
@classmethod
def try_parse(cls, text: str, default: Any = None) -> Any:
    """Return ``parse(text)``, or ``default`` for empty or blank text.

    :raises TypeError: for anything that is not ``str``; only bad *text*
        is answered with ``default``. :func:`netimps.try_parse` is the
        entry that answers ``default`` for any object.
    """
    if not isinstance(text, str):
        raise TypeError(
            "Host.try_parse takes text, not %r" % (type(text).__name__,)
        )
    if not text.strip():
        return default
    return cls(text)

fqdn(*, check=False, ns=None, timeout=5.0, port=53, tcp=False, search=True, backends=None, source=None, cache=False, deadline=None)

fqdn(
    *,
    check: Literal[True],
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "FQDN"
fqdn(
    *,
    check: Literal[False] = False,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "Optional[FQDN]"
fqdn(
    *,
    check: bool,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "Optional[FQDN]"

This host as an :class:FQDN: the name itself, or the name an address reverses to.

A name is returned as written, with no lookup; an address costs a reverse (PTR) lookup, which can block::

Host("www.example.com").fqdn().domain   # FQDN('example.com'), no I/O
Host("10.0.0.5").fqdn()                 # FQDN('db.internal'), or None

The bridge between the two types: :class:Host is "an address or a name", while :class:FQDN is a name algebra that refuses an address. The name is the one written, not the canonical name after search-list expansion, and a reverse answer comes back without its root dot.

None is the answer when nothing was found, and also for text that is not a syntactically possible name (over-long, an empty inner label). With check=True the first raises :class:ResolutionError and the second :class:NetimpsValueError.

The resolver options mean what they do for :meth:ip. There is no ipv6: the address decides which reverse zone is asked. Nothing is memoised.

Source code in src/netimps/_ip/_host.py
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
def fqdn(
    self,
    *,
    check: bool = False,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None,
) -> "Optional[FQDN]":
    """This host as an :class:`FQDN`: the name itself, or the name an address
    reverses to.

    **A name is returned as written, with no lookup**; an address costs a
    reverse (PTR) lookup, which can block::

        Host("www.example.com").fqdn().domain   # FQDN('example.com'), no I/O
        Host("10.0.0.5").fqdn()                 # FQDN('db.internal'), or None

    The bridge between the two types: :class:`Host` is "an address *or* a
    name", while :class:`FQDN` is a name algebra that refuses an address.
    The name is the one written, **not** the canonical name after
    search-list expansion, and a reverse answer comes back without its root
    dot.

    ``None`` is the answer when nothing was found, and also for text that is
    not a syntactically possible name (over-long, an empty inner label).
    With ``check=True`` the first raises :class:`ResolutionError` and the
    second :class:`NetimpsValueError`.

    The resolver options mean what they do for :meth:`ip`. There is no
    ``ipv6``: the address decides which reverse zone is asked. Nothing is
    memoised.
    """
    text = self.value
    if self.is_address:
        from .._dns import lookup_fqdn, resolver_keywords

        return lookup_fqdn(text, **resolver_keywords(locals()))
    return FQDN.parse(text) if check else FQDN.try_parse(text)

ip(*, check=False, ipv6=None, ns=None, timeout=5.0, port=53, tcp=False, search=True, backends=None, source=None, cache=False, deadline=None, refresh=False)

ip(
    *,
    check: Literal[True],
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None,
    refresh: bool = False
) -> "IPAddress"
ip(
    *,
    check: Literal[False] = False,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None,
    refresh: bool = False
) -> "Optional[IPAddress]"
ip(
    *,
    check: bool,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None,
    refresh: bool = False
) -> "Optional[IPAddress]"

Resolve to an address, or None.

A literal is returned as parsed, with no lookup; a name is looked up, which can block.

:param check: raise :class:ResolutionError instead of returning None when nothing was found -- an empty answer, an outage, or an empty host. :param ipv6: True asks for AAAA, False for A, None for either in one lookup, in the order the OS chose. :param ns: nameserver(s) to ask instead of the OS's. :param timeout: seconds per backend attempt; what it bounds differs by backend, see :func:netimps.resolve. :param deadline: seconds for the whole lookup, every backend and candidate included; None sets no overall limit. :param port: nameserver port. :param tcp: query over TCP. :param search: expand an unqualified name through a search list: the system's (True), none (False) or the list given. :param backends: which of "dnspython", "wire", "system", "nslookup", and in what order; see :func:netimps.resolve. :param source: the local address the query is sent from. :param cache: reuse a recent answer, a miss included, from the shared resolution cache: False (the default) never reads or writes it, True keeps an answer for :data:RESOLUTION_CACHE_TTL seconds, a number is that many. Unlike the memo below it is shared by every :class:Host and :class:FQDN, and is keyed on the name and every option; see :func:netimps.resolve. :param refresh: ask again, and replace the memo with the new answer -- a name that failed once may resolve later.

With none of ns, port, tcp, source or backends the OS resolver alone answers, as the standard library's lookups do. A missed name then costs milliseconds, where the full chain behind :func:netimps.resolve costs seconds. Naming any of them hands the choice to that chain.

A call that passes no option memoises its answer, a miss included, because the common use is several lookups in a row on the same object. A call that passes any option, cache= included, neither reads nor writes the memo.

Source code in src/netimps/_ip/_host.py
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
def ip(
    self,
    *,
    check: bool = False,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None,
    refresh: bool = False,
) -> "Optional[IPAddress]":
    """Resolve to an address, or ``None``.

    **A literal is returned as parsed, with no lookup**; a name is looked
    up, which can block.

    :param check: raise :class:`ResolutionError` instead of returning
        ``None`` when nothing was found -- an empty answer, an outage, or an
        empty host.
    :param ipv6: ``True`` asks for AAAA, ``False`` for A, ``None`` for either
        in one lookup, in the order the OS chose.
    :param ns: nameserver(s) to ask instead of the OS's.
    :param timeout: seconds per backend attempt; what it bounds differs by
        backend, see :func:`netimps.resolve`.
    :param deadline: seconds for the whole lookup, every backend and
        candidate included; ``None`` sets no overall limit.
    :param port: nameserver port.
    :param tcp: query over TCP.
    :param search: expand an unqualified name through a search list: the
        system's (``True``), none (``False``) or the list given.
    :param backends: which of ``"dnspython"``, ``"wire"``, ``"system"``,
        ``"nslookup"``, and in what order; see :func:`netimps.resolve`.
    :param source: the local address the query is sent from.
    :param cache: reuse a recent answer, a miss included, from the shared
        resolution cache: ``False`` (the default) never reads or writes it,
        ``True`` keeps an answer for :data:`RESOLUTION_CACHE_TTL` seconds, a
        number is that many. Unlike the memo below it is shared by every
        :class:`Host` and :class:`FQDN`, and is keyed on the name and every
        option; see :func:`netimps.resolve`.
    :param refresh: ask again, and replace the memo with the new answer --
        a name that failed once may resolve later.

    **With none of ``ns``, ``port``, ``tcp``, ``source`` or ``backends`` the
    OS resolver alone answers**, as the standard library's lookups do. A
    missed name then costs milliseconds, where the full chain behind
    :func:`netimps.resolve` costs seconds. Naming any of them hands the
    choice to that chain.

    **A call that passes no option memoises its answer, a miss included**,
    because the common use is several lookups in a row on the same object.
    A call that passes any option, ``cache=`` included, neither reads nor
    writes the memo.
    """
    text = self.value
    if not text:
        if check:
            raise ResolutionError("there is no host to resolve")
        return None

    literal = ip_literal(text)
    if literal is not None:
        return literal

    plain = not (
        check
        or ipv6 is not None
        or ns
        or timeout != 5.0
        or port != 53
        or tcp
        or search is not True
        or backends is not None
        or source
        or cache is not False
        or deadline is not None
    )
    if plain and self._attempted and not refresh:
        return self._resolved

    from .._dns import lookup_ip, resolver_keywords

    found = lookup_ip(text, **resolver_keywords(locals(), ipv6=True))
    if plain:
        object.__setattr__(self, "_attempted", True)
        object.__setattr__(self, "_resolved", found)
    return found

resolve(*, check=False, ipv6=None, ns=None, timeout=5.0, port=53, tcp=False, search=True, backends=None, source=None, cache=False, deadline=None)

resolve(
    *,
    check: Literal[True],
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "Tuple[FQDN, IPAddress]"
resolve(
    *,
    check: Literal[False] = False,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "Tuple[Optional[FQDN], Optional[IPAddress]]"
resolve(
    *,
    check: bool,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "Tuple[Optional[FQDN], Optional[IPAddress]]"

The pair (fqdn, ip); whichever half was not found is None.

===================== ================================= ================== host is fqdn ip ===================== ================================= ================== a name the name as written forward lookup an address reverse lookup the literal ===================== ================================= ==================

The result is always a pair, so fqdn, ip = host.resolve() never fails to unpack. With check=True a half that could not be found raises :class:ResolutionError instead. The options are :meth:ip's, and a name does its one lookup only.

Source code in src/netimps/_ip/_host.py
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
def resolve(
    self,
    *,
    check: bool = False,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None,
) -> "Tuple[Optional[FQDN], Optional[IPAddress]]":
    """The pair ``(fqdn, ip)``; whichever half was not found is ``None``.

    =====================  =================================  ==================
    host is                ``fqdn``                           ``ip``
    =====================  =================================  ==================
    a name                 the name as written                forward lookup
    an address             reverse lookup                     the literal
    =====================  =================================  ==================

    The result is always a pair, so ``fqdn, ip = host.resolve()`` never
    fails to unpack. With ``check=True`` a half that could not be found
    raises :class:`ResolutionError` instead. The options are
    :meth:`ip`'s, and a name does its one lookup only.
    """
    from .._dns import resolver_keywords

    keywords = resolver_keywords(locals())
    return (self.fqdn(**keywords), self.ip(ipv6=ipv6, **keywords))

MACAddress(value)

An IEEE 802 MAC address.

Accepts the common textual forms on construction -- colon (AA:BB:CC:DD:EE:FF), hyphen (AA-BB-CC-DD-EE-FF), dot per octet (aa.bb.cc.dd.ee.ff), dot/Cisco triplets (aabb.ccdd.eeff) or bare (AABBCCDDEEFF) -- as well as an int, six raw bytes or bytearray, or another MACAddress. Separators may not be mixed (00-11:22-33:44-55 is rejected), and a bool is not taken as an integer even though Python says it is one.

The value is normalised to lowercase and compared/hashed by its canonical bytes, so instances are usable as dict keys and set members.

Equality holds only against another MACAddress: comparing with text is False, because no hash can agree with str's across every spelling of one address. Parse the text first::

MACAddress.try_parse(text) == mac

format(sep) renders the address with an arbitrary separator between octets; sep="" produces the bare form, and every separator the constructor accepts round-trips through it. f"{mac:-X}" is the same call spelled as a format spec, and bytes(mac) is the six raw octets.

Source code in src/netimps/_mac.py
 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
def __init__(self, value: MACAddressLike) -> None:
    if isinstance(value, MACAddress):
        object.__setattr__(self, "_octets", value._octets)
        return
    if isinstance(value, (bytes, bytearray)):
        octets = bytes(value)
        if len(octets) != 6:
            raise NetimpsValueError(
                "MAC address must be 6 bytes, got %d" % len(octets)
            )
        object.__setattr__(self, "_octets", octets)
        return
    if isinstance(value, bool):
        # bool is an int subclass, so an unguarded int branch would turn a
        # stray flag into 00:00:00:00:00:01 instead of rejecting it.
        raise TypeError("Cannot build MACAddress from %r" % (type(value).__name__,))
    if isinstance(value, int):
        if value < 0 or value > 0xFFFFFFFFFFFF:
            raise NetimpsValueError("MAC integer out of range: %r" % (value,))
        object.__setattr__(self, "_octets", value.to_bytes(6, "big"))
        return
    if isinstance(value, str):
        text = value.strip()
        if not _MAC_TEXT.match(text):
            raise NetimpsValueError("Invalid MAC address: %r" % (value,))
        hexdigits = _re.sub(r"[.:-]", "", text)
        object.__setattr__(self, "_octets", bytes.fromhex(hexdigits))
        return
    raise TypeError("Cannot build MACAddress from %r" % (type(value).__name__,))

packed property

The 6 raw bytes of the address.

The escape hatch for wire formats and syscalls, mirroring :attr:ipaddress.IPv4Address.packed. MACAddress deliberately is not a :class:bytes subclass -- see the class docstring.

oui property

The first three octets -- the vendor prefix -- verbatim.

The I/G (group) and U/L (locally administered) flags live in the low two bits of octet 0 and are not masked out here, so for a multicast or locally administered address this is not a registered IEEE OUI: 01:00:5e:00:00:01 reports 01:00:5e where the registered assignment is 00:00:5e. Mask them before a registry lookup::

bytes([mac.oui[0] & 0xFC]) + mac.oui[1:]

The bits are kept because this is the prefix as it appears on the wire and in this package's own output; :attr:is_multicast and :attr:is_local say when the masked form would differ.

is_multicast property

True if the group bit (low bit of the first octet) is set.

Multicast MACs are destinations only -- a NIC never has one -- so this is the check for "did I mistake a group address for a host?".

is_local property

True if locally administered (the U/L bit is set).

Locally administered addresses are assigned by software -- VMs, containers, and MAC-randomising clients -- rather than burned in by the vendor, so they are not stable identifiers.

is_universal property

True if universally administered (vendor-assigned). Inverse of :attr:is_local.

is_valid(value) classmethod

Return True if value can be parsed as a MAC. Never raises.

The type-local spelling of netimps.is_valid(value, MACAddress), which is often what reads best at a call site::

if MACAddress.is_valid(user_input):
    ...

A classmethod rather than a staticmethod so a subclass validates against itself.

Returns a plain bool and deliberately does not narrow value: a :data:typing.TypeGuard here would let a checker certify user_input.packed on something that is still a str. Use :meth:try_parse when you want the parsed object.

Source code in src/netimps/_mac.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
@classmethod
def is_valid(cls, value: object) -> bool:
    """Return True if ``value`` can be parsed as a MAC. Never raises.

    The type-local spelling of ``netimps.is_valid(value, MACAddress)``,
    which is often what reads best at a call site::

        if MACAddress.is_valid(user_input):
            ...

    A classmethod rather than a staticmethod so a subclass validates
    against itself.

    Returns a plain ``bool`` and deliberately does **not** narrow
    ``value``: a :data:`typing.TypeGuard` here would let a checker certify
    ``user_input.packed`` on something that is still a ``str``. Use
    :meth:`try_parse` when you want the parsed object.
    """
    try:
        cls(value)  # type: ignore[arg-type]
        return True
    except (ValueError, TypeError):
        return False

parse(text) classmethod

Build a MACAddress from text, in any spelling the class accepts.

:raises NetimpsValueError: for text that is not a MAC address. It is a ValueError. :raises TypeError: for anything that is not str. The constructor is the entry for int and bytes.

Source code in src/netimps/_mac.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
@classmethod
def parse(cls: "Type[_M]", text: str) -> "_M":
    """Build a ``MACAddress`` from text, in any spelling the class accepts.

    :raises NetimpsValueError: for text that is not a MAC address. It is a
        ``ValueError``.
    :raises TypeError: for anything that is not ``str``. The constructor
        is the entry for ``int`` and ``bytes``.
    """
    if not isinstance(text, str):
        raise TypeError(
            "MACAddress.parse takes text, not %r" % (type(text).__name__,)
        )
    return cls(text)

try_parse(text, default=None) classmethod

try_parse(text: str) -> 'Optional[_M]'
try_parse(text: str, default: _D) -> 'Union[_M, _D]'

Return parse(text), or default for text that is not a MAC.

Prefer it to :meth:is_valid followed by construction -- one call, and no window in which the two disagree. It is also how text is compared against a MAC, since :meth:__eq__ does not coerce a str::

MACAddress.try_parse(user_input) == known_mac

:raises TypeError: for anything that is not str; only bad text is answered with default. :func:netimps.try_parse is the entry that answers default for any object.

Source code in src/netimps/_mac.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
@classmethod
def try_parse(cls, text: str, default: Any = None) -> Any:
    """Return ``parse(text)``, or ``default`` for text that is not a MAC.

    Prefer it to :meth:`is_valid` followed by construction -- one call, and
    no window in which the two disagree. It is also how text is compared
    against a MAC, since :meth:`__eq__` does not coerce a ``str``::

        MACAddress.try_parse(user_input) == known_mac

    :raises TypeError: for anything that is not ``str``; only bad *text*
        is answered with ``default``. :func:`netimps.try_parse` is the
        entry that answers ``default`` for any object.
    """
    if not isinstance(text, str):
        raise TypeError(
            "MACAddress.try_parse takes text, not %r" % (type(text).__name__,)
        )
    try:
        return cls(text)
    except ValueError:
        return default

hex(sep=None, bytes_per_sep=1)

The octets as hex, exactly like :meth:bytes.hex.

sep and bytes_per_sep mean what they do there; omitting sep gives the unseparated form::

MACAddress("aa:bb:cc:dd:ee:ff").hex()        # 'aabbccddeeff'
MACAddress("aa:bb:cc:dd:ee:ff").hex(":")     # 'aa:bb:cc:dd:ee:ff'
MACAddress("aa:bb:cc:dd:ee:ff").hex("-", 2)  # 'aabb-ccdd-eeff'

A pure passthrough to self.packed.hex, which exists because this is a value object rather than a bytes subclass, so the method is not inherited -- and callers reasonably expect it, to the point of subclassing the type to add it back.

For the conventional colon- or hyphen-separated forms prefer :meth:format, which also offers the uppercase rendering; this is the one to reach for when the target wants bytes.hex semantics specifically, such as bytes_per_sep grouping.

Source code in src/netimps/_mac.py
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 hex(
    self, sep: "Optional[Union[str, bytes]]" = None, bytes_per_sep: int = 1
) -> str:
    """The octets as hex, exactly like :meth:`bytes.hex`.

    ``sep`` and ``bytes_per_sep`` mean what they do there; omitting ``sep``
    gives the unseparated form::

        MACAddress("aa:bb:cc:dd:ee:ff").hex()        # 'aabbccddeeff'
        MACAddress("aa:bb:cc:dd:ee:ff").hex(":")     # 'aa:bb:cc:dd:ee:ff'
        MACAddress("aa:bb:cc:dd:ee:ff").hex("-", 2)  # 'aabb-ccdd-eeff'

    A pure passthrough to ``self.packed.hex``, which exists because this is
    a value object rather than a ``bytes`` subclass, so the method is not
    inherited -- and callers reasonably expect it, to the point of
    subclassing the type to add it back.

    For the conventional colon- or hyphen-separated forms prefer
    :meth:`format`, which also offers the uppercase rendering; this is the
    one to reach for when the target wants ``bytes.hex`` semantics
    specifically, such as ``bytes_per_sep`` grouping.
    """
    if sep is None:
        return self.packed.hex()
    return self.packed.hex(sep, bytes_per_sep)  # type: ignore[arg-type]

format(sep=':', *, upper=False)

Return the MAC as a string with sep between octets.

Lowercase by default (the canonical form used by str(mac) and by equality/hashing); pass upper=True for the uppercase rendering favoured by Windows tooling and much vendor output::

mac.format("-")               # 'aa-bb-cc-dd-ee-ff'
mac.format("-", upper=True)   # 'AA-BB-CC-DD-EE-FF'

sep always goes between octets, "." included: the Cisco triplet spelling aabb.ccdd.eeff is accepted on input but never produced, so that one separator does not quietly mean something different from the others. ":", "-", "." and "" all parse back through the constructor; any other separator renders but does not round-trip.

Case affects only this rendering -- two MACAddress values that differ solely in the case they were parsed from remain equal.

Source code in src/netimps/_mac.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
def format(self, sep: str = ":", *, upper: bool = False) -> str:
    """Return the MAC as a string with ``sep`` between octets.

    Lowercase by default (the canonical form used by ``str(mac)`` and by
    equality/hashing); pass ``upper=True`` for the uppercase rendering
    favoured by Windows tooling and much vendor output::

        mac.format("-")               # 'aa-bb-cc-dd-ee-ff'
        mac.format("-", upper=True)   # 'AA-BB-CC-DD-EE-FF'

    ``sep`` always goes between *octets*, ``"."`` included: the Cisco
    triplet spelling ``aabb.ccdd.eeff`` is accepted on input but never
    produced, so that one separator does not quietly mean something
    different from the others. ``":"``, ``"-"``, ``"."`` and ``""`` all
    parse back through the constructor; any other separator renders but
    does not round-trip.

    Case affects only this rendering -- two ``MACAddress`` values that
    differ solely in the case they were parsed from remain equal.
    """
    fmt = "%02X" if upper else "%02x"
    return sep.join(fmt % b for b in self._octets)

Interface(name, index=0, *, mac=None, ips=None, mtu=None, raw=None, is_loopback=None, is_up=None, is_multicast=None, is_point_to_point=None)

One network interface, normalised to be identical across platforms.

Attributes:

Name Type Description
name str

Human-usable adapter name ("eth0", "en0", or the Windows friendly name -- never the raw GUID).

index int

:func:socket.if_nametoindex value, or 0 when unknown.

mac 'Optional[MACAddress]'

The hardware address, or None for interfaces without one (loopback, tunnels). An all-zero address is normalised to None: Linux reports the loopback MAC as 00:00:00:00:00:00 where macOS and Windows report nothing at all, and mac is None should mean the same thing on every platform.

ips 'Tuple[_IPInterface, ...]'

Every address bound to the interface, each as an IPv4Interface/IPv6Interface carrying its real prefix.

mtu 'Optional[int]'

Link MTU in bytes, or None when the platform genuinely could not read one. An unbounded MTU is not None: the Windows loopback adapter reports ULONG max, meaning there is no link to constrain it, and that is reported as 65535 -- the largest datagram the 16-bit IP total-length field can describe, so a clamp to reality rather than an invented figure. Measured: that interface really does carry a 65507-octet UDP payload, which is what :func:netimps.max_udp_payload derives from it, and Linux reports its own lo as 65536 rather than as nothing. This is the link MTU; for a path see :func:netimps.discover_mtu.

is_up 'Optional[bool]'

Whether the interface is usable: IFF_UP and IFF_RUNNING on POSIX, the operational status (IfOperStatusUp) on Windows. None when the system did not say. POSIX keeps the addresses of an interface that is down, since they are configured and can be bound; on Windows an adapter that is down stays listed with its name, MAC, index and MTU, and an address the system marks tentative or duplicate is left out of ips.

is_multicast 'Optional[bool]'

Whether the interface can carry multicast: IFF_MULTICAST on POSIX, and on Windows the absence of the adapter's IP_ADAPTER_NO_MULTICAST flag. The kernel's own answer, never the name. None when the system did not say (the degraded enumeration path, hand-built objects, a POSIX platform whose flag value has not been measured). Linux reports its loopback interface as not multicast-capable and Windows reports its own as capable, so the value is a fact about the interface, not about its kind.

is_point_to_point 'Optional[bool]'

Whether the interface joins two endpoints with no broadcast domain (a tunnel, a PPP or VPN link): IFF_POINTOPOINT on POSIX, a PPP, SLIP or tunnel IfType on Windows. None when the system did not say, as for is_multicast.

is_loopback bool

The kernel's own loopback flag (IFF_LOOPBACK on POSIX, IF_TYPE_SOFTWARE_LOOPBACK on Windows) when the enumeration reported one; otherwise derived from the addresses. The constructor argument of the same name is None for "not reported" -- the degraded enumeration path, and objects built by hand.

raw 'Optional[Mapping[str, Any]]'

None unless enumerated with get_interfaces(raw=True), in which case a read-only mapping of platform-specific leftovers, with a tuple wherever the system gave a list. Not portable and explicitly outside the stability guarantee.

The constructor raises :class:TypeError for a field of the wrong type (mac must be a :class:MACAddress, each of ips an IPv4Interface/IPv6Interface).

Source code in src/netimps/_ifaddrs/_model.py
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
def __init__(
    self,
    name: str,
    index: int = 0,
    *,
    mac: "Optional[MACAddress]" = None,
    ips: "Optional[Iterable[_IPInterface]]" = None,
    mtu: "Optional[int]" = None,
    raw: "Optional[Mapping[str, Any]]" = None,
    is_loopback: "Optional[bool]" = None,
    is_up: "Optional[bool]" = None,
    is_multicast: "Optional[bool]" = None,
    is_point_to_point: "Optional[bool]" = None,
) -> None:
    if not isinstance(name, str):
        raise TypeError("name must be str, not %r" % (type(name).__name__,))
    if not isinstance(index, int) or isinstance(index, bool):
        raise TypeError("index must be int, not %r" % (type(index).__name__,))
    _check_optional("mac", mac, MACAddress)
    _check_optional("mtu", mtu, int)
    _check_optional("is_loopback", is_loopback, bool)
    _check_optional("is_up", is_up, bool)
    _check_optional("is_multicast", is_multicast, bool)
    _check_optional("is_point_to_point", is_point_to_point, bool)
    if raw is not None and not isinstance(raw, Mapping):
        raise TypeError(
            "raw must be a mapping or None, not %r" % (type(raw).__name__,)
        )
    if isinstance(ips, (str, bytes)):
        raise TypeError("ips must be an iterable of IPv4Interface/IPv6Interface")
    entries = () if ips is None else tuple(ips)
    for entry in entries:
        if not isinstance(
            entry, (_ipaddress.IPv4Interface, _ipaddress.IPv6Interface)
        ):
            raise TypeError(
                "ips must hold IPv4Interface/IPv6Interface, not %r"
                % (type(entry).__name__,)
            )
    object.__setattr__(self, "name", name)
    object.__setattr__(self, "index", index)
    object.__setattr__(self, "mac", mac)
    object.__setattr__(self, "ips", entries)
    object.__setattr__(self, "mtu", mtu)
    object.__setattr__(self, "is_up", is_up)
    object.__setattr__(self, "is_multicast", is_multicast)
    object.__setattr__(self, "is_point_to_point", is_point_to_point)
    object.__setattr__(self, "_is_loopback", is_loopback)
    object.__setattr__(self, "raw", None if raw is None else _freeze(raw))

is_loopback property

True when this is the loopback interface.

The kernel's own answer when there is one: IFF_LOOPBACK on POSIX, IF_TYPE_SOFTWARE_LOOPBACK on Windows, captured during enumeration. Never the name -- lo (Linux), lo0 (macOS) and Loopback Pseudo-Interface 1 (Windows) share no common spelling.

Falls back to the addresses only when the flag was not reported (the degraded enumeration path reports no flags, and neither do hand-built objects). That fallback requires a loopback address and no routable one, which is a guess rather than an answer: WSL2 binds a routable 10.255.255.254/32 to lo on every installation, and binding a service address to the loopback interface is the standard keepalived/anycast pattern -- on such a host the heuristic reports that there is no loopback interface at all. Link-local addresses are ignored by it, since macOS's lo0 also carries fe80::1/64 and a non-routable address cannot make an interface non-loopback.

ipv4 property

Just the IPv4 addresses, as a tuple like :attr:ips.

ipv6 property

Just the IPv6 addresses, as a tuple like :attr:ips.

primary_ip(ipv6=False, *, loopback_ok=True)

Pick the one entry that best represents this interface, or None.

Answers "which of this adapter's addresses do I use?" -- the question IP_MULTICAST_IF, a bind target and ping -S all ask::

iface.primary_ip()               # IPv4Interface('10.0.0.5/24')
iface.primary_ip().ip            # IPv4Address('10.0.0.5')
iface.primary_ip(ipv6=True)      # its IPv6 entry instead

Ranked, not first-non-loopback: routable, then loopback, then link-local, keeping the OS order within a rank. The rank matters because an interface commonly lists a link-local address before its routable one -- fe80:: is configured first on Linux and macOS NICs -- and "the first entry that is not loopback" therefore picked an address that is useless as a bind target and unreachable off-link.

Loopback outranks link-local deliberately, which is not the obvious order. The only interface that carries both is the loopback adapter, and there ::1 is the address every caller means; a real NIC has no loopback entry, so the rank never takes anything from it. A NIC holding only a link-local address -- before SLAAC completes, say -- still yields it, because there is nothing else to yield. With loopback_ok=False the loopback rank is skipped entirely, so such a caller still gets the link-local in preference to None.

Measured on a macOS loopback adapter, whose entries are 127.0.0.1/8, ::1/128, fe80::1/64: taking the first IPv6 entry returns fe80::1 for ipv6=True where ::1 is wanted, and bind(interface=...) then fails with "Can't assign requested address". Likewise, on a NIC listing fe80:: before a global address, first-entry order picks the link-local over the global one.

Link-local covers fe80::/10 and IPv4 169.254.0.0/16 (LINK_LOCAL_V4): the same problem in the other family, and an interface holding both a LINK_LOCAL_V4 address and a DHCP lease should answer with the lease.

Named primary rather than ip because this is a selection, not "the" address: an interface routinely has several, and the full lists remain on :attr:ips / :attr:ipv4 / :attr:ipv6.

:param ipv6: pick from :attr:ipv6 rather than :attr:ipv4. :param loopback_ok: when False, an interface holding only loopback addresses yields None instead -- for callers that need a routable address specifically.

Returns an IPv4Interface/IPv6Interface, the same element type as :attr:ips -- one of them, not a different shape. Use .ip for the bare address that socket options take.

Source code in src/netimps/_ifaddrs/_model.py
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
def primary_ip(
    self, ipv6: bool = False, *, loopback_ok: bool = True
) -> "Optional[_IPInterface]":
    """Pick the one entry that best represents this interface, or ``None``.

    Answers "which of this adapter's addresses do I use?" -- the question
    ``IP_MULTICAST_IF``, a bind target and ``ping -S`` all ask::

        iface.primary_ip()               # IPv4Interface('10.0.0.5/24')
        iface.primary_ip().ip            # IPv4Address('10.0.0.5')
        iface.primary_ip(ipv6=True)      # its IPv6 entry instead

    **Ranked, not first-non-loopback: routable, then loopback, then
    link-local**, keeping the OS order within a rank. The rank matters
    because an interface commonly lists a link-local address *before* its
    routable one -- ``fe80::`` is configured first on Linux and macOS NICs
    -- and "the first entry that is not loopback" therefore picked an
    address that is useless as a bind target and unreachable off-link.

    **Loopback outranks link-local deliberately**, which is not the obvious
    order. The only interface that carries both is the loopback adapter, and
    there ``::1`` is the address every caller means; a real NIC has no
    loopback entry, so the rank never takes anything from it. A NIC holding
    *only* a link-local address -- before SLAAC completes, say -- still
    yields it, because there is nothing else to yield. With
    ``loopback_ok=False`` the loopback rank is skipped entirely, so such a
    caller still gets the link-local in preference to ``None``.

    Measured on a macOS loopback adapter, whose entries are
    ``127.0.0.1/8``, ``::1/128``, ``fe80::1/64``: taking the first IPv6
    entry returns ``fe80::1`` for ``ipv6=True`` where ``::1`` is wanted,
    and ``bind(interface=...)`` then fails with "Can't assign requested
    address". Likewise, on a NIC listing ``fe80::`` before a global
    address, first-entry order picks the link-local over the global one.

    Link-local covers ``fe80::/10`` and IPv4 ``169.254.0.0/16``
    (``LINK_LOCAL_V4``): the same problem in the other family, and an
    interface holding both a ``LINK_LOCAL_V4`` address and a DHCP lease
    should answer with the lease.

    Named *primary* rather than *ip* because this is a **selection**, not
    "the" address: an interface routinely has several, and the full lists
    remain on :attr:`ips` / :attr:`ipv4` / :attr:`ipv6`.

    :param ipv6: pick from :attr:`ipv6` rather than :attr:`ipv4`.
    :param loopback_ok: when False, an interface holding only loopback
        addresses yields ``None`` instead -- for callers that need a
        routable address specifically.

    Returns an ``IPv4Interface``/``IPv6Interface``, the **same element type
    as** :attr:`ips` -- one of them, not a different shape. Use ``.ip`` for
    the bare address that socket options take.
    """
    candidates = self.ipv6 if ipv6 else self.ipv4
    if not candidates:
        return None

    # Three ranks rather than a loopback test, and stable within each so the
    # OS order still decides between two equals.
    routable, link_local, loopback = [], [], []
    for entry in candidates:
        if entry.ip.is_loopback:
            loopback.append(entry)
        elif entry.ip.is_link_local:
            link_local.append(entry)
        else:
            routable.append(entry)

    if routable:
        return routable[0]
    if loopback and loopback_ok:
        # Before link-local: the only interface holding both is loopback
        # itself, where `::1` is what every caller means.
        return loopback[0]
    if link_local:
        return link_local[0]
    return None

PingResult(ok, dst, *, rtt=None, ttl=None, src=None, attempts=1)

Outcome of a :func:ping, usable directly as a boolean.

ping() answers "did it reply?", so this is truthy on success and falsy on failure -- if ping(host): works -- while carrying the details a caller would otherwise re-run ping to scrape.

Attributes:

Name Type Description
ok bool

whether the destination replied.

dst 'HostLike'

the destination as given.

rtt Optional[float]

round-trip time in seconds, or None if not reported. Sub-millisecond replies (time<1ms) are recorded as 0.0, which is falsy -- test is None rather than truthiness.

ttl Optional[int]

TTL/hop-limit of the reply, or None. Counts down from the sender's initial value, so a smaller number means more hops.

src 'Optional[IPAddress]'

address that answered, which on success is the destination.

attempts int

how many probes were sent before this outcome.

Source code in src/netimps/_ping/_result.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def __init__(
    self,
    ok: bool,
    dst: "HostLike",
    *,
    rtt: Optional[float] = None,
    ttl: Optional[int] = None,
    src: "Optional[IPAddress]" = None,
    attempts: int = 1,
) -> None:
    object.__setattr__(self, "ok", ok)
    object.__setattr__(self, "dst", dst)
    object.__setattr__(self, "rtt", rtt)
    object.__setattr__(self, "ttl", ttl)
    object.__setattr__(self, "src", src)
    object.__setattr__(self, "attempts", attempts)

FQDN(*parts)

A domain name, with label algebra. Immutable, hashable and ordered.

Built from a dotted string or from separate labels, leftmost first -- the order they appear in the text::

FQDN("www.example.com")
FQDN("www", "example", "com")        # the same name
FQDN("www", FQDN("example.com"))     # composition works too

A trailing dot marks the name fully qualified and is preserved by str(). See the module docstring for the pathlib inversion, the absoluteness rule, and the two things this type deliberately omits.

:raises ValueError: for an address literal, an empty name, an over-long name or label, or an empty inner label (a..b).

Source code in src/netimps/_fqdn/_name.py
 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
def __init__(self, *parts: "Union[FQDNLike, Iterable[Union[str, FQDN]]]") -> None:
    labels: "List[str]" = []
    absolute = False

    flat: "List[Union[str, FQDN]]" = []
    for part in parts:
        if isinstance(part, (str, FQDN)):
            flat.append(part)
        elif isinstance(part, (bytes, bytearray, memoryview)):
            raise TypeError(
                "FQDN parts are text, not %r; decode the bytes, or use "
                "FQDN.decode for the wire form" % (type(part).__name__,)
            )
        elif isinstance(part, Iterable):
            for item in part:
                if not isinstance(item, (str, FQDN)):
                    raise TypeError(
                        "FQDN labels must be str or FQDN, not %r"
                        % (type(item).__name__,)
                    )
                flat.append(item)
        else:
            raise TypeError(
                "FQDN parts must be str, FQDN or an iterable of labels, not %r"
                % (type(part).__name__,)
            )

    if not flat:
        raise NetimpsValueError("FQDN requires at least one label")

    for index, part in enumerate(flat):
        last = index == len(flat) - 1
        if isinstance(part, FQDN):
            labels.extend(part._labels)
            if last and part._absolute:
                absolute = True
            continue
        # IDNA reads these as dots; splitting on them here keeps the
        # printed name and its labels the same thing.
        text = part.strip().translate(_IDEOGRAPHIC_DOTS)
        if not text:
            # Caught here rather than falling through to the empty-label
            # check, which would report "consecutive dots" for a string
            # that has no dots at all.
            raise NetimpsValueError("a name cannot be empty")
        # Only the final part may carry the root dot; `FQDN("a.", "b")`
        # would otherwise silently produce a name with a hole in it.
        if text.endswith(".") and text != ".":
            if not last:
                raise NetimpsValueError(
                    "only the last part may end in a dot, got %r at position %d"
                    % (text, index)
                )
            absolute = True
            text = text[:-1]
        if text == ".":
            if not last:
                raise NetimpsValueError("the root label may only come last")
            absolute = True
            continue
        labels.extend(text.split(".") if "." in text else [text])

    if not labels:
        raise NetimpsValueError("FQDN requires at least one label")

    # IDNA first: the address and label rules apply to the ASCII a name
    # becomes, so a spelling that maps to `127.0.0.1` cannot get in.
    shown = ".".join(labels)
    encoded = []
    for label in labels:
        if not label:
            raise NetimpsValueError(
                "empty label in %r -- consecutive dots are not a name" % (shown,)
            )
        label = _idna_encode(label)
        if len(label) > MAX_LABEL_LENGTH:
            raise NetimpsValueError(
                "label %r is %d octets, over the %d-octet limit"
                % (label, len(label), MAX_LABEL_LENGTH)
            )
        encoded.append(label)

    # Reject an address *before* the label rules, so the error names the
    # real problem: "10.0.0.1" would otherwise pass every label check and
    # produce a nonsense "name". Routed through the package's own parser
    # rather than a second address detector.
    candidate = ".".join(encoded)
    if (
        ip_literal(candidate) is not None
        or ip_literal(candidate.strip("[]")) is not None
    ):
        raise NetimpsValueError(
            "%r is an IP address, not a domain name -- use netimps.Host for "
            "a value that may be either" % (candidate,)
        )

    for label in encoded:
        if not is_label(label.encode("ascii")) or _URI_DELIMITERS.intersection(
            label
        ):
            raise NetimpsValueError(
                "label %r cannot hold a space, a control character or "
                "any of : / ? # [ ] @" % (label,)
            )

    total = len(candidate)
    if total > MAX_NAME_LENGTH:
        raise NetimpsValueError(
            "name is %d octets, over the %d-octet limit" % (total, MAX_NAME_LENGTH)
        )

    object.__setattr__(self, "_labels", tuple(encoded))
    object.__setattr__(self, "_absolute", absolute)

labels property

The labels, leftmost first, without the root.

FQDN("www.example.com").labels == ("www", "example", "com"). The root is carried by :meth:is_fully_qualified rather than as an empty final label, because an empty string in this tuple would be a trap for every caller that iterates it.

parts property

Alias of :attr:labels, for readers coming from pathlib.

hostname property

The leftmost label -- www in www.example.com.

The opposite end from PurePath.name, which is the whole point of the inversion warning. For a single-label name this is the whole name.

name property

Alias of :attr:hostname, for readers coming from pathlib.

tld property

The rightmost label -- com in www.example.com.

Deliberately has no .suffix alias: a filesystem suffix is part of a name (.txt) while a TLD is a whole label, so the analogy misleads.

domain property

The name with its leftmost label removed, or None at the top.

FQDN("www.example.com").domain == FQDN("example.com"). Absoluteness is preserved.

None for a single-label name, rather than a self-reference: pathlib makes Path("/").parent return itself, which is right for a filesystem root but would make while f.domain: loop forever here.

This is not the registrable domain. The .domain of example.com is com. See the module docstring.

parent property

Alias of :attr:domain, for readers coming from pathlib.

domains property

Every enclosing domain, nearest first.

FQDN("a.b.example.com").domains is (FQDN('b.example.com'), FQDN('example.com'), FQDN('com')).

parents property

Alias of :attr:domains, for readers coming from pathlib.

is_wildcard property

Whether the leftmost label is * -- a DNS wildcard name.

A predicate only. Deliberately no matching method: DNS wildcards (RFC 4592) synthesise for names at any depth below the wildcard's parent, while TLS certificate matching (RFC 6125) allows exactly one label. Those are different answers for a.b.example.com against *.example.com, and picking one silently would be wrong for half the callers. Write the rule you need against :meth:is_subdomain_of.

wire_length property

Octets this name occupies on the wire, root terminator included.

This is the figure the 255-octet protocol limit applies to, while the 253 limit checked at construction is on the printable form -- the difference being one length prefix per label plus the root. Worth reaching for when a name is going into a packet you are sizing.

is_valid(value) classmethod

True if value can be parsed as a domain name. Never raises.

A classmethod rather than a staticmethod so a subclass validates against itself. Returns a plain bool and deliberately does not narrow value -- see :meth:MACAddress.is_valid for why a TypeGuard here would be unsound.

Source code in src/netimps/_fqdn/_name.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
@classmethod
def is_valid(cls, value: object) -> bool:
    """True if ``value`` can be parsed as a domain name. Never raises.

    A classmethod rather than a staticmethod so a subclass validates
    against itself. Returns a plain ``bool`` and deliberately does not
    narrow ``value`` -- see :meth:`MACAddress.is_valid` for why a
    ``TypeGuard`` here would be unsound.
    """
    try:
        cls(value)  # type: ignore[arg-type]
        return True
    except (ValueError, TypeError):
        return False

parse(text) classmethod

Build an :class:FQDN from dotted text.

:raises NetimpsValueError: for text that is not a domain name: an address literal, an empty or over-long name or label, an empty inner label. It is a ValueError. :raises TypeError: for anything that is not str. The constructor also takes labels and another FQDN.

Source code in src/netimps/_fqdn/_name.py
205
206
207
208
209
210
211
212
213
214
215
216
217
@classmethod
def parse(cls: "Type[_F]", text: str) -> "_F":
    """Build an :class:`FQDN` from dotted text.

    :raises NetimpsValueError: for text that is not a domain name: an
        address literal, an empty or over-long name or label, an empty
        inner label. It is a ``ValueError``.
    :raises TypeError: for anything that is not ``str``. The constructor
        also takes labels and another ``FQDN``.
    """
    if not isinstance(text, str):
        raise TypeError("FQDN.parse takes text, not %r" % (type(text).__name__,))
    return cls(text)

try_parse(text, default=None) classmethod

try_parse(text: str) -> 'Optional[_F]'
try_parse(text: str, default: _D) -> 'Union[_F, _D]'

Return parse(text), or default for text that is not a name.

Prefer it to :meth:is_valid followed by construction -- one call, and no window in which the two disagree.

:raises TypeError: for anything that is not str; only bad text is answered with default. :func:netimps.try_parse is the entry that answers default for any object.

Source code in src/netimps/_fqdn/_name.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
@classmethod
def try_parse(cls, text: str, default: Any = None) -> Any:
    """Return ``parse(text)``, or ``default`` for text that is not a name.

    Prefer it to :meth:`is_valid` followed by construction -- one call, and
    no window in which the two disagree.

    :raises TypeError: for anything that is not ``str``; only bad *text*
        is answered with ``default``. :func:`netimps.try_parse` is the
        entry that answers ``default`` for any object.
    """
    if not isinstance(text, str):
        raise TypeError(
            "FQDN.try_parse takes text, not %r" % (type(text).__name__,)
        )
    try:
        return cls(text)
    except ValueError:
        return default

is_fully_qualified()

Whether the name carries the root dot, so needs no search list.

The DNS reading of PurePath.is_absolute. A relative name resolves against the resolver's search domains and can therefore mean different things on different hosts.

Source code in src/netimps/_fqdn/_name.py
334
335
336
337
338
339
340
341
def is_fully_qualified(self) -> bool:
    """Whether the name carries the root dot, so needs no search list.

    The DNS reading of ``PurePath.is_absolute``. A relative name resolves
    against the resolver's search domains and can therefore mean different
    things on different hosts.
    """
    return self._absolute

is_absolute()

Alias of :meth:is_fully_qualified.

Source code in src/netimps/_fqdn/_name.py
343
344
345
def is_absolute(self) -> bool:
    """Alias of :meth:`is_fully_qualified`."""
    return self._absolute

fully_qualified()

This name with the root dot, unchanged if it already has one.

Source code in src/netimps/_fqdn/_name.py
347
348
349
350
351
def fully_qualified(self) -> "FQDN":
    """This name with the root dot, unchanged if it already has one."""
    if self._absolute:
        return self
    return self._from_labels(self._labels, True)

relative()

This name without the root dot, unchanged if it has none.

Source code in src/netimps/_fqdn/_name.py
353
354
355
356
357
def relative(self) -> "FQDN":
    """This name without the root dot, unchanged if it has none."""
    if not self._absolute:
        return self
    return self._from_labels(self._labels, False)

child(*labels)

Spelled-out form of :meth:__truediv__, for several labels at once.

Source code in src/netimps/_fqdn/_name.py
379
380
381
382
383
384
def child(self, *labels: "Union[FQDNLike, Iterable[str]]") -> "FQDN":
    """Spelled-out form of :meth:`__truediv__`, for several labels at once."""
    result = self
    for label in labels:
        result = result / label
    return result

with_hostname(hostname)

Replace the leftmost label.

FQDN("www.example.com").with_hostname("mail") is FQDN('mail.example.com'). For a single-label name this replaces the whole name.

Source code in src/netimps/_fqdn/_name.py
386
387
388
389
390
391
392
393
394
395
396
def with_hostname(self, hostname: str) -> "FQDN":
    """Replace the leftmost label.

    ``FQDN("www.example.com").with_hostname("mail")`` is
    ``FQDN('mail.example.com')``. For a single-label name this replaces the
    whole name.
    """
    replacement = FQDN(hostname)
    if len(replacement._labels) != 1:
        raise ValueError("with_hostname takes one label, got %r" % (hostname,))
    return self._from_labels(replacement._labels + self._labels[1:], self._absolute)

with_name(name)

Alias of :meth:with_hostname, for readers coming from pathlib.

Source code in src/netimps/_fqdn/_name.py
398
399
400
def with_name(self, name: str) -> "FQDN":
    """Alias of :meth:`with_hostname`, for readers coming from ``pathlib``."""
    return self.with_hostname(name)

is_subdomain_of(other)

Whether this name sits under other.

A name is not a subdomain of itself, matching the ordinary reading of the word -- use == for that, or f == other or f.is_subdomain_of(other) for "at or under". Qualification is ignored, since example.com and example.com. describe the same place in the tree.

Source code in src/netimps/_fqdn/_name.py
402
403
404
405
406
407
408
409
410
411
412
413
414
def is_subdomain_of(self, other: "FQDNLike") -> bool:
    """Whether this name sits under ``other``.

    A name is **not** a subdomain of itself, matching the ordinary reading
    of the word -- use ``==`` for that, or
    ``f == other or f.is_subdomain_of(other)`` for "at or under".
    Qualification is ignored, since ``example.com`` and ``example.com.``
    describe the same place in the tree.
    """
    suffix = other if isinstance(other, FQDN) else FQDN(other)
    if len(self._labels) <= len(suffix._labels):
        return False
    return self._key()[-len(suffix._labels) :] == suffix._key()

relative_to(other)

The labels of this name that are not part of other.

FQDN("www.example.com").relative_to("example.com") is FQDN('www'), always relative (never fully qualified -- a fragment of a name has no root). Labels compare case-blind and the remainder keeps this name's spelling.

:raises ValueError: if this name is not under other, mirroring PurePath.relative_to.

Source code in src/netimps/_fqdn/_name.py
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
def relative_to(self, other: "FQDNLike") -> "FQDN":
    """The labels of this name that are not part of ``other``.

    ``FQDN("www.example.com").relative_to("example.com")`` is
    ``FQDN('www')``, always relative (never fully qualified -- a fragment
    of a name has no root). Labels compare case-blind and the remainder
    keeps this name's spelling.

    :raises ValueError: if this name is not under ``other``, mirroring
        ``PurePath.relative_to``.
    """
    suffix = other if isinstance(other, FQDN) else FQDN(other)
    if not self.is_subdomain_of(suffix):
        raise ValueError("%s is not under %s" % (self, suffix))
    return self._from_labels(self._labels[: -len(suffix._labels)], False)

reverse()

The same labels in reverse order -- com.example.www.

A mechanical flip, for display and for building keys. The result is not a resolvable name; it is the labels the other way round. Note this is unrelated to a reverse DNS pointer, which is built from an address and so has no place on this type.

Source code in src/netimps/_fqdn/_name.py
432
433
434
435
436
437
438
439
440
def reverse(self) -> "FQDN":
    """The same labels in reverse order -- ``com.example.www``.

    A mechanical flip, for display and for building keys. **The result is
    not a resolvable name**; it is the labels the other way round. Note
    this is unrelated to a reverse DNS pointer, which is built from an
    address and so has no place on this type.
    """
    return self._from_labels(tuple(reversed(self._labels)), self._absolute)

resolve(*, check=False, ipv6=None, ns=None, timeout=5.0, port=53, tcp=False, search=True, backends=None, source=None, cache=False, deadline=None)

resolve(
    *,
    check: Literal[True],
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "Tuple[FQDN, _IPAddress]"
resolve(
    *,
    check: Literal[False] = False,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "Tuple[FQDN, Optional[_IPAddress]]"
resolve(
    *,
    check: bool,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "Tuple[FQDN, Optional[_IPAddress]]"

The pair (self, ip): this name, and the address it resolves to.

The same shape as :meth:netimps.Host.resolve, so the two types answer .resolve() alike. The name is returned as it is -- no reverse lookup, and not the canonical name after search-list expansion. ip is None when nothing was found, or :class:ResolutionError is raised with check=True. The fully-qualified form is asked as such, so a name with a trailing dot keeps bypassing the search list.

The resolver options are those of :meth:netimps.Host.ip, including the rule that with none of ns, port, tcp, source or backends only the OS resolver answers. DNS records of any type come from :func:netimps.resolve.

Source code in src/netimps/_fqdn/_name.py
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
def resolve(
    self,
    *,
    check: bool = False,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None,
) -> "Tuple[FQDN, Optional[_IPAddress]]":
    """The pair ``(self, ip)``: this name, and the address it resolves to.

    The same shape as :meth:`netimps.Host.resolve`, so the two types
    answer ``.resolve()`` alike. The name is returned as it is -- no
    reverse lookup, and not the canonical name after search-list expansion.
    ``ip`` is ``None`` when nothing was found, or :class:`ResolutionError`
    is raised with ``check=True``. The fully-qualified form is asked as
    such, so a name with a trailing dot keeps bypassing the search list.

    The resolver options are those of :meth:`netimps.Host.ip`, including
    the rule that with none of ``ns``, ``port``, ``tcp``, ``source`` or
    ``backends`` only the OS resolver answers. DNS records of any type come
    from :func:`netimps.resolve`.
    """
    from .._dns import resolver_keywords

    keywords = resolver_keywords(locals())
    return (self, self.ip(ipv6=ipv6, **keywords))

ping(**kwargs)

Ping this name. Straight through to :func:netimps.ping.

Source code in src/netimps/_fqdn/_name.py
529
530
531
532
533
def ping(self, **kwargs: "Any") -> "PingResult":
    """Ping this name. Straight through to :func:`netimps.ping`."""
    from .._ping import ping

    return ping(str(self), **kwargs)

ip(*, check=False, ipv6=None, ns=None, timeout=5.0, port=53, tcp=False, search=True, backends=None, source=None, cache=False, deadline=None)

ip(
    *,
    check: Literal[True],
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "_IPAddress"
ip(
    *,
    check: Literal[False] = False,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "Optional[_IPAddress]"
ip(
    *,
    check: bool,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None
) -> "Optional[_IPAddress]"

The first address this name resolves to, or None.

Always a lookup, which can block, and never memoised: this type is immutable, and a cache on it would be a lie about freshness. :meth:netimps.Host.ip documents the options.

Source code in src/netimps/_fqdn/_name.py
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
def ip(
    self,
    *,
    check: bool = False,
    ipv6: "Optional[bool]" = None,
    ns: "Optional[Union[str, List[str]]]" = None,
    timeout: "Optional[float]" = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: "Union[bool, List[str]]" = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    source: "Optional[Union[str, List[str]]]" = None,
    cache: "Union[bool, float]" = False,
    deadline: "Optional[float]" = None,
) -> "Optional[_IPAddress]":
    """The first address this name resolves to, or ``None``.

    Always a lookup, which can block, and **never memoised**: this type is
    immutable, and a cache on it would be a lie about freshness.
    :meth:`netimps.Host.ip` documents the options.
    """
    from .._dns import lookup_ip, resolver_keywords

    return lookup_ip(str(self), **resolver_keywords(locals(), ipv6=True))

to_unicode()

The name in its display form, decoding punycode back to Unicode.

Labels are stored ASCII-encoded, because that is what goes on the wire and what comparisons must use. This is the other direction, for showing a name to a person::

FQDN("münchen.de").to_unicode()     # 'münchen.de'
str(FQDN("münchen.de"))             # 'xn--mnchen-3ya.de'

A label that is not valid punycode is passed through unchanged rather than raising -- xn-- on its own is undecodable, and a display helper that throws is worse than one that shows the stored form.

Source code in src/netimps/_fqdn/_name.py
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
def to_unicode(self) -> str:
    """The name in its display form, decoding punycode back to Unicode.

    Labels are stored ASCII-encoded, because that is what goes on the wire
    and what comparisons must use. This is the other direction, for showing
    a name to a person::

        FQDN("münchen.de").to_unicode()     # 'münchen.de'
        str(FQDN("münchen.de"))             # 'xn--mnchen-3ya.de'

    A label that is not valid punycode is passed through unchanged rather
    than raising -- ``xn--`` on its own is undecodable, and a display helper
    that throws is worse than one that shows the stored form.
    """
    out = []
    for label in self._labels:
        if label.lower().startswith("xn--"):
            try:
                out.append(label.encode("ascii").decode("idna"))
                continue
            except (UnicodeError, ValueError):
                pass
        out.append(label)
    text = ".".join(out)
    return text + "." if self._absolute else text

is_hostname()

Whether every label is a legal host name label (RFC 1123 LDH).

Letters, digits and hyphens only, and no leading or trailing hyphen.

This is narrower than what this type accepts, and deliberately so: plenty of real DNS names are not hostnames. _dmarc.example.com, _sip._tcp.example.com and _acme-challenge.example.com all carry an underscore, and a wildcard carries *. Rejecting them at construction would make the type useless for SRV, DMARC and ACME work, so the constructor takes the broad DNS rule and this reports the narrow one::

FQDN("_dmarc.example.com").is_hostname()   # False -- but valid DNS
FQDN("www.example.com").is_hostname()      # True
Source code in src/netimps/_fqdn/_name.py
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
def is_hostname(self) -> bool:
    """Whether every label is a legal *host* name label (RFC 1123 LDH).

    Letters, digits and hyphens only, and no leading or trailing hyphen.

    This is **narrower than what this type accepts**, and deliberately so:
    plenty of real DNS names are not hostnames. ``_dmarc.example.com``,
    ``_sip._tcp.example.com`` and ``_acme-challenge.example.com`` all carry
    an underscore, and a wildcard carries ``*``. Rejecting them at
    construction would make the type useless for SRV, DMARC and ACME work,
    so the constructor takes the broad DNS rule and this reports the narrow
    one::

        FQDN("_dmarc.example.com").is_hostname()   # False -- but valid DNS
        FQDN("www.example.com").is_hostname()      # True
    """
    for label in self._labels:
        if not label or label[0] == "-" or label[-1] == "-":
            return False
        if not all(
            char.isascii() and (char.isalnum() or char == "-") for char in label
        ):
            return False
    return True

common_ancestor(other)

The deepest domain enclosing both names, or None if unrelated.

FQDN("a.example.com").common_ancestor("b.example.com") is FQDN('example.com'). Compared from the right, since that is the end names share. Qualification follows this name's.

None rather than an empty name when the two share no label at all: there is no such thing as a zero-label name, and the root is not a useful answer.

Source code in src/netimps/_fqdn/_name.py
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
def common_ancestor(self, other: "FQDNLike") -> "Optional[FQDN]":
    """The deepest domain enclosing both names, or ``None`` if unrelated.

    ``FQDN("a.example.com").common_ancestor("b.example.com")`` is
    ``FQDN('example.com')``. Compared from the right, since that is the end
    names share. Qualification follows this name's.

    ``None`` rather than an empty name when the two share no label at all:
    there is no such thing as a zero-label name, and the root is not a
    useful answer.
    """
    suffix = other if isinstance(other, FQDN) else FQDN(other)
    mine, theirs = self._key(), suffix._key()
    shared = 0
    for a, b in zip(reversed(mine), reversed(theirs)):
        if a != b:
            break
        shared += 1
    if not shared:
        return None
    return self._from_labels(
        self._labels[len(self._labels) - shared :], self._absolute
    )

encode()

The DNS wire encoding: each label length-prefixed, root terminated.

FQDN("www.example.com").encode() is b'\x03www\x07example\x03com\x00', uncompressed. Delegates to the package's own encoder, so it cannot drift from what :func:netimps.resolve_wire actually sends.

Always absolute -- the root terminator is present whether or not this name carries a trailing dot, because there is no relative wire form. So :meth:decode of the result equals :meth:fully_qualified, which is this name only when it already is.

Source code in src/netimps/_fqdn/_name.py
703
704
705
706
707
708
709
710
711
712
713
714
715
716
def encode(self) -> bytes:
    """The DNS wire encoding: each label length-prefixed, root terminated.

    ``FQDN("www.example.com").encode()`` is
    ``b'\\x03www\\x07example\\x03com\\x00'``, uncompressed. Delegates to
    the package's own encoder, so it cannot drift from what
    :func:`netimps.resolve_wire` actually sends.

    Always absolute -- the root terminator is present whether or not this
    name carries a trailing dot, because there is no relative wire form.
    So :meth:`decode` of the result equals :meth:`fully_qualified`, which is
    this name only when it already is.
    """
    return encode_name(".".join(self._labels))

decode(data) classmethod

The name in a buffer that holds exactly one, in wire form.

:raises DNSDecodeError: for a malformed name, a compression loop, or bytes left over after the name. It is a ValueError.

Source code in src/netimps/_fqdn/_name.py
721
722
723
724
725
726
727
728
729
730
731
732
@classmethod
def decode(cls: "Type[_F]", data: "Union[bytes, bytearray, memoryview]") -> "_F":
    """The name in a buffer that holds exactly one, in wire form.

    :raises DNSDecodeError: for a malformed name, a compression loop, or
        bytes left over after the name. It is a ``ValueError``.
    """
    buffer = bytes(data)
    name, end = cls.decode_at(buffer, 0)
    if end != len(buffer):
        raise DNSDecodeError("%d byte(s) follow the name" % (len(buffer) - end,))
    return name

decode_at(data, offset) classmethod

The name starting at offset in a DNS message, and where it ends.

Follows compression pointers, with loop detection. The returned offset is the first byte after the name in the record -- just past the two-byte pointer when the name was compressed -- so the caller can carry on parsing from it. The name is always fully qualified.

:raises DNSDecodeError: for an offset outside the message, a name that runs past it, a label over 63 octets, a name over 255, a compression loop, the root alone, or a label no :class:FQDN can hold (a non-printable byte or a dot). It is a ValueError.

Source code in src/netimps/_fqdn/_name.py
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
@classmethod
def decode_at(
    cls: "Type[_F]", data: "Union[bytes, bytearray, memoryview]", offset: int
) -> "Tuple[_F, int]":
    """The name starting at ``offset`` in a DNS message, and where it ends.

    Follows compression pointers, with loop detection. The returned offset
    is the first byte after the name *in the record* -- just past the
    two-byte pointer when the name was compressed -- so the caller can
    carry on parsing from it. The name is always fully qualified.

    :raises DNSDecodeError: for an offset outside the message, a name that
        runs past it, a label over 63 octets, a name over 255, a compression
        loop, the root alone, or a label no :class:`FQDN` can hold (a
        non-printable byte or a dot). It is a ``ValueError``.
    """
    buffer = bytes(data)
    if not 0 <= offset <= len(buffer):
        raise DNSDecodeError("offset %d is outside the message" % (offset,))
    raw, end = read_labels(buffer, offset)
    if not raw:
        raise DNSDecodeError("the root has no labels, so it is not an FQDN")
    labels = []
    for label in raw:
        if not is_label(label):
            raise DNSDecodeError(
                "label %r holds a byte an FQDN label cannot" % (label,)
            )
        labels.append(label.decode("ascii"))
    return cls._from_labels(tuple(labels), True), end

Backoff(delay=0.5, *, multiplier=2.0, max_delay=30.0, jitter=0.0, jitter_seconds=None, symmetric=False)

A retransmission timer: grows on loss, resets on progress.

:func:backoff_delays is a one-shot schedule for "retry this call a few times". A long-lived session needs the other shape -- a current delay that advances when a reply does not come and goes back to the base when the peer moves the transfer forward. The loop it replaces is hand-written in every protocol client::

timer = Backoff(delay=timeout, max_delay=timeout * 8, jitter=0)
while not done:
    deadline = now() + timer.delay
    if replied:
        timer.reset()          # progress: fresh timer
    elif timer.attempt >= retries:
        raise TransferTimeout(...)
    else:
        timer.advance()        # loss: back off

delay is stable between calls to :meth:advance or :meth:reset, so arming a deadline, logging it and comparing against it all see one value; a property that re-jittered on each read would be a trap here.

Two guard rails a hand-rolled version tends to miss, both refused with :class:ValueError when the timer is built: a multiplier below 1, which would shrink the wait on loss, and a max_delay below delay, which would truncate the first wait. :func:backoff_delays and :func:retry refuse the same arguments.

Jitter is off by default here, the opposite of :func:backoff_delays. Its purpose is to desynchronise many clients retrying together; a point-to-point session retransmitting to one peer has no herd to avoid, and TFTP and TCP both specify plain doubling. Set jitter, jitter_seconds or symmetric for the protocols that do ask for it -- the arguments mean exactly what they mean on :func:backoff_delays.

Source code in src/netimps/_retry.py
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
def __init__(
    self,
    delay: float = 0.5,
    *,
    multiplier: float = 2.0,
    max_delay: float = 30.0,
    jitter: float = 0.0,
    jitter_seconds: "Optional[float]" = None,
    symmetric: bool = False,
) -> None:
    base, growth, ceiling, fraction, amplitude = _normalise(
        delay, multiplier, max_delay, jitter, jitter_seconds
    )
    self._base = base
    self._multiplier = growth
    self._max_delay = ceiling
    self._jitter = fraction
    self._jitter_seconds = amplitude
    self._symmetric = symmetric
    self._random = _draw()
    self._current = self._base
    self._attempt = 0
    self._delay = self._compute()

delay property

The wait for the current attempt. Stable until advance/reset.

attempt property

How many times :meth:advance has been called since the last reset.

advance()

Back off one step and return the new :attr:delay.

Source code in src/netimps/_retry.py
319
320
321
322
323
324
def advance(self) -> float:
    """Back off one step and return the new :attr:`delay`."""
    self._current = min(self._current * self._multiplier, self._max_delay)
    self._attempt += 1
    self._delay = self._compute()
    return self._delay

reset()

Return to the base delay -- the peer made progress.

Source code in src/netimps/_retry.py
326
327
328
329
330
331
def reset(self) -> float:
    """Return to the base delay -- the peer made progress."""
    self._current = self._base
    self._attempt = 0
    self._delay = self._compute()
    return self._delay

Datagram

Bases: NamedTuple

One received datagram and where it came from.

Attributes:

Name Type Description
data bytes

the payload.

sender 'SocketAddress'

(address, port) of the peer, as recvfrom reports it -- a four-tuple for an IPv6 socket.

destination 'Optional[IPAddress]'

the address the datagram was sent to, or None. For a broadcast this is the broadcast address, not the interface's own address -- use interface to identify the adapter. On a dual-stack IPv6 socket an IPv4 arrival reports the v4-mapped form (::ffff:10.0.0.1), matching what sender shows. Absent without pktinfo.

interface_index int

receiving interface index, or 0 when unknown.

interface 'Optional[Interface]'

the resolved :class:Interface, or None when unavailable (no pktinfo, or no matching adapter).

control_truncated bool

the kernel had more ancillary data than the buffer held (MSG_CTRUNC). When this is True and the interface fields are empty, they are empty because something was dropped -- not because the kernel had nothing to say.

truncated bool

the payload did not fit bufsize and data is the leading part of a longer datagram (MSG_TRUNC). A different question from control_truncated, and the one that silently corrupts a decode: a protocol parser handed a message cut mid-field reports a malformed packet rather than a short read. Measured on Linux with bufsize=576: a 1102-octet datagram arrived with MSG_TRUNC set and the flag discarded, leaving the caller nothing to check. Reported, not raised -- deciding that a short datagram is fatal belongs to the protocol, not here.

is_unicast property

Whether the datagram was addressed to one host: not a broadcast, a multicast group or the wildcard.

None when destination is unknown -- there was no pktinfo -- since a guess in either direction would make a server answer, or ignore, the wrong packets. The subnet broadcast is judged against the arrival interface, so this costs no enumeration when that is set; see :func:netimps.is_unicast.

reply_address property

sender, in the family a :meth:UDPEndpoint.reply_socket will use.

Use this, not sender, to answer a datagram. On a dual-stack AF_INET6 listener a v4 client's sender is the v6 4-tuple ('::ffff:127.0.0.1', port, 0, 0), while reply_socket correctly hands back an AF_INET socket -- so reply.sendto(answer, packet.sender) raises TypeError: AF_INET address must be a pair (host, port). reply_address is the sender already in the family the library chose, so the caller does not unmap it::

with endpoint.reply_socket(packet) as reply:
    reply.sendto(answer, packet.reply_address)

A v4-mapped sender becomes the plain (host, port) pair; everything else is returned unchanged, so this is the right thing to pass on a single-family listener too.

UDPEndpoint(sock, *, pktinfo=True, interfaces=())

Bases: _SendMixin, _ReplyMixin, _AdmitMixin

A UDP socket that can report which interface each datagram arrived on.

::

endpoint = UDPEndpoint(netimps.bind("", 67, broadcast=True))
while True:
    packet = endpoint.recv(2048)
    if packet.interface is not None:
        reply_on(packet.interface, packet.data)

Wraps rather than subclasses socket.socket: the raw socket stays reachable as :attr:socket for anything this does not cover.

:param sock: an already-bound UDP socket -- build it with :func:netimps.bind. Its family decides which pktinfo option is used; an AF_INET6 socket gets the v6 one, including when it is dual-stack. :param pktinfo: request arrival-interface data. True (the default) enables it where supported and is a no-op elsewhere. This governs receiving only -- :meth:send's src needs no socket option. :param interfaces: the interfaces this endpoint serves, each an :class:Interface with an index; empty (the default) serves all. :meth:admits tests a datagram against them; receiving never filters.

Two flags report what this socket can actually do, so a caller never has to infer it from an empty result:

:ivar has_pktinfo: recv will report the arrival interface. This is False -- not an optimistic True -- whenever the option for this socket's family is missing or refused. :ivar has_src_pinning: :meth:send can honour src. False where the platform exports no pktinfo cmsg for this family, and for an IPv4 endpoint on FreeBSD that is bound to an address (the kernel pins only on a wildcard-bound socket); src is then ignored, and the kernel picks the source as it always would.

Source code in src/netimps/_udp/_endpoint.py
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
def __init__(
    self,
    sock: "_socket.socket",
    *,
    pktinfo: bool = True,
    interfaces: "Iterable[Interface]" = (),
) -> None:
    self.interfaces = self._served(interfaces)
    self.socket = sock
    self.has_pktinfo = False
    self.has_src_pinning = False
    self._cmsg_size = 0
    self._iface_cache: "Dict[int, Optional[Interface]]" = {}
    self._iface_cache_at = 0.0
    #: Created on the first `arecv`, so a synchronous caller never pays
    #: for it -- on Windows it owns a thread.
    self._notifier: "Any" = None
    self._closed = False

    family = getattr(sock, "family", _socket.AF_INET)
    level, receive_option, send_type, _layout = _pktinfo_options(family)

    if _freebsd.IS_FREEBSD and family == _socket.AF_INET:
        # FreeBSD carries IPv4 arrival data and the source pin in other
        # options; see `_freebsd`. The pin needs a wildcard-bound socket.
        self.has_src_pinning = _supports_recvmsg() and _freebsd.can_pin(sock)
        if pktinfo and _supports_recvmsg() and _freebsd.enable_receive(sock):
            self.has_pktinfo = True
            self._cmsg_size = _cmsg_space(_CMSG_SLOT_BYTES) * _CMSG_SLOTS
        return

    # Sending needs no socket option, only ``sendmsg`` and a cmsg type for
    # the family -- so it is decided independently of ``pktinfo=``, which
    # is about what arrives.
    self.has_src_pinning = send_type is not None and _supports_recvmsg()

    if not pktinfo or receive_option is None or not _supports_recvmsg():
        return
    try:
        sock.setsockopt(level, receive_option, 1)
    except OSError:
        return  # option exists but this socket/family refuses it

    if family == _socket.AF_INET6 and _IP_PKTINFO is not None:
        # Dual-stack, and the three platforms disagree about who reports a
        # v4 arrival on an AF_INET6 socket. Measured on CI runners:
        #   Linux   -- accepts IP_PKTINFO here and sends BOTH cmsgs; the v6
        #              one already carries the v4-mapped address.
        #   macOS   -- REFUSES it (EINVAL), and the v6 cmsg carries the
        #              v4-mapped address anyway.
        #   Windows -- accepts it, and it is the ONLY way a v4 arrival is
        #              visible: the v6 option delivers no cmsg at all for
        #              one, so without this the arrival address is lost.
        # Hence try-and-ignore rather than a platform test: the two that do
        # not need it either tolerate it or refuse it harmlessly, and a
        # v6-only socket refuses it on both macOS and Windows.
        try:
            sock.setsockopt(_socket.IPPROTO_IP, _IP_PKTINFO, 1)
        except OSError:
            pass

    self.has_pktinfo = True
    self._cmsg_size = _cmsg_space(_CMSG_SLOT_BYTES) * _CMSG_SLOTS

recv(bufsize=65535, *, resolve_interface=True)

Receive one datagram.

:param resolve_interface: look the arrival index up in :func:netimps.get_interfaces to populate .interface. Pass False in a hot loop and use .interface_index directly -- enumeration is not free.

When pktinfo is unavailable this still works; the interface fields are simply empty. When it is available -- :attr:has_pktinfo -- the interface fields are filled for both address families, and .control_truncated says whether anything was dropped for want of buffer space.

A timeout set on the socket raises the builtin :class:TimeoutError on every supported Python.

Source code in src/netimps/_udp/_endpoint.py
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
@_builtin_timeout
def recv(self, bufsize: int = 65535, *, resolve_interface: bool = True) -> Datagram:
    """Receive one datagram.

    :param resolve_interface: look the arrival index up in
        :func:`netimps.get_interfaces` to populate ``.interface``. Pass
        ``False`` in a hot loop and use ``.interface_index`` directly --
        enumeration is not free.

    When pktinfo is unavailable this still works; the interface fields are
    simply empty. When it *is* available -- :attr:`has_pktinfo` --
    the interface fields are filled for both address families, and
    ``.control_truncated`` says whether anything was dropped for want of
    buffer space.

    A timeout set on the socket raises the builtin :class:`TimeoutError`
    on every supported Python.
    """
    if not self.has_pktinfo:
        # No interface information here, but `recvmsg` still reports
        # `MSG_TRUNC`, and `recvfrom` cannot -- so the degraded path goes
        # through it anyway, with a zero-length control buffer. Losing
        # pktinfo is a documented degrade; losing the only signal that the
        # payload was cut short is silent data corruption, and the two do
        # not have to be given up together.
        if _supports_recvmsg():
            data, _anc, flags, raw = _recvmsg(self.socket, bufsize, 0)
            return Datagram(
                data=data,
                sender=cast("SocketAddress", raw),
                truncated=bool(flags & _MSG_TRUNC),
            )
        data, sender = self.socket.recvfrom(bufsize)
        return Datagram(data=data, sender=sender)

    # Routed through `_msg`, not `self.socket.recvmsg`, so this works on
    # Windows whether or not the stdlib patch is installed -- a caller who
    # sets NETIMPS_SOCKET_PATCH=0 must not thereby lose pktinfo here.
    data, ancdata, flags, raw_sender = _recvmsg(
        self.socket, bufsize, self._cmsg_size
    )
    # `_msg.recvmsg` types the address as optional because it decodes only
    # AF_INET/AF_INET6 sockaddrs and answers None for anything else. This
    # endpoint is one of those two by construction -- `_pktinfo_options`
    # already dispatched on the family -- so the narrowing is sound here and
    # would not be in the general case.
    sender = cast("SocketAddress", raw_sender)

    index = 0
    local: "Optional[IPAddress]" = None
    for level, ctype, cdata in ancdata:
        decoded = _unpack_pktinfo(level, ctype, cdata)
        if decoded is not None:
            index, local = decoded
            break
        if _freebsd.IS_FREEBSD:
            # Two messages, one per fact, so neither ends the scan.
            arrival = _freebsd.decode_arrival(level, ctype, cdata)
            if arrival is not None:
                index = arrival[0] or index
                local = arrival[1] or local

    if self.socket.family == _socket.AF_INET6 and isinstance(local, IPv4Address):
        # A v4 arrival on a dual-stack socket. Windows reports it at level
        # IPPROTO_IP carrying the **plain** v4 address, while Linux and macOS
        # report the v4-mapped form in the v6 cmsg -- measured on CI, and the
        # two halves of the same Windows datagram even disagree, since its
        # `sender` is already ::ffff:127.0.0.1. `destination` is documented
        # as v4-mapped on an AF_INET6 endpoint, so normalise rather than let
        # the platform show through.
        local = IPv6Address("::ffff:%s" % (local,))

    interface = None
    if resolve_interface and index:
        interface = self._interface_for(int(index))

    return Datagram(
        data=data,
        sender=sender,
        destination=local,
        interface_index=int(index),
        interface=interface,
        control_truncated=bool(flags & _MSG_CTRUNC),
        truncated=bool(flags & _MSG_TRUNC),
    )

arecv(bufsize=65535, *, resolve_interface=True) async

:meth:recv, awaited. Same arguments, same :class:Datagram.

Works on every asyncio loop, including the Windows default ProactorEventLoop, which has no add_reader and whose recvfrom would discard the ancillary data this class exists for::

async def serve(endpoint):
    while True:
        packet = await endpoint.arecv()
        with endpoint.reply_socket(packet) as reply:
            reply.sendto(answer(packet), packet.reply_address)

The read itself happens on the loop, not in a helper thread, so bufsize stays a per-call argument and :attr:Datagram.truncated keeps meaning what it means. Only the readability notification is platform-specific -- add_reader where the loop has it, a thread where it does not. See :class:netimps._udp._notifier.ReadNotifier.

One waiter at a time: this is a receive loop's method, and two coroutines awaiting the same endpoint would race for the same datagram regardless of how the waiting were arranged.

Every call suspends once, datagram waiting or not, so a loop that reads as fast as datagrams arrive still lets other tasks run.

The socket's timeout is left as it is: the read is made non-blocking only for the duration of one call, so a synchronous :meth:recv on the same endpoint is unaffected.

:raises RuntimeError: if the endpoint is closed, or is closed while this is waiting.

Source code in src/netimps/_udp/_endpoint.py
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
async def arecv(
    self, bufsize: int = 65535, *, resolve_interface: bool = True
) -> "Datagram":
    """:meth:`recv`, awaited. Same arguments, same :class:`Datagram`.

    Works on **every** asyncio loop, including the Windows default
    ``ProactorEventLoop``, which has no ``add_reader`` and whose
    ``recvfrom`` would discard the ancillary data this class exists for::

        async def serve(endpoint):
            while True:
                packet = await endpoint.arecv()
                with endpoint.reply_socket(packet) as reply:
                    reply.sendto(answer(packet), packet.reply_address)

    The read itself happens **on the loop**, not in a helper thread, so
    ``bufsize`` stays a per-call argument and
    :attr:`Datagram.truncated` keeps meaning what it means. Only the
    *readability notification* is platform-specific -- ``add_reader`` where the
    loop has it, a thread where it does not. See
    :class:`netimps._udp._notifier.ReadNotifier`.

    One waiter at a time: this is a receive loop's method, and two coroutines
    awaiting the same endpoint would race for the same datagram regardless of
    how the waiting were arranged.

    Every call suspends once, datagram waiting or not, so a loop that
    reads as fast as datagrams arrive still lets other tasks run.

    The socket's timeout is left as it is: the read is made non-blocking
    only for the duration of one call, so a synchronous :meth:`recv` on the
    same endpoint is unaffected.

    :raises RuntimeError: if the endpoint is closed, or is closed while this
        is waiting.
    """
    notifier = self._ensure_notifier()
    while True:
        await notifier.wait()
        try:
            return self._recv_nowait(bufsize, resolve_interface)
        except BlockingIOError:
            # A spurious wakeup, or another reader took it. Wait again rather
            # than returning an empty datagram.
            continue

asend(data, dst, port, *, src=None) async

:meth:send, awaited. Same arguments, same return value.

The datagram is handed to the kernel without blocking; when the send buffer is full this waits (without blocking the loop) until the socket is writable and tries again. The socket's timeout is left as it was found, as for :meth:arecv.

:raises RuntimeError: if the endpoint is closed.

Source code in src/netimps/_udp/_endpoint.py
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
async def asend(
    self,
    data: bytes,
    dst: "HostLike",
    port: int,
    *,
    src: "InterfaceLike" = None,
) -> int:
    """:meth:`send`, awaited. Same arguments, same return value.

    The datagram is handed to the kernel without blocking; when the send
    buffer is full this waits (without blocking the loop) until the socket
    is writable and tries again. The socket's timeout is left as it was
    found, as for :meth:`arecv`.

    :raises RuntimeError: if the endpoint is closed.
    """
    sock = self.socket
    while True:
        if self._closed or sock.fileno() < 0:
            raise RuntimeError("endpoint is closed")
        previous = sock.gettimeout()
        sock.settimeout(0.0)
        try:
            return self.send(data, dst, port, src=src)
        except BlockingIOError:
            pass
        finally:
            try:
                sock.settimeout(previous)
            except OSError:  # closed while sending
                pass
        from ._notifier import wait_writable

        await wait_writable(sock)

datagrams(bufsize=65535, *, resolve_interface=True, on_error=None) async

Yield datagrams until the endpoint is closed -- async for sugar.

::

async for packet in endpoint.datagrams():
    ...

Stops cleanly on :meth:close. By default any other error propagates and ends the loop, because a receive loop that swallows them is how a dead server looks healthy.

:param on_error: called with the exception when a receive fails. Return true to carry on with the next datagram, false (or raise) to stop with that error. The caller decides, so log or count there; nothing is dropped silently. Not called for the error a close causes.

Source code in src/netimps/_udp/_endpoint.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
async def datagrams(
    self,
    bufsize: int = 65535,
    *,
    resolve_interface: bool = True,
    on_error: "Optional[Callable[[BaseException], bool]]" = None,
) -> "AsyncIterator[Datagram]":
    """Yield datagrams until the endpoint is closed -- ``async for`` sugar.

    ::

        async for packet in endpoint.datagrams():
            ...

    Stops cleanly on :meth:`close`. By default any other error propagates and
    ends the loop, because a receive loop that swallows them is how a dead
    server looks healthy.

    :param on_error: called with the exception when a receive fails. Return
        true to carry on with the next datagram, false (or raise) to stop
        with that error. The caller decides, so log or count there; nothing
        is dropped silently. Not called for the error a close causes.
    """
    while True:
        try:
            yield await self.arecv(bufsize, resolve_interface=resolve_interface)
        except (RuntimeError, OSError, ValueError) as exc:
            if self._closed_for_async():
                return
            if on_error is not None and on_error(exc):
                continue
            raise

close()

Close the wrapped socket, and stop the async notifier if one was started.

The notifier goes first: its thread selects on this socket, and closing the socket underneath it would turn an orderly shutdown into a caught OSError. Complete on return, and harmless when called again. From a coroutine use :meth:aclose, which does not block the loop.

A task awaiting :meth:arecv is woken with :class:RuntimeError, and :meth:datagrams finishes.

Source code in src/netimps/_udp/_endpoint.py
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
def close(self) -> None:
    """Close the wrapped socket, and stop the async notifier if one was started.

    The notifier goes first: its thread selects on this socket, and closing
    the socket underneath it would turn an orderly shutdown into a caught
    ``OSError``. Complete on return, and harmless when called again. From a
    coroutine use :meth:`aclose`, which does not block the loop.

    A task awaiting :meth:`arecv` is woken with :class:`RuntimeError`, and
    :meth:`datagrams` finishes.
    """
    self._closed = True
    notifier, self._notifier = self._notifier, None
    if notifier is not None:
        notifier.close()
    self.socket.close()

aclose() async

:meth:close, awaited: the loop keeps running while the notifier's thread leaves.

Complete on return -- the thread is gone and the socket is closed -- and harmless when called again, or after :meth:close. Use it from a coroutine; :meth:close would block the loop for as long as the thread takes to stop.

Source code in src/netimps/_udp/_endpoint.py
470
471
472
473
474
475
476
477
478
479
480
481
482
483
async def aclose(self) -> None:
    """:meth:`close`, awaited: the loop keeps running while the notifier's
    thread leaves.

    Complete on return -- the thread is gone and the socket is closed --
    and harmless when called again, or after :meth:`close`. Use it from a
    coroutine; :meth:`close` would block the loop for as long as the thread
    takes to stop.
    """
    self._closed = True
    notifier, self._notifier = self._notifier, None
    if notifier is not None:
        await notifier.aclose()
    self.socket.close()

ListenAddress

Bases: NamedTuple

One socket a specification names: an address, a port and the interfaces limiting it.

interfaces is empty for a socket that is not limited; otherwise address is the wildcard of the socket's family.

SocketOption

Bases: NamedTuple

One setsockopt triple, for :func:bind's options=.

bind accepts bare (level, name, value) tuples as well -- this only gives the triple a name, so a caller building a list of them reads as something other than Iterable[Tuple[int, int, Any]]::

bind("", 67, options=[SocketOption(SOL_SOCKET, SO_RCVBUF, 1 << 20)])

A :class:typing.NamedTuple, so it is a tuple: plain tuples are accepted and these unpack like any other.

Route(dst, *, src=None, gateway=None, interface_index=0, on_link=None)

How traffic to a destination leaves this host.

Attributes:

Name Type Description
dst 'Union[str, IPAddress]'

the destination this route was computed for.

src 'Optional[IPAddress]'

local address the kernel would use (see :func:get_source_ip).

gateway 'Optional[IPAddress]'

next-hop router, or None when the destination is on-link (same subnet, or loopback) and no router is involved -- or when no next-hop lookup could be made. on_link tells the two apart.

interface_index int

index of the outgoing interface, 0 if unknown.

on_link 'Optional[bool]'

True when no gateway is needed, False when one is, and None when the next hop could not be looked up at all.

on_link is three-state deliberately. gateway is None would turn "we never looked" into a confident True: on macOS, where the lookup has no source to read, get_route('1.1.1.1') from a 192.168.64.3/24 host would report on_link=True. None is falsy, so if route.on_link: takes the safe branch, and route.on_link is True asks the exact question.

Source code in src/netimps/_sockets/_route.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def __init__(
    self,
    dst: "Union[str, IPAddress]",
    *,
    src: "Optional[IPAddress]" = None,
    gateway: "Optional[IPAddress]" = None,
    interface_index: int = 0,
    on_link: "Optional[bool]" = None,
) -> None:
    object.__setattr__(self, "dst", dst)
    object.__setattr__(self, "src", src)
    object.__setattr__(self, "gateway", gateway)
    object.__setattr__(self, "interface_index", interface_index)
    object.__setattr__(self, "_on_link", on_link)

True without a router, False with one, None if unknown.

A gateway is proof on its own, so it wins over whatever was recorded. Its absence proves nothing by itself, which is why the constructor takes the flag separately.

collapse(networks)

Merge an iterable of networks into the smallest equivalent list.

Adjacent and overlapping networks are combined; the result is sorted and covers exactly the same addresses::

collapse(["10.0.0.0/25", "10.0.0.128/25"])   # [IPv4Network('10.0.0.0/24')]
collapse(["10.0.0.0/24", "10.0.0.8/29"])     # [IPv4Network('10.0.0.0/24')]

Accepts anything :func:parse does, mixed v4 and v6 -- the families are collapsed independently and returned v4 first. Raises :class:ValueError on malformed input.

Source code in src/netimps/_ip/_cidr.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
def collapse(networks: "Iterable[IPNetworkLike]") -> "List[IPNetwork]":
    """Merge an iterable of networks into the smallest equivalent list.

    Adjacent and overlapping networks are combined; the result is sorted and
    covers exactly the same addresses::

        collapse(["10.0.0.0/25", "10.0.0.128/25"])   # [IPv4Network('10.0.0.0/24')]
        collapse(["10.0.0.0/24", "10.0.0.8/29"])     # [IPv4Network('10.0.0.0/24')]

    Accepts anything :func:`parse` does, mixed v4 and v6 -- the families are
    collapsed independently and returned v4 first. Raises :class:`ValueError`
    on malformed input.
    """
    v4: "List[IPv4Network]" = []
    v6: "List[IPv6Network]" = []
    for item in networks:
        net = _parse(item, IPNetwork)
        if isinstance(net, IPv4Network):
            v4.append(net)
        else:
            v6.append(net)
    out: "List[IPNetwork]" = []
    if v4:
        out.extend(_ipaddress.collapse_addresses(v4))
    if v6:
        out.extend(_ipaddress.collapse_addresses(v6))
    return out

get_hostname(*, fqdn=False)

This machine's host name, asked for when called.

:func:platform.node by default. With fqdn=True, :func:socket.getfqdn, which may consult the resolver and so can block; where no qualified name is known it returns the bare one.

Nothing is read at import: on Windows :func:platform.node is a WMI query, which is too much to charge every import netimps.

Source code in src/netimps/_ip/_host.py
539
540
541
542
543
544
545
546
547
548
549
550
551
def get_hostname(*, fqdn: bool = False) -> str:
    """This machine's host name, asked for when called.

    :func:`platform.node` by default. With ``fqdn=True``,
    :func:`socket.getfqdn`, which **may consult the resolver** and so can
    block; where no qualified name is known it returns the bare one.

    Nothing is read at import: on Windows :func:`platform.node` is a WMI
    query, which is too much to charge every ``import netimps``.
    """
    if fqdn:
        return _socket.getfqdn()
    return _platform.node()

True if ip is confined to link scope or narrower.

Covers loopback (127/8, ::1 -- host scope) and link-local (169.254/16, fe80::/10 -- link scope), borrowing IPv6's scope vocabulary for both families::

is_link_scoped(parse("127.0.0.1"))      # True  -- host scope
is_link_scoped(parse("169.254.1.1"))    # True  -- link scope
is_link_scoped(parse("10.0.0.5"))       # False -- private, global scope

The shared practical property is that neither can usefully be routed off the local host or link, so proxying, forwarding or advertising such an address is always wrong. Keeping the definition in one place stops each caller from writing a subtly different version.

.. note:: This is not "is private". RFC 1918 ranges (10/8, 192.168/16) are globally scoped and routable within a site, so they return False -- use ip.is_private for that question.

Source code in src/netimps/_ip/_classify.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def is_link_scoped(ip: "IPAddressLike") -> bool:
    """True if ``ip`` is confined to link scope or narrower.

    Covers loopback (``127/8``, ``::1`` -- host scope) and link-local
    (``169.254/16``, ``fe80::/10`` -- link scope), borrowing IPv6's scope
    vocabulary for both families::

        is_link_scoped(parse("127.0.0.1"))      # True  -- host scope
        is_link_scoped(parse("169.254.1.1"))    # True  -- link scope
        is_link_scoped(parse("10.0.0.5"))       # False -- private, global scope

    The shared practical property is that neither can usefully be routed off
    the local host or link, so proxying, forwarding or advertising such an
    address is always wrong. Keeping the definition in one place stops each
    caller from writing a subtly different version.

    .. note::
       This is **not** "is private". RFC 1918 ranges (``10/8``,
       ``192.168/16``) are globally *scoped* and routable within a site, so
       they return ``False`` -- use ``ip.is_private`` for that question.
    """
    address = unmap(_as_address(ip))
    return address.is_loopback or address.is_link_local

split_host(text, *, default_port=None)

Split "host:port" into (host, port), handling IPv6 brackets.

The parsing that looks trivial until IPv6 arrives, because a bare v6 address is full of colons::

split_host("example.com:8080")     # ('example.com', 8080)
split_host("10.0.0.5")             # ('10.0.0.5', None)
split_host("[::1]:8080")           # ('::1', 8080)
split_host("::1")                  # ('::1', None)   -- not port 1
split_host("example.com", 443)     # ('example.com', 443)

The rule this implements: a bare IPv6 address must not be split on its last colon, and only a bracketed one may carry a port. "::1" is the address, never host "::" port 1 -- the mistake hand-rolled splitters almost always make.

Brackets are stripped from the returned host, and a scope id is preserved ("[fe80::1%eth0]:80" -> ("fe80::1%eth0", 80)). A bracketed literal with no port is the host alone: "[::1]" is ("::1", None). default_port is used when no port is present.

A (host, port) pair is taken too, with port an integer or None and default_port filling a None::

split_host(("example.com", None), default_port=69)   # ('example.com', 69)
split_host(("[::1]", 80))                            # ('::1', 80)

The host of a pair is split like any other, so a port written in both places is an error unless they agree.

text accepts the package's usual loose union, not only a str: an address object, an :class:IPv4Interface/:class:IPv6Interface (its .ip is used), a :class:Host or an :class:FQDN, as :func:join_host, the inverse, does. A network, or a value that is not a host type at all (None, an int, bytes), raises :class:TypeError.

Port text is ASCII digits, as RFC 3986 section 3.2.3 has it: "8_0", "+80", a space and non-ASCII digits are refused. What sits inside brackets has to be an IPv6 literal (section 3.2.2).

Raises :class:NetimpsValueError on empty input, an unclosed bracket, brackets around anything but an IPv6 address, a port that is not ASCII digits in 0-65535, or an unbracketed string with two or more colons that is not a valid IPv6 address ("host:80:extra", a half-typed address).

Source code in src/netimps/_ip/_hosttext.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
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
def split_host(
    text: "Union[HostLike, Tuple[HostLike, Optional[int]]]",
    *,
    default_port: Optional[int] = None,
) -> "Tuple[str, Optional[int]]":
    """Split ``"host:port"`` into ``(host, port)``, handling IPv6 brackets.

    The parsing that looks trivial until IPv6 arrives, because a bare v6
    address is *full of colons*::

        split_host("example.com:8080")     # ('example.com', 8080)
        split_host("10.0.0.5")             # ('10.0.0.5', None)
        split_host("[::1]:8080")           # ('::1', 8080)
        split_host("::1")                  # ('::1', None)   -- not port 1
        split_host("example.com", 443)     # ('example.com', 443)

    The rule this implements: a bare IPv6 address must **not** be split on its
    last colon, and only a bracketed one may carry a port. ``"::1"`` is the
    address, never host ``"::"`` port ``1`` -- the mistake hand-rolled splitters
    almost always make.

    Brackets are stripped from the returned host, and a scope id is preserved
    (``"[fe80::1%eth0]:80"`` -> ``("fe80::1%eth0", 80)``). A bracketed literal
    with no port is the host alone: ``"[::1]"`` is ``("::1", None)``.
    ``default_port`` is used when no port is present.

    A ``(host, port)`` **pair** is taken too, with ``port`` an integer or
    ``None`` and ``default_port`` filling a ``None``::

        split_host(("example.com", None), default_port=69)   # ('example.com', 69)
        split_host(("[::1]", 80))                            # ('::1', 80)

    The host of a pair is split like any other, so a port written in both places
    is an error unless they agree.

    ``text`` accepts the package's usual loose union, not only a ``str``: an
    address object, an :class:`IPv4Interface`/:class:`IPv6Interface` (its ``.ip``
    is used), a :class:`Host` or an :class:`FQDN`, as :func:`join_host`, the
    inverse, does. A *network*, or a value that is not a host type at all
    (``None``, an ``int``, ``bytes``), raises :class:`TypeError`.

    Port text is ASCII digits, as RFC 3986 section 3.2.3 has it: ``"8_0"``,
    ``"+80"``, a space and non-ASCII digits are refused. What sits inside
    brackets has to be an IPv6 literal (section 3.2.2).

    Raises :class:`NetimpsValueError` on empty input, an unclosed bracket,
    brackets around anything but an IPv6 address, a port that is not ASCII digits
    in 0-65535, or an unbracketed string with two or more colons that is **not**
    a valid IPv6 address (``"host:80:extra"``, a half-typed address).
    """
    if isinstance(text, tuple):
        return _split_pair(text, default_port)
    text = _host_text(text)
    if not text.strip():
        raise NetimpsValueError("host must be a non-empty string, got %r" % (text,))
    text = text.strip()

    if text.startswith("["):
        end = text.find("]")
        if end == -1:
            raise NetimpsValueError("unclosed '[' in %r" % (text,))
        host = text[1:end]
        if not _is_ipv6_literal(host):
            raise NetimpsValueError(
                "%r is bracketed but not an IPv6 address" % (text[: end + 1],)
            )
        rest = text[end + 1 :]
        if not rest:
            port = default_port
        elif rest.startswith(":"):
            port = _parse_port(rest[1:], text)
        else:
            raise NetimpsValueError("unexpected %r after ']' in %r" % (rest, text))
    elif text.count(":") > 1:
        # More than one colon and no brackets: a bare IPv6 address is the only
        # thing that can be, and splitting would turn "::1" into host "::"
        # port 1. Confirm it really parses rather than assuming: taking the
        # whole unparseable string as the host is a confident wrong answer the
        # caller cannot detect.
        if not _is_ipv6_literal(text):
            raise NetimpsValueError(
                "%r has several colons but is not an IPv6 address; "
                "bracket it as [host]:port if a port was meant" % (text,)
            )
        host, port = text, default_port
    elif ":" in text:
        host, _, raw_port = text.partition(":")
        port = _parse_port(raw_port, text)
    else:
        host, port = text, default_port

    if not host:
        raise NetimpsValueError("empty host in %r" % (text,))
    return host, port

split_zone(text)

Split an IPv6 %zone suffix off a host: (host, zone).

::

split_zone("fe80::1%eth0")   # ('fe80::1', 'eth0')
split_zone("fe80::1%12")     # ('fe80::1', '12')
split_zone("10.0.0.5")       # ('10.0.0.5', None)
split_zone("example.com")    # ('example.com', None)

Use it before :func:try_parse or an address comparison: ipaddress keeps the zone as part of the address, so fe80::1%eth0 is not equal to fe80::1 and matches nothing in :func:get_interfaces. The zone names an interface, which is why it is returned rather than thrown away. Brackets are :func:split_host's business, not this function's.

text takes the package's loose host union. Raises :class:TypeError for a value that is not a host type, and :class:NetimpsValueError for a % with nothing after it.

Source code in src/netimps/_ip/_hosttext.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
def split_zone(text: "HostLike") -> "Tuple[str, Optional[str]]":
    """Split an IPv6 ``%zone`` suffix off a host: ``(host, zone)``.

    ::

        split_zone("fe80::1%eth0")   # ('fe80::1', 'eth0')
        split_zone("fe80::1%12")     # ('fe80::1', '12')
        split_zone("10.0.0.5")       # ('10.0.0.5', None)
        split_zone("example.com")    # ('example.com', None)

    Use it before :func:`try_parse` or an address comparison: ``ipaddress`` keeps
    the zone as part of the address, so ``fe80::1%eth0`` is not equal to
    ``fe80::1`` and matches nothing in :func:`get_interfaces`. The zone names an
    *interface*, which is why it is returned rather than thrown away. Brackets are
    :func:`split_host`'s business, not this function's.

    ``text`` takes the package's loose host union. Raises :class:`TypeError`
    for a value that is not a host type, and :class:`NetimpsValueError` for a
    ``%`` with nothing after it.
    """
    value = _host_text(text)
    host, sep, zone = value.partition("%")
    if not sep:
        return value, None
    if not zone:
        raise NetimpsValueError("empty zone after '%%' in %r" % (value,))
    return host, zone

join_host(host, port=None)

Build "host:port" from its parts -- the inverse of :func:split_host.

The direction everyone writes by hand and gets wrong on IPv6, because a bare v6 address is full of colons and must be bracketed before a port can be appended::

join_host("example.com", 8080)     # 'example.com:8080'
join_host("10.0.0.5", 8080)        # '10.0.0.5:8080'
join_host("::1", 8080)             # '[::1]:8080'     -- not '::1:8080'
join_host("fe80::1%eth0", 80)      # '[fe80::1%eth0]:80'
join_host("::1")                   # '::1'            -- no port, no brackets
join_host("example.com")           # 'example.com'

host accepts a string, an :class:IPv4Address/:class:IPv6Address, an :class:IPv4Interface/:class:IPv6Interface (its .ip is used) or an :class:FQDN. An already-bracketed string is accepted and not double-bracketed.

Only an IPv6 literal is bracketed. A hostname never is, however many colons someone has put in it -- brackets in a URI authority mean "the thing inside is an address", so bracketing a name would produce something no resolver will accept.

Brackets are added when a port is present or absent, following the same rule: a lone "::1" needs none, since there is no colon to disambiguate from. That is what makes split_host(join_host(h, p)) == (h, p) hold.

:raises TypeError: for a network, a value that is not a host type, or a port that is not an int (a bool is not one). :raises NetimpsValueError: for an empty host, brackets around anything but an IPv6 address, or a port outside 0-65535.

Source code in src/netimps/_ip/_hosttext.py
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
def join_host(host: "HostLike", port: "Optional[int]" = None) -> str:
    """Build ``"host:port"`` from its parts -- the inverse of :func:`split_host`.

    The direction everyone writes by hand and gets wrong on IPv6, because a bare
    v6 address is full of colons and must be bracketed before a port can be
    appended::

        join_host("example.com", 8080)     # 'example.com:8080'
        join_host("10.0.0.5", 8080)        # '10.0.0.5:8080'
        join_host("::1", 8080)             # '[::1]:8080'     -- not '::1:8080'
        join_host("fe80::1%eth0", 80)      # '[fe80::1%eth0]:80'
        join_host("::1")                   # '::1'            -- no port, no brackets
        join_host("example.com")           # 'example.com'

    ``host`` accepts a string, an :class:`IPv4Address`/:class:`IPv6Address`, an
    :class:`IPv4Interface`/:class:`IPv6Interface` (its ``.ip`` is used) or an
    :class:`FQDN`. An already-bracketed string is accepted and not
    double-bracketed.

    **Only an IPv6 *literal* is bracketed.** A hostname never is, however many
    colons someone has put in it -- brackets in a URI authority mean "the thing
    inside is an address", so bracketing a name would produce something no
    resolver will accept.

    Brackets are added when a port is present **or** absent, following the same
    rule: a lone ``"::1"`` needs none, since there is no colon to disambiguate
    from. That is what makes ``split_host(join_host(h, p)) == (h, p)`` hold.

    :raises TypeError: for a network, a value that is not a host type, or a
        ``port`` that is not an ``int`` (a ``bool`` is not one).
    :raises NetimpsValueError: for an empty host, brackets around anything but
        an IPv6 address, or a port outside 0-65535.
    """
    text = _host_text(host).strip()
    if not text:
        raise NetimpsValueError("host must not be empty")

    if text.startswith("[") or text.endswith("]"):
        # A mismatched bracket has to raise, not fall through: `"[::1"` is not an
        # IPv6 literal, so it would otherwise emerge unbracketed as `"[::1:80"`,
        # which is garbage the caller cannot detect. `split_host` rejects the
        # same input, and the pair must agree.
        if not (text.startswith("[") and text.endswith("]")):
            raise NetimpsValueError("mismatched brackets in %r" % (text,))
        # Already bracketed: validate the inside rather than trusting it, so a
        # malformed literal is caught here and not by the caller's resolver.
        if not _is_ipv6_literal(text[1:-1]):
            raise NetimpsValueError("%r is bracketed but not an IPv6 address" % (text,))
        text = text[1:-1]

    if _is_ipv6_literal(text):
        text = "[%s]" % (text,)

    if port is None:
        # Strip the brackets back off: with no port there is nothing to
        # disambiguate, and `split_host` returns the bare form, so keeping
        # them would break the round trip.
        return text[1:-1] if text.startswith("[") else text

    return "%s:%d" % (text, _port_number(port, port))

format_address(address)

The text of an address, the same on every supported Python.

str() of a v4-mapped IPv6 address is the hex form (::ffff:102:304) before Python 3.13 and the mixed form (::ffff:1.2.3.4, RFC 5952 section 5) from it. This writes the mixed form on every interpreter and, for any other address, what str() writes. A %zone is kept and the family never changes (unmap is the function that changes it); an interface keeps its /prefix.

:raises TypeError: for anything that is not an address or interface object: text is parsed first, and a network names no one address.

Source code in src/netimps/_ip/_types.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
def format_address(address: "Union[IPAddress, IPInterface]") -> str:
    """The text of an address, the same on every supported Python.

    ``str()`` of a v4-mapped IPv6 address is the hex form (``::ffff:102:304``)
    before Python 3.13 and the mixed form (``::ffff:1.2.3.4``, RFC 5952 section
    5) from it. This writes the mixed form on every interpreter and, for any
    other address, what ``str()`` writes. A ``%zone`` is kept and the family
    never changes (``unmap`` is the function that changes it); an interface
    keeps its ``/prefix``.

    :raises TypeError: for anything that is not an address or interface object:
        text is parsed first, and a network names no one address.
    """
    if isinstance(address, (IPv4Interface, IPv6Interface)):
        return "%s/%d" % (format_address(address.ip), address.network.prefixlen)
    if isinstance(address, IPv4Address):
        return str(address)
    if not isinstance(address, IPv6Address):
        raise TypeError(
            "expected an address object (IPv4Address, IPv6Address or an "
            "interface), not %r" % (type(address).__name__,)
        )
    mapped = address.ipv4_mapped
    if mapped is None:
        return str(address)
    text = "::ffff:%s" % (mapped,)
    scope = address.scope_id
    return text if scope is None else "%s%%%s" % (text, scope)

unmap(value)

Collapse an IPv4-mapped IPv6 address to plain IPv4; pass anything else through.

::ffff:10.0.0.5 is how a dual-stack socket reports an IPv4 peer, and almost nothing a caller does wants it in that form -- an ACL comparing against 10.0.0.0/8, a log line, a config lookup::

unmap("::ffff:10.0.0.5")    # IPv4Address('10.0.0.5')
unmap("10.0.0.5")           # IPv4Address('10.0.0.5')   -- unchanged
unmap("2001:db8::1")        # IPv6Address('2001:db8::1') -- unchanged

Built on :attr:ipaddress.IPv6Address.ipv4_mapped rather than stripping a "::ffff:" prefix from the text. The string version looks equivalent and is not -- measured, these are all genuinely mapped addresses that a startswith("::ffff:") and "." in text test leaves untouched:

=========================== ========== ============================ input this gives the string test gives =========================== ========== ============================ ::FFFF:10.0.0.5 10.0.0.5 unchanged (wrong case) ::ffff:0:1 0.0.0.1 unchanged (no dot) 0:0:0:0:0:ffff:0a00:0005 10.0.0.5 unchanged (expanded form) =========================== ========== ============================

One address has many spellings, and only the parsed form sees through them.

:raises NetimpsValueError: for text that is no address. :raises TypeError: for a value that is no address type.

Source code in src/netimps/_ip/_classify.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
def unmap(value: "IPAddressLike") -> "IPAddress":
    """Collapse an IPv4-mapped IPv6 address to plain IPv4; pass anything else through.

    ``::ffff:10.0.0.5`` is how a dual-stack socket reports an IPv4 peer, and
    almost nothing a caller does wants it in that form -- an ACL comparing against
    ``10.0.0.0/8``, a log line, a config lookup::

        unmap("::ffff:10.0.0.5")    # IPv4Address('10.0.0.5')
        unmap("10.0.0.5")           # IPv4Address('10.0.0.5')   -- unchanged
        unmap("2001:db8::1")        # IPv6Address('2001:db8::1') -- unchanged

    Built on :attr:`ipaddress.IPv6Address.ipv4_mapped` rather than stripping a
    ``"::ffff:"`` prefix from the text. The string version looks equivalent and
    is not -- measured, these are all genuinely mapped addresses that a
    ``startswith("::ffff:") and "." in text`` test leaves untouched:

    ===========================  ==========  ============================
    input                        this gives  the string test gives
    ===========================  ==========  ============================
    ``::FFFF:10.0.0.5``          ``10.0.0.5``  unchanged (wrong case)
    ``::ffff:0:1``               ``0.0.0.1``   unchanged (no dot)
    ``0:0:0:0:0:ffff:0a00:0005`` ``10.0.0.5``  unchanged (expanded form)
    ===========================  ==========  ============================

    One address has many spellings, and only the parsed form sees through them.

    :raises NetimpsValueError: for text that is no address.
    :raises TypeError: for a value that is no address type.
    """
    address = _as_address(value)
    if isinstance(address, IPv6Address):
        mapped = address.ipv4_mapped
        if mapped is not None:
            return mapped
    return address

is_wildcard(value)

Whether value means "every local address" -- the bind-anything form.

True for "", None, "0.0.0.0", "::" and any other spelling whose address form is unspecified ("::0", "0000::0"), and for the v4-mapped ::ffff:0.0.0.0::

is_wildcard("")           # True -- what bind("") means
is_wildcard("0.0.0.0")    # True
is_wildcard("::")         # True
is_wildcard("127.0.0.1")  # False

A %zone suffix does not change whether the address is unspecified.

Text that is no address is a host name, and a name is never the wildcard: the answer is False. This is the one classifier that takes a name, because it is asked of a listen host, and bind takes a name there. A :class:Host or an :class:FQDN is read as the text it holds.

:raises TypeError: for a value that is neither text nor an address.

Source code in src/netimps/_ip/_classify.py
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
def is_wildcard(value: "Union[IPAddressLike, HostLike, None]") -> bool:
    """Whether ``value`` means "every local address" -- the bind-anything form.

    True for ``""``, ``None``, ``"0.0.0.0"``, ``"::"`` and any other spelling
    whose address form is unspecified (``"::0"``, ``"0000::0"``), and for the
    v4-mapped ``::ffff:0.0.0.0``::

        is_wildcard("")           # True -- what bind("") means
        is_wildcard("0.0.0.0")    # True
        is_wildcard("::")         # True
        is_wildcard("127.0.0.1")  # False

    A ``%zone`` suffix does not change whether the address is unspecified.

    Text that is no address is a host name, and a name is never the wildcard:
    the answer is ``False``. This is the one classifier that takes a name,
    because it is asked of a listen host, and ``bind`` takes a name there. A
    :class:`Host` or an :class:`FQDN` is read as the text it holds.

    :raises TypeError: for a value that is neither text nor an address.
    """
    if value is None:
        return True
    if isinstance(value, (Host, FQDN)):
        value = str(value)
    if isinstance(value, str):
        value = value.strip().split("%", 1)[0]
        if not value:
            return True
        literal = ip_literal(value)
        if literal is None:
            return False
        value = literal
    return bool(unmap(_as_address(value)).is_unspecified)

subtract(networks, remove)

Return networks minus every address in remove.

The set difference :mod:ipaddress leaves out -- it ships collapse_addresses but nothing to punch holes::

subtract(["10.0.0.0/24"], ["10.0.0.64/26"])
# [IPv4Network('10.0.0.0/26'), IPv4Network('10.0.0.128/25')]

subtract(["0.0.0.0/0"], ["10.0.0.0/8", "192.168.0.0/16"])  # public v4

The result is collapsed, so it is the minimal set of networks covering what is left. Removing something absent is a no-op, and removing a superset yields []. Mixed families are handled independently: an IPv6 exclusion never affects IPv4 output.

Source code in src/netimps/_ip/_cidr.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
def subtract(
    networks: "Iterable[IPNetworkLike]", remove: "Iterable[IPNetworkLike]"
) -> "List[IPNetwork]":
    """Return ``networks`` minus every address in ``remove``.

    The set difference :mod:`ipaddress` leaves out -- it ships
    ``collapse_addresses`` but nothing to punch holes::

        subtract(["10.0.0.0/24"], ["10.0.0.64/26"])
        # [IPv4Network('10.0.0.0/26'), IPv4Network('10.0.0.128/25')]

        subtract(["0.0.0.0/0"], ["10.0.0.0/8", "192.168.0.0/16"])  # public v4

    The result is collapsed, so it is the minimal set of networks covering
    what is left. Removing something absent is a no-op, and removing a
    superset yields ``[]``. Mixed families are handled independently: an IPv6
    exclusion never affects IPv4 output.
    """
    remaining = collapse(networks)
    for item in remove:
        excluded = _parse(item, IPNetwork)
        next_round: "List[IPNetwork]" = []
        for net in remaining:
            if net.version != excluded.version:
                next_round.append(net)  # different family: untouched
                continue
            # mypy cannot see that the `.version` check above guarantees `net`
            # and `excluded` share a concrete type -- ipaddress's own stubs
            # type subnet_of/address_exclude as same-family-only, stricter
            # than the runtime, which accepts the IPv4Network|IPv6Network
            # union fine once the families actually match.
            if not (
                net.subnet_of(excluded)  # type: ignore[arg-type]
                or excluded.subnet_of(net)  # type: ignore[arg-type]
                or net.overlaps(excluded)
            ):
                next_round.append(net)
                continue
            if net.subnet_of(excluded):  # type: ignore[arg-type]
                continue  # fully removed
            next_round.extend(net.address_exclude(excluded))  # type: ignore[arg-type]
        remaining = next_round
    return collapse(remaining)

classify(text)

Read text as whichever of a MAC, a network, an interface or an address it spells, and return that value.

The order, which decides text that could be read two ways:

  1. a :class:MACAddress (any spelling it accepts);
  2. text with a / prefix length: an :data:IPNetwork when it has no host bits set ("10.0.0.0/24", "10.0.0.5/32"), otherwise an :data:IPInterface ("10.0.0.5/24");
  3. an :data:IPAddress ("10.0.0.5", "fe80::1%eth0").

So a bare address is never read as a /32 network, and an address with a prefix is never an address. A name is none of these: classify never asks a resolver, so classify("example.com") raises. Resolve it with :meth:Host.resolve first.

Raises :class:NetimpsValueError for text that is none of them, and :class:TypeError for a non-str.

Source code in src/netimps/_parse.py
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
def classify(text: str) -> "Union[MACAddress, IPNetwork, IPInterface, IPAddress]":
    """Read ``text`` as whichever of a MAC, a network, an interface or an
    address it spells, and return that value.

    The order, which decides text that could be read two ways:

    1. a :class:`MACAddress` (any spelling it accepts);
    2. text with a ``/`` prefix length: an :data:`IPNetwork` when it has no
       host bits set (``"10.0.0.0/24"``, ``"10.0.0.5/32"``), otherwise an
       :data:`IPInterface` (``"10.0.0.5/24"``);
    3. an :data:`IPAddress` (``"10.0.0.5"``, ``"fe80::1%eth0"``).

    So a bare address is never read as a ``/32`` network, and an address with a
    prefix is never an address. A name is none of these: ``classify`` never
    asks a resolver, so ``classify("example.com")`` raises. Resolve it with
    :meth:`Host.resolve` first.

    Raises :class:`NetimpsValueError` for text that is none of them, and
    :class:`TypeError` for a non-``str``.
    """
    if not isinstance(text, str):
        raise TypeError("text must be a str, got %r" % (type(text).__name__,))
    mac = try_parse(text, MACAddress)
    if mac is not None:
        return mac
    if "/" in text:
        network = try_parse(text, IPNetwork, strict=True)
        if network is not None:
            return network
        interface = try_parse(text, IPInterface)
        if interface is not None:
            return interface
    else:
        address = try_parse(text, IPAddress)
        if address is not None:
            return address
    raise NetimpsValueError(
        "%r is not a MAC address, network, interface or address" % (text,)
    )

is_valid(value, type=IPAddress, *, strict=None, **options)

is_valid(
    value: object,
    type: TypeForm[_T],
    *,
    strict: Optional[bool] = ...,
    **options: object
) -> bool
is_valid(
    value: object,
    type: Callable[..., _T],
    *,
    strict: Optional[bool] = ...,
    **options: object
) -> bool
is_valid(value: object) -> bool

Return True if value parses as type.

A rejected value is False, never an exception; an unusable type or option raises TypeError, as in :func:try_parse.

Accepts the same type forms as :func:try_parse -- a type, a union alias, or any callable::

is_valid("10.0.0.5", IPAddress)      # True  (the type alias)
is_valid("10.0.0.0/24", IPNetwork)   # True
is_valid("aa:bb:cc:dd:ee:ff", MACAddress)
is_valid("nonsense", IPAddress)      # False

When you want the parsed value too, use :func:try_parse instead of calling this first -- one call, no double work. Same exception policy: only ValueError/TypeError count as "invalid".

.. note:: A parser that legitimately returns None for valid input still counts as valid here -- the parse succeeded. That is why this delegates via a sentinel rather than testing try_parse(...) is not None, which cannot tell "returned None" from "rejected the input".

Source code in src/netimps/_parse.py
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
def is_valid(  # type: ignore[no-redef]  # the overloads above are the signature
    value: object,
    type: "object" = IPAddress,
    *,
    strict: "Optional[bool]" = None,
    **options: "object",
) -> "bool":
    """Return ``True`` if ``value`` parses as ``type``.

    A rejected value is ``False``, never an exception; an unusable ``type`` or
    option raises ``TypeError``, as in :func:`try_parse`.

    Accepts the same ``type`` forms as :func:`try_parse` -- a type, a union
    alias, or any callable::

        is_valid("10.0.0.5", IPAddress)      # True  (the type alias)
        is_valid("10.0.0.0/24", IPNetwork)   # True
        is_valid("aa:bb:cc:dd:ee:ff", MACAddress)
        is_valid("nonsense", IPAddress)      # False

    When you want the parsed value too, use :func:`try_parse` instead of
    calling this first -- one call, no double work. Same exception policy: only
    ``ValueError``/``TypeError`` count as "invalid".

    .. note::
       A parser that legitimately returns ``None`` for valid input still counts
       as valid here -- the parse *succeeded*. That is why this delegates via a
       sentinel rather than testing ``try_parse(...) is not None``, which cannot
       tell "returned None" from "rejected the input".
    """
    target: Any = type
    return (
        try_parse(value, target, default=_MISSING, strict=strict, **options)
        is not _MISSING
    )

parse(value, type=IPAddress, *, strict=None, **options)

parse(
    value: object,
    type: TypeForm[_T],
    *,
    strict: Optional[bool] = ...,
    **options: object
) -> _T
parse(
    value: object,
    type: Callable[..., _T],
    *,
    strict: Optional[bool] = ...,
    **options: object
) -> _T
parse(value: object) -> IPAddress

Build type from value, raising on bad input.

The single parsing entry point. type is a result type -- one of the :data:IPAddress/:data:IPInterface/:data:IPNetwork unions, a concrete IPv4Address &co, or any callable::

parse("10.0.0.5")                        # IPv4Address  (the default)
parse("10.0.0.5/24", IPInterface)        # IPv4Interface
parse("10.0.0.5/24", IPNetwork)          # IPv4Network('10.0.0.0/24')
parse("10.0.0.5/24", IPNetwork, strict=True)   # raises: host bits set
parse("aa:bb:cc:dd:ee:ff", MACAddress)   # MACAddress

Every type accepts the full range of stdlib inputs -- str, int, packed bytes, or an existing object -- because the builders are the ipaddress.ip_* functions rather than the concrete constructors.

A union accepts either family; a concrete type enforces its own, so parse("::1", IPv4Address) raises rather than quietly returning an IPv6Address.

Networks are parsed non-strict by default (unlike the stdlib), so a host address with a prefix normalises to its network instead of raising. strict is the network builders' option and is passed only when given; any other keyword passes through to the underlying builder.

Raises :class:NetimpsValueError (a :class:ValueError) on malformed input or a family mismatch -- including the ipaddress builders' own errors -- and :class:TypeError for an unusable type. A callable type that is not one of the package's builders raises whatever it raises. Use :func:try_parse for the non-raising form.

Source code in src/netimps/_parse.py
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
def parse(  # type: ignore[no-redef]  # the overloads above are the signature
    value: object,
    type: "object" = IPAddress,
    *,
    strict: "Optional[bool]" = None,
    **options: "object",
) -> "Any":
    """Build ``type`` from ``value``, raising on bad input.

    The single parsing entry point. ``type`` is a result type -- one of the
    :data:`IPAddress`/:data:`IPInterface`/:data:`IPNetwork` unions, a concrete
    ``IPv4Address`` &co, or any callable::

        parse("10.0.0.5")                        # IPv4Address  (the default)
        parse("10.0.0.5/24", IPInterface)        # IPv4Interface
        parse("10.0.0.5/24", IPNetwork)          # IPv4Network('10.0.0.0/24')
        parse("10.0.0.5/24", IPNetwork, strict=True)   # raises: host bits set
        parse("aa:bb:cc:dd:ee:ff", MACAddress)   # MACAddress

    Every type accepts the full range of stdlib inputs -- ``str``, ``int``,
    packed ``bytes``, or an existing object -- because the builders are the
    ``ipaddress.ip_*`` functions rather than the concrete constructors.

    A **union** accepts either family; a **concrete** type enforces its own, so
    ``parse("::1", IPv4Address)`` raises rather than quietly returning an
    ``IPv6Address``.

    Networks are parsed **non-strict** by default (unlike the stdlib), so a host
    address with a prefix normalises to its network instead of raising.
    ``strict`` is the network builders' option and is passed only when given;
    any other keyword passes through to the underlying builder.

    Raises :class:`NetimpsValueError` (a :class:`ValueError`) on malformed input
    or a family mismatch -- including the ``ipaddress`` builders' own errors --
    and :class:`TypeError` for an unusable ``type``. A callable ``type`` that is
    not one of the package's builders raises whatever it raises. Use
    :func:`try_parse` for the non-raising form.
    """
    target: Any = type
    kwargs: "Dict[str, Any]" = dict(options)
    if strict is not None:
        kwargs["strict"] = strict
    # Guarded: an unhashable ``type`` would make these lookups raise TypeError,
    # which try_parse would then swallow into `default` -- turning a caller bug
    # into a silent "invalid value". Fall through to the explicit checks below.
    try:
        wanted = _CONCRETE.get(target)
        builder = _BUILDERS.get(wanted if wanted is not None else target)
    except TypeError:
        wanted = builder = None

    if builder is None:
        _check_parser(target)  # raises for anything unusable
        # Text goes through the type's own ``parse``, so the spellings a type
        # accepts are defined in one place. Anything else (an ``int`` or
        # ``bytes`` MAC, a ``Host`` or ``FQDN`` already built) is the
        # constructor's, which ``parse`` deliberately does not take.
        if (
            isinstance(value, str)
            and isinstance(target, _ClassType)
            and issubclass(target, _TEXT_TYPES)
        ):
            return target.parse(value, **kwargs)
        return target(value, **kwargs)

    built = dict(_BUILDER_DEFAULTS.get(builder, {}))
    built.update(kwargs)
    try:
        result = builder(value, **built)
    except NetimpsValueError:
        raise
    except ValueError as exc:
        # `ipaddress` raises its own ValueError subclasses (`AddressValueError`,
        # `NetmaskValueError`) or a plain one; a caller catches one type.
        raise NetimpsValueError(str(exc)) from exc

    if wanted is not None and not isinstance(result, target):
        raise NetimpsValueError("%r is not a %s" % (value, target.__name__))
    return result

try_parse(value, type=IPAddress, *, default=None, strict=None, **options)

try_parse(
    value: object,
    type: TypeForm[_T],
    *,
    default: None = ...,
    strict: Optional[bool] = ...,
    **options: object
) -> Optional[_T]
try_parse(
    value: object,
    type: TypeForm[_T],
    *,
    default: _D,
    strict: Optional[bool] = ...,
    **options: object
) -> Union[_T, _D]
try_parse(
    value: object,
    type: Callable[..., _T],
    *,
    default: None = ...,
    strict: Optional[bool] = ...,
    **options: object
) -> Optional[_T]
try_parse(
    value: object,
    type: Callable[..., _T],
    *,
    default: _D,
    strict: Optional[bool] = ...,
    **options: object
) -> Union[_T, _D]
try_parse(
    value: object, *, default: None = ...
) -> Optional[IPAddress]
try_parse(
    value: object, *, default: _D
) -> Union[IPAddress, _D]

Return type(value), or default if it rejects the input.

A rejected value never raises; an unusable type or option does.

The one non-raising parse for the whole package. type is either a type -- including the union aliases, which are not themselves callable -- or any callable that signals bad input with ValueError/TypeError::

try_parse("10.0.0.5", IPAddress)     # IPv4Address('10.0.0.5')
try_parse("10.0.0.5", IPv4Address)   # concrete: v6 input rejected
try_parse("nonsense", IPAddress)     # None
try_parse(user_input, MACAddress) or DEFAULT_MAC
try_parse(raw, IPAddress, default=LOCALHOST)   # explicit fallback

The union aliases IPAddress/IPInterface/IPNetwork accept either family. A concrete type stays strict, so asking for one family and getting the other is impossible::

try_parse("::1", IPAddress)      # IPv6Address('::1')  -- either family
try_parse("::1", IPv4Address)    # None                -- v4 was asked for
try_parse("10.0.0.5", IPv4Address)   # IPv4Address('10.0.0.5')

Prefer this to is_valid followed by a parse: that pattern does the work twice and leaves a window where the two disagree.

Generic in the type: try_parse(x, MACAddress) is typed Optional[MACAddress], so a checker knows the result without a cast.

Only ValueError and TypeError are swallowed -- the two exceptions that mean "bad input". Anything else (an OSError from a builder that touches the network, a bug in it) propagates, because turning it into None would disguise a real failure as a rejected value. A type that is neither callable nor a known type raises TypeError: that is a caller bug, not a rejected value.

:param default: returned instead of None when the input is rejected. Also the seam :func:is_valid uses -- passing a sentinel is the only way to tell "the parse returned None" from "it rejected the input".

Source code in src/netimps/_parse.py
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
def try_parse(  # type: ignore[no-redef]  # the overloads above are the signature
    value: object,
    type: "object" = IPAddress,
    *,
    default: "object" = None,
    strict: "Optional[bool]" = None,
    **options: "object",
) -> "Any":
    """Return ``type(value)``, or ``default`` if it rejects the input.

    A rejected value never raises; an unusable ``type`` or option does.

    The one non-raising parse for the whole package. ``type`` is either a
    **type** -- including the union aliases, which are not themselves callable
    -- or any callable that signals bad input with ``ValueError``/``TypeError``::

        try_parse("10.0.0.5", IPAddress)     # IPv4Address('10.0.0.5')
        try_parse("10.0.0.5", IPv4Address)   # concrete: v6 input rejected
        try_parse("nonsense", IPAddress)     # None
        try_parse(user_input, MACAddress) or DEFAULT_MAC
        try_parse(raw, IPAddress, default=LOCALHOST)   # explicit fallback

    The union aliases ``IPAddress``/``IPInterface``/``IPNetwork`` accept either
    family. A **concrete** type stays strict, so asking for one family and
    getting the other is impossible::

        try_parse("::1", IPAddress)      # IPv6Address('::1')  -- either family
        try_parse("::1", IPv4Address)    # None                -- v4 was asked for
        try_parse("10.0.0.5", IPv4Address)   # IPv4Address('10.0.0.5')

    Prefer this to ``is_valid`` followed by a parse: that pattern does the work
    twice and leaves a window where the two disagree.

    Generic in the type: ``try_parse(x, MACAddress)`` is typed
    ``Optional[MACAddress]``, so a checker knows the result without a cast.

    Only ``ValueError`` and ``TypeError`` are swallowed -- the two exceptions
    that mean "bad input". Anything else (an ``OSError`` from a builder that
    touches the network, a bug in it) propagates, because turning it
    into ``None`` would disguise a real failure as a rejected value. A
    ``type`` that is neither callable nor a known type raises ``TypeError``:
    that is a caller bug, not a rejected value.

    :param default: returned instead of ``None`` when the input is rejected.
        Also the seam :func:`is_valid` uses -- passing a sentinel is the only
        way to tell "the parse returned ``None``" from "it rejected the input".
    """
    # Validate the type *before* the try, so the TypeError raised for an
    # unusable one is not swallowed as if the value had been rejected. Only the
    # parse itself is guarded.
    target: Any = type
    _check_parser(target)
    kwargs = dict(options)
    if strict is not None:
        kwargs["strict"] = strict
    _check_options(target, value, kwargs)
    try:
        return parse(value, target, strict=strict, **options)
    except (ValueError, TypeError):
        return default

get_default_port(scheme)

Return the conventional port for a URL scheme, or None if unknown.

Checks the built-in/registered table first, then falls back to the system services database via :func:socket.getservbyname::

get_default_port("https")    # 443
get_default_port("socks5")   # 1080  (absent from /etc/services)
get_default_port("443")      # 443   (a port number as text)
get_default_port("nope")     # None

The database is asked for TCP first, then UDP, so a name carrying both answers with its TCP port on every platform rather than with whichever entry that host's database lists first.

Text made of ASCII digits is a port, not a scheme: it comes back as an int, so a command line or a config file can hand over either spelling. A number outside 0-65535 raises :class:NetimpsValueError; it is neither a port nor a scheme. Any other spelling of a number ("+80", "8_0", surrounding spaces) is looked up as a name and is unknown.

:raises TypeError: for a scheme that is not text.

Case-insensitive. Extend the table with :func:register_port.

Source code in src/netimps/_scheme.py
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
def get_default_port(scheme: str) -> Optional[int]:
    """Return the conventional port for a URL scheme, or ``None`` if unknown.

    Checks the built-in/registered table first, then falls back to the system
    services database via :func:`socket.getservbyname`::

        get_default_port("https")    # 443
        get_default_port("socks5")   # 1080  (absent from /etc/services)
        get_default_port("443")      # 443   (a port number as text)
        get_default_port("nope")     # None

    The database is asked for **TCP first, then UDP**, so a name carrying both
    answers with its TCP port on every platform rather than with whichever
    entry that host's database lists first.

    Text made of ASCII digits is a port, not a scheme: it comes back as an
    ``int``, so a command line or a config file can hand over either spelling.
    A number outside ``0-65535`` raises :class:`NetimpsValueError`; it is
    neither a port nor a scheme. Any other spelling of a number (``"+80"``,
    ``"8_0"``, surrounding spaces) is looked up as a name and is unknown.

    :raises TypeError: for a ``scheme`` that is not text.

    Case-insensitive. Extend the table with :func:`register_port`.
    """
    if not isinstance(scheme, str):
        raise TypeError(
            "scheme must be text (a name or a port number), got %s"
            % (type(scheme).__name__,)
        )
    scheme = scheme.lower()
    # A port is ASCII digits and nothing else, the rule split_host applies:
    # int() alone also reads "+80", "8_0", " 80 " and non-ASCII digits.
    if scheme.isascii() and scheme.isdigit():
        number = int(scheme)
        if not MIN_PORT <= number <= MAX_PORT:
            raise NetimpsValueError(
                "port out of range: %r (must be %d-%d)" % (scheme, MIN_PORT, MAX_PORT)
            )
        return number
    if scheme in _DEFAULT_PORTS:
        return _DEFAULT_PORTS[scheme]
    return _service_port(scheme)

get_default_scheme(port)

Return the conventional scheme for a port, or None if unknown.

The inverse of :func:get_default_port::

get_default_scheme(443)     # 'https'
get_default_scheme(1080)    # 'socks'   (canonical, not an alias)
get_default_scheme(9999)    # None

Falls back to the system services database via :func:socket.getservbyport, asked for TCP first, then UDP: port 514 is shell/cmd over TCP and syslog over UDP, and a protocol-less lookup picks between them per platform. Where several schemes share a port, the canonical one is returned -- see :func:register_port.

An out-of-range port is None rather than an error: this is a table lookup, not a socket operation, so "no such entry" is the honest answer. The port-taking network helpers validate instead, via :func:coerce_port.

Source code in src/netimps/_scheme.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
def get_default_scheme(port: int) -> Optional[str]:
    """Return the conventional scheme for a port, or ``None`` if unknown.

    The inverse of :func:`get_default_port`::

        get_default_scheme(443)     # 'https'
        get_default_scheme(1080)    # 'socks'   (canonical, not an alias)
        get_default_scheme(9999)    # None

    Falls back to the system services database via
    :func:`socket.getservbyport`, asked for **TCP first, then UDP**: port 514
    is ``shell``/``cmd`` over TCP and ``syslog`` over UDP, and a
    protocol-less lookup picks between them per platform. Where several
    schemes share a port, the canonical one is returned -- see
    :func:`register_port`.

    An out-of-range ``port`` is ``None`` rather than an error: this is a table
    lookup, not a socket operation, so "no such entry" is the honest answer.
    The port-taking network helpers validate instead, via
    :func:`coerce_port`.
    """
    if port in _PORT_SCHEMES:
        return _PORT_SCHEMES[port]
    return _service_name(port)

register_port(scheme, port, *, canonical=False)

Register (or override) a scheme's conventional port.

The built-in table covers the common cases; a protocol of the caller's own is added here::

register_port("myproto", 9999)
get_default_port("myproto")     # 9999
get_default_scheme(9999)        # 'myproto'

Re-registering a scheme moves it: the port it occupied before the call stops mapping back to it, or the registry would contradict itself::

register_port("myproto", 8888)
get_default_port("myproto")     # 8888
get_default_scheme(9999)        # None, not 'myproto'

If another scheme is still registered on the vacated port, it inherits the slot (earliest registration first, the same rule the initial index uses).

:param scheme: scheme name; matched case-insensitively. :param port: TCP/UDP port number, 0-65535. :param canonical: make scheme the name :func:get_default_scheme returns for port, displacing any existing one. By default the first registration for a port keeps that slot, so adding an alias does not silently change what an existing port maps back to.

Raises :class:ValueError on an out-of-range port or empty scheme, and :class:TypeError on a non-int port.

Source code in src/netimps/_scheme.py
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
def register_port(scheme: str, port: int, *, canonical: bool = False) -> None:
    """Register (or override) a scheme's conventional port.

    The built-in table covers the common cases; a protocol of the caller's own
    is added here::

        register_port("myproto", 9999)
        get_default_port("myproto")     # 9999
        get_default_scheme(9999)        # 'myproto'

    Re-registering a scheme **moves** it: the port it occupied before the call
    stops mapping back to it, or the registry would contradict itself::

        register_port("myproto", 8888)
        get_default_port("myproto")     # 8888
        get_default_scheme(9999)        # None, not 'myproto'

    If another scheme is still registered on the vacated port, it inherits the
    slot (earliest registration first, the same rule the initial index uses).

    :param scheme: scheme name; matched case-insensitively.
    :param port: TCP/UDP port number, 0-65535.
    :param canonical: make ``scheme`` the name :func:`get_default_scheme` returns for
        ``port``, displacing any existing one. By default the first registration
        for a port keeps that slot, so adding an alias does not silently change
        what an existing port maps back to.

    Raises :class:`ValueError` on an out-of-range port or empty scheme, and
    :class:`TypeError` on a non-``int`` port.
    """
    if not scheme or not scheme.strip():
        raise ValueError("scheme must be a non-empty string")
    port = coerce_port(port)

    scheme = scheme.strip().lower()
    previous = _DEFAULT_PORTS.get(scheme)
    _DEFAULT_PORTS[scheme] = port
    if previous is not None and previous != port:
        # The scheme moved. Leaving the stale reverse entry in place is how the
        # registry ends up stating both "myproto is 8888" and "9999 is
        # myproto" at once; hand the vacated slot to whichever scheme still
        # claims that port, if any.
        if _PORT_SCHEMES.get(previous) == scheme:
            del _PORT_SCHEMES[previous]
            for name, num in _DEFAULT_PORTS.items():
                if num == previous:
                    _PORT_SCHEMES[previous] = name
                    break
    if canonical or port not in _PORT_SCHEMES:
        _PORT_SCHEMES[port] = scheme

clear_interface_cache()

Drop any cached enumeration, so the next cached call re-enumerates.

For a caller that knows the adapter set changed -- it bound a socket, watched netlink, or handled WM_NETWORKCHANGE -- and should not wait out the TTL. Harmless when nothing is cached.

Source code in src/netimps/_ifaddrs/_cache.py
49
50
51
52
53
54
55
56
57
58
59
def clear_interface_cache() -> None:
    """Drop any cached enumeration, so the next cached call re-enumerates.

    For a caller that *knows* the adapter set changed -- it bound a socket,
    watched netlink, or handled ``WM_NETWORKCHANGE`` -- and should not wait out
    the TTL. Harmless when nothing is cached.
    """
    global _GENERATION
    with _CACHE_LOCK:
        _GENERATION += 1
        _INTERFACE_CACHE.clear()

interface_enumerations()

How many times this process has really enumerated its adapters.

Monotonic, and counts only the syscall, never a cached hit -- which is the point: with cache= a lookup and an enumeration stop being the same event, and the enumeration is the one a packet flood multiplies. A test reads it either side of the code under test::

before = interface_enumerations()
for _ in range(20):
    get_interface(address, cache=True)
assert interface_enumerations() - before == 1

It is also worth exporting as a metric: how often a long-running server re-reads its adapters answers whether its cache is sized right.

Both raw=True and raw=False count into this one total. The cache is keyed by raw, so a process using both pays two enumerations to warm up and will see this advance twice.

:func:clear_interface_cache does not advance it -- dropping a cache enumerates nothing by itself; the next cached call is what pays.

Source code in src/netimps/_ifaddrs/_cache.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
def interface_enumerations() -> int:
    """How many times this process has really enumerated its adapters.

    Monotonic, and counts only the **syscall**, never a cached hit -- which is
    the point: with ``cache=`` a lookup and an enumeration stop being the same
    event, and the enumeration is the one a packet flood multiplies. A test
    reads it either side of the code under test::

        before = interface_enumerations()
        for _ in range(20):
            get_interface(address, cache=True)
        assert interface_enumerations() - before == 1

    It is also worth exporting as a metric: how often a long-running server
    re-reads its adapters answers whether its cache is sized right.

    Both ``raw=True`` and ``raw=False`` count into this one total. The cache is
    keyed by ``raw``, so a process using both pays two enumerations to warm up
    and will see this advance twice.

    :func:`clear_interface_cache` does not advance it -- dropping a cache
    enumerates nothing by itself; the next cached call is what pays.
    """
    return _ENUMERATIONS

get_interface(query=_NO_QUERY, *, index=None, strict=True, cache=False)

Return the first local interface matching query, or None.

The reverse of interface enumeration -- "a socket is bound here, which adapter is that?"::

get_interface(sock.getsockname()[0])

Accepts the same query forms as :func:iter_interfaces, an adapter name and index= among them (get_interface(iface.name), get_interface(index=iface.index)); singular lookup is exactly the first plural result. Since addresses can appear on more than one adapter (especially unscoped IPv6 link-local addresses) and so can a MAC (a teamed or virtual adapter), this returns the first in enumeration order: use the plural form when every match matters.

:param strict: when True (the default), a miss returns None. When False, an address or IPInterface miss produces a synthetic single-address Interface so a caller can still attribute traffic. Network and MAC misses cannot be synthesized honestly and remain None. :param cache: reuse a recent enumeration rather than making the syscall -- True for :data:netimps.INTERFACE_CACHE_TTL seconds, or a number for that TTL. This is the argument a per-packet caller wants. Each call otherwise enumerates every adapter, measured at 35-42 ms on a host with many of them. cache=0 is a TTL of zero, so it enumerates and reseeds -- which is the whole of "force a refresh". :func:netimps.clear_interface_cache invalidates without a lookup.

The synthetic interface is named "<unknown>" and carries a host route (/32 or /128), matching how degraded enumeration reports itself.

Source code in src/netimps/_ifaddrs/_lookup.py
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
def get_interface(
    query: "Optional[InterfaceQuery]" = _NO_QUERY,
    *,
    index: "Optional[int]" = None,
    strict: bool = True,
    cache: "Union[bool, float]" = False,
) -> "Optional[Interface]":
    """Return the first local interface matching ``query``, or ``None``.

    The reverse of interface enumeration -- "a socket is bound here, which
    adapter is that?"::

        get_interface(sock.getsockname()[0])

    Accepts the same query forms as :func:`iter_interfaces`, an adapter name and
    ``index=`` among them (``get_interface(iface.name)``,
    ``get_interface(index=iface.index)``); singular lookup is exactly the first
    plural result. Since addresses can appear on more than one adapter
    (especially unscoped IPv6 link-local addresses) and so can a MAC (a teamed
    or virtual adapter), this returns the first in enumeration order: use the
    plural form when every match matters.

    :param strict: when True (the default), a miss returns ``None``. When
        False, an address or ``IPInterface`` miss produces a synthetic
        single-address ``Interface`` so a caller can still attribute traffic.
        Network and MAC misses cannot be synthesized honestly and remain
        ``None``.
    :param cache: reuse a recent enumeration rather than making the syscall --
        ``True`` for :data:`netimps.INTERFACE_CACHE_TTL` seconds, or a number
        for that TTL. **This is the argument a per-packet caller wants.** Each
        call otherwise enumerates every adapter, measured at 35-42 ms on a host
        with many of them.
        ``cache=0`` is a TTL of zero, so it enumerates and reseeds -- which is
        the whole of "force a refresh". :func:`netimps.clear_interface_cache`
        invalidates without a lookup.

    The synthetic interface is named ``"<unknown>"`` and carries a host route
    (``/32`` or ``/128``), matching how degraded enumeration reports itself.
    """
    kind, wanted = _interface_target(query, index)
    match = next(_interfaces_for_query(kind, wanted, cache), None)
    if match is not None or strict or kind != "address":
        return match

    built = _make_host_route(_without_zone(wanted))
    return Interface(name="<unknown>", ips=[built] if built else [])

get_interfaces(*, raw=False, cache=False)

Return this host's network interfaces.

Uses getifaddrs(3) on POSIX and GetAdaptersAddresses on Windows via :mod:ctypes -- no third-party dependency -- so adapter names, MACs and real prefix lengths are all available::

for iface in get_interfaces():
    print(iface.name, iface.mac, [str(ip) for ip in iface.ips])

:param raw: when True, populate :attr:Interface.raw with the untouched platform data (Linux/BSD flags; Windows adapter guid, if_type, ...). Not portable -- outside the stability guarantee. :param cache: reuse a recent enumeration instead of making the syscall. False (the default) never caches and never reads a cached value, so every call enumerates afresh. True uses :data:INTERFACE_CACHE_TTL seconds, and a number is that TTL in seconds -- so cache=0 enumerates now and reseeds the cache, a TTL of zero being always stale. That is the only "force a refresh" anyone needs, which is why there is no second argument for it; :func:clear_interface_cache covers invalidating without a lookup.

The cache is process-wide and shared with
:func:`netimps.get_interface`, :func:`netimps.iter_interfaces` and
:func:`netimps.is_local_address`, which all take the same argument.

Opt-in on purpose. Enumeration is a syscall, and on a host with many adapters a measured 35-42 ms of one, so a per-packet caller needs a cache; but an adapter set changes under you, and silently answering from a stale snapshot by default would turn a cheap call into a wrong one. The caller knows which it wants.

Prefer an event to a TTL when you have one. A TTL is a guess about how long the answer stays true; if your program already knows the moment it can change, say so instead::

get_interfaces(cache=math.inf)   # never expires on its own
...
clear_interface_cache()          # at the moment it can change

That is strictly better than any TTL: no window of wrong answers, and no re-enumeration while nothing has changed. A server binding its sockets has exactly such a moment, since binding is when the set of addresses it serves can change.

A TTL (cache=True, or a number) is for the caller with no such moment -- a loop making many calls in a row that just wants to stop paying for every one, and can tolerate :data:INTERFACE_CACHE_TTL of staleness.

Enumerating per datagram is not merely slow: at 35-42 ms it is slow enough that a packet flood can deny service on its own.

A cached call returns the same immutable objects, in a fresh list. Interface cannot change after construction, ips is a tuple and raw a read-only mapping, so one caller cannot corrupt another's view. :func:clear_interface_cache also discards the result of an enumeration that was already running when it was called.

Does not raise when the platform will not answer (an OSError from the native call): it degrades to a hostname-resolution fallback in which prefixes are not real (every address becomes a /32//128 under an interface named "<unknown>", with the reason in raw and logged once at debug). A degraded result is cached like any other -- it is the honest answer for as long as the native call keeps failing. Any other exception is a defect and propagates.

Source code in src/netimps/_ifaddrs/_cache.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
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
def get_interfaces(
    *,
    raw: bool = False,
    cache: "Union[bool, float]" = False,
) -> "List[Interface]":
    """Return this host's network interfaces.

    Uses ``getifaddrs(3)`` on POSIX and ``GetAdaptersAddresses`` on Windows via
    :mod:`ctypes` -- no third-party dependency -- so adapter names, MACs and
    real prefix lengths are all available::

        for iface in get_interfaces():
            print(iface.name, iface.mac, [str(ip) for ip in iface.ips])

    :param raw: when True, populate :attr:`Interface.raw` with the untouched
        platform data (Linux/BSD ``flags``; Windows adapter ``guid``,
        ``if_type``, ...). **Not portable** -- outside the stability guarantee.
    :param cache: reuse a recent enumeration instead of making the syscall.
        ``False`` (the default) never caches and never reads a cached value, so
        every call enumerates afresh. ``True`` uses
        :data:`INTERFACE_CACHE_TTL` seconds, and a number is that TTL in
        seconds -- so **``cache=0`` enumerates now and reseeds the cache**, a
        TTL of zero being always stale. That is the only "force a refresh"
        anyone needs, which is why there is no second argument for it;
        :func:`clear_interface_cache` covers invalidating without a lookup.

        The cache is process-wide and shared with
        :func:`netimps.get_interface`, :func:`netimps.iter_interfaces` and
        :func:`netimps.is_local_address`, which all take the same argument.

    **Opt-in on purpose.** Enumeration is a syscall, and on a host with many
    adapters a measured 35-42 ms of one, so a per-packet caller needs a cache;
    but an adapter set changes under you, and silently answering from a stale
    snapshot by default would turn a cheap call into a wrong one. The caller
    knows which it wants.

    **Prefer an event to a TTL when you have one.** A TTL is a guess about how
    long the answer stays true; if your program already knows the moment it can
    change, say so instead::

        get_interfaces(cache=math.inf)   # never expires on its own
        ...
        clear_interface_cache()          # at the moment it can change

    That is strictly better than any TTL: no window of wrong answers, and no
    re-enumeration while nothing has changed. A server binding its sockets has
    exactly such a moment, since binding is when the set of addresses it serves
    can change.

    A TTL (``cache=True``, or a number) is for the caller with no such moment --
    a loop making many calls in a row that just wants to stop paying for every
    one, and can tolerate :data:`INTERFACE_CACHE_TTL` of staleness.

    Enumerating per datagram is not merely slow: at 35-42 ms it is slow enough
    that a packet flood can deny service on its own.

    **A cached call returns the same immutable objects, in a fresh list.**
    ``Interface`` cannot change after construction, ``ips`` is a tuple and
    ``raw`` a read-only mapping, so one caller cannot corrupt another's view.
    :func:`clear_interface_cache` also discards the result of an enumeration
    that was already running when it was called.

    Does not raise when the platform will not answer (an ``OSError`` from the
    native call): it degrades to a hostname-resolution fallback in which
    prefixes are *not* real (every address becomes a ``/32``/``/128`` under an
    interface named ``"<unknown>"``, with the reason in ``raw`` and logged once
    at debug). A degraded result is cached like any other -- it is the honest
    answer for as long as the native call keeps failing. Any other exception is
    a defect and propagates.
    """
    if cache is False:
        return _enumerate_interfaces(raw)

    # `is True` rather than truthiness, and the distinction carries weight:
    # `cache=1` is a one-second TTL rather than the default one, and `cache=0`
    # is "always stale" -- enumerate and store -- rather than "do not cache".
    # That last case is what a caller means by "refresh", so it needs no
    # argument of its own.
    ttl = INTERFACE_CACHE_TTL if cache is True else float(cache)
    return _copy_interfaces(_interface_snapshot(raw, ttl)[1])

is_broadcast(address, interface=None, *, cache=False)

Whether address is an IPv4 broadcast address, limited or subnet.

The question a wildcard-bound UDP server asks about :attr:netimps.Datagram.destination before answering: RFC 1123 says a TFTP server must ignore a broadcast request, and DHCP has to tell a broadcast DISCOVER from a unicast RENEW.

Pass interface whenever the caller has it -- a :attr:netimps.Datagram.interface, say. Without it this enumerates every adapter, measured at 1.25 ms against 0.004 ms, and a server asking the question of every request pays that per packet. cache= is the fallback for when the interface is genuinely unknown; it means what it means on :func:get_interfaces.

255.255.255.255 (limited broadcast) needs no context and short-circuits before any of that. The subnet broadcast does: 10.0.0.255 is only a broadcast if some interface carries 10.0.0.0/24, so this consults interface prefixes -- which is why it lives here and not in :mod:netimps._ip. Pass interface to check one adapter (the arrival interface, from Datagram.interface); omit it to check every local one, which costs an enumeration.

A v4-mapped address is unmapped first, because a dual-stack listener reports an IPv4 arrival as ::ffff:a.b.c.d and the broadcast question is about the v4 address inside.

IPv6 has no broadcast -- it uses multicast instead -- so a genuine v6 address is always False here. :func:netimps.is_multicast is the companion predicate, kept separate on purpose: "do not answer this" is usually is_broadcast(a, i) or is_multicast(a), and one name meaning both would hide which of the two it matched.

:raises NetimpsValueError: for text that is no address. :raises TypeError: for a network or a value of another type.

Source code in src/netimps/_ifaddrs/_addresses.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
def is_broadcast(
    address: "IPAddressLike",
    interface: "Optional[Interface]" = None,
    *,
    cache: "Union[bool, float]" = False,
) -> bool:
    """Whether *address* is an IPv4 broadcast address, limited or subnet.

    The question a wildcard-bound UDP server asks about
    :attr:`netimps.Datagram.destination` before answering: RFC 1123 says a TFTP
    server must ignore a broadcast request, and DHCP has to tell a broadcast
    DISCOVER from a unicast RENEW.

    Pass ``interface`` whenever the caller has it -- a
    :attr:`netimps.Datagram.interface`, say. Without it this enumerates every
    adapter, measured at **1.25 ms against 0.004 ms**, and a server asking the
    question of every request pays that per packet. ``cache=`` is the fallback
    for when the interface is genuinely unknown; it means what it means on
    :func:`get_interfaces`.

    ``255.255.255.255`` (limited broadcast) needs no context and short-circuits
    before any of that. The **subnet** broadcast does: ``10.0.0.255`` is only a
    broadcast if some interface carries ``10.0.0.0/24``, so this consults
    interface prefixes -- which is why it lives
    here and not in :mod:`netimps._ip`. Pass ``interface`` to check one adapter
    (the arrival interface, from ``Datagram.interface``); omit it to check every
    local one, which costs an enumeration.

    A **v4-mapped** address is unmapped first, because a dual-stack listener
    reports an IPv4 arrival as ``::ffff:a.b.c.d`` and the broadcast question is
    about the v4 address inside.

    IPv6 has **no broadcast** -- it uses multicast instead -- so a genuine v6
    address is always ``False`` here. :func:`netimps.is_multicast` is the
    companion predicate, kept separate on purpose: "do not answer this" is
    usually ``is_broadcast(a, i) or is_multicast(a)``, and one name meaning both
    would hide which of the two it matched.

    :raises NetimpsValueError: for text that is no address.
    :raises TypeError: for a network or a value of another type.
    """
    parsed = unmap(_as_address(address))
    if not isinstance(parsed, IPv4Address):
        return False

    # The limited broadcast, which needs no interface context at all.
    if parsed == IPv4Address("255.255.255.255"):
        return True

    if interface is not None:
        candidates: "Iterable[Interface]" = [interface]
    elif cache is False:
        candidates = get_interfaces()
    else:
        candidates = get_interfaces(cache=cache)
    for entry in candidates:
        for bound in getattr(entry, "ips", ()) or ():
            network = getattr(bound, "network", None)
            if network is None or network.version != 4:
                continue
            # A /31 or /32 has no broadcast address distinct from its hosts;
            # `broadcast_address` still answers, so size it out explicitly.
            if network.prefixlen >= 31:
                continue
            if parsed == network.broadcast_address:
                return True
    return False

is_local_address(address, *, cache=False)

Return whether address is loopback or assigned on this host.

This is deliberately narrower than private, link-local, on-link, routable or reachable: those properties do not mean an address belongs to this machine. Text that is no address raises :class:NetimpsValueError, and a network or a value of another type :class:TypeError.

A %zone suffix is honoured rather than rejected (see :func:iter_interfaces), so the address getsockname() hands back can be passed straight in.

:param cache: reuse a recent enumeration -- see :func:get_interface. A loopback address short-circuits before any enumeration, so the cache only matters for the addresses that actually reach the adapter scan.

Source code in src/netimps/_ifaddrs/_lookup.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
def is_local_address(
    address: "IPAddressLike",
    *,
    cache: "Union[bool, float]" = False,
) -> bool:
    """Return whether ``address`` is loopback or assigned on this host.

    This is deliberately narrower than private, link-local, on-link, routable
    or reachable: those properties do not mean an address belongs to this
    machine. Text that is no address raises :class:`NetimpsValueError`, and a
    network or a value of another type :class:`TypeError`.

    A ``%zone`` suffix is honoured rather than rejected (see
    :func:`iter_interfaces`), so the address ``getsockname()`` hands back can be
    passed straight in.

    :param cache: reuse a recent enumeration -- see :func:`get_interface`. A
        loopback address short-circuits before any enumeration, so the cache
        only matters for the addresses that actually reach the adapter scan.
    """

    wanted = _as_address(address)
    if wanted.is_loopback:
        return True
    return next(iter_interfaces(wanted, cache=cache), None) is not None

is_local_host(host, *, resolve=False, cache=False)

Whether host names this machine.

::

is_local_host("localhost")      # True
is_local_host("127.0.0.1")      # True
is_local_host("[::1]:22")       # True   -- a port is ignored
is_local_host("10.0.0.5")       # True only if an interface holds it
is_local_host("example.com")    # False, without asking the resolver

True for a literal that :func:is_local_address accepts (loopback, or assigned to an interface; a %zone is ignored, and a v4-mapped address is judged as the v4 address inside; the short and numeric IPv4 spellings that inet_aton reads, such as 127.1 and 2130706433, are literals), for localhost and any *.localhost (RFC 6761), and for this machine's own host name, compared without case and without a trailing dot.

Any other name is not resolved unless resolve=True: the answer then comes from the OS resolver (the hosts file included) and is true when any address it returns is local, and the fully qualified name of this machine counts as well. That can block on the network, which is why it is opt-in.

cache is :func:get_interfaces's, and matters only for the literals that reach the adapter scan. Never raises: text that is not a host, an empty one, or a name that does not resolve is simply not local.

Source code in src/netimps/_ifaddrs/_lookup.py
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
def is_local_host(
    host: "HostLike",
    *,
    resolve: bool = False,
    cache: "Union[bool, float]" = False,
) -> bool:
    """Whether *host* names this machine.

    ::

        is_local_host("localhost")      # True
        is_local_host("127.0.0.1")      # True
        is_local_host("[::1]:22")       # True   -- a port is ignored
        is_local_host("10.0.0.5")       # True only if an interface holds it
        is_local_host("example.com")    # False, without asking the resolver

    True for a literal that :func:`is_local_address` accepts (loopback, or
    assigned to an interface; a ``%zone`` is ignored, and a v4-mapped address is
    judged as the v4 address inside; the short and numeric IPv4 spellings that
    ``inet_aton`` reads, such as ``127.1`` and ``2130706433``, are literals), for ``localhost`` and any ``*.localhost``
    (RFC 6761), and for this machine's own host name, compared without case and
    without a trailing dot.

    Any other name is **not resolved** unless ``resolve=True``: the answer then
    comes from the OS resolver (the hosts file included) and is true when any
    address it returns is local, and the fully qualified name of this machine
    counts as well. That can block on the network, which is why it is opt-in.

    *cache* is :func:`get_interfaces`'s, and matters only for the literals that
    reach the adapter scan. Never raises: text that is not a host, an empty one,
    or a name that does not resolve is simply not local.
    """
    try:
        name, _port = split_host(_host_text(host))
        name, _zone = split_zone(name)
    except (TypeError, ValueError):
        return False
    name = name.rstrip(".").lower()
    if not name:
        return False

    address = try_parse(name, IPAddress)
    if address is None:
        # inet_aton text (127.1, 2130706433, 0x7f.0.0.1) is a literal to the
        # socket layer, which sends it to the address it spells.
        try:
            address = parse(_socket.inet_ntoa(_socket.inet_aton(name)), IPAddress)
        except (OSError, ValueError):
            address = None
    if address is not None:
        return is_local_address(unmap(address), cache=cache)

    if name == "localhost" or name.endswith(".localhost"):
        return True
    # `socket.gethostname()` rather than `platform.node()`: the latter is a WMI
    # query on Windows, and this is asked of every name that is not a literal.
    if name == _socket.gethostname().lower():
        return True
    if not resolve:
        return False

    if name == get_hostname(fqdn=True).lower():
        return True
    try:
        found = _socket.getaddrinfo(name, None)
    except (OSError, UnicodeError):
        # UnicodeError: a name the IDNA codec refuses (an empty label, a label
        # over 63 octets) is no name of this machine.
        return False
    return any(
        is_local_address(unmap(parsed), cache=cache)
        for parsed in (
            try_parse(str(info[4][0]).split("%", 1)[0], IPAddress) for info in found
        )
        if parsed is not None
    )

is_unicast(address, interface=None, *, cache=False)

Whether a datagram sent to address was meant for one host.

False for the wildcard, a multicast group, and a broadcast (limited or subnet); true for everything else. This is the "answer it or ignore it" question a DHCP or TFTP server asks of :attr:netimps.Datagram.destination, which callers wrote as not (is_broadcast(...) or is_multicast(...)) plus a wildcard test of their own.

interface and cache mean what they mean on :func:is_broadcast, the one part that can enumerate. A v4-mapped address is judged as the v4 address inside it.

:raises NetimpsValueError: for text that is no address. :raises TypeError: for a network or a value of another type.

Source code in src/netimps/_ifaddrs/_addresses.py
 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
def is_unicast(
    address: "IPAddressLike",
    interface: "Optional[Interface]" = None,
    *,
    cache: "Union[bool, float]" = False,
) -> bool:
    """Whether a datagram sent to *address* was meant for one host.

    False for the wildcard, a multicast group, and a broadcast (limited or
    subnet); true for everything else. This is the "answer it or ignore it"
    question a DHCP or TFTP server asks of :attr:`netimps.Datagram.destination`,
    which callers wrote as ``not (is_broadcast(...) or is_multicast(...))`` plus
    a wildcard test of their own.

    *interface* and *cache* mean what they mean on :func:`is_broadcast`, the one
    part that can enumerate. A v4-mapped address is judged as the v4 address
    inside it.

    :raises NetimpsValueError: for text that is no address.
    :raises TypeError: for a network or a value of another type.
    """
    if isinstance(address, str):
        address = address.split("%", 1)[0]
    parsed = unmap(_as_address(address))
    if is_wildcard(parsed) or parsed.is_multicast:
        return False
    return not is_broadcast(parsed, interface, cache=cache)

iter_addresses(interfaces=None, *, family=None, cache=False)

Yield (interface, address) once per address, not once per adapter.

:func:get_interfaces groups every address under its adapter, which is the right shape for "describe this host". Callers that filter or act per address -- picking a bind target, excluding link-local, matching a subnet -- want the flattened view instead, and would otherwise write the same nested loop each time::

for iface, addr in iter_addresses():
    if addr.ip in some_network:
        bind_to(addr.ip)

:param interfaces: reuse an existing enumeration instead of calling :func:get_interfaces again. Worth passing in a loop, since enumeration is a syscall. :param family: 4 or AF_INET, 6 or AF_INET6 to yield only that family; None for both. Anything else raises :class:ValueError when this function is called, not on the first next(): a generator that validates lazily reports a bad argument from somewhere the traceback does not name the caller. :param cache: when interfaces is not given, reuse a recent enumeration, as :func:get_interfaces does. False (the default) enumerates when the first address is asked for. Ignored when interfaces is given.

The interface is the full :class:Interface, so its name, MAC and MTU stay reachable -- the flattening loses no information.

Source code in src/netimps/_ifaddrs/_addresses.py
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
def iter_addresses(
    interfaces: "Optional[Iterable[Interface]]" = None,
    *,
    family: "Optional[int]" = None,
    cache: "Union[bool, float]" = False,
) -> "Iterator[Tuple[Interface, _IPInterface]]":
    """Yield ``(interface, address)`` once per address, not once per adapter.

    :func:`get_interfaces` groups every address under its adapter, which is the
    right shape for "describe this host". Callers that filter or act *per
    address* -- picking a bind target, excluding link-local, matching a subnet
    -- want the flattened view instead, and would otherwise write the same
    nested loop each time::

        for iface, addr in iter_addresses():
            if addr.ip in some_network:
                bind_to(addr.ip)

    :param interfaces: reuse an existing enumeration instead of calling
        :func:`get_interfaces` again. Worth passing in a loop, since
        enumeration is a syscall.
    :param family: ``4`` or ``AF_INET``, ``6`` or ``AF_INET6`` to yield only
        that family; ``None`` for both. Anything else raises
        :class:`ValueError` **when this function is called**, not on the first
        ``next()``: a generator that validates lazily reports a bad argument
        from somewhere the traceback does not name the caller.
    :param cache: when *interfaces* is not given, reuse a recent enumeration, as
        :func:`get_interfaces` does. ``False`` (the default) enumerates when the
        first address is asked for. Ignored when *interfaces* is given.

    The ``interface`` is the full :class:`Interface`, so its name, MAC and MTU
    stay reachable -- the flattening loses no information.
    """
    normalised = _family_argument(family)
    version = (
        None if normalised is None else (4 if normalised == _socket.AF_INET else 6)
    )
    return _iter_addresses(interfaces, version, cache)

iter_interfaces(query=_NO_QUERY, *, index=None, cache=False)

Yield every local interface matching query, in OS order.

query may be an :class:Interface (yielded directly), an address, an IPInterface (matched by its exact .ip), an IPNetwork (matched when it contains any assigned address), or a :class:MACAddress. Address- like strings, integers and packed bytes are accepted too; a slash-bearing string is interpreted as a network when it is not an address.

MAC text and 6-byte packed values are recognised after IP parsing. Integer MACs must be wrapped in MACAddress because an integer is also a valid IP-address representation. Text that is no address, network or MAC is an adapter name; index= names an interface by its index instead of a query (an int query stays an address). Invalid queries and misses yield nothing. Each matching interface is yielded once even if several of its assigned addresses fall within a requested network.

A %zone-qualified IPv6 address (fe80::1%15, fe80::1%eth0) is matched on its bare address and filtered by the zone, which names the adapter -- the index on Linux/Windows, the adapter name on BSD. That form is what getsockname(), getaddrinfo and every OS tool emit, and matching it on the bare address keeps an address the library just reported from being denied as non-local. A zone naming an adapter that does not hold the address yields nothing, which is the honest answer to a contradiction.

Source code in src/netimps/_ifaddrs/_lookup.py
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
def iter_interfaces(
    query: "Optional[InterfaceQuery]" = _NO_QUERY,
    *,
    index: "Optional[int]" = None,
    cache: "Union[bool, float]" = False,
) -> "Iterator[Interface]":
    """Yield every local interface matching ``query``, in OS order.

    ``query`` may be an :class:`Interface` (yielded directly), an address, an
    ``IPInterface`` (matched by its exact ``.ip``), an ``IPNetwork`` (matched
    when it contains any assigned address), or a :class:`MACAddress`. Address-
    like strings, integers and packed bytes are accepted too; a slash-bearing
    string is interpreted as a network when it is not an address.

    MAC text and 6-byte packed values are recognised after IP parsing.
    Integer MACs must be wrapped in ``MACAddress`` because an integer is also
    a valid IP-address representation. Text that is no address, network or MAC
    is an **adapter name**; ``index=`` names an interface by its index instead
    of a ``query`` (an ``int`` query stays an address). Invalid queries and
    misses yield nothing. Each matching interface is yielded once even if
    several of its assigned addresses fall within a requested network.

    A ``%zone``-qualified IPv6 address (``fe80::1%15``, ``fe80::1%eth0``) is
    matched on its bare address and **filtered** by the zone, which names the
    adapter -- the index on Linux/Windows, the adapter name on BSD. That form
    is what ``getsockname()``, ``getaddrinfo`` and every OS tool emit, and
    matching it on the bare address keeps an address the library just reported
    from being denied as non-local. A zone naming an adapter that does not hold the
    address yields nothing, which is the honest answer to a contradiction.
    """
    kind, wanted = _interface_target(query, index)
    yield from _interfaces_for_query(kind, wanted, cache)

clear_resolution_cache()

Drop every cached answer, so the next cache= lookup asks again.

For a caller that knows a record changed and should not wait out the TTL. Harmless when nothing is cached.

Source code in src/netimps/_dns/_cache.py
28
29
30
31
32
33
34
35
def clear_resolution_cache() -> None:
    """Drop every cached answer, so the next ``cache=`` lookup asks again.

    For a caller that *knows* a record changed and should not wait out the TTL.
    Harmless when nothing is cached.
    """
    with _CACHE_LOCK:
        _RESOLUTION_CACHE.clear()

has_dns()

Whether dnspython is installed, so :func:resolve_dnspython can run.

Without it :func:resolve still answers address and reverse records from the other backends, and raises :class:ResolutionError naming the extra for a record type only dnspython serves.

Source code in src/netimps/_dns/_dnspython.py
20
21
22
23
24
25
26
27
28
29
30
31
def has_dns() -> bool:
    """Whether ``dnspython`` is installed, so :func:`resolve_dnspython` can run.

    Without it :func:`resolve` still answers address and reverse records from
    the other backends, and raises :class:`ResolutionError` naming the extra for
    a record type only ``dnspython`` serves.
    """
    try:
        import dns.resolver  # noqa: F401
    except ImportError:
        return False
    return True

resolve(query, rdtype=None, *, ns=None, timeout=5.0, port=53, tcp=False, search=True, backends=None, strict=False, source=None, cache=False, deadline=None)

resolve(
    query: "HostLike",
    rdtype: "Literal['a', 'A']",
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    strict: bool = False,
    source: Optional[Union[str, List[str]]] = None,
    cache: "Union[bool, float]" = False,
    deadline: Optional[float] = None
) -> "List[IPv4Address]"
resolve(
    query: "HostLike",
    rdtype: "Literal['aaaa', 'AAAA']",
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    strict: bool = False,
    source: Optional[Union[str, List[str]]] = None,
    cache: "Union[bool, float]" = False,
    deadline: Optional[float] = None
) -> "List[IPv6Address]"
resolve(
    query: "HostLike",
    rdtype: "Literal['ptr', 'PTR']",
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    strict: bool = False,
    source: Optional[Union[str, List[str]]] = None,
    cache: "Union[bool, float]" = False,
    deadline: Optional[float] = None
) -> "List[str]"
resolve(
    query: "HostLike",
    rdtype: "Optional[Union[str, Tuple[str, ...]]]" = None,
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    strict: bool = False,
    source: Optional[Union[str, List[str]]] = None,
    cache: "Union[bool, float]" = False,
    deadline: Optional[float] = None
) -> "List[Any]"

Resolve query, trying each backend in backends until one gives a definitive answer.

::

resolve("example.com")                    # ['93.184.216.34']
resolve("example.com", "aaaa")
resolve("example.com", "mx", ns="1.1.1.1")
resolve("host", backends="system")        # OS resolver only
resolve("host", backends=["nslookup", "dnspython"])  # custom order
resolve("8.8.8.8")                        # rdtype=None -> ptr -> ['dns.google']

Default order is ["dnspython", "wire", "system", "nslookup"]: dnspython first (structured records, full rdtype support, explicit ns=/search= control), then the standard-library DNS client -- only for an explicit ns= or source=, so ns= works without dnspython -- then the OS resolver (hosts file, NSS, OS cache -- address and reverse records, but no ns= override), then nslookup as a last resort if no Python-level path is usable.

A backend is skipped, not tried and failed, when it structurally cannot serve the request: :func:resolve_system for a rdtype outside "a"/"aaaa"/"ptr", or for an explicit ns=/port=/ tcp= (the OS resolver takes no per-call nameserver, port or transport, so running it would silently ignore the caller's choice); :func:resolve_dnspython if dnspython is not installed.

Only a non-empty answer stops the chain. An empty one does not, and that is deliberate: the backends resolve by structurally different mechanisms, and DNS's "no such name" says nothing about what the OS resolver can still answer. dnspython gets NXDOMAIN for localhost on Windows and macOS while :func:resolve_system answers it from the hosts file; the same holds for .local/mDNS names and anything else only an NSS source knows. A backend that could not even attempt the query (missing binary, timeout, transport failure) falls through as well. If every applicable backend answers empty, the result is [] -- and so it is when every one of them fails to attempt, unless strict=True.

The cost is latency on a genuinely non-existent name: two or three backend calls instead of one, the last of which may spawn nslookup. Narrow backends to opt out -- backends="dnspython" keeps the single call.

:param query: the name (or address, for reverse types) to look up. Also accepts an address object or an :class:IPv4Interface/ :class:IPv6Interface (its .ip is used), not just a string. :param rdtype: DNS record type. None (default) auto-selects: "ptr" when query is an address literal (an "a"/"aaaa" lookup of an address makes no sense), "a" otherwise. Pass an explicit rdtype to opt out. "a"/"aaaa"/"ptr" reach every backend; other types are dnspython-only (see :func:resolve_dnspython). ("a", "aaaa") asks for both families at once: the OS resolver answers in one call, in the order it chose, and every other backend is asked for "a" and then "aaaa" with the answers joined. :param ns: nameserver(s) to query instead of the system resolver. Honoured by dnspython and nslookup (a single nameserver for the latter); excludes system from the chain, since it cannot honour a per-call nameserver. :param timeout: seconds for one backend attempt. What it bounds differs by backend: for dnspython and wire the whole backend call, every nameserver and retry included; for system and nslookup each candidate name of a search list. A record-type pair is one attempt per type. :param deadline: seconds for the whole call: every backend, record type and search candidate shares it, and each attempt gets timeout or what is left, whichever is smaller. None (the default) sets no overall limit. Once it has passed the remaining backends are not started; the outcome is [], or with strict=True the :class:ResolutionTimeoutError. :param port: nameserver port; dnspython only, and excludes system from the chain when it is not 53. :param tcp: query over TCP; dnspython only, and excludes system from the chain when true. :param search: search-list behaviour, honoured by all three backends (see :func:resolve_dnspython, :func:resolve_system and :func:resolve_nslookup respectively). Only dnspython has this built in; the other two try one candidate name per call instead. :param backends: explicit backend order/subset, by name ("dnspython", "system", "nslookup") or a single name as a plain string. None (default) uses all three in the default order, each filtered for applicability as described above. :param source: the local address -- or one per address family, as a list -- the queries go out from. Honoured by wire (each nameserver gets the address of its family) and dnspython (one address only); excludes system and nslookup, which cannot choose it. :param cache: reuse a recent answer. False (the default) neither reads nor writes the cache; True keeps an answer for :data:RESOLUTION_CACHE_TTL seconds and a number is that many. Keyed on the name and every option, so a different ns or rdtype is a different entry. An empty answer is cached like any other, so a name that does not resolve is asked once per TTL; an outage (every backend failed to ask) and an exception are not cached. Call :func:clear_resolution_cache when the answer is known to have changed. Process-wide and thread-safe. :param strict: raise instead of returning [] when no backend could ask -- every applicable one failed with a :class:ResolutionError (resolver unreachable, timeout, nslookup missing). Off by default, so a resolver outage looks the same to the caller as a name that does not exist. Pass strict=True when the two must be told apart: the last backend's :class:ResolutionError is raised, with the reason in its message. It does not turn an empty answer into an error -- a name that genuinely does not resolve still returns [].

Contract: always a list, empty when the backends answered that there is no such name or record or, without strict, when none could be asked; never None. strict=True is the only way this raises for a resolver outage. A request only dnspython could serve raises :class:ResolutionError naming the dns extra when it is missing, strict or not. A malformed query or unknown record type raises :class:ValueError immediately, without trying every backend, since that is a caller bug rather than a resolution outcome.

Source code in src/netimps/_dns/_chain.py
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
def resolve(
    query: "HostLike",
    rdtype: "Optional[Union[str, Tuple[str, ...]]]" = None,
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    backends: "Optional[Union[str, List[str]]]" = None,
    strict: bool = False,
    source: Optional[Union[str, List[str]]] = None,
    cache: "Union[bool, float]" = False,
    deadline: Optional[float] = None,
) -> "List[Any]":
    """Resolve ``query``, trying each backend in ``backends`` until one gives
    a definitive answer.

    ::

        resolve("example.com")                    # ['93.184.216.34']
        resolve("example.com", "aaaa")
        resolve("example.com", "mx", ns="1.1.1.1")
        resolve("host", backends="system")        # OS resolver only
        resolve("host", backends=["nslookup", "dnspython"])  # custom order
        resolve("8.8.8.8")                        # rdtype=None -> ptr -> ['dns.google']

    Default order is ``["dnspython", "wire", "system", "nslookup"]``:
    dnspython first (structured records, full ``rdtype`` support, explicit
    ``ns=``/``search=`` control), then the standard-library DNS client --
    only for an explicit ``ns=`` or ``source=``, so ``ns=`` works without
    dnspython -- then the OS resolver (hosts file, NSS, OS cache -- address
    and reverse records, but no ``ns=`` override), then ``nslookup`` as a last
    resort if no Python-level path is usable.

    A backend is **skipped**, not tried and failed, when it structurally
    cannot serve the request: :func:`resolve_system` for a ``rdtype`` outside
    ``"a"``/``"aaaa"``/``"ptr"``, or for an explicit ``ns=``/``port=``/
    ``tcp=`` (the OS resolver takes no per-call nameserver, port or transport,
    so running it would silently ignore the caller's choice);
    :func:`resolve_dnspython` if ``dnspython`` is not installed.

    **Only a non-empty answer stops the chain.** An empty one does not, and
    that is deliberate: the backends resolve by structurally different
    mechanisms, and DNS's "no such name" says nothing about what the OS
    resolver can still answer. ``dnspython`` gets NXDOMAIN for ``localhost``
    on Windows and macOS while :func:`resolve_system` answers it from the
    hosts file; the same holds for ``.local``/mDNS names and anything else
    only an NSS source knows. A backend that could not even attempt the query
    (missing binary, timeout, transport failure) falls through as well. If
    every applicable backend answers empty, the result is ``[]`` -- and so it
    is when every one of them fails to *attempt*, unless ``strict=True``.

    The cost is latency on a genuinely non-existent name: two or three backend
    calls instead of one, the last of which may spawn ``nslookup``. Narrow
    ``backends`` to opt out -- ``backends="dnspython"`` keeps the single call.

    :param query: the name (or address, for reverse types) to look up. Also
        accepts an address object or an :class:`IPv4Interface`/
        :class:`IPv6Interface` (its ``.ip`` is used), not just a string.
    :param rdtype: DNS record type. ``None`` (default) auto-selects: ``"ptr"``
        when ``query`` is an address literal (an ``"a"``/``"aaaa"`` lookup
        *of* an address makes no sense), ``"a"`` otherwise. Pass an explicit
        ``rdtype``
        to opt out. ``"a"``/``"aaaa"``/``"ptr"`` reach every backend; other
        types are ``dnspython``-only (see :func:`resolve_dnspython`).
        ``("a", "aaaa")`` asks for both families at once: the OS resolver
        answers in one call, in the order it chose, and every other backend
        is asked for ``"a"`` and then ``"aaaa"`` with the answers joined.
    :param ns: nameserver(s) to query instead of the system resolver. Honoured
        by ``dnspython`` and ``nslookup`` (a single nameserver for the
        latter); excludes ``system`` from the chain, since it cannot honour a
        per-call nameserver.
    :param timeout: seconds for one backend attempt. What it bounds differs by
        backend: for ``dnspython`` and ``wire`` the whole backend call, every
        nameserver and retry included; for ``system`` and ``nslookup`` each
        candidate name of a search list. A record-type pair is one attempt per
        type.
    :param deadline: seconds for the **whole call**: every backend, record type
        and search candidate shares it, and each attempt gets ``timeout`` or
        what is left, whichever is smaller. ``None`` (the default) sets no
        overall limit. Once it has passed the remaining backends are not
        started; the outcome is ``[]``, or with ``strict=True`` the
        :class:`ResolutionTimeoutError`.
    :param port: nameserver port; ``dnspython`` only, and excludes ``system``
        from the chain when it is not 53.
    :param tcp: query over TCP; ``dnspython`` only, and excludes ``system``
        from the chain when true.
    :param search: search-list behaviour, honoured by all three backends (see
        :func:`resolve_dnspython`, :func:`resolve_system` and
        :func:`resolve_nslookup` respectively). Only ``dnspython`` has this
        built in; the other two try one candidate name per call instead.
    :param backends: explicit backend order/subset, by name (``"dnspython"``,
        ``"system"``, ``"nslookup"``) or a single name as a plain string.
        ``None`` (default) uses all three in the default order, each filtered
        for applicability as described above.
    :param source: the local address -- or one per address family, as a
        list -- the queries go out from. Honoured by ``wire`` (each nameserver
        gets the address of its family) and ``dnspython`` (one address only);
        excludes ``system`` and ``nslookup``, which cannot choose it.
    :param cache: reuse a recent answer. ``False`` (the default) neither reads
        nor writes the cache; ``True`` keeps an answer for
        :data:`RESOLUTION_CACHE_TTL` seconds and a number is that many.
        Keyed on the name and **every** option, so a different ``ns`` or
        ``rdtype`` is a different entry. **An empty answer is cached like any
        other**, so a name that does not resolve is asked once per TTL; an
        outage (every backend failed to ask) and an exception are not cached.
        Call :func:`clear_resolution_cache` when the answer is known to have
        changed. Process-wide and thread-safe.
    :param strict: raise instead of returning ``[]`` when no backend could
        *ask* -- every applicable one failed with a :class:`ResolutionError`
        (resolver unreachable, timeout, ``nslookup`` missing). Off by default,
        so a resolver outage looks the same to the caller as a name that does
        not exist. Pass ``strict=True`` when the two must be told apart: the
        last backend's :class:`ResolutionError` is raised, with the reason in
        its message. It does **not** turn an empty *answer* into an error --
        a name that genuinely does not resolve still returns ``[]``.

    Contract: always a ``list``, **empty** when the backends answered that there
    is no such name or record or, without ``strict``, when none could be asked;
    never ``None``. ``strict=True`` is the only way this raises for a resolver
    outage. A request only ``dnspython`` could serve raises
    :class:`ResolutionError` naming the ``dns`` extra when it is missing, strict
    or not. A malformed query or unknown record type raises
    :class:`ValueError` immediately, without trying every backend, since that
    is a caller bug rather than a resolution outcome.
    """
    query = query_argument(query)
    if cache is not False:
        key = _cache_key(
            query, rdtype, ns, timeout, port, tcp, search, backends, strict, source
        )
        ttl = RESOLUTION_CACHE_TTL if cache is True else float(cache)
        hit = _cache_get(key, ttl)
        if hit is not None:
            return list(hit)
    # An enclosing deadline stays in force; this call's own can only shorten it.
    outer = _DEADLINE.get()
    end = None if deadline is None else _time.monotonic() + deadline
    if end is None or (outer is not None and outer < end):
        end = outer
    token = _DEADLINE.set(end)
    try:
        result, definitive = _resolve_chain(
            query, rdtype, ns, timeout, port, tcp, search, backends, strict, source
        )
    finally:
        _DEADLINE.reset(token)
    if cache is not False and definitive:
        _cache_put(key, result)
    return result

resolve_dnspython(query, rdtype=None, *, ns=None, timeout=5.0, port=53, tcp=False, search=True, source=None)

resolve_dnspython(
    query: "HostLike",
    rdtype: "Literal['a', 'A']",
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    source: Optional[str] = None
) -> "List[IPv4Address]"
resolve_dnspython(
    query: "HostLike",
    rdtype: "Literal['aaaa', 'AAAA']",
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    source: Optional[str] = None
) -> "List[IPv6Address]"
resolve_dnspython(
    query: "HostLike",
    rdtype: "Literal['ptr', 'PTR']",
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    source: Optional[str] = None
) -> "List[str]"
resolve_dnspython(
    query: "HostLike",
    rdtype: Optional[str] = None,
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    source: Optional[str] = None
) -> "List[Any]"

Resolve query via dnspython and return the answers as a list.

::

resolve_dnspython("example.com")                    # ['93.184.216.34']
resolve_dnspython("example.com", "aaaa")
resolve_dnspython("example.com", "mx", ns="1.1.1.1")
resolve_dnspython("host")                           # tries the resolv.conf search list
resolve_dnspython("host", search=["eng.example.com", "example.com"])
resolve_dnspython("host", search=False)             # look up "host" literally
resolve_dnspython("8.8.8.8")                        # rdtype=None -> ptr -> ['dns.google']

Contract: always a list, empty when the resolver answered that the name or the record does not exist -- never None. Callers can therefore write if result: and index result[0] safely. A resolver that could not be asked is not an empty answer: see below.

Records come back as native types: address records (A/AAAA) are :class:ipaddress objects, everything else is a str::

resolve_dnspython("example.com")[0].is_private   # an IPv4Address, not "1.2.3.4"
resolve_dnspython("example.com", "mx")           # ['10 mail.example.com']
resolve_dnspython("example.com", "txt")          # ['v=spf1 -all']  -- unquoted

Names lose their trailing root dot and TXT strings lose their surrounding quotes, since neither is wanted in practice.

:param query: the name (or address, for a "ptr" lookup) to look up. Also accepts an address object or an :class:IPv4Interface/ :class:IPv6Interface (its .ip is used), not just a string. :param rdtype: DNS record type ("a", "aaaa", "mx" ...). Second because it is the argument callers actually vary. None (default) auto-selects: "ptr" when query is an address literal (an "a"/"aaaa" lookup of an address makes no sense), "a" otherwise. Pass an explicit rdtype to opt out of the auto-selection. :param ns: optional nameserver, or list of nameservers, to query instead of the system resolver. When omitted, the system resolver configuration (/etc/resolv.conf, or the Windows equivalent) supplies both the nameservers and, if search is enabled, the search list. :param timeout: seconds to spend on the whole resolution, retries included (None for dnspython's default). Bounds total time, not each query -- a list of unreachable nameservers cannot stretch past it. :param port: nameserver port, for resolvers not on 53. :param tcp: query over TCP instead of UDP. Useful for large responses that would otherwise be truncated. :param search: how to expand an unqualified query, the way ping or a browser would. True (default) tries the system resolver's own search list (the search/domain directive in resolv.conf, or the Windows per-adapter DNS suffix list) -- the same source ns draws its nameservers from when ns is omitted. False looks up query as-is only, no expansion. A list of domain names tries exactly those suffixes instead of the system list, regardless of ns. Ignored for an already-qualified (trailing-dot) query. :param source: the local address the queries are sent from (None: whatever the OS picks).

NXDOMAIN and "no answer" yield []. A timeout raises :class:ResolutionTimeoutError; every server failing, or no resolver configuration to read, raises :class:ResolutionError. A malformed query or unknown record type raises :class:ValueError, since that is a caller bug rather than a DNS result.

Needs the dns extra (pip install "netimps[dns]"); without it this raises :class:ResolutionError saying so. :func:has_dns tells which.

Source code in src/netimps/_dns/_dnspython.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
def resolve_dnspython(
    query: "HostLike",
    rdtype: Optional[str] = None,
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    source: Optional[str] = None,
) -> "List[Any]":
    """Resolve ``query`` via ``dnspython`` and return the answers as a list.

    ::

        resolve_dnspython("example.com")                    # ['93.184.216.34']
        resolve_dnspython("example.com", "aaaa")
        resolve_dnspython("example.com", "mx", ns="1.1.1.1")
        resolve_dnspython("host")                           # tries the resolv.conf search list
        resolve_dnspython("host", search=["eng.example.com", "example.com"])
        resolve_dnspython("host", search=False)             # look up "host" literally
        resolve_dnspython("8.8.8.8")                        # rdtype=None -> ptr -> ['dns.google']

    Contract: always a ``list``, **empty** when the resolver answered that the
    name or the record does not exist -- never ``None``. Callers can therefore
    write ``if result:`` and index ``result[0]`` safely. A resolver that could
    not be asked is not an empty answer: see below.

    Records come back as **native types**: address records (``A``/``AAAA``) are
    :class:`ipaddress` objects, everything else is a ``str``::

        resolve_dnspython("example.com")[0].is_private   # an IPv4Address, not "1.2.3.4"
        resolve_dnspython("example.com", "mx")           # ['10 mail.example.com']
        resolve_dnspython("example.com", "txt")          # ['v=spf1 -all']  -- unquoted

    Names lose their trailing root dot and TXT strings lose their surrounding
    quotes, since neither is wanted in practice.

    :param query: the name (or address, for a ``"ptr"`` lookup) to look up.
        Also accepts an address object or an :class:`IPv4Interface`/
        :class:`IPv6Interface` (its ``.ip`` is used), not just a string.
    :param rdtype: DNS record type (``"a"``, ``"aaaa"``, ``"mx"`` ...). Second
        because it is the argument callers actually vary. ``None`` (default)
        auto-selects: ``"ptr"`` when ``query`` is an address literal (an
        ``"a"``/``"aaaa"`` lookup *of* an address makes no sense), ``"a"``
        otherwise. Pass an explicit ``rdtype`` to opt out of the
        auto-selection.
    :param ns: optional nameserver, or list of nameservers, to query instead of
        the system resolver. When omitted, the system resolver configuration
        (``/etc/resolv.conf``, or the Windows equivalent) supplies both the
        nameservers and, if ``search`` is enabled, the search list.
    :param timeout: seconds to spend on the whole resolution, retries included
        (``None`` for dnspython's default). Bounds *total* time, not each query
        -- a list of unreachable nameservers cannot stretch past it.
    :param port: nameserver port, for resolvers not on 53.
    :param tcp: query over TCP instead of UDP. Useful for large responses that
        would otherwise be truncated.
    :param search: how to expand an unqualified ``query``, the way ``ping`` or
        a browser would. ``True`` (default) tries the system resolver's own
        search list (the ``search``/``domain`` directive in ``resolv.conf``,
        or the Windows per-adapter DNS suffix list) -- the same source ``ns``
        draws its nameservers from when ``ns`` is omitted. ``False`` looks up
        ``query`` as-is only, no expansion. A list of domain names tries
        exactly those suffixes instead of the system list, regardless of
        ``ns``. Ignored for an already-qualified (trailing-dot) ``query``.
    :param source: the local address the queries are sent from (``None``:
        whatever the OS picks).

    NXDOMAIN and "no answer" yield ``[]``. A timeout raises
    :class:`ResolutionTimeoutError`; every server failing, or no resolver
    configuration to read, raises :class:`ResolutionError`. A malformed query
    or unknown record type raises :class:`ValueError`, since that is a caller
    bug rather than a DNS result.

    Needs the ``dns`` extra (``pip install "netimps[dns]"``); without it this
    raises :class:`ResolutionError` saying so. :func:`has_dns` tells which.
    """
    query = query_argument(query)
    if not rdtype:
        rdtype = _auto_rdtype(query)
    rdtype = rdtype.lower()

    try:
        from dns import exception as _dnsexc
        from dns import name as _name
        from dns import resolver as _resolver
    except ImportError as exc:
        raise ResolutionError(_NEEDS_DNS) from exc

    # Looked up by name rather than referenced directly: LifetimeTimeout only
    # exists in dnspython >= 2.0, and the set has shifted between releases, so
    # a hard reference would break on older versions. Anything missing simply
    # drops out of the tuple.
    def _classes(*names: str) -> "Tuple[Type[Exception], ...]":
        found = (getattr(_resolver, name, None) for name in names)
        return tuple(
            cls for cls in found if isinstance(cls, type) and issubclass(cls, Exception)
        )

    # Only when asked: the keyword is not in every dnspython release.
    extra: "Dict[str, Any]" = {"source": source} if source else {}
    # Parsed before the resolver exists: a malformed spelling is the caller's
    # mistake, whatever the install.
    entries = _ns_entries(ns)
    doh = [entry for entry in entries if _is_doh_server(entry)]
    servers = _nameservers([e for e in entries if e not in doh], port)
    try:
        r = _resolver.Resolver(configure=not (servers or doh))
        if servers or doh:
            _set_nameservers(r, servers, doh)
        elif port != 53:
            r.port = port
        search_domains = None
        if isinstance(search, (list, tuple)):
            search_domains = list(search)
            search = True
        if search_domains is not None:
            # An explicit domain list replaces the system search list
            # outright, regardless of where the nameservers came from.
            r.search = [_name.from_text(d) for d in search_domains]
        timeout = _budget(timeout)
        if timeout is not None:
            # `timeout` bounds a single query; `lifetime` bounds the whole
            # resolution including retries against every nameserver. Without
            # the lifetime, a list of dead servers blocks far longer than asked.
            r.timeout = timeout
            r.lifetime = timeout
        if rdtype == "ptr":
            # resolve_address builds the reverse (in-addr.arpa/ip6.arpa) name
            # from a plain address itself -- r.resolve(query, "ptr") would
            # require the caller to already have that name, which defeats
            # the point of accepting a literal address as `query`.
            answer = r.resolve_address(query, tcp=tcp, search=search, **extra)
        else:
            answer = r.resolve(query, rdtype, tcp=tcp, search=search, **extra)
    except ResolutionError:
        raise  # the deadline ending the resolution, not a socket failure
    except _classes("NXDOMAIN", "NoAnswer"):
        return []  # the resolver answered: there is no such record
    except _classes("LifetimeTimeout", "Timeout") as exc:
        raise ResolutionTimeoutError(
            "dnspython timed out asking about %r: %s" % (query, exc)
        ) from exc
    except _classes("NoNameservers", "NoResolverConfiguration") as exc:
        raise ResolutionError(
            "dnspython could not ask about %r: %s" % (query, exc)
        ) from exc
    except _dnsexc.DNSException as exc:
        # A malformed name or an unknown record type is the caller's mistake,
        # not a lookup outcome.
        raise ValueError(
            "invalid DNS query %r (%s): %s" % (query, rdtype, exc)
        ) from exc
    except OSError as exc:
        raise ResolutionError(
            "dnspython could not ask about %r: %s" % (query, exc)
        ) from exc
    return [_native_record(record) for record in answer]

resolve_system(query, rdtype=None, *, timeout=5.0, search=True)

resolve_system(
    query: "HostLike",
    rdtype: "Literal['a', 'A']",
    *,
    timeout: Optional[float] = 5.0,
    search: Union[bool, List[str]] = True
) -> "List[IPv4Address]"
resolve_system(
    query: "HostLike",
    rdtype: "Literal['aaaa', 'AAAA']",
    *,
    timeout: Optional[float] = 5.0,
    search: Union[bool, List[str]] = True
) -> "List[IPv6Address]"
resolve_system(
    query: "HostLike",
    rdtype: "Literal['ptr', 'PTR']",
    *,
    timeout: Optional[float] = 5.0,
    search: Union[bool, List[str]] = True
) -> "List[str]"
resolve_system(
    query: "HostLike",
    rdtype: "Optional[Union[str, Tuple[str, ...]]]" = None,
    *,
    timeout: Optional[float] = 5.0,
    search: Union[bool, List[str]] = True
) -> "List[Any]"

Resolve query via the OS resolver (:func:socket.getaddrinfo/ :func:socket.gethostbyaddr).

::

resolve_system("example.com")           # ['93.184.216.34']
resolve_system("example.com", "aaaa")
resolve_system("localhost")              # /etc/hosts, no DNS query
resolve_system("host", search=False)     # "host" only, no suffix expansion
resolve_system("host", search=["eng.example.com", "example.com"])
resolve_system("8.8.8.8")                # rdtype=None -> ptr -> ['dns.google']

Goes through hosts file, NSS (nsswitch.conf) and DNS, in the order the OS resolver applies them -- the same path getaddrinfo(3)/ gethostbyaddr(3)-based tools use. Unlike :func:resolve_dnspython, this sees /etc/hosts entries, nsswitch.conf sources (mDNS, LDAP, whatever NSS is configured with) and any OS-level resolver cache.

The trade-off: address and reverse records only (rdtype must be "a", "aaaa" or "ptr"; anything else raises :class:ResolutionError immediately, no query attempted), no ns= (it always asks whatever resolver the OS is configured with -- there is no per-call override), and no priority/TTL/other record metadata, since neither :func:socket.getaddrinfo nor :func:socket.gethostbyaddr exposes any of that.

:param query: the hostname (or address, for "ptr") to look up. Also accepts an address object or an :class:IPv4Interface/ :class:IPv6Interface (its .ip is used), not just a string. :param rdtype: "a", "aaaa" or "ptr". None (default) auto-selects: "ptr" when query is an address literal, "a" otherwise. Anything else raises. :param timeout: seconds to wait per candidate name tried (see search). There is no native per-call timeout for getaddrinfo/gethostbyaddr, so each attempt runs in a daemon helper thread and is abandoned (without cancelling the underlying blocking call) past the deadline. The deadline bounds wall time: a resolver that hangs for a minute still raises :class:ResolutionTimeoutError at timeout, and the abandoned thread holds up neither the caller nor interpreter exit. None waits indefinitely. :param search: how to expand an unqualified query. Ignored for rdtype="ptr" -- an address has no search-list suffix to try. There is no per-call search-list override on :func:socket.getaddrinfo itself -- unlike dnspython/ nslookup, the OS resolver takes no such parameter -- so search=True (default) simply leaves query as given and lets the OS resolver apply its own configured search list (glibc's ndots/search, the Windows per-adapter DNS suffix). search=False appends a trailing ., which every resolver reads as "already fully qualified" and skips search-list expansion for -- the same trick a shell's own host/getent scripts use. A list of domain names instead tries query qualified with each, in order, one :func:socket.getaddrinfo call per candidate, stopping at the first with actual results -- this package's own expansion, independent of (and untouched by) the OS resolver's. Already- qualified (trailing-dot) names are unaffected by any of this.

Every candidate tried and none resolving (EAI_NONAME, no data) yields [], never None. A temporary failure (EAI_AGAIN, EAI_FAIL) raises :class:ResolutionError, and a deadline :class:ResolutionTimeoutError: the resolver could not be asked. An unsupported rdtype raises :class:ResolutionError too, since that is this backend's fixed limitation, not a DNS outcome to report as "no records".

Source code in src/netimps/_dns/_system.py
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
def resolve_system(
    query: "HostLike",
    rdtype: "Optional[Union[str, Tuple[str, ...]]]" = None,
    *,
    timeout: Optional[float] = 5.0,
    search: Union[bool, List[str]] = True,
) -> "List[Any]":
    """Resolve ``query`` via the OS resolver (:func:`socket.getaddrinfo`/
    :func:`socket.gethostbyaddr`).

    ::

        resolve_system("example.com")           # ['93.184.216.34']
        resolve_system("example.com", "aaaa")
        resolve_system("localhost")              # /etc/hosts, no DNS query
        resolve_system("host", search=False)     # "host" only, no suffix expansion
        resolve_system("host", search=["eng.example.com", "example.com"])
        resolve_system("8.8.8.8")                # rdtype=None -> ptr -> ['dns.google']

    Goes through **hosts file, NSS (`nsswitch.conf`) and DNS, in the order
    the OS resolver applies them** -- the same path ``getaddrinfo(3)``/
    ``gethostbyaddr(3)``-based tools use. Unlike :func:`resolve_dnspython`,
    this sees ``/etc/hosts`` entries, ``nsswitch.conf`` sources (mDNS, LDAP,
    whatever NSS is configured with) and any OS-level resolver cache.

    The trade-off: **address and reverse records only** (``rdtype`` must be
    ``"a"``, ``"aaaa"`` or ``"ptr"``; anything else raises
    :class:`ResolutionError` immediately, no query attempted), no ``ns=`` (it
    always asks whatever resolver the OS is configured with -- there is no
    per-call override), and no priority/TTL/other record metadata, since
    neither :func:`socket.getaddrinfo` nor :func:`socket.gethostbyaddr`
    exposes any of that.

    :param query: the hostname (or address, for ``"ptr"``) to look up. Also
        accepts an address object or an :class:`IPv4Interface`/
        :class:`IPv6Interface` (its ``.ip`` is used), not just a string.
    :param rdtype: ``"a"``, ``"aaaa"`` or ``"ptr"``. ``None`` (default)
        auto-selects: ``"ptr"`` when ``query`` is an address literal, ``"a"``
        otherwise. Anything else raises.
    :param timeout: seconds to wait *per candidate name tried* (see
        ``search``). There is no native per-call timeout for
        ``getaddrinfo``/``gethostbyaddr``, so each attempt runs in a daemon
        helper thread and is abandoned (without cancelling the underlying
        blocking call) past the deadline. The deadline bounds **wall time**:
        a resolver that hangs for a minute still raises
        :class:`ResolutionTimeoutError` at ``timeout``, and the abandoned thread
        holds up neither the caller nor interpreter exit. ``None`` waits
        indefinitely.
    :param search: how to expand an unqualified ``query``. Ignored for
        ``rdtype="ptr"`` -- an address has no search-list suffix to try.
        There is no per-call search-list override on
        :func:`socket.getaddrinfo` itself -- unlike ``dnspython``/
        ``nslookup``, the OS resolver takes no such parameter -- so
        ``search=True`` (default) simply leaves ``query`` as given and lets
        the OS resolver apply its own configured search list (glibc's
        ``ndots``/``search``, the Windows per-adapter DNS suffix).
        ``search=False`` appends a trailing ``.``, which every resolver reads
        as "already fully qualified" and skips search-list expansion for --
        the same trick a shell's own ``host``/``getent`` scripts use. A list
        of domain names instead tries ``query`` qualified with each, in
        order, one :func:`socket.getaddrinfo` call per candidate, stopping at
        the first with actual results -- this package's own expansion,
        independent of (and untouched by) the OS resolver's. Already-
        qualified (trailing-dot) names are unaffected by any of this.

    Every candidate tried and none resolving (``EAI_NONAME``, no data) yields
    ``[]``, never ``None``. A temporary failure (``EAI_AGAIN``, ``EAI_FAIL``)
    raises :class:`ResolutionError`, and a deadline
    :class:`ResolutionTimeoutError`: the resolver could not be asked. An
    unsupported ``rdtype`` raises :class:`ResolutionError` too, since that is
    this backend's fixed limitation, not a DNS outcome to report as "no records".
    """
    query = query_argument(query)
    if isinstance(rdtype, (tuple, list)):
        # Both families in one getaddrinfo call, in the order the OS chose.
        _address_rdtypes(rdtype)
        family = _socket.AF_UNSPEC if len(rdtype) > 1 else _family_of(rdtype[0])
        return _search_system(query, family, timeout, search)
    if not rdtype:
        rdtype = _auto_rdtype(query)
    rdtype = rdtype.lower()

    if rdtype == "ptr":
        # Bounded by the same daemon-thread helper as the address path:
        # gethostbyaddr has no timeout of its own either, so calling it
        # directly would ignore `timeout` outright.
        try:
            hostname, _aliases, _addrs = _bounded_lookup(
                _partial(_socket.gethostbyaddr, query), timeout
            )
        except (_socket.herror, _socket.gaierror) as exc:
            # herror: no PTR data for a literal address. gaierror: `query`
            # was treated as a hostname (gethostbyaddr's own behaviour for a
            # non-literal argument) and that hostname itself did not resolve.
            # Both are answers when the code says so; a temporary failure is not.
            if _is_no_answer(exc):
                return []
            raise _system_outage(exc, query) from exc
        return [hostname]

    if rdtype not in _ADDRESS_RDTYPES:
        raise ResolutionError(
            "resolve_system only supports rdtype in %r, got %r"
            % (_ADDRESS_RDTYPES + ("ptr",), rdtype)
        )

    return _search_system(query, _family_of(rdtype), timeout, search)

resolve_nslookup(query, rdtype=None, *, ns=None, timeout=5.0, search=True)

resolve_nslookup(
    query: "HostLike",
    rdtype: "Literal['a', 'A']",
    *,
    ns: Optional[str] = None,
    timeout: Optional[float] = 5.0,
    search: Union[bool, List[str]] = True
) -> "List[IPv4Address]"
resolve_nslookup(
    query: "HostLike",
    rdtype: "Literal['aaaa', 'AAAA']",
    *,
    ns: Optional[str] = None,
    timeout: Optional[float] = 5.0,
    search: Union[bool, List[str]] = True
) -> "List[IPv6Address]"
resolve_nslookup(
    query: "HostLike",
    rdtype: "Literal['ptr', 'PTR']",
    *,
    ns: Optional[str] = None,
    timeout: Optional[float] = 5.0,
    search: Union[bool, List[str]] = True
) -> "List[str]"
resolve_nslookup(
    query: "HostLike",
    rdtype: Optional[str] = None,
    *,
    ns: Optional[str] = None,
    timeout: Optional[float] = 5.0,
    search: Union[bool, List[str]] = True
) -> "List[Any]"

Resolve query by shelling out to the nslookup binary.

::

resolve_nslookup("example.com")               # ['93.184.216.34']
resolve_nslookup("example.com", "aaaa")
resolve_nslookup("example.com", ns="1.1.1.1")
resolve_nslookup("host")                       # tries the system search list
resolve_nslookup("host", search=["eng.example.com", "example.com"])
resolve_nslookup("8.8.8.8")                    # rdtype=None -> ptr -> ['dns.google']

A fallback for when neither dnspython nor :func:resolve_system is usable: goes through whatever resolver nslookup itself is configured to use (the OS resolver's nameservers, unless ns= overrides it).

:param query: the name (or address, for ptr) to look up. Also accepts an address object or an :class:IPv4Interface/:class:IPv6Interface (its .ip is used), not just a string. :param rdtype: "a", "aaaa" or "ptr". None (default) auto-selects: "ptr" when query is an address literal, "a" otherwise. Anything else raises :class:ResolutionError immediately, no subprocess spawned -- nslookup's plain-text output is only parsed reliably for these three. :param ns: nameserver to query, passed as nslookup's trailing server argument. None uses nslookup's own default. :param timeout: seconds to allow each subprocess attempt to run -- one per candidate name tried under search. None allows 30 seconds: a program never runs unbounded. :param search: how to expand an unqualified query. nslookup has no built-in search-list handling (unlike dnspython), so this tries one nslookup call per candidate name, in order, and returns the first with actual records: True (default) tries query qualified with each domain in the system resolver's search list (via the same resolv.conf/registry parsing :func:resolve_dnspython uses -- [] there, e.g. dnspython not installed, falls back to the literal name only); False looks up query as-is only; a list of domain names tries exactly those suffixes. Ignored for an already-qualified (trailing-dot) query or a "ptr" lookup, neither of which a search list applies to.

A server answering "no such name" or "no such record" (NXDOMAIN or equivalent, for every candidate tried) yields []. A missing nslookup binary, a timeout, a non-zero exit with no "can't find" line (no reachable server, a refused connection), a "can't find" line whose reason after the colon is not a "no such name" answer (SERVFAIL, REFUSED, Windows' No response from server), or an unsupported rdtype raises :class:ResolutionError -- there was no definitive DNS answer to report, so :func:resolve's chain moves on rather than treating it as NXDOMAIN.

A query that nslookup would read as an option (one starting with -) or that cannot be a name at all (empty, whitespace, control characters) raises :class:ValueError before any subprocess is spawned: nslookup has no -- end-of-options separator, so such a query cannot be escaped into position -- left to reach the binary it becomes an option, and an nslookup with no name argument goes interactive and reads query names from stdin. The subprocess is given :data:subprocess.DEVNULL for stdin as well, so it can never read the caller's.

Source code in src/netimps/_dns/_nslookup.py
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
def resolve_nslookup(
    query: "HostLike",
    rdtype: Optional[str] = None,
    *,
    ns: Optional[str] = None,
    timeout: Optional[float] = 5.0,
    search: Union[bool, List[str]] = True,
) -> "List[Any]":
    """Resolve ``query`` by shelling out to the ``nslookup`` binary.

    ::

        resolve_nslookup("example.com")               # ['93.184.216.34']
        resolve_nslookup("example.com", "aaaa")
        resolve_nslookup("example.com", ns="1.1.1.1")
        resolve_nslookup("host")                       # tries the system search list
        resolve_nslookup("host", search=["eng.example.com", "example.com"])
        resolve_nslookup("8.8.8.8")                    # rdtype=None -> ptr -> ['dns.google']

    A fallback for when neither ``dnspython`` nor :func:`resolve_system` is
    usable: goes through whatever resolver ``nslookup`` itself is configured
    to use (the OS resolver's nameservers, unless ``ns=`` overrides it).

    :param query: the name (or address, for ``ptr``) to look up. Also accepts
        an address object or an :class:`IPv4Interface`/:class:`IPv6Interface`
        (its ``.ip`` is used), not just a string.
    :param rdtype: ``"a"``, ``"aaaa"`` or ``"ptr"``. ``None`` (default)
        auto-selects: ``"ptr"`` when ``query`` is an address literal, ``"a"``
        otherwise. Anything else raises :class:`ResolutionError` immediately,
        no subprocess spawned -- ``nslookup``'s plain-text output is only
        parsed reliably for these three.
    :param ns: nameserver to query, passed as ``nslookup``'s trailing
        ``server`` argument. ``None`` uses ``nslookup``'s own default.
    :param timeout: seconds to allow *each* subprocess attempt to run --
        one per candidate name tried under ``search``. ``None`` allows 30
        seconds: a program never runs unbounded.
    :param search: how to expand an unqualified ``query``. ``nslookup`` has
        no built-in search-list handling (unlike ``dnspython``), so this
        tries one ``nslookup`` call per candidate name, in order, and returns
        the first with actual records: ``True`` (default) tries ``query``
        qualified with each domain in the system resolver's search list (via
        the same ``resolv.conf``/registry parsing :func:`resolve_dnspython`
        uses -- ``[]`` there, e.g. ``dnspython`` not installed, falls back to
        the literal name only); ``False`` looks up ``query`` as-is only; a
        list of domain names tries exactly those suffixes. Ignored for an
        already-qualified (trailing-dot) ``query`` or a ``"ptr"`` lookup,
        neither of which a search list applies to.

    A server answering "no such name" or "no such record" (NXDOMAIN or
    equivalent, for every candidate tried) yields ``[]``. A missing
    ``nslookup`` binary, a timeout, a **non-zero exit with no "can't find"
    line** (no reachable server, a refused connection), a "can't find" line
    whose reason after the colon is not a "no such name" answer (``SERVFAIL``,
    ``REFUSED``, Windows' ``No response from server``), or an unsupported
    ``rdtype`` raises :class:`ResolutionError` -- there was no definitive DNS
    answer to report, so :func:`resolve`'s chain moves on rather than treating
    it as NXDOMAIN.

    A ``query`` that ``nslookup`` would read as an option (one starting with
    ``-``) or that cannot be a name at all (empty, whitespace, control
    characters) raises :class:`ValueError` **before any subprocess is
    spawned**: ``nslookup`` has no ``--`` end-of-options separator, so such a
    query cannot be escaped into position -- left to reach the binary it
    becomes an option, and an ``nslookup`` with no name argument goes
    *interactive* and reads query names from stdin. The subprocess is given
    :data:`subprocess.DEVNULL` for stdin as well, so it can never read the
    caller's.
    """
    query = query_argument(query)
    _check_nslookup_query(query)
    if ns:
        _check_nslookup_query(ns, "nameserver")
        host, number = _nameservers([ns], 53, names=True)[0]
        if number != 53:
            raise NetimpsValueError(
                "nslookup cannot ask nameserver %r on port %d: it has no "
                "per-call port; use resolve_wire or resolve_dnspython with "
                "port=" % (ns, number)
            )
        ns = host
    if isinstance(search, (list, tuple)):
        for domain in search:
            if domain.strip("."):
                _check_nslookup_query(domain, "search domain")
    if not rdtype:
        rdtype = _auto_rdtype(query)
    rdtype = rdtype.lower()
    if rdtype not in ("a", "aaaa", "ptr"):
        raise ResolutionError(
            "resolve_nslookup only supports rdtype in ('a', 'aaaa', 'ptr'), got %r"
            % (rdtype,)
        )

    candidates = (
        [query]
        if rdtype == "ptr"
        else search_candidates(query, search, system_domains=True)
    )

    last_error: Optional[ResolutionError] = None
    for candidate in candidates:
        try:
            result = _resolve_nslookup_once(candidate, rdtype, ns, timeout)
        except ResolutionError as exc:
            last_error = exc
            continue
        if result:
            return result
        # A definitive empty answer for this candidate -- try the next
        # search suffix, the same way the OS resolver would, but keep [] as
        # the fallback if every candidate is genuinely a dead end.
    if last_error is not None:
        raise last_error
    return []

resolve_wire(query, rdtype=None, *, ns=None, timeout=5.0, port=53, tcp=False, search=True, source=None)

resolve_wire(
    query: "HostLike",
    rdtype: "Literal['a', 'A']",
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    source: Optional[Union[str, List[str]]] = None
) -> "List[IPv4Address]"
resolve_wire(
    query: "HostLike",
    rdtype: "Literal['aaaa', 'AAAA']",
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    source: Optional[Union[str, List[str]]] = None
) -> "List[IPv6Address]"
resolve_wire(
    query: "HostLike",
    rdtype: "Literal['ptr', 'PTR']",
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    source: Optional[Union[str, List[str]]] = None
) -> "List[str]"
resolve_wire(
    query: "HostLike",
    rdtype: Optional[str] = None,
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    source: Optional[Union[str, List[str]]] = None
) -> "List[Any]"

Resolve query by speaking DNS to ns directly -- no dnspython, no OS resolver. UDP, retried over TCP when the reply is truncated (or TCP throughout with tcp=True); the nameservers in turn until one answers.

::

resolve_wire("example.com", ns="1.1.1.1")
resolve_wire("example.com", "aaaa", ns=["10.0.0.53:5353", "[fd00::53]"])
resolve_wire("example.com", ns="10.0.0.53", source="10.0.0.7")

:param ns: nameserver(s): host, host:port, [v6]:port. None: /etc/resolv.conf's (POSIX; elsewhere :class:ResolutionError). :param rdtype: a, aaaa, cname, ptr, mx, txt, ns, srv; None auto-selects as :func:resolve does. Another type is a :class:ResolutionError (the chain moves on). :param timeout: seconds for the whole resolution, every server included. :param port: the port of an ns entry that names none. :param source: the address the queries leave from -- one, or one per address family; a server whose family has none is skipped. :param search: a list of domains tries query under each, then as given; True/False ask for query as given (this backend reads no system search list).

Contract as the other backends: native values, [] for NXDOMAIN or no record of the type, :class:ResolutionError when no server answered (:class:ResolutionTimeoutError when the deadline or a socket timeout was the reason). A reply the codec cannot read, or a name it cannot encode, is not raised as such: it counts as that server not answering, and the :class:DNSDecodeError is the __cause__ of the final error. A CNAME chain inside the reply is followed.

Source code in src/netimps/_dns/_wire.py
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
def resolve_wire(
    query: "HostLike",
    rdtype: Optional[str] = None,
    *,
    ns: Optional[Union[str, List[str]]] = None,
    timeout: Optional[float] = 5.0,
    port: int = 53,
    tcp: bool = False,
    search: Union[bool, List[str]] = True,
    source: Optional[Union[str, List[str]]] = None,
) -> "List[Any]":
    """Resolve ``query`` by speaking DNS to ``ns`` directly -- no dnspython,
    no OS resolver. UDP, retried over TCP when the reply is truncated (or TCP
    throughout with ``tcp=True``); the nameservers in turn until one answers.

    ::

        resolve_wire("example.com", ns="1.1.1.1")
        resolve_wire("example.com", "aaaa", ns=["10.0.0.53:5353", "[fd00::53]"])
        resolve_wire("example.com", ns="10.0.0.53", source="10.0.0.7")

    :param ns: nameserver(s): ``host``, ``host:port``, ``[v6]:port``.
        ``None``: ``/etc/resolv.conf``'s (POSIX; elsewhere
        :class:`ResolutionError`).
    :param rdtype: ``a``, ``aaaa``, ``cname``, ``ptr``, ``mx``, ``txt``,
        ``ns``, ``srv``; ``None`` auto-selects as :func:`resolve` does.
        Another type is a :class:`ResolutionError` (the chain moves on).
    :param timeout: seconds for the whole resolution, every server included.
    :param port: the port of an ``ns`` entry that names none.
    :param source: the address the queries leave from -- one, or one per
        address family; a server whose family has none is skipped.
    :param search: a list of domains tries ``query`` under each, then as
        given; ``True``/``False`` ask for ``query`` as given (this backend
        reads no system search list).

    Contract as the other backends: native values, ``[]`` for NXDOMAIN or no
    record of the type, :class:`ResolutionError` when no server answered
    (:class:`ResolutionTimeoutError` when the deadline or a socket timeout
    was the reason). A reply the codec cannot read, or a name it cannot encode,
    is not raised as such: it counts as that server not answering, and the
    :class:`DNSDecodeError` is the ``__cause__`` of the final error.
    A CNAME chain inside the reply is followed.
    """
    query = query_argument(query)
    name, rdtype = _question(query, rdtype)
    servers = _servers(ns, port)
    names = search_candidates(name, search, wire=True)
    deadline = _time.monotonic() + (_budget(5.0 if timeout is None else timeout) or 0.0)
    last: Optional[Exception] = None
    answered = False
    for candidate in names:
        for index, server in enumerate(servers):
            remaining = deadline - _time.monotonic()
            if remaining <= 0:
                raise ResolutionTimeoutError(
                    "no answer within %ss (last: %s)" % (timeout, last)
                ) from last
            # Each server gets its share of what is left, so a dead first one
            # cannot use up the time the next would have answered in.
            remaining /= len(servers) - index
            try:
                from_ = _source_for(source, server[0])
                ident = int.from_bytes(_os.urandom(2), "big")
                payload = _dnswire.build_query(candidate, rdtype, ident)
                reply = _dnswire.parse_response(
                    _exchange(server, payload, remaining, tcp, from_), ident
                )
                if reply.truncated and not tcp:
                    reply = _dnswire.parse_response(
                        _exchange(
                            server,
                            payload,
                            max(deadline - _time.monotonic(), 0.01),
                            True,
                            from_,
                        ),
                        ident,
                    )
            except (OSError, ValueError) as exc:
                last = exc
                continue
            if reply.rcode == _dnswire.NXDOMAIN:
                answered = True
                break
            if reply.rcode != _dnswire.NOERROR:
                last = ResolutionError(
                    "%s:%d answered rcode %d" % (server[0], server[1], reply.rcode)
                )
                continue
            values = reply.records(candidate, rdtype)
            if values:
                return values
            answered = True
            break
    if answered:
        return []
    if isinstance(last, _socket.timeout):
        raise ResolutionTimeoutError(
            "no nameserver answered in time: %s" % (last,)
        ) from last
    raise ResolutionError("no nameserver answered: %s" % (last,)) from last

resolve_doh(query, url, *, rdtype=None, timeout=5.0, fetch=None, allow_http=False)

resolve_doh(
    query: "HostLike",
    url: str,
    *,
    rdtype: "Literal['a', 'A']",
    timeout: Optional[float] = 5.0,
    fetch: "Optional[Callable[[str, bytes, dict, Optional[float]], bytes]]" = None,
    allow_http: bool = False
) -> "List[IPv4Address]"
resolve_doh(
    query: "HostLike",
    url: str,
    *,
    rdtype: "Literal['aaaa', 'AAAA']",
    timeout: Optional[float] = 5.0,
    fetch: "Optional[Callable[[str, bytes, dict, Optional[float]], bytes]]" = None,
    allow_http: bool = False
) -> "List[IPv6Address]"
resolve_doh(
    query: "HostLike",
    url: str,
    *,
    rdtype: "Literal['ptr', 'PTR']",
    timeout: Optional[float] = 5.0,
    fetch: "Optional[Callable[[str, bytes, dict, Optional[float]], bytes]]" = None,
    allow_http: bool = False
) -> "List[str]"
resolve_doh(
    query: "HostLike",
    url: str,
    *,
    rdtype: Optional[str] = None,
    timeout: Optional[float] = 5.0,
    fetch: "Optional[Callable[[str, bytes, dict, Optional[float]], bytes]]" = None,
    allow_http: bool = False
) -> "List[Any]"

Resolve query with DNS over HTTPS (RFC 8484): the DNS message POSTed to url as application/dns-message.

::

resolve_doh("example.com", "https://cloudflare-dns.com/dns-query")

:param fetch: fetch(url, body, headers, timeout) -> bytes sends the request -- so a caller with its own HTTP stack (a proxy, a CA bundle) uses it. None: :mod:urllib.request, which follows no redirect and reads at most 65,536 bytes. An OSError or ValueError from it is a :class:ResolutionError. :param rdtype: as :func:resolve_wire. :param allow_http: accept an http:// URL. Without it anything but https is a :class:ValueError before a request is made: a DNS answer over plain HTTP can be rewritten on the path.

Contract as the other backends: native values, [] for NXDOMAIN or no record of the type, :class:ResolutionError when the endpoint could not be asked or answered something else (:class:ResolutionTimeoutError on a timeout), with an unreadable reply as the error's __cause__ (:class:DNSDecodeError). A query the codec cannot encode (an empty or over-long label) raises :class:DNSDecodeError itself, before anything is sent. Not part of :func:resolve's chain. A reply body over 65,536 bytes, a redirect and an HTTP error status are :class:ResolutionError; messages name the URL without its credentials or query string.

Source code in src/netimps/_dns/_doh.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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def resolve_doh(
    query: "HostLike",
    url: str,
    *,
    rdtype: Optional[str] = None,
    timeout: Optional[float] = 5.0,
    fetch: "Optional[Callable[[str, bytes, dict, Optional[float]], bytes]]" = None,
    allow_http: bool = False,
) -> "List[Any]":
    """Resolve ``query`` with DNS over HTTPS (RFC 8484): the DNS message
    POSTed to ``url`` as ``application/dns-message``.

    ::

        resolve_doh("example.com", "https://cloudflare-dns.com/dns-query")

    :param fetch: ``fetch(url, body, headers, timeout) -> bytes`` sends the
        request -- so a caller with its own HTTP stack (a proxy, a CA bundle)
        uses it. ``None``: :mod:`urllib.request`, which follows no redirect and
        reads at most 65,536 bytes. An ``OSError`` or ``ValueError`` from it is
        a :class:`ResolutionError`.
    :param rdtype: as :func:`resolve_wire`.
    :param allow_http: accept an ``http://`` URL. Without it anything but
        ``https`` is a :class:`ValueError` before a request is made: a DNS
        answer over plain HTTP can be rewritten on the path.

    Contract as the other backends: native values, ``[]`` for NXDOMAIN or no
    record of the type, :class:`ResolutionError` when the endpoint could not
    be asked or answered something else (:class:`ResolutionTimeoutError` on a
    timeout), with an unreadable reply as the error's ``__cause__``
    (:class:`DNSDecodeError`). A ``query`` the codec cannot encode (an empty or
    over-long label) raises :class:`DNSDecodeError` itself, before anything is
    sent. Not part of :func:`resolve`'s chain. A reply body over 65,536 bytes, a
    redirect and an HTTP error status are :class:`ResolutionError`; messages
    name the URL without its credentials or query string.
    """
    from urllib.parse import urlsplit

    scheme = urlsplit(url).scheme.lower()
    if scheme != "https" and not (allow_http and scheme == "http"):
        raise ValueError(
            "DNS over HTTPS needs an https:// URL, got scheme %r; pass "
            "allow_http=True for a plain-http endpoint" % (scheme,)
        )
    query = query_argument(query)
    name, rdtype = _question(query, rdtype)
    payload = _dnswire.build_query(name, rdtype, 0)
    headers = {
        "Content-Type": "application/dns-message",
        "Accept": "application/dns-message",
    }
    shown = _shown_url(url)
    try:
        body = (fetch or _urllib_fetch)(url, payload, headers, timeout)
        if not _dnswire.is_reply_to(payload, body):
            raise DNSDecodeError("reply to another query")
        reply = _dnswire.parse_response(body, 0)
    except ResolutionError:
        raise
    except _socket.timeout as exc:
        raise ResolutionTimeoutError(
            "DNS over HTTPS via %s: %s" % (shown, exc)
        ) from exc
    except (OSError, ValueError) as exc:
        raise ResolutionError("DNS over HTTPS via %s: %s" % (shown, exc)) from exc
    if reply.rcode == _dnswire.NXDOMAIN:
        return []
    if reply.rcode != _dnswire.NOERROR:
        raise ResolutionError("%s answered rcode %d" % (shown, reply.rcode))
    return reply.records(name, rdtype)

ping(dst, *, tries=1, timeout=1.0, ipv6=None, src=None, size=None, ttl=None, dont_fragment=False, method='icmp', port=None, cache=False)

Ping dst; the result is truthy if it answered.

Returns a :class:PingResult rather than a bare bool, so the reply details are available without re-running and re-parsing ping::

ping("8.8.8.8")                          # ICMP echo
ping("8.8.8.8", method="tcp", port=53)   # time a TCP handshake
ping("10.0.0.5", method="udp", port=53)  # datagram + reply or ICMP

if ping("8.8.8.8"):                  # still reads as a boolean
    ...
result = ping("8.8.8.8")
result.rtt                           # 0.005 (seconds)
result.ttl                           # 119

Shells out to the platform ping binary, translating timeout into the right per-platform flags (Windows -n/-w in milliseconds; POSIX -c/-W in whole seconds), and returns on the first success::

ping("10.0.0.1")                      # one attempt, 1s timeout
ping("example.com", tries=3, timeout=2.5)
ping("2001:db8::1", ipv6=True)        # force ping6 semantics
ping(get_interfaces()[0].ipv4[0])     # an IPv4Interface -- pings its .ip
ping(IPv4Address("10.0.0.1"))         # an address object directly

:param dst: hostname, address string, :class:IPv4Address/ :class:IPv6Address, or :class:IPv4Interface/:class:IPv6Interface (its .ip is pinged, not the /prefix). A network (:class:IPv4Network/:class:IPv6Network) raises :class:TypeError -- it has no single address to ping. :param tries: attempts before giving up. Values below 1 are treated as 1. :param timeout: seconds to wait per attempt. Linux ping -W takes whole seconds, so sub-second values are rounded up to 1 -- never down to 0, which some implementations read as "wait forever". Windows -w and BSD/macOS -W take milliseconds (rounded up to 1 ms); BSD ping6 has no wait flag and the subprocess deadline bounds it. The timeout also bounds the lookup of a hostname dst, which happens once before the first attempt. :param ipv6: force the IPv6 or IPv4 family. Applies to all three method values: it selects the -6/-4 flag for ICMP and the address family the tcp/udp probes resolve and connect with. None (default) lets the resolver decide, and accepts a reply from either family. An address-literal dst decides for itself. :param src: send from this local address, choosing which interface the echo leaves by. Accepts an :class:Interface, an address object, or a string::

    ping("8.8.8.8", src=get_source_ip())            # address object
    ping("8.8.8.8", src=get_interfaces()[0])        # Interface
    ping("8.8.8.8", src="192.0.2.10")               # literal
    ping("8.8.8.8", src=MACAddress("00:00:5e:00:53:01"))  # by MAC

A **MAC address** (object or string) is resolved to the interface
holding it -- convenient when the adapter is known by hardware address
rather than by a possibly-changing IP. An unknown MAC yields a falsy
result rather than falling back.

An ``Interface`` contributes its first non-loopback IPv4 address (its
IPv6 address when ``ipv6=True``), because Windows ``-S`` requires an
*address* -- passing an adapter name there fails. POSIX ``-I`` would
accept a name, but resolving it here keeps behaviour identical on both.
An interface holding no usable address yields a falsy result rather
than falling back to the default route.

Likewise an address not held by any local interface makes ``ping``
fail, so the result is falsy -- this never silently reroutes.

:param cache: reuse a recent enumeration of the adapters while src is resolved, as :func:get_interfaces does (True for :data:INTERFACE_CACHE_TTL seconds, or a number for that TTL). False (the default) enumerates on each call; ignored without src. :param size: ICMP payload bytes -- Windows -l, POSIX -s. Both flags mean the same thing: neither counts headers, so the wire packet is 28 bytes larger (20 IP + 8 ICMP). Payload 1472 is exactly 1500 on the wire, which is why that is the number a 1500-MTU link tops out at. Verified on Windows against the DF boundary (1472 passes, 1473 does not); ping(8) documents -s as "data bytes" identically. :param ttl: initial hop limit (-i on Windows, -t on Linux, -m on macOS/BSD ping and -h on ping6 -- the letters differ between platforms, a classic source of scripts that silently do the wrong thing).

A ``ttl`` too small to reach the target yields ``False`` on every
platform. That takes explicit work on Windows, whose ``ping`` exits
``0`` for "TTL expired in transit" -- counting a router's error as a
received reply -- so the raw exit code would report success although
the target was never reached. The reply address is verified instead
(see below), which is locale-independent.

:param method: how to probe -- "icmp" (default), "tcp" or "udp". ICMP is the classic echo; the other two reach a host through firewalls that drop echo but permit ordinary traffic.

All three answer **"is the host up?"**. A TCP *refusal* therefore counts
as success -- the RST proves something answered -- and so does an ICMP
port-unreachable for UDP. Use :func:`netimps.tcp_check` when the
question is "is the *service* up?", where a refusal is a failure.

**On Windows a refusal takes about two seconds to arrive**: the SYN is
retried before the RST is reported (measured 2.02 s against a closed
loopback port; macOS answers in 0.9 ms). A ``timeout`` under two seconds
therefore reports a refusing Windows host as down, so give ``method="tcp"``
a ``timeout`` of at least 3 there.

The UDP probe connects its socket before sending, so the ICMP
port-unreachable is delivered on POSIX as well as Windows -- an
unconnected socket never receives one on Linux/BSD, which would make
this method under-report liveness there for exactly the case it exists
for. Silence still means "no answer": UDP cannot tell a filtered
port from an absent host.

:param port: destination port for tcp/udp. Required for those, and ignored for ICMP. :param dont_fragment: set the DF bit (Windows -f, Linux -M do, macOS/BSD ping -D). Combined with size, the standard manual MTU probe: the largest size that still succeeds is the path MTU minus 28. BSD ping6 has no verified DF flag, so the combination raises :class:ValueError instead of sending fragmentable probes.

An empty dst gives a falsy result. A missing ping binary or a non-zero exit also yield a falsy :class:PingResult -- reachability failures are never raised.

Caller mistakes are raised, as they are elsewhere in this package, so a typo cannot masquerade as an unreachable host: :class:ValueError for an unknown method, a negative size, a ttl outside 1-255, a tcp/udp probe with no port, dont_fragment on a method or platform that cannot set it, and a dst beginning with -. That last one matters more than it looks: the binary reads such a destination as an option, and Windows ping -? prints usage and exits 0, which would be reported as a successful ping of a host that was never contacted.

.. note:: This measures whether ICMP echo is answered, which is not the same as whether a host is up -- plenty of hosts and most cloud firewalls drop echo requests while serving traffic normally. Prefer a TCP connect to the port you actually care about when you can.

Source code in src/netimps/_ping/_run.py
 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
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
def ping(
    dst: "HostLike",
    *,
    tries: int = 1,
    timeout: float = 1.0,
    ipv6: Optional[bool] = None,
    src: "InterfaceLike" = None,
    size: Optional[int] = None,
    ttl: Optional[int] = None,
    dont_fragment: bool = False,
    method: "Literal['icmp', 'tcp', 'udp']" = "icmp",
    port: "Optional[int]" = None,
    cache: "Union[bool, float]" = False,
) -> "PingResult":
    """Ping ``dst``; the result is truthy if it answered.

    Returns a :class:`PingResult` rather than a bare bool, so the reply details
    are available without re-running and re-parsing ``ping``::

        ping("8.8.8.8")                          # ICMP echo
        ping("8.8.8.8", method="tcp", port=53)   # time a TCP handshake
        ping("10.0.0.5", method="udp", port=53)  # datagram + reply or ICMP

        if ping("8.8.8.8"):                  # still reads as a boolean
            ...
        result = ping("8.8.8.8")
        result.rtt                           # 0.005 (seconds)
        result.ttl                           # 119

    Shells out to the platform ``ping`` binary, translating ``timeout`` into the
    right per-platform flags (Windows ``-n``/``-w`` in milliseconds; POSIX
    ``-c``/``-W`` in whole seconds), and returns on the first success::

        ping("10.0.0.1")                      # one attempt, 1s timeout
        ping("example.com", tries=3, timeout=2.5)
        ping("2001:db8::1", ipv6=True)        # force ping6 semantics
        ping(get_interfaces()[0].ipv4[0])     # an IPv4Interface -- pings its .ip
        ping(IPv4Address("10.0.0.1"))         # an address object directly

    :param dst: hostname, address string, :class:`IPv4Address`/
        :class:`IPv6Address`, or :class:`IPv4Interface`/:class:`IPv6Interface`
        (its ``.ip`` is pinged, not the ``/prefix``). A network
        (:class:`IPv4Network`/:class:`IPv6Network`) raises :class:`TypeError`
        -- it has no single address to ping.
    :param tries: attempts before giving up. Values below 1 are treated as 1.
    :param timeout: seconds to wait per attempt. Linux ``ping -W`` takes whole
        seconds, so sub-second values are rounded **up** to 1 -- never down to
        0, which some implementations read as "wait forever". Windows ``-w``
        and BSD/macOS ``-W`` take milliseconds (rounded up to 1 ms); BSD
        ``ping6`` has no wait flag and the subprocess deadline bounds it. The
        timeout also bounds the lookup of a hostname ``dst``, which happens
        once before the first attempt.
    :param ipv6: force the IPv6 or IPv4 family. Applies to **all three**
        ``method`` values: it selects the ``-6``/``-4`` flag for ICMP and the
        address family the ``tcp``/``udp`` probes resolve and connect with.
        ``None`` (default) lets the resolver decide, and accepts a reply from
        either family. An address-literal ``dst`` decides for itself.
    :param src: send from this local address, choosing which interface the
        echo leaves by. Accepts an :class:`Interface`, an address object, or a
        string::

            ping("8.8.8.8", src=get_source_ip())            # address object
            ping("8.8.8.8", src=get_interfaces()[0])        # Interface
            ping("8.8.8.8", src="192.0.2.10")               # literal
            ping("8.8.8.8", src=MACAddress("00:00:5e:00:53:01"))  # by MAC

        A **MAC address** (object or string) is resolved to the interface
        holding it -- convenient when the adapter is known by hardware address
        rather than by a possibly-changing IP. An unknown MAC yields a falsy
        result rather than falling back.

        An ``Interface`` contributes its first non-loopback IPv4 address (its
        IPv6 address when ``ipv6=True``), because Windows ``-S`` requires an
        *address* -- passing an adapter name there fails. POSIX ``-I`` would
        accept a name, but resolving it here keeps behaviour identical on both.
        An interface holding no usable address yields a falsy result rather
        than falling back to the default route.

        Likewise an address not held by any local interface makes ``ping``
        fail, so the result is falsy -- this never silently reroutes.
    :param cache: reuse a recent enumeration of the adapters while ``src`` is
        resolved, as :func:`get_interfaces` does (``True`` for
        :data:`INTERFACE_CACHE_TTL` seconds, or a number for that TTL).
        ``False`` (the default) enumerates on each call; ignored without ``src``.
    :param size: ICMP **payload** bytes -- Windows ``-l``, POSIX ``-s``. Both
        flags mean the same thing: neither counts headers, so the wire packet is
        28 bytes larger (20 IP + 8 ICMP). Payload 1472 is exactly 1500 on the
        wire, which is why that is the number a 1500-MTU link tops out at.
        Verified on Windows against the DF boundary (1472 passes, 1473 does
        not); ``ping(8)`` documents ``-s`` as "data bytes" identically.
    :param ttl: initial hop limit (``-i`` on Windows, ``-t`` on Linux, ``-m`` on
        macOS/BSD ``ping`` and ``-h`` on ``ping6`` -- the letters differ between
        platforms, a classic source of scripts that silently do the wrong
        thing).

        A ``ttl`` too small to reach the target yields ``False`` on every
        platform. That takes explicit work on Windows, whose ``ping`` exits
        ``0`` for "TTL expired in transit" -- counting a router's error as a
        received reply -- so the raw exit code would report success although
        the target was never reached. The reply address is verified instead
        (see below), which is locale-independent.
    :param method: how to probe -- ``"icmp"`` (default), ``"tcp"`` or
        ``"udp"``. ICMP is the classic echo; the other two reach a host through
        firewalls that drop echo but permit ordinary traffic.

        All three answer **"is the host up?"**. A TCP *refusal* therefore counts
        as success -- the RST proves something answered -- and so does an ICMP
        port-unreachable for UDP. Use :func:`netimps.tcp_check` when the
        question is "is the *service* up?", where a refusal is a failure.

        **On Windows a refusal takes about two seconds to arrive**: the SYN is
        retried before the RST is reported (measured 2.02 s against a closed
        loopback port; macOS answers in 0.9 ms). A ``timeout`` under two seconds
        therefore reports a refusing Windows host as down, so give ``method="tcp"``
        a ``timeout`` of at least 3 there.

        The UDP probe connects its socket before sending, so the ICMP
        port-unreachable is delivered on POSIX as well as Windows -- an
        unconnected socket never receives one on Linux/BSD, which would make
        this method under-report liveness there for exactly the case it exists
        for. Silence still means "no answer": UDP cannot tell a filtered
        port from an absent host.
    :param port: destination port for ``tcp``/``udp``. Required for those, and
        ignored for ICMP.
    :param dont_fragment: set the DF bit (Windows ``-f``, Linux ``-M do``,
        macOS/BSD ``ping`` ``-D``). Combined with ``size``, the standard manual
        MTU probe: the largest ``size`` that still succeeds is the path MTU
        minus 28. BSD ``ping6`` has no verified DF flag, so the combination
        raises :class:`ValueError` instead of sending fragmentable probes.

    An empty ``dst`` gives a falsy result. A missing ``ping`` binary or a
    non-zero exit also yield a falsy :class:`PingResult` -- *reachability*
    failures are never raised.

    **Caller mistakes are raised**, as they are elsewhere in this package, so a
    typo cannot masquerade as an unreachable host: :class:`ValueError` for an
    unknown ``method``, a negative ``size``, a ``ttl`` outside 1-255, a
    ``tcp``/``udp`` probe with no ``port``, ``dont_fragment`` on a method or
    platform that cannot set it, and a ``dst`` beginning with ``-``. That last
    one matters more than it looks: the binary reads such a destination as an
    option, and Windows ``ping -?`` prints usage and **exits 0**, which would
    be reported as a successful ping of a host that was never contacted.

    .. note::
       This measures whether *ICMP echo* is answered, which is not the same as
       whether a host is up -- plenty of hosts and most cloud firewalls drop
       echo requests while serving traffic normally. Prefer a TCP connect to
       the port you actually care about when you can.
    """
    text = _dst_argument(dst)
    if not text:
        return PingResult(False, dst, attempts=0)
    dst = text

    # A destination that begins with "-" is read by the binary as an option,
    # not a host. Windows `ping -?` then prints usage and exits 0, which would
    # come back as a *truthy* PingResult for a host that was never contacted --
    # a false positive, which is the worse direction for a liveness check.
    if dst.startswith("-"):
        raise ValueError(
            "dst %r starts with '-' and would be read as an option, not a host" % (dst,)
        )

    lowered = (method or "icmp").lower()
    if lowered not in ("icmp", "tcp", "udp"):
        raise ValueError("method must be 'icmp', 'tcp' or 'udp', got %r" % (lowered,))
    method = _cast("Literal['icmp', 'tcp', 'udp']", lowered)

    # Validate every argument the same way for every method, so one bad value
    # cannot raise for one method and be silently accepted by another.
    if size is not None and size < 0:
        raise ValueError("size must be non-negative, got %r" % (size,))
    if ttl is not None and not 1 <= ttl <= 255:
        raise ValueError("ttl must be 1-255, got %r" % (ttl,))

    resolved_source = None
    expected: "Optional[List[IPAddress]]" = None
    if src is not None:
        # The source has to be of the destination's family: a literal decides
        # for itself, and a name is resolved (once, bounded) to learn it.
        if ipv6 is None and _try_parse(dst) is None:
            expected = _expected_addresses(dst, ipv6, timeout)
        resolved_source = _interface_address(
            src,
            want_ipv6=_wants_ipv6(dst, ipv6, expected),
            strict=False,
            cache=cache,
        )
        if resolved_source is None:
            # An interface with no usable address cannot be a src.
            return PingResult(False, dst, attempts=0)

    if method != "icmp":
        if port is None:
            raise ValueError("method=%r needs a port" % (method,))
        if dont_fragment:
            # DF is a property of the probe packet, which only the ICMP path
            # builds. Silently ignoring it here is what let discover_mtu report
            # a confident wrong MTU; say so instead.
            raise ValueError(
                "dont_fragment applies to method='icmp' only, not %r" % (method,)
            )
        prober = _tcp_ping if method == "tcp" else _udp_ping
        probe_size = size or 0
        # `ipv6=`, `src=` and `ttl=` apply to these methods too, not just to the
        # ICMP binary.
        probe_src = _try_parse(dst)
        last = None
        for attempt in range(1, max(1, tries) + 1):
            ok, rtt, note = prober(
                dst, port, timeout, probe_size, ipv6, resolved_source, ttl
            )
            if ok:
                return PingResult(
                    True,
                    dst,
                    rtt=rtt,
                    src=probe_src,
                    attempts=attempt,
                )
            last = attempt
        return PingResult(False, dst, attempts=last or 1)

    tries = max(1, tries)

    # Resolve once, here, and let both decisions read the same answer. The
    # reply-address expectation and (on BSD) the choice of binary are two
    # questions about one lookup; asking twice costs an extra round trip and
    # lets them disagree about which host is being pinged.
    if expected is None:
        expected = _expected_addresses(dst, ipv6, timeout)

    if dont_fragment and not supports_dont_fragment(dst, ipv6, expected):
        raise ValueError(
            "dont_fragment cannot be set for this destination on %s "
            "(no verified DF flag for ping6 here); a probe without DF measures "
            "nothing, so this refuses rather than answering wrongly" % (_sys.platform,)
        )
    argv, used_ping6 = _ping_command(
        dst,
        ipv6,
        timeout,
        str(resolved_source) if resolved_source is not None else None,
        size,
        ttl,
        dont_fragment,
        expected,
    )
    # A hard cap on the subprocess itself: the per-reply flag bounds how long
    # ping waits for an answer, but not how long name resolution can hang
    # beforehand -- and BSD `ping6` has no wait flag at all, so this is the only
    # bound there.
    wall_timeout = max(timeout, 1.0) + 5.0

    for attempt in range(1, tries + 1):
        try:
            response = _proc.run(argv[0], argv[1:], timeout=wall_timeout)
        except OSError:
            # No ping binary, or it hung past the wall clock (TimeoutError).
            return PingResult(False, dst, attempts=attempt)
        if response.returncode != 0:
            continue

        text = response.stdout

        # A zero exit is not proof the *target* answered: Windows also exits 0
        # for "TTL expired in transit", where a router replied instead. Confirm
        # by address, which is locale-independent.
        rtt, reply_ttl, reply_src = _parse_ping_output(text, expected)
        if expected:
            if reply_src is None:
                continue  # zero exit, but nothing from the destination
        elif rtt is None and reply_ttl is None:
            # Nothing resolved, so there is no address to verify against. A bare
            # exit code is not enough on its own -- `ping -?` prints usage and
            # exits 0 -- so require some positive evidence of a reply instead.
            continue
        return PingResult(
            True,
            dst,
            rtt=rtt,
            ttl=reply_ttl,
            src=reply_src,
            attempts=attempt,
        )
    return PingResult(False, dst, attempts=tries)

scan_hosts(network, port=None, *, ports=None, timeout=1.0, workers=_DEFAULT_WORKERS)

Find responsive hosts on network, with the ports each answers on.

::

scan_hosts("192.168.1.0/24", port=22)        # who has SSH open
scan_hosts("10.0.0.0/28", ports=[80, 443])
scan_hosts("192.168.1.0/24")                 # the 'common' set each
scan_hosts("192.168.1.0/24", port="https")   # scheme name works too

:param network: anything :func:netimps.parse accepts as a network. Only usable host addresses are probed -- the network and broadcast addresses are skipped. :param port: shorthand for ports=[port]. Accepts a scheme name too, so port="ssh" is port=22. :param ports: ports to probe per host; defaults to the "common" set when omitted. Same forms as :func:scan_ports. An explicitly empty ports means "nothing to scan" and returns [] -- it does not fall back to the default set, which would sweep 36 ports across the network a caller just said to probe on none. :param timeout: per-connection timeout, floored to 50 ms as in :func:scan_ports; a negative value raises. :param workers: total concurrent connections across all hosts.

Returns [(address, [open_ports]), ...] sorted by address, including only hosts with at least one open port.

.. note:: This is a TCP sweep, so a host that is up but answers on none of the probed ports does not appear. That is the honest result for the question asked -- it is not an ARP or ICMP discovery scan, and a firewalled host is indistinguishable from an absent one. Widen ports if you need better coverage.

Refuses networks larger than /16 (or IPv6 /112), and any sweep of more than 4,194,304 probes (hosts times ports): a /8 sweep is 16 million hosts, and a /16 over every port 4.3 billion probes, which is a mistake rather than an intention. Both raise :class:ValueError before the first probe. The work is fed to the pool as it runs, so memory does not grow with the sweep.

Source code in src/netimps/_scan.py
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
def scan_hosts(
    network: "IPNetworkLike",
    port: "Optional[Union[int, str]]" = None,
    *,
    ports: "Optional[PortsLike]" = None,
    timeout: float = 1.0,
    workers: int = _DEFAULT_WORKERS,
) -> "List[Tuple[IPAddress, List[int]]]":
    """Find responsive hosts on ``network``, with the ports each answers on.

    ::

        scan_hosts("192.168.1.0/24", port=22)        # who has SSH open
        scan_hosts("10.0.0.0/28", ports=[80, 443])
        scan_hosts("192.168.1.0/24")                 # the 'common' set each
        scan_hosts("192.168.1.0/24", port="https")   # scheme name works too

    :param network: anything :func:`netimps.parse` accepts as a network. Only
        usable host addresses are probed -- the network and broadcast addresses
        are skipped.
    :param port: shorthand for ``ports=[port]``. Accepts a scheme name too, so
        ``port="ssh"`` is ``port=22``.
    :param ports: ports to probe per host; defaults to the ``"common"`` set
        when **omitted**. Same forms as :func:`scan_ports`. An explicitly
        empty ``ports`` means "nothing to scan" and returns ``[]`` -- it does
        not fall back to the default set, which would sweep 36 ports across
        the network a caller just said to probe on none.
    :param timeout: per-connection timeout, floored to 50 ms as in
        :func:`scan_ports`; a negative value raises.
    :param workers: total concurrent connections across all hosts.

    Returns ``[(address, [open_ports]), ...]`` sorted by address, including
    only hosts with at least one open port.

    .. note::
       This is a **TCP** sweep, so a host that is up but answers on none of the
       probed ports does not appear. That is the honest result for the question
       asked -- it is not an ARP or ICMP discovery scan, and a firewalled host
       is indistinguishable from an absent one. Widen ``ports`` if you need
       better coverage.

    Refuses networks larger than /16 (or IPv6 /112), and any sweep of more than
    4,194,304 probes (hosts times ports): a /8 sweep is 16 million hosts, and a
    /16 over every port 4.3 billion probes, which is a mistake rather than an
    intention. Both raise :class:`ValueError` before the first probe. The work is
    fed to the pool as it runs, so memory does not grow with the sweep.
    """

    net = parse(network, IPNetwork)
    if net.version == 4 and net.prefixlen < 16:
        raise ValueError(
            "%s has %d addresses; scan a /16 or smaller" % (net, net.num_addresses)
        )
    if net.version == 6 and net.prefixlen < 112:
        raise ValueError("%s is too large to sweep; scan a /112 or smaller" % (net,))

    if port is not None and ports is not None:
        raise ValueError("pass either port or ports, not both")
    # `ports or "common"` would read an explicitly empty list as "unset" and
    # sweep the default 36 ports instead of none, so test for omission.
    if port is not None:
        spec: "PortsLike" = [port]
    elif ports is not None:
        spec = ports
    else:
        spec = "common"
    targets = _resolve_ports(spec)
    if not targets:
        return []
    timeout = _checked_timeout(timeout)

    probes = net.num_addresses * len(targets)
    if probes > _MAX_PROBES:
        raise ValueError(
            "%s with %d ports is %d probes; the most one call makes is %d"
            % (net, len(targets), probes, _MAX_PROBES)
        )

    # A /31 or /32 has no separate network/broadcast address, and .hosts()
    # already accounts for that.
    hosts = _cast("Iterator[IPAddress]", iter(net.hosts()))
    first = next(hosts, None)
    addresses: "Iterator[IPAddress]" = (
        iter([net.network_address])
        if first is None
        else _itertools.chain([first], hosts)
    )
    work = ((address, probe) for address in addresses for probe in targets)

    found: "Dict[IPAddress, List[int]]" = {}
    results = _map_lazily(
        # Every address here is already a literal from the network, so the
        # workers never consult the resolver -- same property scan_ports
        # gets from _probe_addresses.
        lambda item: (item[0], item[1], _probe([str(item[0])], item[1], timeout)),
        work,
        min(workers, probes),
    )
    for address, probe, is_open in results:
        if is_open:
            found.setdefault(address, []).append(probe)

    return [(address, sorted(found[address])) for address in sorted(found)]

scan_ports(host, ports='common', *, timeout=1.0, workers=_DEFAULT_WORKERS)

Return the sorted open TCP ports on host.

::

scan_ports("192.168.1.1")                    # the 'common' set
scan_ports("localhost", "well-known")        # ports 1-1023
scan_ports("10.0.0.5", range(8000, 8100))
scan_ports("10.0.0.5", [22, 80, 443])
scan_ports("10.0.0.5", "https")              # scheme name -> 443
scan_ports("10.0.0.5", ["ssh", "https"])     # -> 22, 443
scan_ports("10.0.0.5", "22,https,8000")      # a comma list -> 22, 443, 8000

host also accepts an address object or an :class:IPv4Interface/ :class:IPv6Interface (its .ip is used), same as :func:tcp_check.

:param ports: a :data:PORT_RANGES name ("common", "well-known", "all"), a scheme name resolved via :func:get_default_port, a port number, a comma-separated string of those ("22,80,https"; spaces around an item are ignored, an empty item raises), or any iterable mixing them. A range name wins over a scheme name where the two collide. Each port must be in 0-65535; an empty iterable means "nothing to scan" and returns []. :param timeout: per-port connect timeout. This bounds the whole scan (timeout x rounds), so keep it small on a large range -- but not so small that a slow host reads as closed. Floored to 50 ms, as in :func:tcp_check, since settimeout(0) means non-blocking and would report every port closed; a negative value raises. :param workers: concurrent connections. These tasks are I/O-bound, so the useful number is far above the CPU count; very high values can exhaust file descriptors or trip rate limiting.

host is resolved once for the whole scan rather than once per port -- see :func:_probe_addresses for why that matters for correctness and not only speed. A name that does not resolve returns [].

Open means "the TCP handshake completed" -- not that the service is healthy, and not that a filtered port is distinguishable from a closed one (both simply fail to connect).

Raises :class:NetimpsValueError for an unknown scheme or range name or an empty item in a comma list, and :class:ValueError for a port outside 0-65535 or a negative timeout.

Source code in src/netimps/_scan.py
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
def scan_ports(
    host: "HostLike",
    ports: "PortsLike" = "common",
    *,
    timeout: float = 1.0,
    workers: int = _DEFAULT_WORKERS,
) -> "List[int]":
    """Return the sorted open TCP ports on ``host``.

    ::

        scan_ports("192.168.1.1")                    # the 'common' set
        scan_ports("localhost", "well-known")        # ports 1-1023
        scan_ports("10.0.0.5", range(8000, 8100))
        scan_ports("10.0.0.5", [22, 80, 443])
        scan_ports("10.0.0.5", "https")              # scheme name -> 443
        scan_ports("10.0.0.5", ["ssh", "https"])     # -> 22, 443
        scan_ports("10.0.0.5", "22,https,8000")      # a comma list -> 22, 443, 8000

    ``host`` also accepts an address object or an :class:`IPv4Interface`/
    :class:`IPv6Interface` (its ``.ip`` is used), same as :func:`tcp_check`.

    :param ports: a :data:`PORT_RANGES` name (``"common"``, ``"well-known"``,
        ``"all"``), a scheme name resolved via :func:`get_default_port`, a port
        number, a comma-separated string of those (``"22,80,https"``; spaces
        around an item are ignored, an empty item raises), or any iterable
        mixing them. A range name wins over a scheme name where the two
        collide. Each port must be in ``0-65535``; an empty iterable means
        "nothing to scan" and returns ``[]``.
    :param timeout: per-port connect timeout. This bounds the whole scan
        (``timeout`` x rounds), so keep it small on a large range -- but not so
        small that a slow host reads as closed. Floored to 50 ms, as in
        :func:`tcp_check`, since ``settimeout(0)`` means *non-blocking* and
        would report every port closed; a negative value raises.
    :param workers: concurrent connections. These tasks are I/O-bound, so the
        useful number is far above the CPU count; very high values can exhaust
        file descriptors or trip rate limiting.

    ``host`` is resolved **once** for the whole scan rather than once per port
    -- see :func:`_probe_addresses` for why that matters for correctness and
    not only speed. A name that does not resolve returns ``[]``.

    Open means "the TCP handshake completed" -- not that the service is
    healthy, and not that a filtered port is distinguishable from a closed one
    (both simply fail to connect).

    Raises :class:`NetimpsValueError` for an unknown scheme or range name or an
    empty item in a comma list, and :class:`ValueError` for a port outside
    ``0-65535`` or a negative ``timeout``.
    """
    targets = _resolve_ports(ports)
    if not targets:
        return []
    timeout = _checked_timeout(timeout)

    addresses = _probe_addresses(host)
    if not addresses:
        return []

    results = _map_lazily(
        lambda p: (p, _probe(addresses, p, timeout)),
        targets,
        min(workers, len(targets)),
    )
    return sorted(port for port, is_open in results if is_open)

is_multicast(address)

True if address is a multicast group (224.0.0.0/4 or ff00::/8).

::

is_multicast("224.0.0.251")   # True  -- mDNS
is_multicast("ff02::fb")      # True
is_multicast("10.0.0.1")      # False

Accepts an address as text, an int, packed bytes or an address object, and an :class:IPv4Interface/:class:IPv6Interface, whose .ip is tested::

is_multicast(IPv4Interface("239.1.2.3/32"))   # True

That matters because this is the gatekeeper :func:join_group and :func:leave_group use: testing the interface object directly asks whether a network is multicast, which it never is.

A v4-mapped address is judged as the v4 address inside it, the same on every Python (the stdlib delegates only from 3.13). A mapped group is a real group -- it is how a dual-stack listener sees one.

:raises NetimpsValueError: for text that is no address. :raises TypeError: for a network or a value of another type.

Source code in src/netimps/_multicast.py
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
def is_multicast(address: "IPAddressLike") -> bool:
    """True if ``address`` is a multicast group (``224.0.0.0/4`` or ``ff00::/8``).

    ::

        is_multicast("224.0.0.251")   # True  -- mDNS
        is_multicast("ff02::fb")      # True
        is_multicast("10.0.0.1")      # False

    Accepts an address as text, an ``int``, packed ``bytes`` or an address
    object, and an :class:`IPv4Interface`/:class:`IPv6Interface`, whose ``.ip``
    is tested::

        is_multicast(IPv4Interface("239.1.2.3/32"))   # True

    That matters because this is the gatekeeper :func:`join_group` and
    :func:`leave_group` use: testing the interface object directly asks whether
    a *network* is multicast, which it never is.

    A v4-mapped address is judged as the v4 address inside it, the same on
    every Python (the stdlib delegates only from 3.13). A mapped group is a
    real group -- it is how a dual-stack listener sees one.

    :raises NetimpsValueError: for text that is no address.
    :raises TypeError: for a network or a value of another type.
    """
    return bool(unmap(_as_address(address)).is_multicast)

join_group(sock, group, *, interface=None, cache=False)

Join group on sock, optionally via a specific interface.

interface accepts an :class:Interface, a MAC, an adapter name or a local address. Without one the kernel chooses by routing table, which on a host with VMs or a VPN is often the wrong adapter -- and the failure is silent: the socket simply never receives.

The spec is resolved per family: to a local address for an IPv4 group, to an interface index for an IPv6 one, since that is what each mreq carries.

Raises :class:ValueError for a non-multicast group, or for an interface that resolves to no usable address (IPv4) or no index (IPv6), and :class:OSError if the kernel rejects the join.

cache is :func:get_interfaces's argument: with True or a TTL in seconds, resolving interface reuses a recent enumeration instead of making the syscall. False (the default) enumerates on each call.

Source code in src/netimps/_multicast.py
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
def join_group(
    sock: "_socket.socket",
    group: str,
    *,
    interface: "InterfaceLike" = None,
    cache: "Union[bool, float]" = False,
) -> None:
    """Join ``group`` on ``sock``, optionally via a specific ``interface``.

    ``interface`` accepts an :class:`Interface`, a MAC, an adapter name or a
    local address. Without one the kernel chooses by routing table, which on a
    host with VMs or a VPN is often the wrong adapter -- and the failure is
    silent: the socket simply never receives.

    The spec is resolved per family: to a local **address** for an IPv4
    group, to an interface **index** for an IPv6 one, since that is what each
    ``mreq`` carries.

    Raises :class:`ValueError` for a non-multicast group, or for an interface
    that resolves to no usable address (IPv4) or no index (IPv6), and
    :class:`OSError` if the kernel rejects the join.

    ``cache`` is :func:`get_interfaces`'s argument: with ``True`` or a TTL in
    seconds, resolving *interface* reuses a recent enumeration instead of
    making the syscall. ``False`` (the default) enumerates on each call.
    """
    _require_group(group)

    ipv6 = ":" in group
    request = _membership_request(group, interface, ipv6, cache)
    if ipv6:
        sock.setsockopt(_socket.IPPROTO_IPV6, _socket.IPV6_JOIN_GROUP, request)
    else:
        sock.setsockopt(_socket.IPPROTO_IP, _socket.IP_ADD_MEMBERSHIP, request)

leave_group(sock, group, *, interface=None, cache=False)

Leave group on sock. The inverse of :func:join_group.

Closing the socket drops membership too, so this is only needed to leave a group while keeping the socket open. cache is :func:join_group's.

Source code in src/netimps/_multicast.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def leave_group(
    sock: "_socket.socket",
    group: str,
    *,
    interface: "InterfaceLike" = None,
    cache: "Union[bool, float]" = False,
) -> None:
    """Leave ``group`` on ``sock``. The inverse of :func:`join_group`.

    Closing the socket drops membership too, so this is only needed to leave a
    group while keeping the socket open. ``cache`` is :func:`join_group`'s.
    """
    _require_group(group)

    ipv6 = ":" in group
    request = _membership_request(group, interface, ipv6, cache)
    if ipv6:
        sock.setsockopt(_socket.IPPROTO_IPV6, _socket.IPV6_LEAVE_GROUP, request)
    else:
        sock.setsockopt(_socket.IPPROTO_IP, _socket.IP_DROP_MEMBERSHIP, request)

multicast_socket(group=None, port=0, *, interface=None, ttl=1, loop=True, bind=True, reuse=True, ipv6=None, cache=False)

Return a UDP socket configured for multicast, joined to group.

The whole setup in one call::

sock = multicast_socket("224.0.0.251", 5353)     # mDNS listener
data, sender = sock.recvfrom(2048)

sender = multicast_socket(ttl=32, bind=False)    # send-only, off-link
sender.sendto(b"hello", ("239.1.2.3", 9999))

:param group: group to join, or a list of them. None configures the socket for sending without joining anything. :param port: local port to bind. 0 picks a free one; a listener must pass the port the senders use. :param interface: :class:Interface, MAC, adapter name or local address to join through. Strongly recommended on multi-homed hosts -- the routing-table default is frequently the wrong adapter. Pins outgoing traffic as well as the joins (IP_MULTICAST_IF for IPv4, IPV6_MULTICAST_IF for IPv6), so sends and receives cannot end up on different adapters. :param ttl: hop limit for outgoing datagrams. The default of 1 keeps traffic on the local link; raise it deliberately to cross routers. :param loop: whether this host receives its own transmissions. True (the default) is what you want when sender and listener share a host. :param bind: bind to port. Binding to "" rather than the group address, because binding to the group fails on Windows. :param reuse: set SO_REUSEADDR (plus SO_REUSEPORT where it exists), so several listeners can share the port. SO_REUSEPORT is absent on Windows and is skipped there rather than raising.

:param ipv6: force the address family. None (the default) takes it from group, which is what you want whenever there is one::

    multicast_socket(ttl=32, bind=False)              # IPv4 sender
    multicast_socket(ttl=32, bind=False, ipv6=True)   # IPv6 sender

It exists for the send-only case above, where there is no group to
infer from: without it that socket is IPv4, and an IPv6 sender would
be unreachable through this function. Passing a value that
contradicts ``group`` raises rather than quietly winning.

:param cache: :func:join_group's: reuse a recent enumeration while interface is resolved, for every group and for the outgoing adapter.

The caller owns the socket and should close it; closing drops membership. Raises :class:ValueError for a non-multicast group, for groups of mixed address families (one socket has one family -- open two), for an ipv6 that contradicts group, and :class:OSError if binding or joining fails.

Source code in src/netimps/_multicast.py
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
def multicast_socket(
    group: "Union[str, List[str], None]" = None,
    port: int = 0,
    *,
    interface: "InterfaceLike" = None,
    ttl: int = 1,
    loop: bool = True,
    bind: bool = True,
    reuse: bool = True,
    ipv6: "Optional[bool]" = None,
    cache: "Union[bool, float]" = False,
) -> "_socket.socket":
    """Return a UDP socket configured for multicast, joined to ``group``.

    The whole setup in one call::

        sock = multicast_socket("224.0.0.251", 5353)     # mDNS listener
        data, sender = sock.recvfrom(2048)

        sender = multicast_socket(ttl=32, bind=False)    # send-only, off-link
        sender.sendto(b"hello", ("239.1.2.3", 9999))

    :param group: group to join, or a list of them. ``None`` configures the
        socket for sending without joining anything.
    :param port: local port to bind. ``0`` picks a free one; a listener must
        pass the port the senders use.
    :param interface: :class:`Interface`, MAC, adapter name or local address to
        join through. **Strongly recommended on multi-homed hosts** -- the
        routing-table default is frequently the wrong adapter. Pins outgoing
        traffic as well as the joins (``IP_MULTICAST_IF`` for IPv4,
        ``IPV6_MULTICAST_IF`` for IPv6), so sends and receives cannot end up
        on different adapters.
    :param ttl: hop limit for outgoing datagrams. The default of **1 keeps
        traffic on the local link**; raise it deliberately to cross routers.
    :param loop: whether this host receives its own transmissions. ``True``
        (the default) is what you want when sender and listener share a host.
    :param bind: bind to ``port``. Binding to ``""`` rather than the group
        address, because binding to the group fails on Windows.
    :param reuse: set ``SO_REUSEADDR`` (plus ``SO_REUSEPORT`` where it exists),
        so several listeners can share the port. ``SO_REUSEPORT`` is absent on
        Windows and is skipped there rather than raising.

    :param ipv6: force the address family. ``None`` (the default) takes it from
        ``group``, which is what you want whenever there is one::

            multicast_socket(ttl=32, bind=False)              # IPv4 sender
            multicast_socket(ttl=32, bind=False, ipv6=True)   # IPv6 sender

        It exists for the send-only case above, where there is no group to
        infer from: without it that socket is IPv4, and an IPv6 sender would
        be unreachable through this function. Passing a value that
        contradicts ``group`` raises rather than quietly winning.

    :param cache: :func:`join_group`'s: reuse a recent enumeration while
        *interface* is resolved, for every group and for the outgoing adapter.

    The caller owns the socket and should close it; closing drops membership.
    Raises :class:`ValueError` for a non-multicast group, for groups of mixed
    address families (one socket has one family -- open two), for an ``ipv6``
    that contradicts ``group``, and :class:`OSError` if binding or joining
    fails.
    """
    groups = [group] if isinstance(group, str) else list(group or [])
    families = {":" in entry for entry in groups}
    if len(families) > 1:
        # One socket has one family. Picking either and letting the other join
        # fail surfaces as an opaque OSError from deep inside setsockopt, which
        # says nothing about the real mistake.
        raise ValueError(
            "groups must be all IPv4 or all IPv6, got %r -- one socket has one "
            "address family; open two" % (groups,)
        )
    if ipv6 is None:
        # No groups at all is the send-only case, and `any()` over an empty list
        # is False: the family would silently be IPv4, and a send-only socket
        # could never be given an IPv6 hop limit or reach an IPv6 group.
        ipv6 = bool(families and families.pop())
    family = _socket.AF_INET6 if ipv6 else _socket.AF_INET
    if groups and any((":" in entry) != ipv6 for entry in groups):
        raise ValueError(
            "ipv6=%r contradicts the address family of %r" % (ipv6, groups)
        )

    sock = _socket.socket(family, _socket.SOCK_DGRAM)
    try:
        if reuse:
            sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
            # Absent on Windows; setting it unconditionally would raise there.
            reuse_port = getattr(_socket, "SO_REUSEPORT", None)
            if reuse_port is not None:
                try:
                    sock.setsockopt(_socket.SOL_SOCKET, reuse_port, 1)
                except OSError:
                    pass  # present but refused (some kernels) -- not fatal

        if ipv6:
            sock.setsockopt(_socket.IPPROTO_IPV6, _socket.IPV6_MULTICAST_HOPS, ttl)
            sock.setsockopt(
                _socket.IPPROTO_IPV6, _socket.IPV6_MULTICAST_LOOP, int(loop)
            )
        else:
            sock.setsockopt(_socket.IPPROTO_IP, _socket.IP_MULTICAST_TTL, ttl)
            sock.setsockopt(_socket.IPPROTO_IP, _socket.IP_MULTICAST_LOOP, int(loop))

        # Pin outgoing traffic to the chosen adapter as well as incoming, or
        # sends leave by the default route while joins listen elsewhere. The
        # option is per-family: IPv4 names the adapter by local address, IPv6
        # by interface index.
        if interface is not None:
            if ipv6:
                index = _interface_index(interface, cache=cache)
                if index:
                    sock.setsockopt(
                        _socket.IPPROTO_IPV6,
                        _socket.IPV6_MULTICAST_IF,
                        _struct.pack("@I", index),
                    )
            else:
                outgoing = _interface_address(interface, want_ipv6=False, cache=cache)
                if outgoing is not None:
                    sock.setsockopt(
                        _socket.IPPROTO_IP,
                        _socket.IP_MULTICAST_IF,
                        _socket.inet_aton(str(outgoing)),
                    )

        if bind:
            # "" rather than the group address: binding to the group works on
            # Linux but fails on Windows.
            sock.bind(("", port))

        for entry in groups:
            join_group(sock, entry, interface=interface, cache=cache)
    except BaseException:
        sock.close()
        raise
    return sock

aretry(func, attempts=3, *, delay=0.5, multiplier=2.0, max_delay=30.0, jitter=0.1, retryable=DEFAULT_RETRYABLE, on_retry=None, jitter_seconds=None, symmetric=False) async

:func:retry for a coroutine: await func(), waiting on the running loop.

::

result = await aretry(lambda: client.fetch(url))
result = await aretry(partial(fetch, url), attempts=5, delay=1.0)

func is called with no arguments and must return an awaitable (a coroutine function, or a lambda returning one); anything else raises :class:TypeError. Every other argument means what it means on :func:retry, is checked by the same rules before the first call, and the last exception is re-raised unwrapped. on_retry is a plain function.

The waits are asyncio.sleep on the running loop, so no thread is used and the loop keeps running. Cancelling the task cancels the call or the wait in progress and propagates CancelledError at once; retryable never catches it, whatever it names, unless it names BaseException.

Source code in src/netimps/_retry.py
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
async def aretry(
    func: "Callable[[], Awaitable[_T]]",
    attempts: int = 3,
    *,
    delay: float = 0.5,
    multiplier: float = 2.0,
    max_delay: float = 30.0,
    jitter: float = 0.1,
    retryable: "Tuple[Type[BaseException], ...]" = DEFAULT_RETRYABLE,
    on_retry: "Optional[Callable[[int, BaseException, float], None]]" = None,
    jitter_seconds: "Optional[float]" = None,
    symmetric: bool = False,
) -> "_T":
    """:func:`retry` for a coroutine: ``await func()``, waiting on the running loop.

    ::

        result = await aretry(lambda: client.fetch(url))
        result = await aretry(partial(fetch, url), attempts=5, delay=1.0)

    ``func`` is called with no arguments and must return an awaitable (a
    coroutine function, or a lambda returning one); anything else raises
    :class:`TypeError`. Every other argument means what it means on
    :func:`retry`, is checked by the same rules before the first call, and the
    last exception is re-raised unwrapped. ``on_retry`` is a plain function.

    The waits are ``asyncio.sleep`` on the running loop, so no thread is used
    and the loop keeps running. Cancelling the task cancels the call or the wait
    in progress and propagates ``CancelledError`` at once; ``retryable`` never
    catches it, whatever it names, unless it names ``BaseException``.
    """
    import asyncio

    delays = list(
        backoff_delays(
            attempts=attempts,
            delay=delay,
            multiplier=multiplier,
            max_delay=max_delay,
            jitter=jitter,
            jitter_seconds=jitter_seconds,
            symmetric=symmetric,
        )
    )

    for index in range(attempts):
        try:
            pending = func()
            if not hasattr(pending, "__await__"):
                raise TypeError(
                    "aretry needs a callable that returns an awaitable, got %r"
                    % (type(pending).__name__,)
                )
            return await pending
        except retryable as exc:
            if index >= len(delays):
                raise  # last attempt: surface the real error, unwrapped
            wait = delays[index]
            if on_retry is not None:
                on_retry(index + 1, exc, wait)
            if wait:
                await asyncio.sleep(wait)

    # Unreachable: the loop either returns or raises.
    raise AssertionError("retry loop exited without result")

backoff_delays(attempts=3, delay=0.5, *, multiplier=2.0, max_delay=30.0, jitter=0.1, jitter_seconds=None, symmetric=False)

Yield the delay before each retry -- attempts - 1 values.

Exposed separately so a caller driving its own loop (async, or with progress reporting) gets the same schedule without reimplementing it::

for wait in backoff_delays(attempts=5):
    ...

:param jitter: fraction of each delay to randomise, spreading retries so that many clients failing together do not resynchronise into a thundering herd. 0 disables it and makes the schedule exact. :param jitter_seconds: randomise by an absolute, symmetric amount instead -- uniform in [-jitter_seconds, +jitter_seconds], which is what RFC 2131 §4.1 (DHCPv4) specifies. Overrides jitter when set. The amplitude is capped at the current delay so the result cannot go negative before clamping; for RFC 2131's own schedule (4 s upward against a 1 s amplitude) that cap never engages. :param symmetric: make the fractional jitter spread both ways, delay * (1 ± jitter), which is what RFC 8415 §15 (DHCPv6) specifies as RT = 2*RTprev + RAND*RTprev.

Every argument is checked when the function is called, not at the first next(): attempts below 1, a negative delay, a multiplier below 1, a max_delay below delay and a jitter outside 0 to 1 raise :class:ValueError. :func:retry and :class:Backoff apply the same rules.

Delays are capped at max_delay. The default jitter is applied after the cap and only ever reduces the wait, so max_delay is a genuine ceiling -- the right default for generic retries, where overshooting a ceiling is worse than a slightly short wait.

In the two symmetric modes max_delay caps the base, not the final value, and a delay may exceed it by up to the jitter amplitude. That is what the specifications require: RFC 8415 applies its jitter after the cap (if RT > MRT: RT = MRT + RAND*MRT) and RFC 2131 randomises +/-1 s around its 64 s maximum. Clamping instead would leave those modes one-sided exactly at the cap, where a backed-off client spends nearly all of its time, which would defeat the point of choosing them. No mode ever returns a negative delay.

For a long-lived session that backs off on loss and resets on progress -- a retransmission timer rather than a one-shot schedule -- use :class:Backoff instead.

Source code in src/netimps/_retry.py
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 backoff_delays(
    attempts: int = 3,
    delay: float = 0.5,
    *,
    multiplier: float = 2.0,
    max_delay: float = 30.0,
    jitter: float = 0.1,
    jitter_seconds: "Optional[float]" = None,
    symmetric: bool = False,
) -> "Iterator[float]":
    """Yield the delay before each retry -- ``attempts - 1`` values.

    Exposed separately so a caller driving its own loop (async, or with
    progress reporting) gets the same schedule without reimplementing it::

        for wait in backoff_delays(attempts=5):
            ...

    :param jitter: fraction of each delay to randomise, spreading retries so
        that many clients failing together do not resynchronise into a
        thundering herd. ``0`` disables it and makes the schedule exact.
    :param jitter_seconds: randomise by an **absolute, symmetric** amount
        instead -- uniform in ``[-jitter_seconds, +jitter_seconds]``, which is
        what **RFC 2131 §4.1** (DHCPv4) specifies. Overrides ``jitter`` when
        set. The amplitude is capped at the current delay so the result cannot
        go negative before clamping; for RFC 2131's own schedule (4 s upward
        against a 1 s amplitude) that cap never engages.
    :param symmetric: make the fractional ``jitter`` spread **both** ways,
        ``delay * (1 ± jitter)``, which is what **RFC 8415 §15** (DHCPv6)
        specifies as ``RT = 2*RTprev + RAND*RTprev``.

    Every argument is checked when the function is called, not at the first
    ``next()``: ``attempts`` below 1, a negative ``delay``, a ``multiplier``
    below 1, a ``max_delay`` below ``delay`` and a ``jitter`` outside 0 to 1
    raise :class:`ValueError`. :func:`retry` and :class:`Backoff` apply the
    same rules.

    Delays are capped at ``max_delay``. **The default jitter is applied after
    the cap and only ever reduces the wait**, so ``max_delay`` is a genuine
    ceiling -- the right default for generic retries, where overshooting a
    ceiling is worse than a slightly short wait.

    **In the two symmetric modes ``max_delay`` caps the base, not the final
    value**, and a delay may exceed it by up to the jitter amplitude. That is
    what the specifications require: RFC 8415 applies its jitter after the cap
    (``if RT > MRT: RT = MRT + RAND*MRT``) and RFC 2131 randomises +/-1 s
    around its 64 s maximum. Clamping instead would leave those modes one-sided
    exactly at the cap, where a backed-off client spends nearly all of its
    time, which would defeat the point of choosing them. No mode ever returns a
    negative delay.

    For a long-lived session that backs off on loss and **resets on progress**
    -- a retransmission timer rather than a one-shot schedule -- use
    :class:`Backoff` instead.
    """
    if attempts < 1:
        raise ValueError("attempts must be at least 1, got %r" % (attempts,))
    base, growth, ceiling, fraction, amplitude = _normalise(
        delay, multiplier, max_delay, jitter, jitter_seconds
    )
    return _delays(attempts, base, growth, ceiling, fraction, amplitude, symmetric)

retry(func, attempts=3, *, delay=0.5, multiplier=2.0, max_delay=30.0, jitter=0.1, retryable=DEFAULT_RETRYABLE, on_retry=None, jitter_seconds=None, symmetric=False)

Call func(), retrying transient failures with exponential backoff.

::

result = retry(lambda: client.fetch(url))
result = retry(fetch, attempts=5, delay=1.0)

Returns whatever func returns. If every attempt fails, the last exception is re-raised -- not wrapped -- so the traceback still points at the real problem.

:param retryable: exception types worth another attempt. Defaults to OSError, which covers the socket family and so also :class:ResolutionError, an outage worth another attempt. Anything else propagates immediately: a ValueError means the call is malformed and will fail identically next time. :class:NoAnswerError (a name or record that does not exist, from check=True) is an OSError too and so is retried by default; a caller who does not want that names the types to retry instead of OSError itself, for example retryable=(ConnectionError, TimeoutError). :param on_retry: called as (attempt, exception, next_delay) before each wait -- the hook for logging, since this deliberately does no logging of its own.

attempts counts total calls, not retries: attempts=1 calls once and never sleeps. Delay grows by multiplier each round, capped at max_delay, with jitter applied to avoid a thundering herd.

The schedule arguments are checked before the first call, by the rules of :func:backoff_delays; attempts below 1 raises :class:ValueError.

Synchronous by design -- it blocks. For async, drive :func:backoff_delays from your own loop.

Source code in src/netimps/_retry.py
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
def retry(
    func: "Callable[[], _T]",
    attempts: int = 3,
    *,
    delay: float = 0.5,
    multiplier: float = 2.0,
    max_delay: float = 30.0,
    jitter: float = 0.1,
    retryable: "Tuple[Type[BaseException], ...]" = DEFAULT_RETRYABLE,
    on_retry: "Optional[Callable[[int, BaseException, float], None]]" = None,
    jitter_seconds: "Optional[float]" = None,
    symmetric: bool = False,
) -> "_T":
    """Call ``func()``, retrying transient failures with exponential backoff.

    ::

        result = retry(lambda: client.fetch(url))
        result = retry(fetch, attempts=5, delay=1.0)

    Returns whatever ``func`` returns. If every attempt fails, **the last
    exception is re-raised** -- not wrapped -- so the traceback still points at
    the real problem.

    :param retryable: exception types worth another attempt. Defaults to
        ``OSError``, which covers the socket family and so also
        :class:`ResolutionError`, an outage worth another attempt. **Anything
        else propagates immediately**: a ``ValueError`` means the call is
        malformed and will fail identically next time. :class:`NoAnswerError`
        (a name or record that does not exist, from ``check=True``) is an
        ``OSError`` too and so is retried by default; a caller who does not want
        that names the types to retry instead of ``OSError`` itself, for
        example ``retryable=(ConnectionError, TimeoutError)``.
    :param on_retry: called as ``(attempt, exception, next_delay)`` before each
        wait -- the hook for logging, since this deliberately does no logging
        of its own.

    ``attempts`` counts *total* calls, not retries: ``attempts=1`` calls once
    and never sleeps. Delay grows by ``multiplier`` each round, capped at
    ``max_delay``, with ``jitter`` applied to avoid a thundering herd.

    The schedule arguments are checked before the first call, by the rules of
    :func:`backoff_delays`; ``attempts`` below 1 raises :class:`ValueError`.

    Synchronous by design -- it blocks. For async, drive :func:`backoff_delays`
    from your own loop.
    """
    delays = list(
        backoff_delays(
            attempts=attempts,
            delay=delay,
            multiplier=multiplier,
            max_delay=max_delay,
            jitter=jitter,
            jitter_seconds=jitter_seconds,
            symmetric=symmetric,
        )
    )

    for index in range(attempts):
        try:
            return func()
        except retryable as exc:
            if index >= len(delays):
                raise  # last attempt: surface the real error, unwrapped
            wait = delays[index]
            if on_retry is not None:
                on_retry(index + 1, exc, wait)
            if wait:
                _sleep(wait)

    # Unreachable: the loop either returns or raises.
    raise AssertionError("retry loop exited without result")

CMSG_LEN(length)

Bytes one cmsg of length payload occupies, header included.

Native where CPython provides it; computed from WSACMSGHDR and pointer alignment on Windows.

Source code in src/netimps/_msg/_dispatch.py
101
102
103
104
105
106
107
108
109
110
111
112
@_mark
def CMSG_LEN(length: int) -> int:
    """Bytes one cmsg of ``length`` payload occupies, header included.

    Native where CPython provides it; computed from ``WSACMSGHDR`` and pointer
    alignment on Windows.
    """
    if _NATIVE_CMSG_LEN is not None:
        return _NATIVE_CMSG_LEN(length)
    if _winsock_module is not None:
        return _winsock_module.CMSG_LEN(length)
    return _unsupported("CMSG_LEN")

CMSG_SPACE(length)

Buffer space one cmsg of length payload needs, padding included.

This is the one to size an ancbufsize with: the difference from :func:CMSG_LEN is the trailing pad that lets a following header start aligned, and leaving it out is how a second cmsg gets quietly truncated.

Source code in src/netimps/_msg/_dispatch.py
115
116
117
118
119
120
121
122
123
124
125
126
127
@_mark
def CMSG_SPACE(length: int) -> int:
    """Buffer space one cmsg of ``length`` payload needs, padding included.

    This is the one to size an ``ancbufsize`` with: the difference from
    :func:`CMSG_LEN` is the trailing pad that lets a following header start
    aligned, and leaving it out is how a second cmsg gets quietly truncated.
    """
    if _NATIVE_CMSG_SPACE is not None:
        return _NATIVE_CMSG_SPACE(length)
    if _winsock_module is not None:
        return _winsock_module.CMSG_SPACE(length)
    return _unsupported("CMSG_SPACE")

has_recvmsg()

Whether :func:recvmsg and :func:sendmsg can actually run here.

True on POSIX, and on Windows when the Winsock bindings loaded. Prefer this to probing :mod:socket for a method name, which answers a different question once the patch is installed.

Source code in src/netimps/_msg/_dispatch.py
91
92
93
94
95
96
97
98
def has_recvmsg() -> bool:
    """Whether :func:`recvmsg` and :func:`sendmsg` can actually run here.

    ``True`` on POSIX, and on Windows when the Winsock bindings loaded. Prefer
    this to probing :mod:`socket` for a method name, which answers a different
    question once the patch is installed.
    """
    return _NATIVE_RECVMSG is not None or _winsock_module is not None

recvmsg(sock, bufsize, ancbufsize=0, flags=0)

Receive a datagram with its ancillary data, on any platform.

Same signature and same 4-tuple as :meth:socket.socket.recvmsg: (data, ancdata, msg_flags, address), where ancdata is a list of (cmsg_level, cmsg_type, cmsg_data).

address is (host, port) for AF_INET and (host, port, flowinfo, scope_id) for AF_INET6.

:raises OSError: with ENOTSUP where neither CPython nor Winsock can serve it. Receiving errors propagate as the usual OSError subclasses -- :class:BlockingIOError on an empty non-blocking socket, on Windows as well as POSIX.

Source code in src/netimps/_msg/_dispatch.py
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
def recvmsg(
    sock: "Any",
    bufsize: int,
    ancbufsize: int = 0,
    flags: int = 0,
) -> "Tuple[bytes, List[Tuple[int, int, bytes]], int, Optional[Any]]":
    """Receive a datagram with its ancillary data, on any platform.

    Same signature and same 4-tuple as :meth:`socket.socket.recvmsg`:
    ``(data, ancdata, msg_flags, address)``, where ``ancdata`` is a list of
    ``(cmsg_level, cmsg_type, cmsg_data)``.

    ``address`` is ``(host, port)`` for ``AF_INET`` and
    ``(host, port, flowinfo, scope_id)`` for ``AF_INET6``.

    :raises OSError: with ``ENOTSUP`` where neither CPython nor Winsock can
        serve it. Receiving errors propagate as the usual ``OSError``
        subclasses -- :class:`BlockingIOError` on an empty non-blocking socket,
        on Windows as well as POSIX.
    """
    if _NATIVE_RECVMSG is not None:
        return _NATIVE_RECVMSG(sock, bufsize, ancbufsize, flags)
    if _winsock_module is not None:
        return _winsock_module.recvmsg(sock, bufsize, ancbufsize, flags)
    return _unsupported("recvmsg")

sendmsg(sock, buffers, ancdata=(), flags=0, address=None)

Send a datagram with ancillary data, on any platform.

Same signature as :meth:socket.socket.sendmsg: buffers is a sequence of bytes-like objects (not a bare bytes), ancdata a sequence of (cmsg_level, cmsg_type, cmsg_data), and the return is the byte count sent.

A v6 address accepts a 2-, 3- or 4-tuple, and a %zone suffix on the host is honoured when scope_id is absent.

:raises OSError: with ENOTSUP where neither CPython nor Winsock can serve it.

Source code in src/netimps/_msg/_dispatch.py
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
def sendmsg(
    sock: "Any",
    buffers: "Sequence[bytes]",
    ancdata: "Iterable[Tuple[int, int, bytes]]" = (),
    flags: int = 0,
    address: "Optional[Any]" = None,
) -> int:
    """Send a datagram with ancillary data, on any platform.

    Same signature as :meth:`socket.socket.sendmsg`: ``buffers`` is a sequence
    of bytes-like objects (not a bare ``bytes``), ``ancdata`` a sequence of
    ``(cmsg_level, cmsg_type, cmsg_data)``, and the return is the byte count
    sent.

    A v6 ``address`` accepts a 2-, 3- or 4-tuple, and a ``%zone`` suffix on the
    host is honoured when ``scope_id`` is absent.

    :raises OSError: with ``ENOTSUP`` where neither CPython nor Winsock can
        serve it.
    """
    if _NATIVE_SENDMSG is not None:
        return _NATIVE_SENDMSG(sock, buffers, ancdata, flags, address)
    if _winsock_module is not None:
        return _winsock_module.sendmsg(sock, buffers, ancdata, flags, address)
    return _unsupported("sendmsg")

is_socket_patched()

Whether netimps has added anything to :mod:socket right now.

A function rather than a constant because the answer changes when :func:patch_socket_module is called, and a module-level flag copied by from netimps import ... would go stale at the first toggle.

Source code in src/netimps/_msg/_patch.py
37
38
39
40
41
42
43
44
def is_socket_patched() -> bool:
    """Whether netimps has added anything to :mod:`socket` right now.

    A function rather than a constant because the answer changes when
    :func:`patch_socket_module` is called, and a module-level flag copied by
    ``from netimps import ...`` would go stale at the first toggle.
    """
    return bool(_installed)

patch_socket_module(enable=True, *, iov_max=None)

Add recvmsg/sendmsg/CMSG_LEN/CMSG_SPACE where missing.

netimps calls this on import, so sock.recvmsg(...) works on Windows without the caller changing anything. Returns the names it changed, newest call only -- an empty list means there was nothing to do.

Idempotent in both directions, and strictly additive: a name the platform already provides is never replaced, so on Linux and macOS this is a verified no-op. A name another copy of this package installed is taken over, so the second import of the package in a process owns the patch. enable=False removes exactly what was installed, leaving a natively-provided name alone.

On a platform with no os.sysconf this also installs one -- see :func:_shim_sysconf for why that is part of the same patch rather than a separate feature.

:param enable: install when true, restore the original state when false. :param iov_max: what the installed os.sysconf reports for SC_IOV_MAX. Default :data:_IOV_MAX (1024, matching Linux).

**This is a batch size, not a ceiling, and it is a choice rather than a
measurement** -- which is why it is a parameter. Windows reports no
buffer-count limit anywhere (no ``IOV``-like socket name;
``WSASend``'s ``dwBufferCount`` is a bare ``DWORD``, and 1048576
buffers in one call were accepted on build 28000), and the system limit
is not settable on POSIX either: Linux's is
``#define UIO_MAXIOV 1024`` in ``linux/uio.h``, with no sysctl and no
``/proc`` entry. So there is nothing to query and nothing to set; this
is the only knob that exists. Raise it if you gather more than 1024
buffers per call and want them in one syscall.

:returns: the names added or removed by this call. :raises ValueError: for an iov_max below 1.

Source code in src/netimps/_msg/_patch.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
 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
def patch_socket_module(
    enable: bool = True, *, iov_max: "Optional[int]" = None
) -> "List[str]":
    """Add ``recvmsg``/``sendmsg``/``CMSG_LEN``/``CMSG_SPACE`` where missing.

    netimps calls this on import, so ``sock.recvmsg(...)`` works on Windows
    without the caller changing anything. Returns the names it changed, newest
    call only -- an empty list means there was nothing to do.

    Idempotent in both directions, and strictly additive: a name the platform
    already provides is never replaced, so on Linux and macOS this is a
    verified no-op. A name another copy of this package installed is taken
    over, so the second import of the package in a process owns the patch.
    ``enable=False`` removes exactly what was installed, leaving a
    natively-provided name alone.

    On a platform with no ``os.sysconf`` this also installs one -- see
    :func:`_shim_sysconf` for why that is part of the same patch rather than a
    separate feature.

    :param enable: install when true, restore the original state when false.
    :param iov_max: what the installed ``os.sysconf`` reports for
        ``SC_IOV_MAX``. Default :data:`_IOV_MAX` (1024, matching Linux).

        **This is a batch size, not a ceiling, and it is a choice rather than a
        measurement** -- which is why it is a parameter. Windows reports no
        buffer-count limit anywhere (no ``IOV``-like socket name;
        ``WSASend``'s ``dwBufferCount`` is a bare ``DWORD``, and 1048576
        buffers in one call were accepted on build 28000), and the system limit
        is not settable on POSIX either: Linux's is
        ``#define UIO_MAXIOV 1024`` in ``linux/uio.h``, with no sysctl and no
        ``/proc`` entry. So there is nothing to query and nothing to set; this
        is the only knob that exists. Raise it if you gather more than 1024
        buffers per call and want them in one syscall.
    :returns: the names added or removed by this call.
    :raises ValueError: for an ``iov_max`` below 1.
    """
    changed: "List[str]" = []

    if enable and iov_max is not None and iov_max < 1:
        raise ValueError("iov_max must be at least 1, got %r" % (iov_max,))

    if not enable:
        # Reset the tunable too: leaving a previous call's iov_max in place
        # would make a later re-install silently inherit it.
        _SYSCONF_VALUES["SC_IOV_MAX"] = _IOV_MAX
        for name in list(_installed):
            target, attribute = _installed.pop(name)
            try:
                delattr(target, attribute)
            except AttributeError:  # pragma: no cover - already gone
                pass
            changed.append(name)
        return changed

    if not has_recvmsg():
        # Nothing to offer: leave `socket` exactly as it was rather than
        # installing a function whose only behaviour is to raise.
        return changed

    for name, value in (
        ("socket.recvmsg", _patched_recvmsg),
        ("socket.sendmsg", _patched_sendmsg),
    ):
        attribute = name.split(".", 1)[1]
        if name in _installed or not _free(_socket.socket, attribute):
            continue
        setattr(_socket.socket, attribute, value)
        _installed[name] = (_socket.socket, attribute)
        changed.append(name)

    # `os.sysconf` is part of this patch, not a separate feature: it is needed
    # only because installing `sendmsg` above makes the stdlib's
    # "sendmsg implies POSIX implies os.sysconf" inference wrong. Installing and
    # removing them together is what keeps that coupling honest -- see
    # `_shim_sysconf`.
    if iov_max is not None:
        _SYSCONF_VALUES["SC_IOV_MAX"] = int(iov_max)
        if "os.sysconf_names" in _installed:
            # Keep the advertised table in step with what sysconf answers.
            _os.sysconf_names.update(_SYSCONF_VALUES)  # type: ignore[attr-defined]

    if "os.sysconf" not in _installed and _free(_os, "sysconf"):
        earlier_copy = hasattr(_os, "sysconf")
        setattr(_os, "sysconf", _shim_sysconf)
        _installed["os.sysconf"] = (_os, "sysconf")
        changed.append("os.sysconf")
        if earlier_copy or not hasattr(_os, "sysconf_names"):
            setattr(_os, "sysconf_names", dict(_SYSCONF_VALUES))
            _installed["os.sysconf_names"] = (_os, "sysconf_names")
            changed.append("os.sysconf_names")

    # A separate loop with its own names rather than reusing `name, value`: the
    # two tables hold different callable shapes, and mypy types a reused loop
    # variable from whichever came first.
    for helper, function in (
        ("CMSG_LEN", CMSG_LEN),
        ("CMSG_SPACE", CMSG_SPACE),
    ):
        if helper in _installed or not _free(_socket, helper):
            continue
        setattr(_socket, helper, function)
        _installed[helper] = (_socket, helper)
        changed.append(helper)

    return changed

has_pktinfo(family=_socket.AF_INET)

Whether a UDP socket of family can report each datagram's arrival interface on this host.

The question a server asks before deciding how to bind: with packet info, one wildcard socket serves every address and still knows which one a datagram reached; without it the wildcard has to be expanded into a socket per address, which on Linux then receives no broadcasts at all.

::

if has_pktinfo():
    socks = [bind("", 67)]
else:
    socks = [bind(str(a), 67) for a in addresses]

Decided by asking a socket, not by testing a name, which is the only reliable way: getattr(socket, "IP_PKTINFO", None) is None on CPython 3.9-3.11 on every platform -- the constant arrived in 3.12 -- while the kernel supported it throughout. A name test therefore reports "no" on a platform that works, which silently pushes a server onto the per-address path it did not need. :class:UDPEndpoint already used the documented per-platform values rather than the constants for exactly this reason, and this asks it the same way the endpoint does, on a throwaway socket.

The answer is cached per family for the life of the process, since it is a property of the platform and the interpreter rather than of any socket.

family is 4 or AF_INET, 6 or AF_INET6; anything else raises :class:ValueError, since a silent False for a misspelt family is a wrong answer.

Returns False rather than raising if a socket of that family cannot even be created -- a v6 answer on a host with IPv6 disabled is "no", not an error.

Source code in src/netimps/_udp/_support.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
def has_pktinfo(family: int = _socket.AF_INET) -> bool:
    """Whether a UDP socket of *family* can report each datagram's arrival
    interface on this host.

    The question a server asks **before** deciding how to bind: with packet
    info, one wildcard socket serves every address and still knows which one a
    datagram reached; without it the wildcard has to be expanded into a socket
    per address, which on Linux then receives no broadcasts at all.

    ::

        if has_pktinfo():
            socks = [bind("", 67)]
        else:
            socks = [bind(str(a), 67) for a in addresses]

    **Decided by asking a socket, not by testing a name**, which is the only
    reliable way: ``getattr(socket, "IP_PKTINFO", None)`` is ``None`` on CPython
    3.9-3.11 on *every* platform -- the constant arrived in 3.12 -- while the
    kernel supported it throughout. A name test therefore reports "no" on a
    platform that works, which silently pushes a server onto the per-address
    path it did not need. :class:`UDPEndpoint` already used the documented
    per-platform values rather than the constants for exactly this reason, and
    this asks it the same way the endpoint does, on a throwaway socket.

    The answer is **cached per family** for the life of the process, since it is
    a property of the platform and the interpreter rather than of any socket.

    *family* is ``4`` or ``AF_INET``, ``6`` or ``AF_INET6``; anything else raises
    :class:`ValueError`, since a silent ``False`` for a misspelt family is a
    wrong answer.

    Returns ``False`` rather than raising if a socket of that family cannot even
    be created -- a v6 answer on a host with IPv6 disabled is "no", not an
    error.
    """
    family = _required_family(family)
    cached = _PKTINFO_SUPPORT.get(family)
    if cached is not None:
        return cached

    try:
        probe = _socket.socket(family, _socket.SOCK_DGRAM)
    except OSError:
        # No socket of this family at all, so nothing to report an arrival on.
        _PKTINFO_SUPPORT[family] = False
        return False
    try:
        answer = bool(UDPEndpoint(probe).has_pktinfo)
    finally:
        probe.close()
    _PKTINFO_SUPPORT[family] = answer
    return answer

bind_listen(listen=None, default_ports=0, *, family=None, per_address=None, device_binding=True, allow_address_takeover=False, broadcast=False)

Bind the sockets listen names and return an endpoint for each, in order.

listen, default_ports and family are parse_listen's. An address is one socket; a wildcard is one socket that reports the arrival interface, or one for each address of the host when per_address is true (or None and the host reports no packet info); a wildcard limited to interfaces is one wildcard socket whose endpoint .interfaces are the adapters, bound to the device when device_binding allows and the platform can. Every socket is exclusive, and an IPv6 socket is IPv6 only.

A selector that matches no adapter, and a limited binding with per_address=True or on a host without packet info, raise NetimpsValueError before a socket is opened. A failure closes every socket this call opened and raises the error unchanged. What each kind of entry opens is set out in netimps/_listen/AGENTS.md.

Source code in src/netimps/_listen/_binding.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
def bind_listen(
    listen: "ListenLike" = None,
    default_ports: "Union[int, Sequence[int]]" = 0,
    *,
    family: "Optional[int]" = None,
    per_address: "Optional[bool]" = None,
    device_binding: bool = True,
    allow_address_takeover: bool = False,
    broadcast: bool = False,
) -> "Tuple[UDPEndpoint, ...]":
    """Bind the sockets `listen` names and return an endpoint for each, in order.

    ``listen``, ``default_ports`` and ``family`` are ``parse_listen``'s. An address
    is one socket; a wildcard is one socket that reports the arrival interface,
    or one for each address of the host when ``per_address`` is true (or ``None``
    and the host reports no packet info); a wildcard limited to interfaces is one
    wildcard socket whose endpoint ``.interfaces`` are the adapters, bound to the
    device when ``device_binding`` allows and the platform can. Every socket is
    exclusive, and an IPv6 socket is IPv6 only.

    A selector that matches no adapter, and a limited binding with
    ``per_address=True`` or on a host without packet info, raise
    ``NetimpsValueError`` before a socket is opened. A failure closes every
    socket this call opened and raises the error unchanged. What each kind of
    entry opens is set out in ``netimps/_listen/AGENTS.md``.
    """
    specs = parse_listen(listen, default_ports, family=family)
    clear_interface_cache()
    # Every refusal that needs no socket comes before the first one is opened.
    plans = [_plan(spec, per_address, device_binding) for spec in specs]
    opened: "List[UDPEndpoint]" = []
    held: "Set[Tuple[str, int]]" = set()
    warned: "List[bool]" = []
    try:
        for plan in plans:
            for host, port, adapters, device, pktinfo in plan:
                if (host, port) in held:
                    continue
                held.add((host, port))
                sock = _open(
                    host,
                    port,
                    device,
                    allow_address_takeover,
                    broadcast,
                    warned,
                )
                try:
                    endpoint = UDPEndpoint(sock, pktinfo=pktinfo, interfaces=adapters)
                except BaseException:
                    sock.close()
                    raise
                opened.append(endpoint)
    except BaseException:
        for endpoint in opened:
            try:
                endpoint.close()
            except Exception:  # closing is cleanup and must not mask the error
                LOGGER.debug("closing a listening socket failed", exc_info=True)
        raise
    return tuple(opened)

parse_listen(listen=None, default_ports=0, *, family=None)

The sockets listen names, each address and port once, in the order first written.

listen is None (the wildcard), text ("host", "host:port", "[::1]:69", "*", ":67", "eth1:67", a MAC, several joined by commas), an address, an Interface, a MACAddress, a ListenAddress, a (host, ports) pair, or a sequence of those. Text that is no address names an interface; a host name is never resolved. default_ports is an int or a sequence, given to each binding that names no port. family (AF_INET or AF_INET6) makes the wildcard forms and an interface binding that family's; None takes the wildcard forms as IPv4's.

Raises TypeError for a value of a type the grammar does not take and NetimpsValueError for anything else it refuses. The grammar in full is in netimps/_listen/AGENTS.md.

Source code in src/netimps/_listen/_grammar.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
def parse_listen(
    listen: "ListenLike" = None,
    default_ports: "Union[int, Sequence[int]]" = 0,
    *,
    family: "Optional[int]" = None,
) -> "Tuple[ListenAddress, ...]":
    """The sockets `listen` names, each address and port once, in the order first written.

    ``listen`` is ``None`` (the wildcard), text (``"host"``, ``"host:port"``,
    ``"[::1]:69"``, ``"*"``, ``":67"``, ``"eth1:67"``, a MAC, several joined by
    commas), an address, an ``Interface``, a ``MACAddress``, a ``ListenAddress``,
    a ``(host, ports)`` pair, or a sequence of those. Text that is no address
    names an interface; a host name is never resolved. ``default_ports`` is an
    int or a sequence, given to each binding that names no port. ``family``
    (``AF_INET`` or ``AF_INET6``) makes the wildcard forms and an interface
    binding that family's; ``None`` takes the wildcard forms as IPv4's.

    Raises ``TypeError`` for a value of a type the grammar does not take and
    ``NetimpsValueError`` for anything else it refuses. The grammar in full is in
    ``netimps/_listen/AGENTS.md``.
    """
    wanted = _family(family)
    ports = _default_ports(default_ports)
    limits: "Dict[Tuple[IPAddress, int], List[_Selector]]" = {}
    plain: "Set[Tuple[IPAddress, int]]" = set()
    for parsed in _iter_bindings(listen, wanted):
        if parsed.ports is None and not ports:
            raise NetimpsValueError(
                "%r names no port and there is no default port" % (parsed.label,)
            )
        for port in ports if parsed.ports is None else parsed.ports:
            key = (parsed.address, port)
            found = limits.setdefault(key, [])
            if not parsed.selectors:
                plain.add(key)
            for selector in parsed.selectors:
                if selector not in found:
                    found.append(selector)
    if not limits:
        raise NetimpsValueError("listen names no address: %r" % (listen,))
    return tuple(
        ListenAddress(address, port, () if (address, port) in plain else tuple(found))
        for (address, port), found in limits.items()
    )

bind(address='', port=0, *, family=None, kind=_socket.SOCK_DGRAM, reuse_address=True, allow_address_takeover=False, reuse_port=False, broadcast=False, connreset=None, interface=None, device=None, cache=False, options=(), listen=None)

Create, configure and bind a socket in one call.

The setup every server repeats, with the options that are easy to get wrong handled once::

sock = bind("", 67, broadcast=True)               # DHCP-style listener
sock = bind("127.0.0.1", 0, kind=SOCK_STREAM, listen=5)
sock = bind(port=5353, interface="eth0")          # pin to one adapter

:param address: local address to bind. "" (the default) is the wildcard, which is what a server almost always wants.

Accepts the same loose union as the rest of the package, not just a
``str``: an :class:`IPv4Address`/:class:`IPv6Address`, an
:class:`IPv4Interface`/:class:`IPv6Interface` (its ``.ip`` is used, since
the ``/prefix`` means nothing to ``bind``), a :class:`netimps.Host` or a
:class:`netimps.FQDN`: the value every other entry point in the package
takes, and not the raw ``TypeError`` the socket layer raises for it
("str, bytes or bytearray expected, not IPv4Address"). A *network*
raises :class:`TypeError`, because it has no single
address and guessing one (the network address? the first host?) would be
worse than refusing.

:param port: local port; 0 lets the OS choose. :param family: 4 or AF_INET, 6 or AF_INET6; anything else raises :class:ValueError. None (the default) takes the family from what was given: an IPv6 literal, or an interface whose address is IPv6, gives AF_INET6; an IPv4 literal gives AF_INET; a name gives AF_INET when it has an IPv4 address and AF_INET6 when it has only IPv6. The wildcard "" is IPv4: a wildcard says nothing about the family, and an IPv6 wildcard ("::") must be asked for. A family that contradicts the address fails in the socket layer, as it always has. :param interface: bind to this adapter's address instead of address. Accepts an :class:Interface, a MAC, an adapter name or an address -- the same union as ping(src=). Raises :class:ValueError if it cannot be resolved, rather than silently binding the wildcard. :param device: restrict the socket to this one device, so it receives only what arrives on it. Takes the same union as interface. The address stays what address says: with no address the wildcard is bound (IPv4 unless family says otherwise), which is what a broadcast listener on one adapter of a multi-homed host wants, and a socket bound to an adapter's address hears no broadcast on Linux. With an address too, the kernel applies both: the socket receives what is addressed to that address and arrived on the device. device and interface together raise :class:ValueError, since interface already stands for an address on an adapter. A device naming no local interface raises :class:ValueError and a platform with no device binding raises :class:DeviceBindingUnsupportedError, both before a socket is opened; :func:has_device_binding says which platforms can. Measured on Linux only: see the platform notes in the header. :param reuse_address: ask for the conventional "restart without waiting out TIME_WAIT" behaviour. What that takes differs by platform, so the flag is not a single socket option: POSIX gets SO_REUSEADDR, Windows gets SO_EXCLUSIVEADDRUSE. See below. :param allow_address_takeover: set SO_REUSEADDR on Windows too, where it means something else entirely. Default False; on POSIX it adds nothing, since reuse_address already sets exactly that option. :param reuse_port: share the port with other sockets that ask the same. POSIX sets SO_REUSEPORT. Windows has no such option: for a datagram socket the flag takes the address-sharing path (SO_REUSEADDR, as allow_address_takeover=True does), so two sockets that both pass it bind one port; there, sharing also lets another process take the port over. A stream socket on Windows is unchanged and stays exclusive. :param connreset: Windows only, and only for UDP. False turns SIO_UDP_CONNRESET off, so that an ICMP port-unreachable provoked by an earlier send stops being reported as :class:ConnectionResetError on a later, unrelated receive; True leaves the platform default (reporting on). None (the default) is False for a datagram socket and leaves any other socket alone. A no-op everywhere but Windows. See :func:disable_connreset. :param cache: reuse a recent enumeration of the adapters when interface or device has to be resolved, as :func:get_interfaces does: True for :data:INTERFACE_CACHE_TTL seconds or a number for that TTL. False (the default) enumerates on each call. Ignored when neither is given. :param options: extra (level, name, value) triples -- or :class:SocketOption values -- for anything not covered by the named arguments. :param listen: call listen(backlog) after binding. Ignored for datagram sockets, where it is meaningless.

.. warning:: SO_REUSEADDR is not the same option on Windows. On POSIX it only permits binding an address still in TIME_WAIT -- and only for a stream socket. TIME_WAIT is a TCP concept, so on a UDP socket the option's one remaining effect on Linux is to permit duplicate bindings of live sockets, which is traffic theft rather than a restart convenience. So this does not set it for SOCK_DGRAM; use reuse_port=True or allow_address_takeover=True to share a UDP port deliberately. On Windows it lets any process bind an addr:port another socket is already listening on, and the later binder can win subsequent connections -- reproduced on Windows 11, where a plain second bind was refused with EACCES while one through this function succeeded. So reuse_address=True sets SO_EXCLUSIVEADDRUSE there instead, which is the safe request with the same intent, and the literal option is available only by asking for it by its consequence: allow_address_takeover=True.

Raises :class:OSError if the bind fails, with the text of :func:bind_error_hint in its message when that function recognises the failure: str(exc) is already something a user can act on. An address that is taken is always :class:AddressInUseError; any other failure keeps its own :class:OSError subclass and errno. The socket is closed before the exception propagates, so a failed call leaks nothing.

Source code in src/netimps/_sockets/_bind.py
 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
def bind(
    address: "HostLike" = "",
    port: int = 0,
    *,
    family: "Optional[int]" = None,
    kind: int = _socket.SOCK_DGRAM,
    reuse_address: bool = True,
    allow_address_takeover: bool = False,
    reuse_port: bool = False,
    broadcast: bool = False,
    connreset: "Optional[bool]" = None,
    interface: "InterfaceLike" = None,
    device: "InterfaceLike" = None,
    cache: "Union[bool, float]" = False,
    options: "Iterable[Tuple[int, int, Any]]" = (),
    listen: "Optional[int]" = None,
) -> "_socket.socket":
    """Create, configure and bind a socket in one call.

    The setup every server repeats, with the options that are easy to get
    wrong handled once::

        sock = bind("", 67, broadcast=True)               # DHCP-style listener
        sock = bind("127.0.0.1", 0, kind=SOCK_STREAM, listen=5)
        sock = bind(port=5353, interface="eth0")          # pin to one adapter

    :param address: local address to bind. ``""`` (the default) is the
        wildcard, which is what a server almost always wants.

        Accepts the same loose union as the rest of the package, not just a
        ``str``: an :class:`IPv4Address`/:class:`IPv6Address`, an
        :class:`IPv4Interface`/:class:`IPv6Interface` (its ``.ip`` is used, since
        the ``/prefix`` means nothing to ``bind``), a :class:`netimps.Host` or a
        :class:`netimps.FQDN`: the value every other entry point in the package
        takes, and not the raw ``TypeError`` the socket layer raises for it
        ("str, bytes or bytearray expected, not IPv4Address"). A *network*
        raises :class:`TypeError`, because it has no single
        address and guessing one (the network address? the first host?) would be
        worse than refusing.
    :param port: local port; ``0`` lets the OS choose.
    :param family: ``4`` or ``AF_INET``, ``6`` or ``AF_INET6``; anything else
        raises :class:`ValueError`. ``None`` (the default) takes the
        family from what was given: an IPv6 literal, or an *interface* whose
        address is IPv6, gives ``AF_INET6``; an IPv4 literal gives ``AF_INET``;
        a name gives ``AF_INET`` when it has an IPv4 address and ``AF_INET6``
        when it has only IPv6. The wildcard ``""`` is IPv4: a wildcard says
        nothing about the family, and an IPv6 wildcard (``"::"``) must be asked
        for. A family that contradicts the address fails in the socket layer,
        as it always has.
    :param interface: bind to this adapter's address instead of ``address``.
        Accepts an :class:`Interface`, a MAC, an adapter name or an address --
        the same union as ``ping(src=)``. Raises :class:`ValueError` if it
        cannot be resolved, rather than silently binding the wildcard.
    :param device: restrict the socket to this one device, so it receives only
        what arrives on it. Takes the same union as ``interface``. **The address
        stays what ``address`` says**: with no address the wildcard is bound
        (IPv4 unless ``family`` says otherwise), which is what a broadcast
        listener on one adapter of a multi-homed host wants, and a socket bound
        to an adapter's *address* hears no broadcast on Linux. With an address
        too, the kernel applies both: the socket receives what is addressed to
        that address *and* arrived on the device. ``device`` and ``interface``
        together raise :class:`ValueError`, since ``interface`` already stands
        for an address on an adapter. A device naming no local interface raises
        :class:`ValueError` and a platform with no device binding raises
        :class:`DeviceBindingUnsupportedError`, both before a socket is
        opened; :func:`has_device_binding` says which platforms can. Measured
        on Linux only: see the platform notes in the header.
    :param reuse_address: ask for the conventional "restart without waiting
        out ``TIME_WAIT``" behaviour. **What that takes differs by platform**,
        so the flag is not a single socket option: POSIX gets
        ``SO_REUSEADDR``, Windows gets ``SO_EXCLUSIVEADDRUSE``. See below.
    :param allow_address_takeover: set ``SO_REUSEADDR`` on Windows too, where
        it means something else entirely. Default ``False``; on POSIX it adds
        nothing, since ``reuse_address`` already sets exactly that option.
    :param reuse_port: share the port with other sockets that ask the same.
        POSIX sets ``SO_REUSEPORT``. **Windows has no such option**: for a
        datagram socket the flag takes the address-sharing path
        (``SO_REUSEADDR``, as ``allow_address_takeover=True`` does), so two
        sockets that both pass it bind one port; there, sharing also lets
        another process take the port over. A stream socket on Windows is
        unchanged and stays exclusive.
    :param connreset: Windows only, and only for UDP. ``False`` turns
        ``SIO_UDP_CONNRESET`` off, so that an ICMP port-unreachable provoked by
        an earlier send stops being reported as
        :class:`ConnectionResetError` on a *later*, unrelated receive; ``True``
        leaves the platform default (reporting on). ``None`` (the default) is
        ``False`` for a datagram socket and leaves any other socket alone. A
        no-op everywhere but Windows. See :func:`disable_connreset`.
    :param cache: reuse a recent enumeration of the adapters when
        ``interface`` or ``device`` has to be resolved, as
        :func:`get_interfaces` does: ``True`` for
        :data:`INTERFACE_CACHE_TTL` seconds or a number for that TTL. ``False``
        (the default) enumerates on each call. Ignored when neither is given.
    :param options: extra ``(level, name, value)`` triples -- or
        :class:`SocketOption` values -- for anything not covered by the named
        arguments.
    :param listen: call ``listen(backlog)`` after binding. Ignored for
        datagram sockets, where it is meaningless.

    .. warning::
       **``SO_REUSEADDR`` is not the same option on Windows.** On POSIX it
       only permits binding an address still in ``TIME_WAIT`` -- and
       **only for a stream socket**. ``TIME_WAIT`` is a TCP concept, so on a
       UDP socket the option's one remaining effect on Linux is to permit
       duplicate bindings of *live* sockets, which is traffic theft rather
       than a restart convenience. So this does not set it for
       ``SOCK_DGRAM``; use ``reuse_port=True`` or
       ``allow_address_takeover=True`` to share a UDP port deliberately. On
       Windows it lets **any process** bind an ``addr:port`` another socket is
       already listening on, and the later binder can win subsequent
       connections -- reproduced on
       Windows 11, where a plain second bind was refused with ``EACCES`` while
       one through this function succeeded. So ``reuse_address=True`` sets
       ``SO_EXCLUSIVEADDRUSE`` there instead, which is the safe request with
       the same intent, and the literal option is available only by asking for
       it by its consequence: ``allow_address_takeover=True``.

    Raises :class:`OSError` if the bind fails, with the text of
    :func:`bind_error_hint` in its message when that function recognises the
    failure: ``str(exc)`` is already something a user can act on. An address
    that is taken is always :class:`AddressInUseError`; any other failure keeps
    its own :class:`OSError` subclass and ``errno``. The socket is closed before
    the exception propagates, so a failed call leaks nothing.
    """
    family = _family_argument(family)
    device_name: "Optional[str]" = None
    device_option: "Optional[_DeviceOption]" = None
    if device is not None:
        if interface is not None:
            raise ValueError(
                "device= and interface= both name an adapter: pass one (an "
                "address on the device goes in address=)"
            )
        # Both checks run before a socket exists, so a refused call leaks nothing.
        device_name = _device_name(device, cache)
        device_option = _device_option()
    # Coerced through the same helper `ping`, `resolve` and `UDPEndpoint.send`
    # use, so one union is accepted everywhere rather than this one entry point
    # being stricter than its neighbours.
    address = _dst_argument(address) if address != "" else ""
    # Read once: a generator is exhausted by the first pass.
    options = tuple(options)

    if interface is not None:
        resolved = _interface_address(
            interface,
            want_ipv6=None if family is None else family == _socket.AF_INET6,
            cache=cache,
        )
        if resolved is None:
            raise ValueError("cannot resolve interface %r to an address" % (interface,))
        address = str(resolved)
        if family is None:
            family = (
                _socket.AF_INET6
                if getattr(resolved, "version", 4) == 6
                else _socket.AF_INET
            )
        if getattr(resolved, "version", None) == 6 and resolved.is_link_local:
            # A link-local bind needs its zone: the same address can exist on
            # several adapters, so the kernel cannot tell which is meant.
            # Measured, POSIX refuses the bare form outright -- EINVAL on Linux,
            # "Can't assign requested address" on macOS -- while Windows accepts
            # it. The zone is attached here as `%index` and turned into the
            # sockaddr's numeric scope id by `_sockaddr_for_bind`, which is the
            # only spelling POSIX accepts.
            zone = _interface_index(interface, strict=False, cache=cache)
            if zone and "%" not in address:
                address = "%s%%%d" % (address, int(zone))

    if family is None:
        family = _infer_family(address, kind)

    sock = _socket.socket(family, kind)
    try:
        exclusive = getattr(_socket, "SO_EXCLUSIVEADDRUSE", None)
        # An explicit `(SOL_SOCKET, SO_REUSEADDR, nonzero)` in `options` is the
        # caller asking for takeover in so many words, so it counts as
        # `allow_address_takeover=True` rather than fighting it.
        #
        # Windows **refuses** `SO_REUSEADDR` on a socket that already carries
        # `SO_EXCLUSIVEADDRUSE`, with a bare `WSAEINVAL` and no indication which
        # of the two it objected to. Since this function started setting
        # `SO_EXCLUSIVEADDRUSE` for both values of `reuse_address`, the
        # stdlib-shaped spelling of "share this address" therefore stopped
        # working here, failing with "[WinError 10022] An invalid argument was
        # supplied".
        takeover_requested = (
            allow_address_takeover
            or _shares_by_address(reuse_port, kind, exclusive)
            or any(
                level == _socket.SOL_SOCKET and name == _socket.SO_REUSEADDR and value
                for level, name, value in options
            )
        )
        if takeover_requested:
            sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
        elif exclusive is not None:
            # Windows, and set for **both** values of `reuse_address`. With
            # neither option set, a *more specific* SO_REUSEADDR bind takes
            # traffic from a wildcard holder: measured, a thief on 127.0.0.1
            # received the datagram while the holder on 0.0.0.0 got nothing and
            # no error. Tying the option to `reuse_address=True` alone would
            # make `reuse_address=False`, the setting that reads as strictest,
            # the least strict one available.
            #
            # Setting it regardless costs nothing: on Windows this option's only
            # effect is denying that takeover. There is no TIME_WAIT restart for
            # UDP it could forbid, and for TCP it is already what
            # `reuse_address=True` asked for. `reuse_address` therefore governs
            # POSIX `SO_REUSEADDR` only, which is what the name means everywhere
            # else.
            sock.setsockopt(_socket.SOL_SOCKET, exclusive, 1)
        elif reuse_address and kind != _socket.SOCK_DGRAM:
            # POSIX, **stream sockets only**. `SO_REUSEADDR` on a datagram socket
            # buys nothing a caller wants and costs the port: `TIME_WAIT` is a
            # TCP concept, so for UDP the option's only effect on Linux is to
            # permit *duplicate bindings of live sockets*. Measured on WSL: with
            # this set by default, a second `bind()` of the same live UDP
            # `addr:port` succeeded and the datagram went to the **second**
            # socket, with the holder getting no error -- silent traffic theft
            # under the default call.
            #
            # `socket(7)` is explicit that the exception is an active *listening*
            # socket, and a UDP socket never listens. So "two live sockets
            # cannot hold one addr:port" is true for TCP and false for UDP.
            #
            # Sharing a UDP port is still reachable, by the names that say so:
            # `reuse_port=True` (`SO_REUSEPORT`, the option actually designed for
            # it) or `allow_address_takeover=True`. `multicast_socket` sets what
            # it needs itself and does not rely on this.
            sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
        if reuse_port:
            # Absent on Windows; setting it unconditionally would raise there.
            option = getattr(_socket, "SO_REUSEPORT", None)
            if option is not None:
                try:
                    sock.setsockopt(_socket.SOL_SOCKET, option, 1)
                except OSError:
                    pass  # present but refused by this kernel -- not fatal
        if broadcast:
            sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_BROADCAST, 1)
        if kind == _socket.SOCK_DGRAM and (connreset is None or not connreset):
            disable_connreset(sock)
        for level, name, value in options:
            sock.setsockopt(level, name, value)
        if device_name is not None and device_option is not None:
            _apply_device(sock, device_option, device_name)

        try:
            sock.bind(_sockaddr_for_bind(family, address, port))
        except OSError as exc:
            raise _with_hint(exc, port) from exc
        if listen is not None and kind == _socket.SOCK_STREAM:
            sock.listen(listen)
    except BaseException:
        sock.close()
        raise
    return sock

has_device_binding()

Whether bind(device=...) can restrict a socket to one device here.

Decided by asking the kernel on a throwaway socket, naming the loopback device, so it also reflects what this process may do (SO_BINDTODEVICE needs CAP_NET_RAW before Linux 5.7). Where the platform has no measured option it is False without a socket being opened.

Source code in src/netimps/_sockets/_device.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def has_device_binding() -> bool:
    """Whether ``bind(device=...)`` can restrict a socket to one device here.

    Decided by asking the kernel on a throwaway socket, naming the loopback
    device, so it also reflects what this process may do (``SO_BINDTODEVICE``
    needs ``CAP_NET_RAW`` before Linux 5.7). Where the platform has no
    measured option it is ``False`` without a socket being opened.
    """
    option = _OPTIONS.get(_platform_key())
    if option is None:
        return False
    try:
        name = _socket.if_indextoname(1)
    except (OSError, AttributeError):
        name = "lo"
    try:
        with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as probe:
            probe.setsockopt(option.level, option.number, name.encode())
    except OSError:
        return False
    return True

max_udp_payload(mtu, *, ipv6=False)

The largest UDP payload that fits mtu without fragmenting.

mtu - ip_header - 8, where the IP header is 20 for v4 and 40 for v6::

max_udp_payload(1500)              # 1472
max_udp_payload(1500, ipv6=True)   # 1452

Pair it with :attr:netimps.Interface.mtu to size a datagram to the interface it will leave by. Three things make this worth a function rather than arithmetic at the call site:

  • the v4 figure uses the minimum 20-byte header, so a packet carrying IP options can still fragment. Subtract more if you set any.
  • IPv6 counts its extension headers as payload, so 40 is exact only without them.
  • Interface.mtu is Optional[int], so a caller must handle None rather than assume. That is why this takes an int and does not accept an Interface: the None decision belongs to the caller, who knows whether to fall back to 1500 or to refuse.

None means the platform genuinely could not read an MTU, and never "unbounded": the Windows loopback adapter reports ULONG max, which Interface.mtu clamps to 65535, and this returns the 65507 that was measured to actually arrive. A None there would send a caller falling back to 1500 to cap loopback at 1472.

Returns 0 rather than a negative number for an MTU too small to carry any payload.

:raises ValueError: for a negative mtu.

Source code in src/netimps/_sockets/_mtu.py
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
def max_udp_payload(mtu: int, *, ipv6: bool = False) -> int:
    """The largest UDP payload that fits *mtu* without fragmenting.

    ``mtu - ip_header - 8``, where the IP header is 20 for v4 and 40 for v6::

        max_udp_payload(1500)              # 1472
        max_udp_payload(1500, ipv6=True)   # 1452

    Pair it with :attr:`netimps.Interface.mtu` to size a datagram to the
    interface it will leave by. Three things make this worth a function rather
    than arithmetic at the call site:

    - the v4 figure uses the **minimum** 20-byte header, so a packet carrying IP
      options can still fragment. Subtract more if you set any.
    - IPv6 counts its extension headers as payload, so 40 is exact only without
      them.
    - ``Interface.mtu`` is ``Optional[int]``, so a caller must handle ``None``
      rather than assume. That is why this takes an ``int`` and does not accept
      an ``Interface``: the ``None`` decision belongs to the caller, who knows
      whether to fall back to 1500 or to refuse.

      ``None`` means the platform genuinely could not read an MTU, and never
      "unbounded": the Windows loopback adapter reports ULONG max, which
      ``Interface.mtu`` clamps to 65535, and this returns the 65507 that was
      measured to actually arrive. A ``None`` there would send a caller falling
      back to 1500 to cap loopback at 1472.

    Returns 0 rather than a negative number for an MTU too small to carry any
    payload.

    :raises ValueError: for a negative *mtu*.
    """
    if mtu < 0:
        raise ValueError("mtu must not be negative, got %r" % (mtu,))
    overhead = (_IPV6_HEADER if ipv6 else _IPV4_HEADER) + _UDP_HEADER
    return max(0, mtu - overhead)

disable_connreset(sock)

Stop Windows reporting an ICMP port-unreachable on a later receive.

Returns whether anything was changed -- False off Windows, where there is nothing to change.

The behaviour this turns off. On Windows an unconnected UDP socket that provoked an ICMP port-unreachable gets it delivered as :class:ConnectionResetError from a subsequent recvfrom -- one that may have nothing to do with the peer that refused. A receive loop then dies on a packet some unrelated host did not want. POSIX reports asynchronous ICMP errors only on connected sockets, so this whole class of surprise does not arise there.

This is the inverse face of a rule this package documents from the other side: an unconnected POSIX probe never sees a port-unreachable, which is why :class:netimps.UDPEndpoint's MTU probing connects.

Not applied to a socket you did not ask for. The report is occasionally what a caller wants -- a client talking to one peer learns the peer is gone -- so this is a call, not a hidden side effect. :func:bind makes it for a datagram socket by default, which is what a server loop wants; pass bind(..., connreset=True) to keep the report, or call this on a socket from elsewhere.

There is no stdlib route to this. Measured on 3.14: CPython exports no socket.SIO_UDP_CONNRESET on any version, and even given the documented value (0x9800000C) socket.ioctl refuses it -- that method whitelists a handful of commands and answers ValueError: invalid ioctl command for the rest. So this goes through WSAIoctl by ctypes. The getattr route is the trap: it compiles, runs, and is a silent no-op on every platform, which is why this lives here once rather than being rewritten per caller.

Best effort: a kernel or socket type that refuses the request leaves the socket as it was rather than raising, since this is an adjustment to behaviour and never a correctness requirement.

Source code in src/netimps/_sockets/_options.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
def disable_connreset(sock: "_socket.socket") -> bool:
    """Stop Windows reporting an ICMP port-unreachable on a *later* receive.

    Returns whether anything was changed -- ``False`` off Windows, where there
    is nothing to change.

    **The behaviour this turns off.** On Windows an unconnected UDP socket that
    provoked an ICMP port-unreachable gets it delivered as
    :class:`ConnectionResetError` from a subsequent ``recvfrom`` -- one that may
    have nothing to do with the peer that refused. A receive loop then dies on a
    packet some unrelated host did not want. POSIX reports asynchronous ICMP
    errors only on *connected* sockets, so this whole class of surprise does not
    arise there.

    This is the inverse face of a rule this package documents from the other
    side: an unconnected POSIX probe never *sees* a port-unreachable, which is
    why :class:`netimps.UDPEndpoint`'s MTU probing connects.

    **Not applied to a socket you did not ask for.** The report is occasionally
    what a caller wants -- a client talking to one peer learns the peer is gone
    -- so this is a call, not a hidden side effect. :func:`bind` makes it for a
    datagram socket by default, which is what a server loop wants; pass
    ``bind(..., connreset=True)`` to keep the report, or call this on a socket
    from elsewhere.

    **There is no stdlib route to this.** Measured on 3.14: CPython exports no
    ``socket.SIO_UDP_CONNRESET`` on any version, and even given the documented
    value (``0x9800000C``) ``socket.ioctl`` refuses it -- that method whitelists
    a handful of commands and answers ``ValueError: invalid ioctl command`` for
    the rest. So this goes through ``WSAIoctl`` by ``ctypes``. The ``getattr``
    route is the trap: it compiles, runs, and is a **silent no-op on every
    platform**, which is why this lives here once rather than being rewritten
    per caller.

    Best effort: a kernel or socket type that refuses the request leaves the
    socket as it was rather than raising, since this is an adjustment to
    behaviour and never a correctness requirement.
    """
    if _sys.platform != "win32":
        return False
    try:
        from .. import _winsock
    except Exception:  # pragma: no cover - a Windows without ws2_32
        return False
    try:
        _winsock.set_udp_connreset(sock, False)
    except (OSError, AttributeError, ValueError):
        return False
    return True

set_buffer_size(sock, *, receive=None, send=None)

Grow SO_RCVBUF/SO_SNDBUF, and report what was actually granted.

Returns (receive, send) as read back from the socket, never as requested::

got_rx, got_tx = set_buffer_size(sock, receive=4 << 20)
if got_rx < 4 << 20:
    log.warning("kernel granted %d of 4 MiB", got_rx)

The default UDP buffers are small -- 64 KiB on Windows -- so a burst of large datagrams overruns them and the tail is dropped, which at the protocol level looks like loss and costs a timeout per window. Raising them is the fix; knowing whether the raise took is the part that gets skipped.

The kernel is not obliged to agree, and does not say so. setsockopt succeeds and then grants less, capped by net.core.rmem_max on Linux, and Linux also doubles what is asked for its own bookkeeping, so a read-back larger than the request is normal there and not a bug. Returning the read-back is the whole point: a silent partial grant is the failure mode.

Only grows, never shrinks: a value already at or above the request is left alone, so this cannot undo a caller's earlier tuning. None skips a direction. A refused option is skipped rather than raised, and the corresponding return value is whatever the socket reports.

A shortfall is logged once per process for each distinct request and grant, at WARNING on this module's logger (netimps._sockets); the package installs no handler. The return value reports it on every call.

Source code in src/netimps/_sockets/_options.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
 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
def set_buffer_size(
    sock: "_socket.socket",
    *,
    receive: "Optional[int]" = None,
    send: "Optional[int]" = None,
) -> "Tuple[int, int]":
    """Grow ``SO_RCVBUF``/``SO_SNDBUF``, and report what was actually granted.

    Returns ``(receive, send)`` as **read back** from the socket, never as
    requested::

        got_rx, got_tx = set_buffer_size(sock, receive=4 << 20)
        if got_rx < 4 << 20:
            log.warning("kernel granted %d of 4 MiB", got_rx)

    The default UDP buffers are small -- 64 KiB on Windows -- so a burst of
    large datagrams overruns them and the tail is dropped, which at the protocol
    level looks like loss and costs a timeout per window. Raising them is the
    fix; knowing whether the raise *took* is the part that gets skipped.

    **The kernel is not obliged to agree, and does not say so.** ``setsockopt``
    succeeds and then grants less, capped by ``net.core.rmem_max`` on Linux, and
    Linux also *doubles* what is asked for its own bookkeeping, so a read-back
    larger than the request is normal there and not a bug. Returning the
    read-back is the whole point: a silent partial grant is the failure mode.

    Only grows, never shrinks: a value already at or above the request is left
    alone, so this cannot undo a caller's earlier tuning. ``None`` skips a
    direction. A refused option is skipped rather than raised, and the
    corresponding return value is whatever the socket reports.

    A shortfall is logged once per process for each distinct request and
    grant, at ``WARNING`` on this module's logger (``netimps._sockets``); the
    package installs no handler. The return value reports it on every call.
    """
    for option, wanted in (
        (_socket.SO_RCVBUF, receive),
        (_socket.SO_SNDBUF, send),
    ):
        if wanted is None:
            continue
        if wanted < 0:
            raise ValueError("buffer size must not be negative, got %r" % (wanted,))
        try:
            if sock.getsockopt(_socket.SOL_SOCKET, option) < wanted:
                sock.setsockopt(_socket.SOL_SOCKET, option, wanted)
        except OSError:
            pass  # refused by this kernel or socket type -- report what it has

    def _current(option: int) -> int:
        try:
            return int(sock.getsockopt(_socket.SOL_SOCKET, option))
        except OSError:  # pragma: no cover - a socket that reports neither
            return 0

    granted = (_current(_socket.SO_RCVBUF), _current(_socket.SO_SNDBUF))
    short = [
        "%s: asked for %d bytes, granted %d" % (name, wanted, got)
        for name, wanted, got in (
            ("SO_RCVBUF", receive, granted[0]),
            ("SO_SNDBUF", send, granted[1]),
        )
        if wanted is not None and got < wanted
    ]
    if short:
        message = "; ".join(short)
        if message not in _warned_shortfalls:
            if len(_warned_shortfalls) < _WARNED_SHORTFALLS_MAX:
                _warned_shortfalls.add(message)
            _log.warning("socket buffer smaller than requested (%s)", message)
    return granted

discover_mtu(dst, *, low=576, high=9000, timeout=1.0, src=None, port=80, probe=True, method='icmp', tries=1, ipv6=None, ttl=None)

Measure the path MTU to dst in bytes, or None if undiscoverable.

Sends DF-flagged pings of growing size, binary-searching for the largest packet that survives the whole path unfragmented::

discover_mtu("example.com")      # 1500, or 1420 through a VPN

This actually traverses the path, which is the difference from :func:get_pmtu: that reports what the kernel already knows (often nothing), while this goes and finds out. Packets really reach dst and come back, so the answer reflects every hop in between -- including a router that silently drops oversized DF packets without sending "fragmentation needed", which nothing else will reveal.

Measured on one host: the local link was 9000 and get_pmtu returned None, while this reported the true 1500.

The cost is a dozen or so probes and a destination willing to answer ICMP.

:param low: smallest MTU to consider. 576 is the IPv4 minimum every host must accept, so anything smaller means the host is simply unreachable. :param high: the size the search tries first as its ceiling. The path cannot be wider than the link it leaves by, so a probe at high that is answered does not end the search: it goes on up to that link's MTU, and the answer is a measurement. Only when the link's MTU cannot be read is high the ceiling, and a result equal to it then means "at least high". A high above the link's MTU is lowered to it. :param src: send from this interface -- same union as ping(src=). :param port: destination port passed through to :func:get_pmtu. :param method: how to probe. "icmp" (default) uses DF-flagged echo; "udp" sends datagrams of growing size to port and needs something there that replies. Use "udp" when ICMP is filtered but a UDP service answers, or to measure what a UDP application can actually push -- a middlebox may cap that below the ICMP-derived MTU.

``"tcp"`` **does not probe** -- it cannot: TCP is a stream and the
kernel segments it transparently, so a large ``send()`` silently
becomes many packets. It instead reads the negotiated MSS
(:func:`get_tcp_mss`) and adds the header of the family that
connected back (40 bytes for IPv4, 60 for IPv6), which is the closest
true equivalent. That is what the two *kernels agreed*,
not necessarily what a middlebox further along will pass -- use
``"icmp"`` or ``"udp"`` when the answer must be measured.

:param probe: set False to skip probing entirely and just return :func:get_pmtu -- the kernel's cached answer, usually None. :param tries: probes per size, passed to :func:ping for method="icmp"; tries=3 tolerates a lossy path. :param ipv6: force the family; None takes whichever dst resolves to. Applies to every method; the name is resolved once and the header overhead is that of the address probed. :param ttl: initial hop limit of the ICMP probes.

size and dont_fragment are what the search varies, so they are not parameters.

Returns the MTU including headers (payload + 28 for IPv4 + ICMP), so it is directly comparable with :attr:Interface.mtu. The platform ping has a largest probe of its own (a 65500-byte payload on Windows, the net.inet.raw.maxdgram sysctl on macOS and the BSDs: 8192 on macOS 15.7), and so has a UDP socket there (net.inet.udp.maxdgram: a 9216-byte payload). A destination on this host that the search takes that far is reported at the loopback interface's MTU, since no hop narrows the path; any other path that reaches it is retried with "udp" and is otherwise reported at that limit, meaning "at least". Returns None when the destination never answers -- common, since many hosts and most cloud firewalls drop echo entirely, and that is indistinguishable from "every size was too big" -- and also when the don't-fragment bit cannot be set for this destination on this platform (BSD's ping6 has no DF flag). That second None is deliberate: without DF the probe is fragmented and reassembled, every size survives, and the search would return high as though it had measured something.

.. note:: The result can be lower than any local Interface.mtu, and that is the useful case: the bottleneck is somewhere along the path, not on this host.

Source code in src/netimps/_sockets/_mtu.py
 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
def discover_mtu(
    dst: "HostLike",
    *,
    low: int = 576,
    high: int = 9000,
    timeout: float = 1.0,
    src: "InterfaceLike" = None,
    port: int = 80,
    probe: bool = True,
    method: "Literal['icmp', 'tcp', 'udp']" = "icmp",
    tries: int = 1,
    ipv6: "Optional[bool]" = None,
    ttl: "Optional[int]" = None,
) -> "Optional[int]":
    """Measure the path MTU to ``dst`` in bytes, or ``None`` if undiscoverable.

    Sends DF-flagged pings of growing size, binary-searching for the largest
    packet that survives the whole path unfragmented::

        discover_mtu("example.com")      # 1500, or 1420 through a VPN

    **This actually traverses the path**, which is the difference from
    :func:`get_pmtu`: that reports what the kernel already knows (often
    nothing), while this goes and finds out. Packets really reach ``dst`` and
    come back, so the answer reflects every hop in between -- including a
    router that silently drops oversized DF packets without sending
    "fragmentation needed", which nothing else will reveal.

    Measured on one host: the local link was 9000 and ``get_pmtu`` returned
    ``None``, while this reported the true 1500.

    The cost is a dozen or so probes and a destination willing to answer ICMP.

    :param low: smallest MTU to consider. 576 is the IPv4 minimum every host
        must accept, so anything smaller means the host is simply unreachable.
    :param high: the size the search tries first as its ceiling. The path
        cannot be wider than the link it leaves by, so a probe at ``high``
        that is answered does not end the search: it goes on up to that link's
        MTU, and the answer is a measurement. Only when the link's MTU cannot
        be read is ``high`` the ceiling, and a result equal to it then means
        "at least ``high``". A ``high`` above the link's MTU is lowered to it.
    :param src: send from this interface -- same union as ``ping(src=)``.
    :param port: destination port passed through to :func:`get_pmtu`.
    :param method: how to probe. ``"icmp"`` (default) uses DF-flagged echo;
        ``"udp"`` sends datagrams of growing size to ``port`` and needs
        something there that replies. Use ``"udp"`` when ICMP is filtered but a
        UDP service answers, or to measure what a **UDP application** can
        actually push -- a middlebox may cap that below the ICMP-derived MTU.

        ``"tcp"`` **does not probe** -- it cannot: TCP is a stream and the
        kernel segments it transparently, so a large ``send()`` silently
        becomes many packets. It instead reads the negotiated MSS
        (:func:`get_tcp_mss`) and adds the header of the family that
        connected back (40 bytes for IPv4, 60 for IPv6), which is the closest
        true equivalent. That is what the two *kernels agreed*,
        not necessarily what a middlebox further along will pass -- use
        ``"icmp"`` or ``"udp"`` when the answer must be measured.
    :param probe: set ``False`` to skip probing entirely and just return
        :func:`get_pmtu` -- the kernel's cached answer, usually ``None``.
    :param tries: probes per size, passed to :func:`ping` for ``method="icmp"``;
        ``tries=3`` tolerates a lossy path.
    :param ipv6: force the family; ``None`` takes whichever ``dst`` resolves to.
        Applies to every method; the name is resolved once and the header
        overhead is that of the address probed.
    :param ttl: initial hop limit of the ICMP probes.

    ``size`` and ``dont_fragment`` are what the search varies, so they are not
    parameters.

    Returns the MTU **including headers** (payload + 28 for IPv4 + ICMP), so it
    is directly comparable with :attr:`Interface.mtu`. The platform ``ping``
    has a largest probe of its own (a 65500-byte payload on Windows, the
    ``net.inet.raw.maxdgram`` sysctl on macOS and the BSDs: 8192 on macOS 15.7),
    and so has a UDP socket there (``net.inet.udp.maxdgram``: a 9216-byte payload).
    A destination on this host that the search takes that far is reported at
    the loopback interface's MTU, since no hop narrows the path; any other
    path that reaches it is retried with ``"udp"`` and is otherwise reported at
    that limit, meaning "at least". Returns ``None`` when
    the destination never answers -- common, since many hosts and most cloud
    firewalls drop echo entirely, and that is indistinguishable from "every
    size was too big" -- **and also when the don't-fragment bit cannot be set**
    for this destination on this platform (BSD's ``ping6`` has no DF flag).
    That second ``None`` is deliberate: without DF the probe is fragmented and
    reassembled, every size survives, and the search would return ``high`` as
    though it had measured something.

    .. note::
       The result can be **lower than any local** ``Interface.mtu``, and that
       is the useful case: the bottleneck is somewhere along the path, not on
       this host.
    """
    dst = _dst_argument(dst)
    port = _coerce_port(port)
    if not probe:
        # Explicitly asked for the kernel's cached answer only.
        return get_pmtu(dst, port, ipv6=ipv6)

    lowered = (method or "icmp").lower()
    if lowered not in ("icmp", "udp", "tcp"):
        raise ValueError("method must be 'icmp', 'udp' or 'tcp', got %r" % (lowered,))
    method = _cast("Literal['icmp', 'tcp', 'udp']", lowered)

    if method == "tcp":
        # TCP cannot probe: the kernel segments the stream, so a large send()
        # silently becomes many packets and measures nothing. The negotiated
        # MSS is the closest true equivalent -- derive the MTU from it rather
        # than refusing to answer.
        measured = _tcp_mss(dst, port, timeout, ipv6)
        if measured is None:
            return None
        mss, family = measured
        # IPv4 is 20 + 20 TCP, IPv6 is 40 + 20, so the family that connected
        # decides the header.
        return mss + (60 if family == _socket.AF_INET6 else 40)

    # One resolution decides the target, the family and the header overhead.
    targets = _resolve_targets(dst, port, ipv6, _socket.SOCK_DGRAM)
    if not targets:
        return None
    family, sockaddr = targets[0]
    address = sockaddr[0]
    wants_six = family == _socket.AF_INET6

    if method == "udp":
        return _discover_mtu_udp(
            address, port, low, high, timeout, wants_six, src=src, sockaddr=sockaddr
        )

    if not supports_dont_fragment(address, wants_six):
        # The binary search is only meaningful when the probe cannot be
        # fragmented. Where the platform's ping has no DF flag for this
        # family -- BSD's ping6 -- passing dont_fragment=True raises, and
        # dropping it would return `high` as if it had been measured.
        return None

    # ping's size= is the ICMP *payload* on both Windows (-l) and POSIX (-s) --
    # neither counts headers -- so the wire packet is larger by the IP header
    # plus 8 (ICMP): 28 for IPv4, 48 for IPv6.
    overhead = (40 if wants_six else 20) + 8

    # Only what the caller changed is forwarded, so ``ping`` keeps owning its
    # own defaults.
    forwarded: "Dict[str, Any]" = {}
    if tries != 1:
        forwarded["tries"] = tries
    if ipv6 is not None:
        forwarded["ipv6"] = ipv6
    if ttl is not None:
        forwarded["ttl"] = ttl

    def survives(mtu: int) -> bool:
        payload = mtu - overhead
        if payload < 0:
            return False
        return bool(
            ping(
                address,
                size=payload,
                dont_fragment=True,
                timeout=timeout,
                src=src,
                **forwarded,
            )
        )

    first_hop, local = _outgoing_path(address, wants_six, src)
    limit = _icmp_packet_limit(overhead)
    found, bound_by_tool = _search_mtu(survives, low, high, first_hop, limit, local)
    if bound_by_tool and found is not None:
        # The platform's ping cannot send a larger packet, and the path may
        # carry one. UDP has no such limit.
        wider = _discover_mtu_udp(
            address,
            port,
            found,
            first_hop or high,
            timeout,
            wants_six,
            src=src,
            sockaddr=sockaddr,
        )
        if wider is not None and wider > found:
            return wider
    return found

get_pmtu(dst, port=80, *, ipv6=None)

Return the path MTU the kernel has already learned, or None.

A lookup, not a measurement -- it reads IP_MTU (or IPV6_PATHMTU) on a connected socket and sends nothing::

get_pmtu("example.com")      # 1420, or None if nothing is cached

dst also accepts an address object or an :class:IPv4Interface/ :class:IPv6Interface (its .ip is used); ipv6= picks which family a hostname is resolved to.

Instant and silent, but it answers a weaker question than :func:discover_mtu:

  • None is a common answer. The kernel only knows a path MTU once its own discovery has learned one, which needs prior traffic that actually hit the limit. A fresh destination may report nothing.
  • Windows has no IP_MTU (nor IP_MTU_DISCOVER / IPV6_PATHMTU), so this always returns None there. Nor is there another route: the dwForwardMtu field of MIB_IPFORWARDROW reads 0 (verified via GetBestRoute; Microsoft lists it as unsupported), and the newer MIB_IPFORWARD_ROW2 dropped the field entirely. Route MTU lives at the interface level on Windows, which is :attr:Interface.mtu. Probing with :func:discover_mtu is the only way to learn a path MTU there.
  • macOS/BSD expose no IPv4 equivalent either, so v4 there is None too.
  • When the kernel has an answer it can still be the local link MTU rather than the path minimum, if nothing has yet forced it lower.

.. note:: IP_MTU, IP_MTU_DISCOVER and IP_PMTUDISC_DO are not exported by CPython on any platform, Linux included (measured on 3.13 and 3.14). Guarding on getattr(socket, "IP_MTU", None) therefore made this function unreachable everywhere rather than on Windows only, which is what the "usually None" story was hiding. The Linux numbers are named from <linux/in.h> instead.

Use it as a free first guess; use :func:discover_mtu when the answer has to be right.

Source code in src/netimps/_sockets/_pmtu.py
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
def get_pmtu(
    dst: "HostLike", port: int = 80, *, ipv6: "Optional[bool]" = None
) -> "Optional[int]":
    """Return the path MTU the kernel has **already learned**, or ``None``.

    A lookup, not a measurement -- it reads ``IP_MTU`` (or ``IPV6_PATHMTU``) on
    a connected socket and sends nothing::

        get_pmtu("example.com")      # 1420, or None if nothing is cached

    ``dst`` also accepts an address object or an :class:`IPv4Interface`/
    :class:`IPv6Interface` (its ``.ip`` is used); ``ipv6=`` picks which family
    a hostname is resolved to.

    Instant and silent, but it answers a weaker question than
    :func:`discover_mtu`:

    * **``None`` is a common answer.** The kernel only knows a path MTU once
      its own discovery has learned one, which needs prior traffic that
      actually hit the limit. A fresh destination may report nothing.
    * **Windows has no ``IP_MTU``** (nor ``IP_MTU_DISCOVER`` / ``IPV6_PATHMTU``),
      so this always returns ``None`` there. Nor is there another route: the
      ``dwForwardMtu`` field of ``MIB_IPFORWARDROW`` reads **0** (verified via
      ``GetBestRoute``; Microsoft lists it as unsupported), and the newer
      ``MIB_IPFORWARD_ROW2`` dropped the field entirely. Route MTU lives at the
      interface level on Windows, which is :attr:`Interface.mtu`. Probing with
      :func:`discover_mtu` is the only way to learn a *path* MTU there.
    * macOS/BSD expose no IPv4 equivalent either, so v4 there is ``None`` too.
    * When the kernel *has* an answer it can still be the **local link** MTU
      rather than the path minimum, if nothing has yet forced it lower.

    .. note::
       ``IP_MTU``, ``IP_MTU_DISCOVER`` and ``IP_PMTUDISC_DO`` are **not
       exported by CPython on any platform**, Linux included (measured on 3.13
       and 3.14). Guarding on ``getattr(socket, "IP_MTU", None)`` therefore
       made this function unreachable everywhere rather than on Windows only,
       which is what the "usually ``None``" story was hiding. The Linux
       numbers are named from ``<linux/in.h>`` instead.

    Use it as a free first guess; use :func:`discover_mtu` when the answer has
    to be right.
    """
    dst = _dst_argument(dst)
    port = _coerce_port(port)
    for family, sockaddr in _resolve_targets(dst, port, ipv6, _socket.SOCK_DGRAM):
        value = _pmtu_for(family, sockaddr)
        if value is not None:
            return value
    return None

get_tcp_mss(dst, port, *, timeout=3.0, ipv6=None)

Return the TCP maximum segment size negotiated with dst, or None.

The TCP counterpart to an MTU: the largest payload a single segment may carry, agreed during the handshake::

get_tcp_mss("example.com", 443)     # 1460 on a 1500-MTU path

dst also accepts an address object or an :class:IPv4Interface/ :class:IPv6Interface (its .ip is used). ipv6= picks which family a hostname is connected over; a destination of the other family gives None.

This opens a real connection to read the value, then closes it.

MSS is normally the path MTU minus 40 (20 IPv4 + 20 TCP), so a reduced value is a useful signal: a VPN or tunnel is shrinking the path. Measured on one host: 1412 over a VPN where the link MTU was 1500, and 32741 on loopback.

Returns None where the platform does not expose TCP_MAXSEG or the connection fails. Note this is what the kernels agreed, not what a middlebox further along will actually pass -- for that, measure with :func:discover_mtu.

Source code in src/netimps/_sockets/_pmtu.py
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
def get_tcp_mss(
    dst: "HostLike",
    port: int,
    *,
    timeout: float = 3.0,
    ipv6: "Optional[bool]" = None,
) -> "Optional[int]":
    """Return the TCP maximum segment size negotiated with ``dst``, or ``None``.

    The TCP counterpart to an MTU: the largest payload a single segment may
    carry, agreed during the handshake::

        get_tcp_mss("example.com", 443)     # 1460 on a 1500-MTU path

    ``dst`` also accepts an address object or an :class:`IPv4Interface`/
    :class:`IPv6Interface` (its ``.ip`` is used). ``ipv6=`` picks which family
    a hostname is connected over; a destination of the other family gives
    ``None``.

    This **opens a real connection** to read the value, then closes it.

    MSS is normally the path MTU minus 40 (20 IPv4 + 20 TCP), so a reduced
    value is a useful signal: a VPN or tunnel is shrinking the path. Measured
    on one host: 1412 over a VPN where the link MTU was 1500, and 32741 on
    loopback.

    Returns ``None`` where the platform does not expose ``TCP_MAXSEG`` or the
    connection fails. Note this is what the *kernels agreed*, not what a
    middlebox further along will actually pass -- for that, measure with
    :func:`discover_mtu`.
    """
    dst = _dst_argument(dst)
    port = _coerce_port(port)
    measured = _tcp_mss(dst, port, timeout, ipv6)
    return None if measured is None else measured[0]

bind_error_hint(exc, port=None)

Turn a bind failure into a sentence a user can act on, or None.

The raw OSError from a failed bind is famously unhelpful, and the errno differs per platform -- Windows reports WinError 10013/10048 where POSIX reports EACCES/EADDRINUSE. :func:bind already puts this text in the exception it raises; call this for an OSError that came from somewhere else, such as a stdlib socket.bind::

try:
    sock.bind(("", 67))
except OSError as exc:
    raise OSError(bind_error_hint(exc, 67) or str(exc)) from exc

Returns None for anything unrecognised, so the caller keeps the original error rather than a worse paraphrase. This does not raise -- deciding what to do with a failure belongs to the caller.

Source code in src/netimps/_sockets/_hint.py
 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
def bind_error_hint(
    exc: BaseException, port: "Optional[int]" = None
) -> "Optional[str]":
    """Turn a bind failure into a sentence a user can act on, or ``None``.

    The raw ``OSError`` from a failed bind is famously unhelpful, and the errno
    differs per platform -- Windows reports ``WinError 10013``/``10048`` where
    POSIX reports ``EACCES``/``EADDRINUSE``. :func:`bind` already puts this text
    in the exception it raises; call this for an ``OSError`` that came from
    somewhere else, such as a stdlib ``socket.bind``::

        try:
            sock.bind(("", 67))
        except OSError as exc:
            raise OSError(bind_error_hint(exc, 67) or str(exc)) from exc

    Returns ``None`` for anything unrecognised, so the caller keeps the
    original error rather than a worse paraphrase. This **does not raise** --
    deciding what to do with a failure belongs to the caller.
    """
    import errno as _errno

    if not isinstance(exc, OSError):
        return None

    winerror = getattr(exc, "winerror", None)
    where = "port %d" % port if port is not None else "that port"

    if winerror == 10013:
        # WSAEACCES, and it is NOT a privilege problem. Windows has no
        # privileged-port concept at all -- any user may bind port 80 -- so the
        # POSIX reading of this code sends the reader after an elevation
        # problem that cannot exist here. What it actually means is that
        # another socket holds the address exclusively (SO_EXCLUSIVEADDRUSE),
        # or that a firewall or an excluded port range is refusing it.
        #
        # Python maps WSAEACCES to PermissionError with errno EACCES, so the
        # winerror must be tested BEFORE the POSIX branch below or the generic
        # "permission denied" wins and says the wrong thing. Measured: binding
        # over an exclusively-held socket reported "permission denied binding
        # port 64514" -- a privilege message about an unprivileged port.
        return (
            "%s is held exclusively by another socket, or blocked by a "
            "firewall or an excluded port range (WSAEACCES); it is in use, "
            "not privileged -- Windows has no privileged ports" % where.capitalize()
        )

    if winerror == 10022:
        # WSAEINVAL, and from a bind path it almost always means the socket
        # carries two reuse options that contradict each other -- Windows
        # refuses `SO_REUSEADDR` on a socket that already has
        # `SO_EXCLUSIVEADDRUSE`, and says only "invalid argument" without
        # naming either. A bare 10022 is undiagnosable, which is the whole
        # reason this branch exists.
        return (
            "Invalid argument (WSAEINVAL) -- on Windows this usually means "
            "conflicting reuse options on one socket: SO_REUSEADDR is refused "
            "once SO_EXCLUSIVEADDRUSE is set. Ask for sharing by name with "
            "allow_address_takeover=True rather than passing SO_REUSEADDR "
            "through options="
        )

    if isinstance(exc, PermissionError) or exc.errno == _errno.EACCES:
        hint = "permission denied binding %s" % where
        if port is not None and port < 1024:
            hint += "; ports below 1024 need root/Administrator"
        return hint

    if exc.errno == _errno.EADDRINUSE or winerror == 10048:
        return "%s is already in use" % where.capitalize()

    if exc.errno == _errno.EADDRNOTAVAIL or winerror == 10049:
        return (
            "that address is not available on this host; "
            "it must belong to a local interface"
        )

    return None

get_free_port(src='127.0.0.1', *, family=None)

Return a port number that was free a moment ago.

Binds port 0, reads back whatever the OS assigned, and closes::

port = get_free_port()
server = start_my_server(port=port)

A getter, despite "free" in the name -- it acquires a number, it does not release anything. The port is not held open for you.

.. warning:: Inherently racy. The port is released the instant this returns, so another process can take it before you bind. There is no way around that with a returned port number -- if you can, bind port 0 in the server itself and read back getsockname() instead of calling this.

SO_REUSEADDR is deliberately not set: it would let the OS hand back a port still in TIME_WAIT, which then fails or steals traffic when the caller binds it for real.

src is any host, as for :func:bind, and family follows it the way it does there: an IPv6 address gives AF_INET6, an IPv4 one or the default AF_INET.

Source code in src/netimps/_sockets/_bind.py
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
def get_free_port(
    src: "HostLike" = "127.0.0.1", *, family: "Optional[int]" = None
) -> int:
    """Return a port number that was free a moment ago.

    Binds port 0, reads back whatever the OS assigned, and closes::

        port = get_free_port()
        server = start_my_server(port=port)

    A *getter*, despite "free" in the name -- it acquires a number, it does not
    release anything. The port is **not** held open for you.

    .. warning::
       **Inherently racy.** The port is released the instant this returns, so
       another process can take it before you bind. There is no way around that
       with a returned port number -- if you can, bind port 0 in the server
       itself and read back ``getsockname()`` instead of calling this.

    ``SO_REUSEADDR`` is deliberately **not** set: it would let the OS hand back
    a port still in ``TIME_WAIT``, which then fails or steals traffic when the
    caller binds it for real.

    ``src`` is any host, as for :func:`bind`, and ``family`` follows it the way
    it does there: an IPv6 address gives ``AF_INET6``, an IPv4 one or the
    default ``AF_INET``.
    """
    address = _dst_argument(src)
    family = _family_argument(family)
    if family is None:
        family = _infer_family(address, _socket.SOCK_STREAM)
    sock = _socket.socket(family, _socket.SOCK_STREAM)
    try:
        sock.bind(_sockaddr_for_bind(family, address, 0))
        return int(sock.getsockname()[1])
    finally:
        sock.close()

get_source_ip(dst=_DEFAULT_PROBE, port=80, *, ipv6=None)

Return the local address the kernel would use to reach dst.

Answers "which of my addresses is the real one for this destination?" -- the question a hostname lookup gets wrong on any host with VMs, containers or a VPN::

get_source_ip()                  # IPv4Address('192.0.2.10')
get_source_ip("192.168.1.1")     # the LAN-facing address
get_source_ip("2001:4860::8888") # an IPv6 src address

No packets are sent. connect() on a UDP socket only fixes the socket's local endpoint by consulting the routing table, so this is immediate and invisible to dst.

The answer depends on dst: with a VPN up, a public probe returns the tunnel address while a LAN probe returns the physical one. Pass the address you actually intend to talk to rather than trusting the default.

:param ipv6: which family to probe -- True for IPv6, False for IPv4, None (the default) for whatever dst resolves to. The family is not guessed from ":" in dst: a hostname never contains a colon, so that test would probe every name as IPv4 and a v6-only one would answer None.

Returns None if no route exists (e.g. IPv6 probe on an IPv4-only host). The returned address carries no %zone: the zone identifies the adapter rather than the address, and :func:get_interface is the way back to it.

Source code in src/netimps/_sockets/_connect.py
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
def get_source_ip(
    dst: "HostLike" = _DEFAULT_PROBE,
    port: int = 80,
    *,
    ipv6: "Optional[bool]" = None,
) -> "Optional[IPAddress]":
    """Return the local address the kernel would use to reach ``dst``.

    Answers "which of my addresses is the *real* one for this destination?" --
    the question a hostname lookup gets wrong on any host with VMs, containers
    or a VPN::

        get_source_ip()                  # IPv4Address('192.0.2.10')
        get_source_ip("192.168.1.1")     # the LAN-facing address
        get_source_ip("2001:4860::8888") # an IPv6 src address

    **No packets are sent.** ``connect()`` on a UDP socket only fixes the
    socket's local endpoint by consulting the routing table, so this is
    immediate and invisible to ``dst``.

    The answer depends on ``dst``: with a VPN up, a public probe returns the
    tunnel address while a LAN probe returns the physical one. Pass the address
    you actually intend to talk to rather than trusting the default.

    :param ipv6: which family to probe -- ``True`` for IPv6, ``False`` for
        IPv4, ``None`` (the default) for whatever ``dst`` resolves to. The
        family is not guessed from ``":" in dst``: **a hostname never contains
        a colon**, so that test would probe every name as IPv4 and a v6-only
        one would answer ``None``.

    Returns ``None`` if no route exists (e.g. IPv6 probe on an IPv4-only host).
    The returned address carries no ``%zone``: the zone identifies the adapter
    rather than the address, and :func:`get_interface` is the way back to it.
    """

    dst = _dst_argument(dst)
    for family, sockaddr in _resolve_targets(dst, port, ipv6, _socket.SOCK_DGRAM):
        try:
            sock = _socket.socket(family, _socket.SOCK_DGRAM)
        except OSError:
            continue
        try:
            sock.connect(sockaddr)
            return parse(sock.getsockname()[0].split("%")[0], IPAddress)
        except (OSError, ValueError):
            continue
        finally:
            sock.close()
    return None

count_hops(dst, *, max_hops=30, timeout=1.0, allow_traceroute=True, ipv6=None)

Return the number of hops to dst, or None if it never answers.

Sends TTL-limited probes and counts the routers that reply, the same technique traceroute uses::

count_hops("8.8.8.8")     # 12

dst also accepts an address object or an :class:IPv4Interface/ :class:IPv6Interface (its .ip is used).

Uses raw-socket probes when available (root/Administrator), and otherwise falls back to driving the system traceroute/tracert, so this works unprivileged on a normal desktop. Pass allow_traceroute=False to require the in-process path, which then raises :class:PermissionError instead of shelling out.

The fallback is slower (seconds) because it runs a whole trace. Only the hop number and the destination address are read from its output, never the localised prose, so it is not locale-dependent.

:param ipv6: which family to resolve dst to -- True v6, False v4, None (the default) whichever the resolver answers with. The probes follow: ICMPv6 with IPV6_UNICAST_HOPS for a v6 target, and the platform's v6 traceroute. gethostbyname is IPv4-only: through it a v6 destination would return None, read as "never answered" rather than "never asked".

Returns None when the destination never responds within max_hops. That is common and usually not a missing route: host firewalls (Windows Firewall in particular) routinely drop inbound ICMP even for an elevated process, so None here means "no answer", never "unreachable". Treat it as unknown rather than as a negative result.

Source code in src/netimps/_sockets/_hops.py
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
def count_hops(
    dst: "HostLike",
    *,
    max_hops: int = 30,
    timeout: float = 1.0,
    allow_traceroute: bool = True,
    ipv6: "Optional[bool]" = None,
) -> Optional[int]:
    """Return the number of hops to ``dst``, or ``None`` if it never answers.

    Sends TTL-limited probes and counts the routers that reply, the same
    technique ``traceroute`` uses::

        count_hops("8.8.8.8")     # 12

    ``dst`` also accepts an address object or an :class:`IPv4Interface`/
    :class:`IPv6Interface` (its ``.ip`` is used).

    Uses raw-socket probes when available (root/Administrator), and otherwise
    falls back to driving the system ``traceroute``/``tracert``, so this works
    unprivileged on a normal desktop. Pass ``allow_traceroute=False`` to require
    the in-process path, which then raises :class:`PermissionError` instead of
    shelling out.

    The fallback is slower (seconds) because it runs a whole trace. Only the
    hop number and the destination address are read from its output, never the
    localised prose, so it is not locale-dependent.

    :param ipv6: which family to resolve ``dst`` to -- ``True`` v6, ``False``
        v4, ``None`` (the default) whichever the resolver answers with. The
        probes follow: ICMPv6 with ``IPV6_UNICAST_HOPS`` for a v6 target, and
        the platform's v6 traceroute. ``gethostbyname`` is IPv4-only: through
        it a v6 destination would return ``None``, read as "never answered"
        rather than "never asked".

    Returns ``None`` when the destination never responds within ``max_hops``.
    That is common and usually **not** a missing route: host firewalls (Windows
    Firewall in particular) routinely drop inbound ICMP even for an elevated
    process, so ``None`` here means "no answer", never "unreachable". Treat it
    as unknown rather than as a negative result.
    """
    dst = _dst_argument(dst)
    targets = _resolve_targets(dst, 0, ipv6, _socket.SOCK_DGRAM)
    if not targets:
        return None
    family, sockaddr = targets[0]
    wants_six = family == _socket.AF_INET6
    target = str(sockaddr[0]).split("%")[0]

    proto = _socket.IPPROTO_ICMPV6 if wants_six else _socket.IPPROTO_ICMP
    try:
        icmp = _socket.socket(family, _socket.SOCK_RAW, proto)
    except (OSError, AttributeError) as exc:
        if allow_traceroute:
            return _hop_count_traceroute(target, max_hops, timeout, wants_six)
        raise PermissionError(
            "count_hops needs a raw socket (root/Administrator); "
            "leave allow_traceroute=True to use the system traceroute, or use "
            "get_route() for the first hop"
        ) from exc

    try:
        icmp.settimeout(timeout)
        # Windows will not deliver ICMP to a raw socket bound to INADDR_ANY --
        # it must be bound to a real local address, and put into promiscuous
        # mode with SIO_RCVALL. On POSIX, binding to "" is both sufficient and
        # correct.
        bind_to = ""
        if _IS_WINDOWS:
            src = get_source_ip(target, ipv6=wants_six)
            bind_to = str(src) if src is not None else ""
        try:
            icmp.bind((bind_to, 0))
        except OSError:
            pass
        if _IS_WINDOWS:
            try:
                icmp.ioctl(  # type: ignore[attr-defined]
                    _socket.SIO_RCVALL,  # type: ignore[attr-defined]
                    _socket.RCVALL_ON,  # type: ignore[attr-defined]
                )
            except (OSError, AttributeError):
                pass
        for ttl in range(1, max_hops + 1):
            probe = _socket.socket(family, _socket.SOCK_DGRAM)
            try:
                if wants_six:
                    # IPv6 has a hop limit, not a TTL, and it is a different
                    # option at a different level -- IP_TTL on an AF_INET6
                    # socket raises rather than limiting anything.
                    probe.setsockopt(
                        _socket.IPPROTO_IPV6, _socket.IPV6_UNICAST_HOPS, ttl
                    )
                    probe_addr = (target, 33434 + ttl) + tuple(sockaddr[2:])
                else:
                    probe.setsockopt(_socket.IPPROTO_IP, _socket.IP_TTL, ttl)
                    probe_addr = (target, 33434 + ttl)
                probe.sendto(b"", probe_addr)
            except OSError:
                continue
            finally:
                probe.close()

            # With SIO_RCVALL the socket sees unrelated traffic too, so keep
            # reading (within this hop's budget) until an ICMP packet that is
            # actually a reply shows up, rather than trusting the first one.
            deadline = _time.monotonic() + timeout
            while _time.monotonic() < deadline:
                try:
                    icmp.settimeout(max(0.01, deadline - _time.monotonic()))
                    packet, addr = icmp.recvfrom(1024)
                except (_socket.timeout, OSError):
                    break
                if not _is_icmp_reply(packet, wants_six):
                    continue
                if str(addr[0]).split("%")[0] == target:
                    return ttl  # destination itself answered: distance found
                break  # a router replied: this hop is done, try the next TTL

        # Raw probes got no answer -- commonly a host firewall dropping inbound
        # ICMP even for an elevated process. Try the system tool before giving
        # up, since it often succeeds where the raw socket does not.
        return (
            _hop_count_traceroute(target, max_hops, timeout, wants_six)
            if allow_traceroute
            else None
        )
    finally:
        icmp.close()

get_route(dst=_DEFAULT_PROBE, *, ipv6=None)

Return how traffic to dst leaves this host.

Reports the src address and the first hop -- the gateway a packet is handed to, or None when the destination is on-link::

r = get_route("8.8.8.8")
r.src        # IPv4Address('192.0.2.10')
r.gateway       # IPv4Address('192.0.2.1')
r.on_link       # False

get_route("127.0.0.1").on_link      # True -- no router involved

dst also accepts an address object or an :class:IPv4Interface/ :class:IPv6Interface (its .ip is used). A hostname is resolved through getaddrinfo, so ipv6= selects which of its records the route is computed for; the IPv4-only gethostbyname used here before meant an AAAA-only name reached no lookup at all.

First hop only, deliberately: it is available unprivileged on every supported platform, whereas the full path requires raw sockets. See :func:count_hops for distance, which does not.

Both address families are looked up, through GetBestRoute2 on Windows, /proc/net/route and /proc/net/ipv6_route on Linux, and route -n get on macOS/BSD -- the one platform where this spawns a short-lived process, because there is no /proc to read and the PF_ROUTE socket is the only other option. Where the lookup cannot be made -- no route, no source to read, an unresolvable name -- gateway is None and so is on_link, which is how "we did not find out" is spelled. on_link is True means a real on-link determination.

Never raises for an unknown route: unknown pieces come back as None/0 rather than an error. A network passed as dst still raises :class:TypeError.

Source code in src/netimps/_sockets/_route.py
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
def get_route(
    dst: "HostLike" = _DEFAULT_PROBE, *, ipv6: "Optional[bool]" = None
) -> Route:
    """Return how traffic to ``dst`` leaves this host.

    Reports the src address and the **first hop** -- the gateway a packet is
    handed to, or ``None`` when the destination is on-link::

        r = get_route("8.8.8.8")
        r.src        # IPv4Address('192.0.2.10')
        r.gateway       # IPv4Address('192.0.2.1')
        r.on_link       # False

        get_route("127.0.0.1").on_link      # True -- no router involved

    ``dst`` also accepts an address object or an :class:`IPv4Interface`/
    :class:`IPv6Interface` (its ``.ip`` is used). A hostname is resolved
    through ``getaddrinfo``, so ``ipv6=`` selects which of its records the
    route is computed for; the IPv4-only ``gethostbyname`` used here before
    meant an AAAA-only name reached no lookup at all.

    First hop only, deliberately: it is available **unprivileged** on every
    supported platform, whereas the full path requires raw sockets. See
    :func:`count_hops` for distance, which does not.

    Both address families are looked up, through ``GetBestRoute2`` on Windows,
    ``/proc/net/route`` and ``/proc/net/ipv6_route`` on Linux, and
    ``route -n get`` on macOS/BSD -- the one platform where this spawns a
    short-lived process, because there is no ``/proc`` to read and the
    ``PF_ROUTE`` socket is the only other option. Where the lookup cannot be
    made -- no route, no source to read, an unresolvable name -- ``gateway``
    is ``None`` **and so is** ``on_link``, which is how "we did not find out"
    is spelled. ``on_link is True`` means a real on-link determination.

    Never raises for an unknown route: unknown pieces come back as
    ``None``/``0`` rather than an error. A network passed as ``dst`` still
    raises :class:`TypeError`.
    """

    dst = _dst_argument(dst)
    parsed_dest = Host(dst).ip(ipv6=ipv6)

    gateway_text = None
    index = 0
    on_link = None
    if parsed_dest is not None:
        # The zone names the adapter, not the address, and neither the route
        # tables nor inet_pton accept it.
        resolved = str(_without_zone(parsed_dest))
        src = get_source_ip(resolved, ipv6=ipv6)
        wants_six = parsed_dest.version == 6
        try:
            if _IS_WINDOWS:
                answer = _windows_next_hop(resolved, wants_six)
            elif _IS_LINUX:
                answer = _posix_next_hop(resolved, wants_six)
            else:
                answer = _bsd_next_hop(resolved, wants_six)
        except (OSError, AttributeError, ValueError, _struct.error):
            answer = None
        if answer is not None:
            gateway_text, index = answer
            on_link = gateway_text is None
    else:
        src = get_source_ip(dst, ipv6=ipv6)

    return Route(
        dst=parsed_dest if parsed_dest is not None else dst,
        src=src,
        gateway=try_parse(gateway_text) if gateway_text else None,
        interface_index=index,
        on_link=on_link,
    )

tcp_check(dst, port, *, timeout=3.0)

Return True if a TCP connection to dst:port is accepted.

The honest reachability test, and what you almost always want instead of :func:netimps.ping: it proves the service answers, not merely that the host replies to ICMP echo (which most cloud firewalls drop anyway)::

tcp_check("example.com", 443)
tcp_check("db.internal", 5432, timeout=1.0)

dst also accepts an address object or an :class:IPv4Interface/ :class:IPv6Interface (its .ip is used).

Never raises for a reachability outcome: refused, timed out, unresolvable and unreachable all yield False. Only TCP handshake completion is checked -- not that the service behind the port is healthy. Two argument bugs are still raised rather than answered, because answering them would be answering a different question: a network (:class:IPv4Network/:class:IPv6Network) as dst raises :class:TypeError, and a port outside 0-65535 raises :class:ValueError instead of being masked to 16 bits by the socket layer (port + 65536 silently answered about port).

:param timeout: bounds the whole call, across every address dst resolves to. socket.create_connection would apply it once per resolved address after an unbounded getaddrinfo, so a name with N addresses could take N x timeout; here resolution happens once and the connects share one monotonic deadline. 0 is floored to a small positive value rather than taken literally -- settimeout(0) means non-blocking, which reported every open port as closed. None means no timeout at all.

.. note:: Not the same question as ping(dst, method="tcp", port=...). This asks "is the service up?", so a refused connection is False. That asks "is the host up?", and counts a refusal as success -- the RST proves something answered. Same distinction as a service check versus an ICMP echo. :func:wait_for_port and the scanners build on this one, because they care about the service.

Source code in src/netimps/_sockets/_connect.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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def tcp_check(dst: "HostLike", port: int, *, timeout: "Optional[float]" = 3.0) -> bool:
    """Return True if a TCP connection to ``dst``:``port`` is accepted.

    The honest reachability test, and what you almost always want instead of
    :func:`netimps.ping`: it proves the *service* answers, not merely that the
    host replies to ICMP echo (which most cloud firewalls drop anyway)::

        tcp_check("example.com", 443)
        tcp_check("db.internal", 5432, timeout=1.0)

    ``dst`` also accepts an address object or an :class:`IPv4Interface`/
    :class:`IPv6Interface` (its ``.ip`` is used).

    Never raises for a reachability outcome: refused, timed out, unresolvable
    and unreachable all yield ``False``. Only TCP handshake completion is
    checked -- not that the service behind the port is healthy. Two argument
    bugs are still raised rather than answered, because answering them would
    be answering a different question: a network
    (:class:`IPv4Network`/:class:`IPv6Network`) as ``dst`` raises
    :class:`TypeError`, and a ``port`` outside ``0-65535`` raises
    :class:`ValueError` instead of being masked to 16 bits by the socket layer
    (``port + 65536`` silently answered about ``port``).

    :param timeout: bounds **the whole call**, across every address ``dst``
        resolves to. ``socket.create_connection`` would apply it once *per
        resolved address* after an unbounded ``getaddrinfo``, so a name with N
        addresses could take N x ``timeout``; here resolution happens once and
        the connects share one monotonic deadline. ``0`` is floored to a small
        positive value rather than taken
        literally -- ``settimeout(0)`` means non-blocking, which reported every
        open port as closed. ``None`` means no timeout at all.

    .. note::
       **Not the same question as** ``ping(dst, method="tcp", port=...)``. This
       asks "is the *service* up?", so a refused connection is ``False``. That
       asks "is the *host* up?", and counts a refusal as success -- the RST
       proves something answered. Same distinction as a service check versus an
       ICMP echo. :func:`wait_for_port` and the scanners build on this one,
       because they care about the service.
    """
    dst = _dst_argument(dst)
    port = _coerce_port(port)
    budget = _connect_timeout(timeout)
    deadline = None if budget is None else _time.monotonic() + budget

    # Resolve once. create_connection resolves per call and then re-applies
    # the full timeout to each address it got, which is the overrun above.
    try:
        infos = _socket.getaddrinfo(dst, port, 0, _socket.SOCK_STREAM)
    except (OSError, UnicodeError, ValueError, OverflowError):
        return False

    for family, kind, proto, _canon, sockaddr in infos:
        remaining: "Optional[float]" = None
        if deadline is not None:
            remaining = deadline - _time.monotonic()
            if remaining <= 0:
                return False
        try:
            sock = _socket.socket(family, kind, proto)
        except OSError:
            continue
        try:
            sock.settimeout(remaining)
            sock.connect(sockaddr)
            return True
        except (OSError, ValueError, OverflowError):
            continue
        finally:
            sock.close()
    return False

wait_for_port(dst, port, *, deadline=30.0, interval=0.1, timeout=None)

Poll until dst:port accepts a connection, or deadline elapses.

The "wait for the service to come up" loop every deploy and container script contains::

if not wait_for_port("localhost", 5432, deadline=60):
    raise RuntimeError("database never started")

dst accepts the same forms as :func:tcp_check (address objects, IPv4Interface/IPv6Interface).

:param interval: delay between attempts. Backs off, growing by half each round, up to the larger of 1s and interval so a long wait does not spin and an interval above a second is never shortened. :param deadline: seconds the whole wait may take. :param timeout: per-attempt connect timeout; defaults to interval bounded to at least 1s.

Returns True as soon as the port answers, False once deadline has passed. The deadline is honoured overall, so this cannot overrun by more than one attempt regardless of how long individual connects block: :func:tcp_check bounds itself overall rather than per resolved address, so a dst resolving to N addresses does not stretch an attempt to N.

An out-of-range port raises :class:ValueError (from :func:tcp_check) rather than being masked to 16 bits.

Source code in src/netimps/_sockets/_connect.py
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
def wait_for_port(
    dst: "HostLike",
    port: int,
    *,
    deadline: float = 30.0,
    interval: float = 0.1,
    timeout: Optional[float] = None,
) -> bool:
    """Poll until ``dst``:``port`` accepts a connection, or ``deadline`` elapses.

    The "wait for the service to come up" loop every deploy and container
    script contains::

        if not wait_for_port("localhost", 5432, deadline=60):
            raise RuntimeError("database never started")

    ``dst`` accepts the same forms as :func:`tcp_check` (address objects,
    ``IPv4Interface``/``IPv6Interface``).

    :param interval: delay between attempts. Backs off, growing by half each
        round, up to the larger of 1s and ``interval`` so a long wait does not
        spin and an interval above a second is never shortened.
    :param deadline: seconds the whole wait may take.
    :param timeout: per-attempt connect timeout; defaults to ``interval``
        bounded to at least 1s.

    Returns ``True`` as soon as the port answers, ``False`` once ``deadline``
    has passed. The deadline is honoured overall, so this cannot overrun by
    more than one attempt regardless of how long individual connects block:
    :func:`tcp_check` bounds *itself* overall rather than per resolved address,
    so a ``dst`` resolving to N addresses does not stretch an attempt to N.

    An out-of-range ``port`` raises :class:`ValueError` (from
    :func:`tcp_check`) rather than being masked to 16 bits.
    """
    expires = _time.monotonic() + deadline
    per_try = timeout if timeout is not None else max(interval, 1.0)
    delay = interval

    while True:
        remaining = expires - _time.monotonic()
        if remaining <= 0:
            return False
        if tcp_check(dst, port, timeout=min(per_try, remaining)):
            return True
        remaining = expires - _time.monotonic()
        if remaining <= 0:
            return False
        _time.sleep(min(delay, remaining))
        # Backing off never shortens the interval the caller asked for.
        delay = min(delay * 1.5, max(interval, 1.0))

await_for_port(dst, port, *, deadline=30.0, interval=0.1, timeout=None) async

:func:wait_for_port as a coroutine: poll until dst:port accepts.

::

if not await await_for_port("localhost", 5432, deadline=60):
    raise RuntimeError("database never started")

Every argument means what it means on :func:wait_for_port, and so do the True and False it returns and the :class:ValueError for a port out of range. Each attempt is a non-blocking connect on the running loop and each wait is asyncio.sleep, so the loop keeps running and no thread is used for an address literal; a name is looked up with loop.getaddrinfo, which uses the loop's executor. Cancelling the task closes the socket of the attempt in progress and leaves nothing behind.

The deadline is a ceiling on the whole wait: an attempt is given no more than what is left of it, so the call returns within a small fraction of a second after deadline whatever the connect is doing.

Source code in src/netimps/_sockets/_aconnect.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
async def await_for_port(
    dst: "HostLike",
    port: int,
    *,
    deadline: float = 30.0,
    interval: float = 0.1,
    timeout: Optional[float] = None,
) -> bool:
    """:func:`wait_for_port` as a coroutine: poll until ``dst``:``port`` accepts.

    ::

        if not await await_for_port("localhost", 5432, deadline=60):
            raise RuntimeError("database never started")

    Every argument means what it means on :func:`wait_for_port`, and so do the
    ``True`` and ``False`` it returns and the :class:`ValueError` for a ``port``
    out of range. Each attempt is a non-blocking connect on the running loop and
    each wait is ``asyncio.sleep``, so the loop keeps running and no thread is
    used for an address literal; a name is looked up with ``loop.getaddrinfo``,
    which uses the loop's executor. Cancelling the task closes the socket of the
    attempt in progress and leaves nothing behind.

    The ``deadline`` is a ceiling on the whole wait: an attempt is given no more
    than what is left of it, so the call returns within a small fraction of a
    second after ``deadline`` whatever the connect is doing.
    """
    import asyncio

    loop = asyncio.get_running_loop()
    expires = loop.time() + deadline
    per_try = timeout if timeout is not None else max(interval, 1.0)
    delay = interval

    while True:
        remaining = expires - loop.time()
        if remaining <= 0:
            return False
        if await _tcp_check(loop, dst, port, min(per_try, remaining)):
            return True
        remaining = expires - loop.time()
        if remaining <= 0:
            return False
        await asyncio.sleep(min(delay, remaining))
        # Backing off never shortens the interval the caller asked for.
        delay = min(delay * 1.5, max(interval, 1.0))