Skip to content

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 08 Oct 02:35

Added

  • bind(..., device=<interface>) restricts a socket to one device, so a
    wildcard socket receives what arrives on that adapter only: the way to listen
    for the broadcasts of one adapter on a multi-homed host, which a socket bound to
    the adapter's address cannot hear on Linux. has_device_binding() says
    whether the platform can, and DeviceBindingUnsupportedError (a
    NetimpsError and an OSError) is what bind(device=...) raises where it
    cannot. Linux uses SO_BINDTODEVICE, measured unprivileged on kernel 6.18 for
    IPv4 and IPv6; Windows has no option that restricts receive, so it is
    unsupported there; macOS 15.7 and FreeBSD 16.0 were measured and are
    unsupported too (macOS accepts IP_BOUND_IF, which scopes what is sent but
    does not restrict what is received). device= with interface= raises ValueError.

  • format_address(address) -> str writes an address object as the same text
    on every Python: a v4-mapped IPv6 address in the mixed form
    (::ffff:1.2.3.4, RFC 5952 section 5), where str() writes the hex form
    (::ffff:102:304) before 3.13; any other address as str() does, a %zone
    kept and an interface's /prefix kept. It takes address and interface
    objects and raises TypeError for anything else.

  • classify(text) reads text as a MACAddress, an IPNetwork, an
    IPInterface or an IPAddress, in that order of preference (a / makes it
    a network, or an interface when host bits are set), and raises
    NetimpsValueError for anything else. It never asks a resolver.
    PortsLike accepts a comma-separated string (scan_ports(host, "22,https,8000"); an empty item raises), and get_default_port accepts a
    port number as text
    and returns it as an int (outside 0-65535 raises
    NetimpsValueError); the text is ASCII digits and nothing else, and a
    scheme that is not text raises TypeError. The command line used to do each of these itself.

  • get_interface and iter_interfaces look an interface up by name and by
    index
    : get_interface("eth0") for text that is no address, network or MAC,
    and get_interface(index=3) (a keyword, since an int query is an address).
    Datagram.interface_index and Route.interface_index now have a way back to
    an Interface.

  • aretry(func, attempts=3, *, delay, multiplier, max_delay, jitter, retryable, on_retry, jitter_seconds, symmetric) and await_for_port(dst, port, *, deadline=30.0, interval=0.1, timeout=None) are retry and wait_for_port
    as coroutines: the same arguments, schedule, answers and exceptions, with the
    waits as asyncio.sleep and the connect as a non-blocking connect on the
    running loop, so no thread is used for an address (a name is looked up with
    loop.getaddrinfo, the loop's executor). Cancelling leaves no socket and no
    task behind, and await_for_port ends within a second after its deadline.
    import netimps still does not import asyncio. The header's asyncio section
    now lists by name what awaits without a thread and what needs to_thread or
    run_in_executor, with the functools.partial form for keyword-only options.

  • Interface.is_multicast and Interface.is_point_to_point: the kernel's
    IFF_MULTICAST and IFF_POINTOPOINT on POSIX, and on Windows the absence of
    the adapter's no-multicast flag and a PPP, SLIP or tunnel adapter type; None
    when the system did not say (the degraded enumeration, a hand-built value, a
    POSIX platform whose flag value is not known). Part of equality, the hash and
    the repr, and shown by netimps interfaces ([point-to-point], and both in
    --json). Linux reports its loopback as not multicast-capable.

  • cache= on the lookups that resolve an adapter: iter_addresses,
    bind (for interface= and device=), join_group, leave_group,
    multicast_socket and ping (for src=) take get_interfaces's argument,
    so a caller making many of them pays one enumeration. The default stays
    False: nothing changes for a caller who does not ask. get_interface(mac)
    returns the first adapter carrying a MAC, in enumeration order, and
    iter_interfaces(mac) yields them all; the header says so.

  • Interface.is_up: True when the interface is usable (IFF_UP and
    IFF_RUNNING on POSIX, the operational status on Windows), False when it is
    not, None when the system did not say; part of equality, the hash and the
    repr, and shown by netimps interfaces ([down], and is_up in --json).

  • UDPEndpoint.asend(data, dst, port, *, src=None): send awaited, on
    every loop type. It waits for writability, with the loop running, when the
    kernel's send buffer is full.

  • One exception base and four new exported classes. NetimpsError is the
    base of everything netimps raises on its own account;
    NetimpsValueError(NetimpsError, ValueError) is for text that is not the
    value it was asked to become; ResolutionTimeoutError(ResolutionError, TimeoutError) is what the resolvers now raise when a deadline expires
    (resolve_system, resolve_nslookup, resolve_wire, resolve_doh);
    DNSDecodeError(NetimpsValueError) is the DNS codec's error, formerly the
    private WireError(ValueError).

  • UDPEndpoint.aclose(), async with UDPEndpoint(...). aclose() is
    close() for a coroutine: it waits for the reader thread arecv may have
    started to leave, with the loop running, where close() joins it on the
    calling thread. close() and aclose() are each complete on return and
    harmless when called again, in either order.

  • Host.resolve() and FQDN.resolve() return the pair (fqdn, ip), and
    Host.ip(), Host.fqdn() and FQDN.ip() take the resolver options
    (ns, timeout, port, tcp, search, backends, source) and
    check=; Host.ip() and FQDN.ip() also take ipv6=, Host.fqdn() does
    not. check=True raises where None would be returned: NoAnswerError
    for an empty answer or an empty host, ResolutionError for an outage. ip() of a name looks
    it up and of an address does not; fqdn() of an address looks it up and of a
    name does not. With none of ns, port, tcp, source or backends the OS
    resolver alone answers, as get_ip did; naming one selects resolve()'s own
    chain. resolve() accepts rdtype=("a", "aaaa") for both families at once.

  • MACAddress.__format__ and __bytes__: f"{mac:-X}" is mac.format("-", upper=True) and bytes(mac) is the six octets.
    FQDN.encode(), bytes(name), FQDN.decode(data) and FQDN.decode_at(data, offset)
    write and read the RFC 1035 wire form; decode_at follows compression
    pointers with loop detection and returns (name, end) for use inside a
    message. Malformed data raises DNSDecodeError. The package's own reply
    reader shares the same label reader, and now also refuses a label over 63
    octets and a name over 255.

  • MACAddress.parse, FQDN.parse and Host.parse build the type from
    text, raising NetimpsValueError for bad text and TypeError for a
    non-str; Host.try_parse(text, default=None) joins the other two, whose
    try_parse gains default=. Host.parse refuses only empty or blank text.

  • is_unicast(address, interface=None, *, cache=False) and Datagram.is_unicast: false for the wildcard, a multicast group and a broadcast (limited or subnet), true otherwise. Datagram.is_unicast is None when there was no pktinfo to say where the datagram went.

  • WS-Management schemes in the registry: wsman 5985, wsmans 5986 (IANA), winrm 5985, winrms 5986 and psrp 5985, so get_default_port("winrm") is no longer None. get_default_scheme(5985) is wsman and get_default_scheme(5986) is wsmans.

  • cache= on resolve(), Host.ip/fqdn/resolve and FQDN.ip/resolve, with clear_resolution_cache() and RESOLUTION_CACHE_TTL (30 seconds). False (the default) never reads or writes the cache, True uses the TTL, a number is the TTL, as on get_interfaces. Keyed on the name and every option; an empty answer is cached like any other, an outage is not. A call that passes cache= neither reads nor writes a Host's memo.

  • split_zone(text), is_local_host(host, *, resolve=False, cache=False) and split_host of a (host, port) pair. split_zone("fe80::1%eth0") is ("fe80::1", "eth0"); is_local_host is true for a loopback or locally assigned literal, localhost, and this machine's own name, and resolves any other name only when asked; split_host(("h", None), default_port=69) is ("h", 69).

  • UDPEndpoint.datagrams(*, on_error=None): a callable given the exception from a failed receive; return true to carry on with the next datagram. Without it the loop still stops at the first error.

  • Four aliases that already appeared in public signatures are exported:
    InterfaceLike (what names a local interface), InterfaceQuery (what
    get_interface looks up), PortsLike and SocketAddress.

Renamed

Breaking. No alias is kept: the old name is gone. Acronyms are spelled in
capitals, an input alias is named for the type it becomes, and a predicate or
probe says so in its prefix.

Before Now
UdpEndpoint UDPEndpoint
Fqdn FQDN
FqdnLike FQDNLike
MACLike MACAddressLike
AddressLike HostLike
normalize_host split_host
interface_for get_interface
interfaces_for iter_interfaces
hop_count count_hops
supports_pktinfo has_pktinfo
supports_recvmsg has_recvmsg
socket_patched is_socket_patched
APIPA LINK_LOCAL_V4
InterfaceSpec InterfaceLike (it was never exported, so importing it from the root already failed)
HOST_DN get_hostname() (a constant becomes a call)
UdpEndpoint.supports_pktinfo UDPEndpoint.has_pktinfo
UdpEndpoint.supports_src_pinning UDPEndpoint.has_src_pinning
Interface.loopback, Interface(loopback=) Interface.is_loopback, Interface(is_loopback=)
MACAddress.as_str(sep, upper) MACAddress.format(sep, *, upper=), format(mac, "-X")
Fqdn.wire (property) FQDN.encode(), bytes(name)
Fqdn.unicode (property) FQDN.to_unicode()
Fqdn.as_fully_qualified() FQDN.fully_qualified()
Host.value = ... read-only; build a new Host
Interface.ips (list) Interface.ips (tuple)
MACAddress._VALID_MAC MACAddress.is_valid(text)
get_ip(x) Host(x).ip() (removed; check=True raises where get_ip returned None)
Host.fqdn (property) Host.fqdn() (method; reverse lookup for an address)
Host.ip(refresh) Host.ip(*, check, ipv6, refresh, <resolver options>)
Fqdn.resolve() -> records FQDN.resolve() -> (fqdn, ip); records come from netimps.resolve(name, rdtype)
wait_for_port(timeout=) (whole wait) wait_for_port(deadline=)
wait_for_port(connect_timeout=) (one attempt) wait_for_port(timeout=)
(Fqdn/Host method) strict= check=
UDPEndpoint.send(data, address=, port, src) UDPEndpoint.send(data, dst, port, *, src=)
UDPEndpoint.recv/arecv/datagrams(bufsize, resolve_interface) positional resolve_interface= keyword-only
UDPEndpoint.reply_socket(datagram, port, connreset) positional connreset= keyword-only
Interface.primary_ip(ipv6, loopback_ok) positional loopback_ok= keyword-only
retry/backoff_delays/Backoff _sleep= and _random= parameters removed; they were the package's own test hooks and have no replacement
PingResult.rtt_ms (milliseconds) PingResult.rtt (seconds)
PingResult.host, PingResult(host=) PingResult.dst, PingResult(dst=)
Datagram.local_address, Datagram(local_address=) Datagram.destination, Datagram(destination=) (the address the datagram was sent to)
NETIMPS_NO_SOCKET_PATCH NETIMPS_SOCKET_PATCH (sense inverted: a false spelling opts out; the old variable set at all is an error at import)
netimps.cli:run netimps.cli:main

Changed

  • The cli extra needs duho 0.7: duho>=0.7.0,<0.8, where it was
    >=0.6.0,<0.7. The netimps command, its options and its output are the
    same; the library itself still imports nothing of it.

  • An address object is written the same on every Python. join_host of a
    v4-mapped IPv6 address on Python before 3.13 is [::ffff:1.2.3.4]:80, where
    it was [::ffff:102:304]:80; the same goes for str(Host(address)),
    split_host and split_zone of an address object, the repr of Route and the
    addresses the command line prints (format_address). Text passed in as a
    str is not re-rendered.

  • The shipped API header is split. netimps/AGENTS.md keeps every public
    name with its signature and one sentence, the exceptions, the command line's
    contract, the environment variables (NETIMPS_SOCKET_PATCH with its accepted
    spellings and its import-time error, NETIMPS_MCP, AGENT_HELP), the asyncio
    rules and the cross-cutting gotchas; the detail of each large topic is in an
    AGENTS.md beside the code that implements it (_dns, _ping, _sockets,
    _ifaddrs, _ip, _fqdn, _msg, _udp and cli), listed in a table in the
    top header. All of them ship inside the package.

  • The command line is the netimps.cli package, and its entry point is
    netimps.cli:main(argv=None) -> int.
    netimps.cli:run is gone (it could
    return None); the netimps console script and python -m netimps call
    main.

  • netimps scan exits 1 when nothing answered (it exited 0), and
    netimps port 99999 exits 2 (it exited 1): 0 found or yes, 1 nothing
    found or the answer was no, 2 an error, for every command.

  • -q prints no result line and answers by the exit status alone, as
    grep -q; it was only the log level. Errors still go to stderr and
    --json is not affected.

  • Method options are keyword-only, breaking. UDPEndpoint.recv, arecv
    and datagrams take bufsize positionally and resolve_interface by name;
    UDPEndpoint.send(data, dst, port, *, src=None) names its destination dst
    (it was address) and takes src by name; reply_socket(datagram, port=0, *, connreset=False); Interface.primary_ip(ipv6=False, *, loopback_ok=True).
    retry, backoff_delays and Backoff no longer take _sleep and _random
    parameters: they were the package's own test hooks. UDPEndpoint.datagrams()
    is typed
    AsyncIterator[Datagram].

  • bind() defaults, breaking. family=None (was AF_INET) infers the
    family from the address, so bind("::1") is an IPv6 socket; the wildcard
    "" stays IPv4, and a name is IPv4 when it has an IPv4 address.
    connreset=None (was True) means off for a datagram socket and
    untouched for any other; True and False still force it. On Windows an
    unconnected UDP socket from bind() no longer raises ConnectionResetError
    on a later receive after an ICMP port-unreachable. A failed bind() now
    carries the bind_error_hint text in the message of every failure that
    function recognises, keeping its OSError subclass and errno;
    set_buffer_size logs a WARNING when the kernel grants less than was
    asked, once per process for each distinct shortfall.

  • MACAddress._VALID_MAC is gone. It was a private class attribute the class docstring invited callers to read; screen text with MACAddress.is_valid(text).

  • UDPEndpoint.send(src=<address>) enumerates no interfaces. An address src is used as given and the kernel picks the adapter (a %zone still names one); a MAC or an adapter name still resolves, and an Interface still pins the adapter as well.

  • Host, MACAddress, PingResult, Route and Interface are read-only.
    Assigning to or deleting any attribute raises AttributeError; build a new
    value instead. All six value types (FQDN already was) copy and pickle.
    Interface.ips is a tuple rather than a list, so iface.ips.append(...)
    and the other list mutators raise; the constructor still takes any iterable.
    Interface.loopback is gone: read is_loopback. A cached
    get_interfaces(cache=...) call now hands back the stored Interface
    objects in a fresh list, copying only raw.

  • Host.fqdn is a method, and Host.ip takes keyword-only options.
    host.fqdn becomes host.fqdn(); host.ip(True) becomes
    host.ip(refresh=True), which asks again and replaces the memo as before.
    A call that passes any other option neither reads nor writes it. FQDN.resolve() no
    longer returns DNS records: call netimps.resolve(name, rdtype) for those.

  • Options are keyword-only. 37 callables keep only their operands as
    positional parameters, and every option after them must be named:
    ping("h", 3) is now ping("h", tries=3). Parameter names, order and
    defaults are unchanged. Positional parameters kept:
    none for get_interfaces; one for Backoff, Route, UDPEndpoint,
    count_hops, discover_mtu, get_free_port, get_interface, get_route,
    iter_addresses, max_udp_payload, patch_socket_module, ping,
    set_buffer_size and split_host; two for Interface (name, index),
    PingResult (ok, dst), backoff_delays, get_pmtu, get_source_ip,
    get_tcp_mss, join_group, leave_group, multicast_socket,
    register_port, resolve, resolve_dnspython, resolve_doh,
    resolve_nslookup, resolve_system, resolve_wire, retry, scan_hosts,
    scan_ports, tcp_check, try_parse and wait_for_port. recvmsg,
    sendmsg, CMSG_LEN, CMSG_SPACE, Datagram and SocketOption keep their
    shapes. parse, try_parse and is_valid name strict (the network
    builders' option) instead of an untyped **kwargs; try_parse(value, type, default) is now try_parse(value, type, default=default).

  • wait_for_port and PingResult say which time they mean. timeout
    is one attempt and deadline the whole operation, as everywhere else in the
    package; PingResult.rtt is a duration in seconds, where rtt_ms was
    milliseconds. A sub-millisecond reply is still 0.0. The command line's
    ping --json keeps its host and rtt_ms keys, converting for display.

  • discover_mtu names the options it forwards. tries=, ipv6= and
    ttl= replace **ping_kwargs; passing size= or dont_fragment=, which the
    search varies itself, is now Python's own TypeError for an unexpected
    keyword rather than a bespoke one.

  • Annotations say what the code accepts and returns. resolve,
    resolve_dnspython, resolve_system, resolve_nslookup, resolve_wire and
    resolve_doh are overloaded on rdtype: "a" gives List[IPv4Address],
    "aaaa" List[IPv6Address], "ptr" List[str], anything else
    List[Any]. method= on ping and discover_mtu is
    Literal["icmp", "tcp", "udp"]; is_broadcast(address: IPAddressLike, interface: Optional[Interface]); join_host and split_host take
    HostLike; parse, try_parse and is_valid take strict and typed
    **options. No parameter of a public callable is a bare Any.

  • MACAddress.try_parse and FQDN.try_parse take text only. They answer
    None (or the new default=) for text that does not parse, and raise
    TypeError for anything that is not a str -- MACAddress.try_parse(None),
    FQDN.try_parse(3.5) -- where they returned None. The generic
    netimps.try_parse(value, type) is unchanged and still answers default
    for any object. For MACAddress, FQDN, Host and subclasses it now
    builds a str through Type.parse; an int, bytes or built value still
    goes to the constructor.

  • HOST_DN is replaced by get_hostname(*, fqdn=False). A constant
    computed from platform.node() made every import netimps ask for the host
    name, a WMI query on Windows; the function asks when called. fqdn=True
    returns socket.getfqdn().

  • NETIMPS_NO_SOCKET_PATCH is replaced by NETIMPS_SOCKET_PATCH, with
    the sense turned round: 0, false, no or off opts out of the
    import-time socket patch, and unset still means patched. A value that is
    none of the documented spellings, or the old variable set to anything,
    raises ValueError at import naming the variable, so that a stale setting
    cannot silently re-enable the patch.

  • HostLike (formerly AddressLike) now includes Host and FQDN, which
    every destination parameter already accepted at run time.

  • Malformed text raises NetimpsValueError from parse, MACAddress,
    FQDN, split_host, join_host and the query check of
    resolve_nslookup; message unchanged. It is a ValueError, so
    except ValueError still catches it. parse now also converts the
    ipaddress builders' own errors, chained as __cause__.

  • UDPEndpoint.recv and send raise the builtin TimeoutError when the
    socket's timeout expires. From Python 3.10 that is what socket.timeout
    already is; on 3.9 it was only an OSError.

  • ResolutionError and AddressInUseError now also derive from
    NetimpsError. Every existing except clause keeps matching.

  • resolve_wire and resolve_doh chain an unreadable reply's
    DNSDecodeError as the __cause__ of the ResolutionError they raise.

  • Every program netimps runs (ping, nslookup, route, traceroute,
    tracert) is started the same way.
    It is found on PATH before anything
    runs, so a missing one is named in the error; standard input is closed;
    LC_ALL=C is added to the child's environment, so output no longer follows
    the user's locale; and output is decoded explicitly with
    errors="replace" (the OEM code page on Windows, UTF-8 elsewhere) instead of
    the locale's default, so a byte that does not decode no longer raises
    UnicodeDecodeError out of count_hops or get_route. A deadline now kills
    the program's children as well as the program. A program that resolves to a
    .bat or .cmd is refused. What each function reports for a missing or hung
    program is unchanged.

  • A resolver that could not be asked is no longer an empty answer.
    resolve_dnspython raises ResolutionTimeoutError for a timeout and
    ResolutionError for every server failing (a SERVFAIL, a refused port) or no
    resolver configuration, where it returned []; resolve_system raises
    ResolutionError for a temporary failure (EAI_AGAIN, EAI_FAIL, herror
    2 and 3) and keeps [] for EAI_NONAME and no data; resolve_nslookup
    reads the reason after the colon of "can't find : ", so
    Windows' No response from server (exit 0) and SERVFAIL raise while
    Non-existent domain stays []. resolve() without strict still answers
    [], strict=True now raises where it could not before, and cache= stores
    nothing for an outage. resolve_dnspython no longer turns every exception
    into ValueError("invalid DNS query"): only a malformed name or an unknown
    record type is a ValueError, an OSError is a ResolutionError, and
    anything else propagates; dnspython's missing-configuration error no longer
    escapes resolve() raw.

  • A missing dns extra is named. resolve_dnspython raises
    ResolutionError saying pip install "netimps[dns]", and resolve() raises
    the same for a request only dnspython could serve (a record type the other
    backends do not read) instead of returning []. New has_dns() -> bool.

  • resolve_doh(..., allow_http=False). A URL that is not https:// is a
    ValueError before a request is made; allow_http=True accepts plain
    http://. The default fetch follows no redirect (a 3xx is a
    ResolutionError), reads at most 65,536 bytes of a reply, and names the URL
    in messages without its credentials, query string or fragment.

  • deadline= on resolve(), Host.ip/fqdn/resolve and FQDN.ip/resolve.
    timeout is one attempt and means different things per backend (the whole
    call for dnspython and the wire backend, each candidate name for the OS
    resolver and nslookup), so a backend chain, a record-type pair and a search
    list multiplied it. deadline is the total for the call, shared by all of
    them; the header states what each timeout bounds.

  • ResolutionError is an OSError, and NoAnswerError is new.
    ResolutionError(NetimpsError, OSError), as the standard library's own name
    failure socket.gaierror is, so except OSError around a connect catches it
    and retry() retries it by default. NoAnswerError(ResolutionError) is what
    check=True on Host.ip/fqdn/resolve and FQDN.ip/resolve raises when the
    lookup completed with no such name or record; an outage stays a plain
    ResolutionError or ResolutionTimeoutError. retry's docstring and the
    header say how to leave NoAnswerError out.

Removed

  • get_ip(address, ipv6=None). Host(x).ip() is the same
    operation with the name kept, and Host(x).ip(check=True) raises
    NoAnswerError (a ResolutionError) where get_ip returned None. get_route and the
    addr command use it; Host(x).resolve() gives (fqdn, ip).

Fixed

  • is_wildcard takes what its annotation says: a Host or an FQDN
    (HostLike), read as its text. A Host holding a name raised
    NetimpsValueError; it answers False, as the same text does.

  • Host.ip, Host.fqdn, Host.resolve, FQDN.ip and FQDN.resolve are
    annotated by check
    : with check=True they raise instead of returning
    None, and the annotations say so (an IPAddress, an FQDN, a pair of
    both), so a caller needs no assertion. Nothing else changes at run time.

  • On Windows the os.sysconf stand-in answers SC_OPEN_MAX. The socket
    patch installs os.sysconf where the platform has none, and it answered
    SC_IOV_MAX alone. A library that reads SC_OPEN_MAX behind
    hasattr(os, "sysconf"), as an SFTP server does to size its handle limit,
    got a ValueError on every session. It is 8192, the size of the C runtime's
    descriptor table.

  • A name the IDNA codec refuses no longer escapes as UnicodeEncodeError.
    An empty label (a..b) or a label over 63 octets is False from
    is_local_host(..., resolve=True) and a NetimpsValueError from
    resolve_system.

  • get_default_port(None) and get_default_port(22) raise TypeError,
    where an AttributeError came out of the function.

  • resolve_doh closes the response of an HTTP error status. The error
    urllib raises is itself an open response; it was left for the garbage
    collector, which reports it as a ResourceWarning at an unrelated moment.

  • is_broadcast and is_unicast read an interface object as its address.
    IPv4Interface is an IPv4Address subclass and was compared as address and
    network, so is_broadcast(IPv4Interface("10.0.0.255/24"), nic) was false and
    is_unicast of it true; every classifier now reduces an interface to its
    .ip first.

  • One zone rule for interface_index, interface_address and
    get_interface.
    interface_index("::1%nosuchadapter") was the loopback
    index with the zone ignored and ("::1%999") was 999 unchecked, while
    get_interface found nothing for both. A zone must name the adapter that
    holds the address, and a strict lookup of one that does not raises. The
    address in an interface spec must be held locally: fe80::1%12 for an
    address no adapter holds is no longer index 12.

  • A family is 4 or AF_INET, 6 or AF_INET6, wherever one is taken.
    has_pktinfo(4) answered False and iter_addresses(family=AF_INET) raised;
    bind, get_free_port, has_pktinfo and iter_addresses now share one
    reader, and anything else raises ValueError.

  • Windows no longer lists addresses it cannot bind. An address the system
    marks tentative or duplicate is left out of Interface.ips: the four
    media-disconnected adapters on the review machine each held a 169.254
    address in the state Tentative, which bind refused with WSAEADDRNOTAVAIL
    while is_local_address and primary_ip returned it. The adapters stay
    listed, with is_up false. The Windows interface index falls back to
    Ipv6IfIndex when IfIndex is 0, so an adapter with IPv4 unbound is not
    reported as having no index.

  • Interface is a value type that checks itself. The constructor raises
    TypeError for a wrong-typed field (it accepted mac="notamac", ips="ab"
    and name=5), ipv4 and ipv6 are tuples like ips, raw is a read-only
    mapping with tuples inside (a list inside a cached raw was shared between
    callers), and repr is a constructor call that rebuilds an equal value
    (it printed the addresses as text).

  • clear_interface_cache() is not lost to an enumeration in flight. A
    thread that began before the clear stored its pre-clear snapshot afterwards,
    and get_interfaces(cache=math.inf) served it indefinitely; a clear now
    invalidates every enumeration started before it.

  • A defect in the native interface walk is not reported as a degraded host.
    The dispatch caught OSError, AttributeError and ValueError, so a bad
    pointer read became a one-interface answer; only OSError degrades, with the
    reason in raw and logged once at debug.

  • A BSD netmask is read no further than its own length. macOS trims a
    netmask sockaddr after its last non-zero byte (255.0.0.0 arrives as 5
    bytes) and what follows belongs to the next structure. The bytes past
    sa_len are taken as zero instead of being read.

  • retry, backoff_delays and Backoff share one set of argument rules,
    checked when they are called.
    backoff_delays was a generator, so
    attempts=0 or a negative delay raised at the first next();
    backoff_delays(3, 1.0, multiplier=0.5) yielded a shrinking [1.0, 0.5]
    and backoff_delays(2, 4.0, max_delay=1.0) cut the first wait to [1.0]
    while Backoff floored the multiplier and the ceiling instead. All three now
    raise ValueError for attempts below 1, a negative delay, a multiplier
    below 1, a max_delay below delay, a jitter outside 0 to 1 and a negative
    jitter_seconds. retry is typed Callable[[], T] -> T (it returned
    Any).

  • scan_hosts bounds the work, not one factor of it, and does not build it
    up front.
    The guard counted hosts only and every host-port pair was built
    and queued before the first probe: a /22 with the common ports peaked at
    63 MiB (about 1.7 kB a pair), and a /16 over all ports was 4.3 billion
    tuples. A sweep of more than 4,194,304 probes now raises ValueError before
    any probe (a /16 with the common ports is still allowed), and scan_hosts
    and scan_ports feed their pool as they run: a /22 with the common ports
    peaks near 0.1 MiB.

  • A destination of None raises TypeError. tcp_check(None, 80),
    get_source_ip(None) and every other dst parameter asked the resolver for
    the host named "None" and answered False or None; dst takes the same
    allowlist as split_host (a string, an address, an interface, a Host or an
    FQDN), so an int, bytes or None raises. Host(None) is still the
    empty host.

  • bind reads options once, and reuse_port=True shares a UDP port on
    Windows.
    A generator passed as options was used up by a scan for an
    address-takeover request, so none of its options were applied. On Windows the
    flag was a no-op and the second bind of a port raised AddressInUseError; a
    datagram socket now takes the address-sharing path, as on Linux and macOS two
    sockets that both pass it bind one port (another process can take the port
    over there too). A stream socket on Windows and every bind without the flag
    stay exclusive.

  • get_free_port infers the family as bind does and takes any host:
    get_free_port("::1") raised gaierror.

  • wait_for_port no longer shortens an interval above one second: it
    backed off to a flat second after the first wait, so interval=5 polled
    sixteen times in twenty seconds instead of four. is_local_host reads the
    inet_aton spellings
    (127.1, 2130706433, 0x7f.0.0.1) as the address
    they spell. One timeout floor: the scanners floored to 1 ms and
    tcp_check again to 50 ms; the scanners now pass the timeout on and the
    floor is tcp_check's 50 ms.

  • discover_mtu reports what it measured. It resolved the name a second
    time for the header overhead and ignored ipv6=, so a 1480-byte IPv4 path
    was reported as 1500 when the name's first record was IPv6; the target, the
    family and the overhead now come from one resolution. method="tcp" ignored
    ipv6= (an IPv4-only listener gave 65555 for ipv6=True) and added the IPv6
    header to an IPv4 segment size; it now uses the family that connected, and
    get_tcp_mss gains ipv6=. A probe that was answered at high returned
    high (9000 on a loopback whose MTU is 65535); the search now continues to
    the outgoing interface's MTU, and high is only the ceiling when that MTU
    cannot be read, where the result means "at least". On macOS the answer
    stopped at 8192, net.inet.raw.maxdgram, and took 8.7 s; the limit is read,
    a local destination is reported at the loopback MTU, and another path that
    reaches it is retried with method="udp". discover_mtu("127.0.0.1") is the
    loopback MTU (65535 on Windows).

  • The socket patch survives a second import of the package, a bad call and
    a stream socket.
    A second copy of the package in one process captured the
    first copy's installed recvmsg as the native one, so on Windows
    UDPEndpoint.recv().destination came out as 1.0.0.0 with index 0 and
    is_socket_patched() said False while the method was installed; it now
    recognises and takes over what the first installed.
    patch_socket_module(iov_max=0) raised after installing the patch; it
    validates first. recvmsg on a Windows stream socket failed with
    WinError 10022; it uses WSARecv.

  • IPv4 on FreeBSD reports the arrival and pins the source. UDPEndpoint
    there set IP_PKTINFO, which FreeBSD lacks, so has_pktinfo was false and
    destination was None, while has_src_pinning was true and
    send(src=) raised OSError 42. It now uses IP_RECVDSTADDR and
    IP_RECVIF to receive and an IP_SENDSRCADDR message to pin; the pin needs
    a wildcard-bound, unconnected socket, so has_src_pinning is false for an
    endpoint bound to an address. Measured on FreeBSD 16.0; IPv6 is unchanged.

  • Pinning a source on Windows. A wildcard-bound IPv6 endpoint pinned by an
    interface with no IPv6 address, or by ::, sent from ::; both raise
    ValueError now, as the IPv4 twin did. A dual-stack endpoint pins an IPv4
    source (it failed with WinError 10022). A pinned send to a host name
    resolves it (it failed with illegal IP address string passed to inet_pton).

  • Closing a UDPEndpoint ends a pending arecv, and arecv leaves the
    socket's timeout alone.
    close() and aclose() from another task now wake
    a task in arecv with RuntimeError and finish a datagrams() loop
    (both hung on the Windows Proactor loop and on Linux). The loop's reader is
    removed by the descriptor it was registered under, so closing the socket and
    then cancelling the task no longer leaves it registered: a Windows selector
    loop died with WinError 10038 out of run_until_complete. One arecv used
    to leave a socket with settimeout(5.0) at 0.0, so the next recv() raised
    BlockingIOError at once.

  • ping bounds its own name lookup by timeout (2.5 s for timeout=0.5
    with a 2 s lookup before), and takes the family of src from the
    destination: ping("::1", src=<loopback Interface>) pinned the interface's
    IPv4 address on an IPv6 probe and was falsy on Windows, macOS and FreeBSD.
    The header and docstring now say that a TCP refusal takes about two seconds
    to arrive on Windows, so ping(method="tcp") needs a timeout of 3 there.

  • A DNS reply is bounded. A short MX or SRV record is a DNSDecodeError
    (it escaped as struct.error); a name follows at most 32 compression
    pointers (one 65,000-byte reply cost 9.6 s of CPU); the UDP buffer is the
    1,232 bytes the query advertises; a datagram with another id or question is
    discarded instead of ending the query; the TCP read carries the whole
    deadline (a byte every 0.3 s held timeout=0.5 for 12 s); a nameserver port
    outside 1-65535 is a ValueError; and bytes outside letters, digits, hyphen
    and underscore in a decoded name are written \DDD. resolve_nslookup
    refuses an ns or search domain that nslookup would read as an option.

  • The programs the library runs are looked up in the absolute PATH
    entries only.
    On Windows shutil.which searched the working directory
    first, so ping, nslookup, tracert and route ran a same-named file
    next to the caller; a relative or empty PATH entry is now skipped on every
    platform. The runner refuses a call without a timeout:
    resolve_nslookup(timeout=None) allows 30 seconds instead of waiting
    forever. count_hops gives None when traceroute or tracert exits
    non-zero, and the header names the supported programs.

  • recvmsg and sendmsg honour a socket timeout on Windows. A socket
    with a timeout is non-blocking underneath, and the Winsock calls returned
    at once: UDPEndpoint.recv() on a socket with settimeout(0.3) raised
    BlockingIOError after 0.000 s instead of waiting. Both now wait for
    readiness and raise socket.timeout when the time runs out, as the
    stdlib methods do on POSIX. A blocking socket and a non-blocking one
    behave as before.

  • FQDN compares by its case-folded key everywhere. is_subdomain_of
    and relative_to were case-sensitive where == and hash were not
    (FQDN("www.Example.com").is_subdomain_of("example.com") was False), and
    example.com against example.com. answered a > b and b > a together.
    The four ordering operators now derive from one key, reversed folded labels
    then absoluteness.

  • FQDN validates after IDNA mapping, with one label rule shared by the
    text and the wire entries.
    Fullwidth digits and the ideographic full stops
    mapped to 127.0.0.1 and constructed FQDN('127.0.0.1'); U+3002 became a
    dot inside one label. A label with a space, a control character or one of
    : / ? # [ ] @ (FQDN("http://example.com")) constructed, then failed
    FQDN.decode(v.encode()). All now raise NetimpsValueError. bytes and an
    iterable item that is not a str or an FQDN (FQDN(b"abc") was
    FQDN('97.98.99'), FQDN(["www", None]) was www.None) raise TypeError.
    /, child and with_hostname refuse a result over 253 octets, where
    encode() used to emit 259; encode_name refuses over 255 on the wire.

  • split_host, split_zone and join_host share one host rule and one
    port rule.
    join_host accepted a network ('10.0.0.0/24:80'), bytes
    ("b'h':80"), a float port ('h:80') and True ('h:1'), and
    split_host read "h:8_0", "h:+80", "h: 80" and Arabic-Indic digits as
    port 80; port text is now ASCII digits and every port goes through the one
    range gate. split_host("[10.0.0.5]:80") and "[not an address]:80" raise,
    as join_host already refused them. A value that is not a host type at all
    (None, an int, bytes) raises TypeError from all three, where
    split_host/split_zone/join_host raised NetimpsValueError; so does a
    non-int port. Text that does not parse stays NetimpsValueError.

  • resolve(ns="host:port") answers the same with and without dnspython.
    The host, host:port, [v6] and [v6]:port spellings resolve_wire
    documents are read by one parser for every backend: resolve_dnspython
    hands dnspython each server's own port (it raised ValueError for
    host:port), and a malformed ns is a NetimpsValueError before any
    backend runs. resolve_nslookup takes host, [v6] or either with :53
    and raises NetimpsValueError naming port= for another port, which
    nslookup cannot use; the chain leaves it out for such an entry.

  • The classifiers answer the same on every Python and take one input rule.
    is_link_scoped and is_wildcard answered True on 3.13 and later and
    False before for a v4-mapped address (::ffff:127.0.0.1,
    ::ffff:0.0.0.0); they unmap first. is_link_scoped, is_wildcard,
    is_multicast, is_local_address, is_broadcast, is_unicast and unmap
    take IPAddressLike (is_link_scoped("127.0.0.1") raised AttributeError,
    unmap(2130706433) raised). All but is_wildcard raise NetimpsValueError
    for text that is no address, where is_multicast, is_broadcast and
    is_unicast answered False. is_wildcard still answers False for a host
    name: it is asked of a listen host, and bind takes a name there. A network
    or a value of another type is a TypeError (is_multicast(None) was
    False). join_group and leave_group still say "not a multicast group".
    is_local_host still never raises.

  • An empty query is refused by every resolver entry point. resolve("")
    and each backend raise NetimpsValueError; the OS resolver and dnspython
    applied the search list to it and answered with the search domain's own
    records, and nslookup refused it.

  • try_parse and is_valid raise TypeError for an option the builder
    does not take.
    try_parse("10.0.0.5", IPAddress, strict=True) answered
    None while parse raised.

  • Host(...) reduces an interface to its address, as every dst parameter
    does.
    Host(IPv4Interface("127.0.0.1/8")) kept the text 127.0.0.1/8, so
    .ip() was None and the resolver was asked for it; a network is a
    TypeError.

  • Copy and pickle of a MACAddress, Host or FQDN subclass return the
    subclass
    (they returned the base class), and the parse, try_parse,
    FQDN.decode and FQDN.decode_at classmethods are typed to return the class
    they were called on. The comparison methods of MACAddress, FQDN.ping and
    FQDN.__getitem__ are annotated (bool, PingResult, str or a tuple of
    labels, where a checker saw Any), and the named networks are typed with their
    concrete classes, not IPv4Network | IPv6Network.

  • Every UDPEndpoint resolves arrival interfaces from the one shared
    enumeration.
    Each endpoint enumerated for itself, beside the process-wide
    cache: twice a second for a server with one socket that also made cached
    lookups, three times with two sockets. An endpoint's index is now built from
    the enumeration get_interfaces(cache=True) shares, and is trusted for
    INTERFACE_CACHE_TTL from the moment that enumeration began; an index it
    lacks still enumerates at once. The UDP header gave that lifetime as 30
    seconds; it is INTERFACE_CACHE_TTL, one second.

  • On Windows a receive does not cost more for a larger bufsize.
    recvmsg, and so UDPEndpoint.recv, allocated a buffer of bufsize for
    every call, and a new 64 KiB buffer is faulted in page by page: 34 against 12
    microseconds per 300-octet datagram at the default size (one Windows machine,
    local timing). Each thread now keeps one buffer of up to 64 KiB.

Full Changelog: v0.3.4...v0.4.0