Changelog
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.4.2 - 2026-10-22
Changed
- The command line ends through
duho.run: Ctrl-C ends with status 130 and a closed standard output with status 1, both without a traceback.--help,--versionand a usage error still raiseSystemExit; every other status is as before. - The
cliextra needsduho>=0.7.8,<0.8(was>=0.7.3).
0.4.1 - 2026-10-12
Added
-
UDPEndpoint(sock, *, pktinfo=True, interfaces=())knows the interfaces it serves:.interfacesis a tuple ofInterface, andadmits(datagram) -> boolis true when it is empty or the datagram arrived on one of them (a datagram with no arrival interface is not admitted by a limited endpoint). Receiving never filters, so a caller can count what it drops. An item that is not anInterface, or has no index, is refused at construction. -
bind_listen(listen=None, default_ports=0, *, family=None, per_address=None, device_binding=True, allow_address_takeover=False, broadcast=False) -> Tuple[UDPEndpoint, ...]binds whatparse_listennames and returns an endpoint for each socket, in order. An address is one exclusive socket; a wildcard is one socket with packet info, or, withper_address=Trueor on a host whose sockets report no packet info, one socket for each address of the host (loopback and link-local included; an IPv6 link-local address is bound with its zone); an interface binding is one wildcard socket whose endpoint serves those adapters (.interfaces), bound to the device where the platform can (device_binding=Falseto decline) and otherwise left toadmits. A device bind the kernel refuses is repeated without it and logged once atWARNINGonnetimps._listen. An IPv6 socket is IPv6 only on every platform, so*and::on one port are two sockets and no arrival is v4-mapped. A selector that matches no adapter isNetimpsValueErrorbefore any socket is opened, and a failure closes every socket the call opened. -
parse_listen(listen=None, default_ports=0, *, family=None) -> Tuple[ListenAddress, ...]reads where a service listens: text ("*",":67","127.0.0.1:67","[::1]:69","eth1:67","aa-bb-cc-dd-ee-ff", several joined by commas), anIPv4AddressorIPv6Address, anInterfaceorMACAddress, a(host, ports)pair (a list from a configuration file too), or a sequence of those. It does no I/O: no name is resolved and no interface is looked up. Each address and port appears once, in the order first written. Text that is no address names an interface, as a MAC or an adapter name, and limits a wildcard socket to it.family=socket.AF_INET6makes the wildcard forms and an interface binding IPv6's; the default takes the wildcard forms as IPv4's, asbind("")does.ListenAddress(.address,.port,.interfaces) is the result's item, and is itself an accepted binding, so parsing a result gives it back.ListenLikeis the alias of what is accepted.
Changed
- The
cliextra needsduho0.7.3:duho>=0.7.3,<0.8, where it was>=0.7.0,<0.8. Thenetimpscommand, its options and its output are the same. Served as tools (NETIMPS_MCP=stdio), that duho also readsNETIMPS_MCP_OUTPUT, where a tool's output that is not text would go; nonetimpscommand writes any, and a value that does not qualify ends the server with status 2.
Fixed
- Windows:
recvmsgsetsMSG_TRUNConly when the payload was cut. A control buffer too small for the packet information, on a socket whose option is on, came back asMSG_TRUNC | MSG_CTRUNCwith the data whole, soUDPEndpoint(sock, pktinfo=False).recv(...).truncatedwas true for a datagram that fit. Each buffer now reports as its own flag, as on POSIX.
0.4.0 - 2026-10-08
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, andDeviceBindingUnsupportedError(aNetimpsErrorand anOSError) is whatbind(device=...)raises where it cannot. Linux usesSO_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 acceptsIP_BOUND_IF, which scopes what is sent but does not restrict what is received).device=withinterface=raisesValueError. -
format_address(address) -> strwrites 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), wherestr()writes the hex form (::ffff:102:304) before 3.13; any other address asstr()does, a%zonekept and an interface's/prefixkept. It takes address and interface objects and raisesTypeErrorfor anything else. -
classify(text)reads text as aMACAddress, anIPNetwork, anIPInterfaceor anIPAddress, in that order of preference (a/makes it a network, or an interface when host bits are set), and raisesNetimpsValueErrorfor anything else. It never asks a resolver.PortsLikeaccepts a comma-separated string (scan_ports(host, "22,https,8000"); an empty item raises), andget_default_portaccepts a port number as text and returns it as anint(outside0-65535raisesNetimpsValueError); the text is ASCII digits and nothing else, and aschemethat is not text raisesTypeError. The command line used to do each of these itself. -
get_interfaceanditer_interfaceslook an interface up by name and by index:get_interface("eth0")for text that is no address, network or MAC, andget_interface(index=3)(a keyword, since anintquery is an address).Datagram.interface_indexandRoute.interface_indexnow have a way back to anInterface. -
aretry(func, attempts=3, *, delay, multiplier, max_delay, jitter, retryable, on_retry, jitter_seconds, symmetric)andawait_for_port(dst, port, *, deadline=30.0, interval=0.1, timeout=None)areretryandwait_for_portas coroutines: the same arguments, schedule, answers and exceptions, with the waits asasyncio.sleepand the connect as a non-blocking connect on the running loop, so no thread is used for an address (a name is looked up withloop.getaddrinfo, the loop's executor). Cancelling leaves no socket and no task behind, andawait_for_portends within a second after itsdeadline.import netimpsstill does not import asyncio. The header's asyncio section now lists by name what awaits without a thread and what needsto_threadorrun_in_executor, with thefunctools.partialform for keyword-only options. -
Interface.is_multicastandInterface.is_point_to_point: the kernel'sIFF_MULTICASTandIFF_POINTOPOINTon POSIX, and on Windows the absence of the adapter's no-multicast flag and a PPP, SLIP or tunnel adapter type;Nonewhen 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 bynetimps 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(forinterface=anddevice=),join_group,leave_group,multicast_socketandping(forsrc=) takeget_interfaces's argument, so a caller making many of them pays one enumeration. The default staysFalse: nothing changes for a caller who does not ask.get_interface(mac)returns the first adapter carrying a MAC, in enumeration order, anditer_interfaces(mac)yields them all; the header says so. -
Interface.is_up:Truewhen the interface is usable (IFF_UPandIFF_RUNNINGon POSIX, the operational status on Windows),Falsewhen it is not,Nonewhen the system did not say; part of equality, the hash and the repr, and shown bynetimps interfaces([down], andis_upin--json). -
UDPEndpoint.asend(data, dst, port, *, src=None):sendawaited, 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.
NetimpsErroris 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 privateWireError(ValueError). -
UDPEndpoint.aclose(),async with UDPEndpoint(...).aclose()isclose()for a coroutine: it waits for the reader threadarecvmay have started to leave, with the loop running, whereclose()joins it on the calling thread.close()andaclose()are each complete on return and harmless when called again, in either order. -
Host.resolve()andFQDN.resolve()return the pair(fqdn, ip), andHost.ip(),Host.fqdn()andFQDN.ip()take the resolver options (ns,timeout,port,tcp,search,backends,source) andcheck=;Host.ip()andFQDN.ip()also takeipv6=,Host.fqdn()does not.check=Trueraises whereNonewould be returned:NoAnswerErrorfor an empty answer or an empty host,ResolutionErrorfor 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 ofns,port,tcp,sourceorbackendsthe OS resolver alone answers, asget_ipdid; naming one selectsresolve()'s own chain.resolve()acceptsrdtype=("a", "aaaa")for both families at once. -
MACAddress.__format__and__bytes__:f"{mac:-X}"ismac.format("-", upper=True)andbytes(mac)is the six octets.FQDN.encode(),bytes(name),FQDN.decode(data)andFQDN.decode_at(data, offset)write and read the RFC 1035 wire form;decode_atfollows compression pointers with loop detection and returns(name, end)for use inside a message. Malformed data raisesDNSDecodeError. 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.parseandHost.parsebuild the type from text, raisingNetimpsValueErrorfor bad text andTypeErrorfor a non-str;Host.try_parse(text, default=None)joins the other two, whosetry_parsegainsdefault=.Host.parserefuses only empty or blank text. -
is_unicast(address, interface=None, *, cache=False)andDatagram.is_unicast: false for the wildcard, a multicast group and a broadcast (limited or subnet), true otherwise.Datagram.is_unicastisNonewhen there was no pktinfo to say where the datagram went. -
WS-Management schemes in the registry:
wsman5985,wsmans5986 (IANA),winrm5985,winrms5986 andpsrp5985, soget_default_port("winrm")is no longerNone.get_default_scheme(5985)iswsmanandget_default_scheme(5986)iswsmans. -
cache=onresolve(),Host.ip/fqdn/resolveandFQDN.ip/resolve, withclear_resolution_cache()andRESOLUTION_CACHE_TTL(30 seconds).False(the default) never reads or writes the cache,Trueuses the TTL, a number is the TTL, as onget_interfaces. Keyed on the name and every option; an empty answer is cached like any other, an outage is not. A call that passescache=neither reads nor writes aHost's memo. -
split_zone(text),is_local_host(host, *, resolve=False, cache=False)andsplit_hostof a(host, port)pair.split_zone("fe80::1%eth0")is("fe80::1", "eth0");is_local_hostis 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(whatget_interfacelooks up),PortsLikeandSocketAddress.
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
cliextra needsduho0.7:duho>=0.7.0,<0.8, where it was>=0.6.0,<0.7. Thenetimpscommand, 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_hostof 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 forstr(Host(address)),split_hostandsplit_zoneof an address object, the repr ofRouteand the addresses the command line prints (format_address). Text passed in as astris not re-rendered. -
The shipped API header is split.
netimps/AGENTS.mdkeeps every public name with its signature and one sentence, the exceptions, the command line's contract, the environment variables (NETIMPS_SOCKET_PATCHwith 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 anAGENTS.mdbeside the code that implements it (_dns,_ping,_sockets,_ifaddrs,_ip,_fqdn,_msg,_udpandcli), listed in a table in the top header. All of them ship inside the package. - The command line is the
netimps.clipackage, and its entry point isnetimps.cli:main(argv=None) -> int.netimps.cli:runis gone (it could returnNone); thenetimpsconsole script andpython -m netimpscallmain. netimps scanexits 1 when nothing answered (it exited 0), andnetimps port 99999exits 2 (it exited 1):0found or yes,1nothing found or the answer was no,2an error, for every command.-
-qprints no result line and answers by the exit status alone, asgrep -q; it was only the log level. Errors still go to stderr and--jsonis not affected. -
Method options are keyword-only, breaking.
UDPEndpoint.recv,arecvanddatagramstakebufsizepositionally andresolve_interfaceby name;UDPEndpoint.send(data, dst, port, *, src=None)names its destinationdst(it wasaddress) and takessrcby name;reply_socket(datagram, port=0, *, connreset=False);Interface.primary_ip(ipv6=False, *, loopback_ok=True).retry,backoff_delaysandBackoffno longer take_sleepand_randomparameters: they were the package's own test hooks.UDPEndpoint.datagrams()is typedAsyncIterator[Datagram]. -
bind()defaults, breaking.family=None(wasAF_INET) infers the family from the address, sobind("::1")is an IPv6 socket; the wildcard""stays IPv4, and a name is IPv4 when it has an IPv4 address.connreset=None(wasTrue) means off for a datagram socket and untouched for any other;TrueandFalsestill force it. On Windows an unconnected UDP socket frombind()no longer raisesConnectionResetErroron a later receive after an ICMP port-unreachable. A failedbind()now carries thebind_error_hinttext in the message of every failure that function recognises, keeping itsOSErrorsubclass anderrno;set_buffer_sizelogs aWARNINGwhen the kernel grants less than was asked, once per process for each distinct shortfall. MACAddress._VALID_MACis gone. It was a private class attribute the class docstring invited callers to read; screen text withMACAddress.is_valid(text).UDPEndpoint.send(src=<address>)enumerates no interfaces. An addresssrcis used as given and the kernel picks the adapter (a%zonestill names one); a MAC or an adapter name still resolves, and anInterfacestill pins the adapter as well.Host,MACAddress,PingResult,RouteandInterfaceare read-only. Assigning to or deleting any attribute raisesAttributeError; build a new value instead. All six value types (FQDNalready was) copy and pickle.Interface.ipsis a tuple rather than a list, soiface.ips.append(...)and the other list mutators raise; the constructor still takes any iterable.Interface.loopbackis gone: readis_loopback. A cachedget_interfaces(cache=...)call now hands back the storedInterfaceobjects in a fresh list, copying onlyraw.Host.fqdnis a method, andHost.iptakes keyword-only options.host.fqdnbecomeshost.fqdn();host.ip(True)becomeshost.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: callnetimps.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 nowping("h", tries=3). Parameter names, order and defaults are unchanged. Positional parameters kept: none forget_interfaces; one forBackoff,Route,UDPEndpoint,count_hops,discover_mtu,get_free_port,get_interface,get_route,iter_addresses,max_udp_payload,patch_socket_module,ping,set_buffer_sizeandsplit_host; two forInterface(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_parseandwait_for_port.recvmsg,sendmsg,CMSG_LEN,CMSG_SPACE,DatagramandSocketOptionkeep their shapes.parse,try_parseandis_validnamestrict(the network builders' option) instead of an untyped**kwargs;try_parse(value, type, default)is nowtry_parse(value, type, default=default). wait_for_portandPingResultsay which time they mean.timeoutis one attempt anddeadlinethe whole operation, as everywhere else in the package;PingResult.rttis a duration in seconds, wherertt_mswas milliseconds. A sub-millisecond reply is still0.0. The command line'sping --jsonkeeps itshostandrtt_mskeys, converting for display.discover_mtunames the options it forwards.tries=,ipv6=andttl=replace**ping_kwargs; passingsize=ordont_fragment=, which the search varies itself, is now Python's ownTypeErrorfor an unexpected keyword rather than a bespoke one.- Annotations say what the code accepts and returns.
resolve,resolve_dnspython,resolve_system,resolve_nslookup,resolve_wireandresolve_dohare overloaded onrdtype:"a"givesList[IPv4Address],"aaaa"List[IPv6Address],"ptr"List[str], anything elseList[Any].method=onpinganddiscover_mtuisLiteral["icmp", "tcp", "udp"];is_broadcast(address: IPAddressLike, interface: Optional[Interface]);join_hostandsplit_hosttakeHostLike;parse,try_parseandis_validtakestrictand typed**options. No parameter of a public callable is a bareAny. MACAddress.try_parseandFQDN.try_parsetake text only. They answerNone(or the newdefault=) for text that does not parse, and raiseTypeErrorfor anything that is not astr--MACAddress.try_parse(None),FQDN.try_parse(3.5)-- where they returnedNone. The genericnetimps.try_parse(value, type)is unchanged and still answersdefaultfor any object. ForMACAddress,FQDN,Hostand subclasses it now builds astrthroughType.parse; anint,bytesor built value still goes to the constructor.HOST_DNis replaced byget_hostname(*, fqdn=False). A constant computed fromplatform.node()made everyimport netimpsask for the host name, a WMI query on Windows; the function asks when called.fqdn=Truereturnssocket.getfqdn().NETIMPS_NO_SOCKET_PATCHis replaced byNETIMPS_SOCKET_PATCH, with the sense turned round:0,false,nooroffopts out of the import-timesocketpatch, and unset still means patched. A value that is none of the documented spellings, or the old variable set to anything, raisesValueErrorat import naming the variable, so that a stale setting cannot silently re-enable the patch.HostLike(formerlyAddressLike) now includesHostandFQDN, which every destination parameter already accepted at run time.- Malformed text raises
NetimpsValueErrorfromparse,MACAddress,FQDN,split_host,join_hostand the query check ofresolve_nslookup; message unchanged. It is aValueError, soexcept ValueErrorstill catches it.parsenow also converts theipaddressbuilders' own errors, chained as__cause__. UDPEndpoint.recvandsendraise the builtinTimeoutErrorwhen the socket's timeout expires. From Python 3.10 that is whatsocket.timeoutalready is; on 3.9 it was only anOSError.ResolutionErrorandAddressInUseErrornow also derive fromNetimpsError. Every existingexceptclause keeps matching.-
resolve_wireandresolve_dohchain an unreadable reply'sDNSDecodeErroras the__cause__of theResolutionErrorthey raise. -
Every program netimps runs (
ping,nslookup,route,traceroute,tracert) is started the same way. It is found onPATHbefore anything runs, so a missing one is named in the error; standard input is closed;LC_ALL=Cis added to the child's environment, so output no longer follows the user's locale; and output is decoded explicitly witherrors="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 raisesUnicodeDecodeErrorout ofcount_hopsorget_route. A deadline now kills the program's children as well as the program. A program that resolves to a.bator.cmdis 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_dnspythonraisesResolutionTimeoutErrorfor a timeout andResolutionErrorfor every server failing (a SERVFAIL, a refused port) or no resolver configuration, where it returned[];resolve_systemraisesResolutionErrorfor a temporary failure (EAI_AGAIN,EAI_FAIL,herror2 and 3) and keeps[]forEAI_NONAMEand no data;resolve_nslookupreads the reason after the colon of "can't find: ", so Windows' No response from server(exit 0) andSERVFAILraise whileNon-existent domainstays[].resolve()withoutstrictstill answers[],strict=Truenow raises where it could not before, andcache=stores nothing for an outage.resolve_dnspythonno longer turns every exception intoValueError("invalid DNS query"): only a malformed name or an unknown record type is aValueError, anOSErroris aResolutionError, and anything else propagates; dnspython's missing-configuration error no longer escapesresolve()raw. -
A missing
dnsextra is named.resolve_dnspythonraisesResolutionErrorsayingpip install "netimps[dns]", andresolve()raises the same for a request onlydnspythoncould serve (a record type the other backends do not read) instead of returning[]. Newhas_dns() -> bool. -
resolve_doh(..., allow_http=False). A URL that is nothttps://is aValueErrorbefore a request is made;allow_http=Trueaccepts plainhttp://. The default fetch follows no redirect (a 3xx is aResolutionError), reads at most 65,536 bytes of a reply, and names the URL in messages without its credentials, query string or fragment. -
deadline=onresolve(),Host.ip/fqdn/resolveandFQDN.ip/resolve.timeoutis 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.deadlineis the total for the call, shared by all of them; the header states what eachtimeoutbounds. -
ResolutionErroris anOSError, andNoAnswerErroris new.ResolutionError(NetimpsError, OSError), as the standard library's own name failuresocket.gaierroris, soexcept OSErroraround a connect catches it andretry()retries it by default.NoAnswerError(ResolutionError)is whatcheck=TrueonHost.ip/fqdn/resolveandFQDN.ip/resolveraises when the lookup completed with no such name or record; an outage stays a plainResolutionErrororResolutionTimeoutError.retry's docstring and the header say how to leaveNoAnswerErrorout.
Removed
get_ip(address, ipv6=None).Host(x).ip()is the same operation with the name kept, andHost(x).ip(check=True)raisesNoAnswerError(aResolutionError) whereget_ipreturnedNone.get_routeand theaddrcommand use it;Host(x).resolve()gives(fqdn, ip).
Fixed
is_wildcardtakes what its annotation says: aHostor anFQDN(HostLike), read as its text. AHostholding a name raisedNetimpsValueError; it answersFalse, as the same text does.-
Host.ip,Host.fqdn,Host.resolve,FQDN.ipandFQDN.resolveare annotated bycheck: withcheck=Truethey raise instead of returningNone, and the annotations say so (anIPAddress, anFQDN, a pair of both), so a caller needs no assertion. Nothing else changes at run time. -
On Windows the
os.sysconfstand-in answersSC_OPEN_MAX. The socket patch installsos.sysconfwhere the platform has none, and it answeredSC_IOV_MAXalone. A library that readsSC_OPEN_MAXbehindhasattr(os, "sysconf"), as an SFTP server does to size its handle limit, got aValueErroron 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 isFalsefromis_local_host(..., resolve=True)and aNetimpsValueErrorfromresolve_system. -
get_default_port(None)andget_default_port(22)raiseTypeError, where anAttributeErrorcame out of the function. -
resolve_dohcloses 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 aResourceWarningat an unrelated moment. -
is_broadcastandis_unicastread an interface object as its address.IPv4Interfaceis anIPv4Addresssubclass and was compared as address and network, sois_broadcast(IPv4Interface("10.0.0.255/24"), nic)was false andis_unicastof it true; every classifier now reduces an interface to its.ipfirst. -
One zone rule for
interface_index,interface_addressandget_interface.interface_index("::1%nosuchadapter")was the loopback index with the zone ignored and("::1%999")was 999 unchecked, whileget_interfacefound 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%12for an address no adapter holds is no longer index 12. -
A family is
4orAF_INET,6orAF_INET6, wherever one is taken.has_pktinfo(4)answeredFalseanditer_addresses(family=AF_INET)raised;bind,get_free_port,has_pktinfoanditer_addressesnow share one reader, and anything else raisesValueError. -
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 a169.254address in the state Tentative, whichbindrefused withWSAEADDRNOTAVAILwhileis_local_addressandprimary_ipreturned it. The adapters stay listed, withis_upfalse. The Windows interface index falls back toIpv6IfIndexwhenIfIndexis 0, so an adapter with IPv4 unbound is not reported as having no index. -
Interfaceis a value type that checks itself. The constructor raisesTypeErrorfor a wrong-typed field (it acceptedmac="notamac",ips="ab"andname=5),ipv4andipv6are tuples likeips,rawis a read-only mapping with tuples inside (a list inside a cachedrawwas shared between callers), andrepris 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, andget_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,AttributeErrorandValueError, so a bad pointer read became a one-interface answer; onlyOSErrordegrades, with the reason inrawand 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.0arrives as 5 bytes) and what follows belongs to the next structure. The bytes pastsa_lenare taken as zero instead of being read. -
retry,backoff_delaysandBackoffshare one set of argument rules, checked when they are called.backoff_delayswas a generator, soattempts=0or a negativedelayraised at the firstnext();backoff_delays(3, 1.0, multiplier=0.5)yielded a shrinking[1.0, 0.5]andbackoff_delays(2, 4.0, max_delay=1.0)cut the first wait to[1.0]whileBackofffloored the multiplier and the ceiling instead. All three now raiseValueErrorforattemptsbelow 1, a negativedelay, amultiplierbelow 1, amax_delaybelowdelay, ajitteroutside 0 to 1 and a negativejitter_seconds.retryis typedCallable[[], T] -> T(it returnedAny). -
scan_hostsbounds 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 raisesValueErrorbefore any probe (a /16 with the common ports is still allowed), andscan_hostsandscan_portsfeed their pool as they run: a /22 with the common ports peaks near 0.1 MiB. -
A destination of
NoneraisesTypeError.tcp_check(None, 80),get_source_ip(None)and every otherdstparameter asked the resolver for the host named"None"and answeredFalseorNone;dsttakes the same allowlist assplit_host(a string, an address, an interface, aHostor anFQDN), so anint,bytesorNoneraises.Host(None)is still the empty host. -
bindreadsoptionsonce, andreuse_port=Trueshares a UDP port on Windows. A generator passed asoptionswas 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 raisedAddressInUseError; 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_portinfers the family asbinddoes and takes any host:get_free_port("::1")raisedgaierror. -
wait_for_portno longer shortens anintervalabove one second: it backed off to a flat second after the first wait, sointerval=5polled sixteen times in twenty seconds instead of four.is_local_hostreads theinet_atonspellings (127.1,2130706433,0x7f.0.0.1) as the address they spell. One timeout floor: the scanners floored to 1 ms andtcp_checkagain to 50 ms; the scanners now pass the timeout on and the floor istcp_check's 50 ms. -
discover_mtureports what it measured. It resolved the name a second time for the header overhead and ignoredipv6=, 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"ignoredipv6=(an IPv4-only listener gave 65555 foripv6=True) and added the IPv6 header to an IPv4 segment size; it now uses the family that connected, andget_tcp_mssgainsipv6=. A probe that was answered athighreturnedhigh(9000 on a loopback whose MTU is 65535); the search now continues to the outgoing interface's MTU, andhighis 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 withmethod="udp".discover_mtu("127.0.0.1")is the loopback MTU (65535 on Windows). -
The
socketpatch 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 installedrecvmsgas the native one, so on WindowsUDPEndpoint.recv().destinationcame out as1.0.0.0with index 0 andis_socket_patched()saidFalsewhile 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.recvmsgon a Windows stream socket failed withWinError 10022; it usesWSARecv. -
IPv4 on FreeBSD reports the arrival and pins the source.
UDPEndpointthere setIP_PKTINFO, which FreeBSD lacks, sohas_pktinfowas false anddestinationwasNone, whilehas_src_pinningwas true andsend(src=)raisedOSError42. It now usesIP_RECVDSTADDRandIP_RECVIFto receive and anIP_SENDSRCADDRmessage to pin; the pin needs a wildcard-bound, unconnected socket, sohas_src_pinningis 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 raiseValueErrornow, as the IPv4 twin did. A dual-stack endpoint pins an IPv4 source (it failed withWinError 10022). A pinnedsendto a host name resolves it (it failed withillegal IP address string passed to inet_pton). -
Closing a
UDPEndpointends a pendingarecv, andarecvleaves the socket's timeout alone.close()andaclose()from another task now wake a task inarecvwithRuntimeErrorand finish adatagrams()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 withWinError 10038out ofrun_until_complete. Onearecvused to leave a socket withsettimeout(5.0)at0.0, so the nextrecv()raisedBlockingIOErrorat once. -
pingbounds its own name lookup bytimeout(2.5 s fortimeout=0.5with a 2 s lookup before), and takes the family ofsrcfrom 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, soping(method="tcp")needs atimeoutof 3 there. -
A DNS reply is bounded. A short MX or SRV record is a
DNSDecodeError(it escaped asstruct.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 heldtimeout=0.5for 12 s); a nameserver port outside 1-65535 is aValueError; and bytes outside letters, digits, hyphen and underscore in a decoded name are written\DDD.resolve_nslookuprefuses annsor search domain thatnslookupwould read as an option. -
The programs the library runs are looked up in the absolute
PATHentries only. On Windowsshutil.whichsearched the working directory first, soping,nslookup,tracertandrouteran a same-named file next to the caller; a relative or emptyPATHentry 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_hopsgivesNonewhentracerouteortracertexits non-zero, and the header names the supported programs. -
recvmsgandsendmsghonour 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 withsettimeout(0.3)raisedBlockingIOErrorafter 0.000 s instead of waiting. Both now wait for readiness and raisesocket.timeoutwhen the time runs out, as the stdlib methods do on POSIX. A blocking socket and a non-blocking one behave as before. -
FQDNcompares by its case-folded key everywhere.is_subdomain_ofandrelative_towere case-sensitive where==andhashwere not (FQDN("www.Example.com").is_subdomain_of("example.com")wasFalse), andexample.comagainstexample.com.answereda > bandb > atogether. The four ordering operators now derive from one key, reversed folded labels then absoluteness. -
FQDNvalidates after IDNA mapping, with one label rule shared by the text and the wire entries. Fullwidth digits and the ideographic full stops mapped to127.0.0.1and constructedFQDN('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 failedFQDN.decode(v.encode()). All now raiseNetimpsValueError.bytesand an iterable item that is not astror anFQDN(FQDN(b"abc")wasFQDN('97.98.99'),FQDN(["www", None])waswww.None) raiseTypeError./,childandwith_hostnamerefuse a result over 253 octets, whereencode()used to emit 259;encode_namerefuses over 255 on the wire. -
split_host,split_zoneandjoin_hostshare one host rule and one port rule.join_hostaccepted a network ('10.0.0.0/24:80'),bytes("b'h':80"), afloatport ('h:80') andTrue('h:1'), andsplit_hostread"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, asjoin_hostalready refused them. A value that is not a host type at all (None, anint,bytes) raisesTypeErrorfrom all three, wheresplit_host/split_zone/join_hostraisedNetimpsValueError; so does a non-intport. Text that does not parse staysNetimpsValueError. -
resolve(ns="host:port")answers the same with and without dnspython. Thehost,host:port,[v6]and[v6]:portspellingsresolve_wiredocuments are read by one parser for every backend:resolve_dnspythonhands dnspython each server's own port (it raisedValueErrorforhost:port), and a malformednsis aNetimpsValueErrorbefore any backend runs.resolve_nslookuptakeshost,[v6]or either with:53and raisesNetimpsValueErrornamingport=for another port, whichnslookupcannot 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_scopedandis_wildcardansweredTrueon 3.13 and later andFalsebefore 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_unicastandunmaptakeIPAddressLike(is_link_scoped("127.0.0.1")raisedAttributeError,unmap(2130706433)raised). All butis_wildcardraiseNetimpsValueErrorfor text that is no address, whereis_multicast,is_broadcastandis_unicastansweredFalse.is_wildcardstill answersFalsefor a host name: it is asked of a listen host, andbindtakes a name there. A network or a value of another type is aTypeError(is_multicast(None)wasFalse).join_groupandleave_groupstill say "not a multicast group".is_local_hoststill never raises. -
An empty query is refused by every resolver entry point.
resolve("")and each backend raiseNetimpsValueError; the OS resolver and dnspython applied the search list to it and answered with the search domain's own records, andnslookuprefused it. -
try_parseandis_validraiseTypeErrorfor an option the builder does not take.try_parse("10.0.0.5", IPAddress, strict=True)answeredNonewhileparseraised. -
Host(...)reduces an interface to its address, as everydstparameter does.Host(IPv4Interface("127.0.0.1/8"))kept the text127.0.0.1/8, so.ip()wasNoneand the resolver was asked for it; a network is aTypeError. -
Copy and pickle of a
MACAddress,HostorFQDNsubclass return the subclass (they returned the base class), and theparse,try_parse,FQDN.decodeandFQDN.decode_atclassmethods are typed to return the class they were called on. The comparison methods ofMACAddress,FQDN.pingandFQDN.__getitem__are annotated (bool,PingResult,stror a tuple of labels, where a checker sawAny), and the named networks are typed with their concrete classes, notIPv4Network | IPv6Network. -
Every
UDPEndpointresolves 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 enumerationget_interfaces(cache=True)shares, and is trusted forINTERFACE_CACHE_TTLfrom the moment that enumeration began; an index it lacks still enumerates at once. The UDP header gave that lifetime as 30 seconds; it isINTERFACE_CACHE_TTL, one second. -
On Windows a receive does not cost more for a larger
bufsize.recvmsg, and soUDPEndpoint.recv, allocated a buffer ofbufsizefor 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.
0.3.4 - 2026-10-03
Fixed
Interface.primary_ip()preferred a link-local address over a routable one. It returned the first entry that was not loopback, and an interface commonly lists its link-local address first --fe80::is configured before SLAAC or DHCPv6 completes on Linux and macOS NICs -- so a NIC with both answered with an address that is useless as a bind target and unreachable off-link. The IPv4 twin was real too and is fixed by the same change: an interface holding an APIPA169.254/16address and a DHCP lease answered with the APIPA one.
Now ranked routable, then loopback, then link-local, keeping OS order
within a rank. Loopback outranks link-local deliberately: the only interface
carrying both is the loopback adapter, where ::1 is what every caller means,
and a real NIC has no loopback entry so the rank takes nothing from it. A NIC
holding only a link-local address still yields it, and loopback_ok=False
skips the loopback rank rather than returning None.
Measured on a macOS loopback adapter (127.0.0.1/8, ::1/128, fe80::1/64):
primary_ip(ipv6=True) returned fe80::1, and bind(interface=...,
family=AF_INET6) then failed with "Can't assign requested address".
bind(interface=...)dropped the scope of a link-local address, and a link-local bind then failed on every POSIX platform -- not only macOS, as first reported. The samefe80::address can exist on several adapters, so the kernel cannot tell which is meant. Measured on Linux against a real NIC:
| spelling | result |
|---|---|
bind(("fe80::1", 0)) |
EINVAL |
bind(("fe80::1%2", 0)) |
EINVAL |
bind(("fe80::1%eth0", 0)) |
EINVAL |
bind(("fe80::1", 0, 0, 2)) |
ok |
So the zone has to become the sockaddr's numeric scope id; it cannot stay in
the address string. bind() now converts a %zone suffix -- name or index
-- into the 4-tuple form, which also means a caller writing
bind("fe80::1%eth0", ...) by hand gets a working bind for the first time.
Windows accepts all four spellings, which is why a Windows-only measurement
says nothing about this and why the first attempt at the fix -- putting
%index in the string, copying what UdpEndpoint.reply_socket does -- passed
locally and broke all six Linux CI jobs and both macOS jobs. reply_socket's
own link-local path went through the same string form and is fixed by the same
change, since both now bind through one helper.
0.3.3 - 2026-10-03
Added
join_host(host, port=None)-- the inverse ofnormalize_host, and the direction everyone writes by hand and gets wrong on IPv6:join_host("::1", 8080)is'[::1]:8080', not'::1:8080'. Accepts a string, an address, anIPv4Interface/IPv6Interfaceor anFqdn. Only an IPv6 literal is bracketed -- a hostname never is, because brackets in a URI authority assert that the inside is an address. A port-less v6 comes back bare, which is what makesnormalize_host(join_host(h, p)) == (h, p)hold in every case. Three consuming projects were writing this themselves.unmap(value)-- collapse an IPv4-mapped IPv6 address (::ffff:10.0.0.5) to plain IPv4; anything else passes through. Built onIPv6Address.ipv4_mapped, not a"::ffff:"prefix test. Measured: all of::FFFF:10.0.0.5,::ffff:0:1and0:0:0:0:0:ffff:0a00:0005are mapped addresses that astartswith("::ffff:") and "." in textcheck leaves untouched. One address has many spellings; only the parsed form sees through them.is_wildcard(value)-- whether a value means "every local address":"",None,"0.0.0.0","::", and any other unspecified spelling. Strips a%zone. Never raises, so it stays usable in a branch without a guard.disable_connreset(sock)andbind(..., connreset=False)-- stop Windows reporting an ICMP port-unreachable provoked by an earlier send asConnectionResetErroron a later, unrelated receive, which kills a server's receive loop over a packet some other host did not want.
There is no stdlib route to this, which is why it belongs here. Measured on
3.14: CPython exports no socket.SIO_UDP_CONNRESET on any version, and even
given the documented value (0x9800000C) socket.ioctl whitelists commands
and answers ValueError: invalid ioctl command. It goes through WSAIoctl by
ctypes instead. The obvious getattr(socket, "SIO_UDP_CONNRESET", None)
version is a silent no-op on every platform. Off by default, since the report
is sometimes wanted -- a client talking to one peer learns the peer is gone.
- set_buffer_size(sock, receive=None, send=None) -- grow
SO_RCVBUF/SO_SNDBUF and return what was granted, read back with
getsockopt rather than echoed from the request. 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 -- which also doubles the request, so a
read-back above it is normal there. The silent partial grant is the failure
mode. Only grows, so it cannot undo earlier tuning.
- SocketOption(level, name, value) -- a named triple for bind's
options=. A NamedTuple, so bare tuples keep working; purely so a list of
them reads better than Iterable[Tuple[int, int, Any]].
- MACAddress.hex(sep=None, bytes_per_sep=1) -- exactly bytes.hex, as a
passthrough to .packed.hex. Present because this is a value object rather
than a bytes subclass, so the method is not inherited -- and a downstream
project was subclassing the type partly to add it back.
interface_enumerations() -> int-- how many times this process has really enumerated its adapters. Counts the syscall and never a cached hit, which is the number that matters oncecache=makes a lookup and an enumeration different events: the enumeration is the one a packet flood multiplies. Worth exporting as a metric, and it is the assertion a test wants -- read it, do the work, expect+1. Bothrawflags count into one total, and the cache is keyed byraw, so a process using both warms up twice.clear_interface_cache()does not advance it.
Without it, a test checking the property cache= exists for had to
monkeypatch a private name -- including this package's own tests, which now
use the counter instead.
-
is_broadcast(address, interface=None, *, cache=False)-- the one per-packet entry point the enumeration cache had left out. Without aninterfaceit consults every adapter's prefixes, since10.0.0.255is only a broadcast if something carries10.0.0.0/24: measured 1.25 ms against 0.004 ms when the interface is passed, and a server asking the question of every request pays that per packet.UdpEndpoint._is_repliableuses the shared cache now, soreply_socket()no longer enumerates per datagram when the arrival index did not resolve. Passinginterfaceremains the fastest path and consults neither the cache nor the syscall. -
supports_pktinfo(family=AF_INET)-- whether a UDP socket of that 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, while without it the wildcard must be expanded into a socket per address -- and on Linux that per-address socket receives no broadcasts at all.
Answered by asking a socket, not by testing a constant's name:
getattr(socket, "IP_PKTINFO", None) is None on CPython 3.9-3.11 on every
platform, while the kernel supported it throughout, so a name test says "no"
on a platform that works. Cached per family.
- An opt-in cache for the adapter enumeration:
get_interfaces(cache=...), and the same argument oninterface_for,interfaces_forandis_local_address, plusclear_interface_cache()andINTERFACE_CACHE_TTL.cache=Falseis the default and changes nothing;cache=Trueuses the 1-second default TTL; a number is that TTL.cache=0is a TTL of zero, so it enumerates and reseeds -- the whole of "force a refresh", which is why there is no second argument for it.
Measured on a 7-adapter host: interface_for goes 0.98 ms to 0.010 ms
(97x), and is_local_address likewise. The uncached call reaches 35-42 ms
where there are many adapters, which is slow enough that a packet flood can
deny service on its own -- an availability problem, not only a slow one.
The default TTL is 1 second because this cache is for collapsing a burst
of back-to-back calls, not for holding a snapshot: it bounds the cost at one
syscall per second whatever the arrival rate, ~0.1% overhead at 1000 packets
per second. UdpEndpoint's own arrival-interface cache now uses the same
constant, down from a separate 30 s, so there is one number rather than two
that can disagree.
Prefer an event to a TTL where you have one -- cache=math.inf plus
clear_interface_cache() at the moment the answer changes is strictly better
than any TTL; a server has such a moment when it binds. A cached call
returns fresh Interface objects: Interface is
not frozen and .ips/.raw are mutable, so handing back the stored ones
would let one caller corrupt every later caller's view. The copy is 0.004 ms
against 0.969 ms to enumerate.
The uncached path still calls get_interfaces() with no keyword argument,
so an existing test double that takes none keeps working.
Backoff-- a retransmission timer: grows on loss, resets on progress.backoff_delaysis a one-shot schedule for "retry this call a few times"; a long-lived session needs a current delay that advances on silence and returns to the base when the peer moves the transfer forward. Every protocol client in this family had written its own, so the shape is taken from the one already working rather than invented.
.delay is stable between advance()/reset(), so arming a deadline,
logging it and comparing against it all see one value -- a property that
re-jittered per read would be a trap for exactly this code. Jitter is off by
default, the opposite of backoff_delays: a point-to-point session
retransmitting to one peer has no thundering herd to avoid, and TFTP and TCP
both specify plain doubling. Two guard rails a hand-rolled version tends to
miss: multiplier is floored at 1.0 so the timer can never shrink on
repeated loss, and max_delay is floored at delay so a ceiling below the
base cannot silently truncate the first wait.
-
backoff_delays(jitter_seconds=)and(symmetric=), also onretry-- the two symmetric jitter shapes that protocol specifications require and the default cannot express, since it only ever shortens a delay: -
jitter_seconds=is absolute and symmetric, uniform in[-jitter_seconds, +jitter_seconds]-- RFC 2131 §4.1 (DHCPv4), "randomized by the value of a uniform random number chosen from the range -1 to +1". The amplitude is capped at the current delay so the value cannot go negative before clamping; uncapped, a sub-second delay would pile much of its distribution on a single clamped value, which is neither uniform nor symmetric. At RFC 2131's own 4 s floor the cap never engages. symmetric=Truespreads the fractionaljitterboth ways -- RFC 8415 §15 (DHCPv6),RT = 2*RTprev + RAND*RTprev.
In these modes max_delay caps the base, not the final value, so a delay
may exceed it by up to the amplitude. Both RFCs require it: RFC 8415 applies
its jitter after the cap (if RT > MRT: RT = MRT + RAND*MRT) and RFC 2131
randomises around its 64 s maximum. Clamping was the obvious reading -- and
the one the request suggested -- and it is wrong invisibly: measured, the
spread at the cap became entirely negative with a mean of -0.024 instead of
~0, because every positive excursion was trimmed back. A backed-off client
spends nearly all its time at the cap, so clamping would reintroduce exactly
the synchronisation the mode is chosen to prevent. No mode returns a negative
delay, and the default schedule is unchanged -- asserted against a copy of
the previous implementation rather than against recorded numbers.
-
UdpEndpoint.reply_socket(datagram, port=...)accepts an iterable of ports, tried in order, for a server that pins transfer ports to a range a firewall can allow (tftp-hpa -R,dnsmasq --tftp-port-range). Materialised once, so a generator is safe but must be finite; empty raisesValueError. -
UdpEndpoint.arecv()and.datagrams()--recv()awaited, and anasync forover arrivals. Same arguments, sameDatagram, and pktinfo survives on every loop type, including the Windows defaultProactorEventLoop.
That default is the whole difficulty: it raises NotImplementedError from
add_reader, and its own IocpProactor.recvfrom discards ancillary data. Nor
is there an IOCP route -- measured on 3.14, CPython's _overlapped exposes no
WSARecvMsg, so posting one through the loop's completion port would mean
reimplementing the overlapped plumbing and calling the private
IocpProactor._register. (asyncio.DatagramProtocol.datagram_received(data,
addr) has no slot for cmsgs either, so a transport could not deliver them
anyway.)
So on such a loop a thread does the waiting -- and it reports readability
only, never reads. The recv stays on the loop, which keeps bufsize a
per-call argument and Datagram.truncated meaning what it means; a thread that
read for you would have to fix bufsize when it started. It is created on the
first await, joined on close(), and a test asserts no thread survives the
endpoint. recv() is untouched.
asyncio is imported lazily, never at package import time: pulling it into
__init__ would force the very import ordering that produced this release's
os.sysconf crash, and a consumer using only the value types should not pay for
an event-loop import. A test spawns a fresh interpreter to assert
'asyncio' not in sys.modules after import netimps.
UdpEndpoint.reply_socket(datagram)-- a socket bound so replies leave from the address the client addressed, which is the point of pktinfo in one call. A wildcard-bound server answering from a fresh socket sends from whatever the routing table prefers, and DHCP and TFTP clients both check and drop a reply from an address they never talked to. Demonstrated by the contrast: a plain wildcard reply to a client that addressed127.0.0.2comes from127.0.0.1.
Three platform traps it absorbs, each measured rather than reasoned about:
a v4 arrival on a dual-stack listener is ::ffff:a.b.c.d, and binding that
needs IPV6_V6ONLY off which Windows does not default to, so it is unmapped
and answered from an AF_INET socket; a broadcast, multicast or unspecified
destination must not be answered from, and is classified and skipped rather
than discovered by a failed bind, because Linux binds 255.255.255.255 and
239.1.2.3 happily where Windows refuses them; and an IPv6 link-local
destination needs the arrival ifindex as its scope, carried as a %zone
suffix. Falls back to the endpoint's own address and then the wildcard, because
a reply from the wrong address still beats no reply. connreset=False by
default, inverted from bind(): a server loop must not die because an earlier
answer drew an ICMP port-unreachable from a client that had gone.
- is_broadcast(address, interface=None) -- whether an address is an IPv4
broadcast, limited or subnet. The question a wildcard-bound server asks
before answering: RFC 1123 says a TFTP server ignores a broadcast request, and
DHCP must tell a broadcast DISCOVER from a unicast RENEW. 255.255.255.255
needs no context; 10.0.0.255 is only a broadcast if some interface carries
10.0.0.0/24, so this consults interface prefixes -- pass interface to check
one adapter and skip the enumeration. A v4-mapped address is unmapped first.
IPv6 has no broadcast, so a genuine v6 address is always False; is_multicast
stays the separate companion, since one name meaning both would hide which
matched.
- An unbounded Interface.mtu is now a number, not None. The Windows
loopback adapter reports 0xFFFFFFFF, which the code called an "unknown"
sentinel and mapped to None. It is ULONG max and means unbounded -- there is
no link to constrain loopback -- so that conflated "no limit" with "could not
read", and cost callers in the one direction that matters: handling None by
falling back to 1500 capped loopback at 1472 when it delivers 65507. Linux
reports its own lo as 65536 rather than as nothing, so the two platforms
disagreed about the same physical reality.
Such an interface now reports 65535, the largest datagram the 16-bit IP
total-length field can describe -- a clamp to reality rather than an invented
figure. max_udp_payload() then yields exactly the 65507 measured to arrive,
and a test moves a datagram that size to prove it. None now means only that
the platform genuinely could not read an MTU.
- max_udp_payload(mtu, ipv6=False) -- the largest UDP payload that fits an
MTU without fragmenting (1472 for 1500, 1452 for v6). Takes an int rather
than an Interface on purpose: Interface.mtu is Optional[int] and Windows
reports no MTU for the loopback adapter, so whether to fall back to 1500 or
refuse is the caller's decision. The v4 figure uses the minimum 20-byte header,
so a packet carrying IP options can still fragment.
AddressInUseError(OSError)-- one stable type for "the address is taken", raised bybind()instead of whatever the platform happened to call it. Measured on Windows 11 ARM64 against an exclusive holder, the same situation had three shapes:PermissionError/errno 13 on 3.14,OSError/errno 10013 on 3.9, andOSError/errno 10048 without the takeover flag. The 3.14 row is the harmful one --PermissionErrorsays "privilege problem", and Windows has no privileged ports, so a consumer branching on the type sent its user after an elevation problem that cannot exist.
errno is normalised to EADDRINUSE, bind_error_hint()'s text is the
message, and the original is chained as __cause__ so winerror stays
reachable. It subclasses OSError and deliberately not
PermissionError: every except OSError keeps working while
except PermissionError stops catching a case that was never about
permission. A genuine POSIX EACCES on a port below 1024 is left exactly as
it was.
Fqdn-- a domain name as a value type, with label algebra:.labels,.hostname,.domain,.domains,.tld,.is_fully_qualified(),.with_hostname(),.is_subdomain_of(),.relative_to(),.reverse(),.common_ancestor(), and/to compose. Immutable, hashable, ordered on reversed labels so sorting groups by TLD. Case-insensitive equality per RFC 4343, with__hash__agreeing..resolve(),.ping()and.ip()delegate to the existing module functions.is_valid/try_parseclassmethods matchMACAddress's shape, andFqdnLikeis the accepted-input union.
name in domain works like the stdlib's address in network --
Fqdn("www.example.com") in Fqdn("example.com"). Inclusive, where
.is_subdomain_of() is strict: a zone contains its own apex just as a /24
contains its network address. Accepts a str, ignores qualification, and
answers False rather than raising for anything unparseable. A label test
is "com" in f.labels.
Text interop: str(f) is the name, and f + str / str + f give a plain
str, for building a URL or a log line. Fqdn + Fqdn raises and points at
/.
Also .unicode (the display form, decoding punycode -- labels are stored
ASCII because that is what goes on the wire), .wire/.wire_length (the DNS
wire encoding, delegated to the package's own encoder; .wire_length is what
the 255-octet protocol limit applies to, against the 253 printable limit),
.is_hostname() (RFC 1123 LDH, narrower than what the type accepts, since
_dmarc and _sip._tcp are real names), and .is_wildcard (a predicate only
-- no matches(), because DNS and TLS disagree about whether *.example.com
covers a.b.example.com).
The algebra is deliberately inverted from pathlib, because DNS puts the
most significant label last: .name is the leftmost label, .parent strips
the leftmost, and / prepends -- Fqdn("example.com") / "www" is
www.example.com. DNS vocabulary is primary and the pathlib spelling is an
alias on the same value (.domain/.parent, .hostname/.name,
.labels/.parts). There is no .suffix alias for .tld: a filesystem
suffix is part of a name while a TLD is a whole label.
An address literal is refused -- Fqdn("10.0.0.1") raises -- because this
is a name algebra and an IP has no labels, parent or TLD. The trailing dot is
absoluteness and is part of identity, so Fqdn("example.com") !=
Fqdn("example.com."), as Path("a") != Path("/a"). .domain is not the
registrable domain (example.com's is com): that needs the Public Suffix
List, which would be a hard dependency, so the gap is documented instead.
- Host.fqdn -- the bridge: an Fqdn when the host is a name, None when
it is an address. Host stays the union type and is otherwise unchanged.
- recvmsg() / sendmsg() now work on Windows, and are public on every
platform, along with CMSG_LEN(), CMSG_SPACE() and supports_recvmsg().
CPython ships neither method on Windows, so this binds WSARecvMsg and
WSASendMsg through ctypes and delegates to CPython's own methods
elsewhere. Signatures and return shapes are CPython's exactly, for both
address families -- an AF_INET6 sender comes back as the usual
(host, port, flowinfo, scope_id) 4-tuple, and a v6 destination accepts a
2-, 3- or 4-tuple with an optional %zone suffix.
- UdpEndpoint reports the arrival interface on Windows -- v4, v6 and
dual-stack ::, on 3.9 through 3.14. supports_pktinfo and
supports_src_pinning are no longer always False there. It calls the
Winsock backend directly rather than the patched stdlib method, so disabling
the patch below does not cost it pktinfo. One gap is deliberate: pinning by
interface index alone raises ValueError on Windows, because that platform
sends a zero source address literally -- a pin of 0.0.0.0 arrives from
0.0.0.0 -- where Linux reads zero as "kernel chooses". Pass an
address-bearing src.
- A v4 arrival on an AF_INET6 endpoint now reports the documented v4-mapped
address on every platform. Linux and macOS carry it in the v6 cmsg; Windows
reports a plain v4 address at IPPROTO_IP and is the only platform that
reports it at all, since its v6 option delivers no cmsg for a v4 arrival. That
needed IP_PKTINFO set on the AF_INET6 socket as well, which macOS refuses
outright and Linux accepts and does not need -- so it is set with the error
ignored.
Changed
- The patched
sock.recvmsgnow normalises the v4IP_PKTINFOpayload to the POSIX layout;netimps.recvmsg()still reports the platform's own bytes, andUdpEndpointis unaffected. On Windows the patched method rewrites{addr, ifindex}(8 bytes) into{ifindex, spec_dst, addr}(12 bytes) withspec_dstzero-filled -- byte-for-byte what macOS produces. The patchedsendmsgaccepts either layout, chosen by length.
Rationale: installing the method name without the layout is a
half-impersonation, and the missing half is what made POSIX-shaped code unpack
=I4s4s from an 8-byte buffer and raise struct.error -- which is not an
OSError, so it escaped receive handlers. spec_dst is zero rather than a copy
of addr because the two genuinely differ for a broadcast (measured on Linux:
the local interface address against 255.255.255.255), and code reads
spec_dst to get the local address -- so copying addr would corrupt exactly
the field it wanted. Zero is visibly wrong; 255.255.255.255 is not.
Known consequence, not fixed by this. The patch is additive in names and
therefore not in behaviour: code testing
hasattr(socket.socket, "recvmsg") to detect POSIX now gets the POSIX answer
on Windows. pydhcp 0.6.1 and earlier read ipi_spec_dst for their
SERVER_IDENTIFIER, and measured, they receive but allocate and reply to
nothing on Windows. A zero-filled spec_dst does not degrade a consumer that
resolves its interface from that field, it silences it -- an earlier draft of
this entry said "will see 0.0.0.0 ... and no longer a crash", which was
inferred from the field's value rather than measured against the consumer. The
owner's decision is to accept this: the two projects are released together and
have no external consumers yet. UdpEndpoint is the supported way to obtain
that address correctly on every platform; NETIMPS_NO_SOCKET_PATCH=1 opts out
of the patch entirely.
bind()andnormalize_host()now accept the package's usual loose union, not just astr: an address object, anIPv4Interface/IPv6Interface(its.ipis used), aHostor anFqdn.bind()previously leaked a raw socket-layerTypeError-- "str, bytes or bytearray expected, not IPv4Address" -- not even a netimps error, for a valueping,resolveandUdpEndpoint.sendall take; they coerce through a shared helper that these two simply never used.join_host,normalize_host's inverse, already accepted them. A network still raisesTypeError, since it names no single host.
normalize_host uses an allowlist, not a str() fallback. A fallback
accepted None and turned it into the hostname "None" -- a plausible answer
that is wrong and that a caller cannot detect -- and did the same for an int or
a list.
Fixed
import netimpsthenimport asynciocrashed on Windows. The import-time socket patch makessocket.socket.sendmsgexist, and CPython'sasyncio/selector_eventsreadshasattr(socket.socket, 'sendmsg')at import time as a POSIX proxy, then callsos.sysconf('SC_IOV_MAX')guarding onlyexcept OSError-- so theAttributeErrorfrom a missingos.sysconfescaped and the import died.sendmsgandos.sysconfare both POSIX and had always travelled together; the patch was the first thing to separate them.
The patch now installs an os.sysconf shim alongside, answering per name:
SC_IOV_MAX returns 1024 (tunable with patch_socket_module(iov_max=...)) and
every other name raises ValueError. Per name because the three stdlib callers
guard differently and no single behaviour satisfies them -- asyncio catches
OSError, concurrent.futures catches (AttributeError, ValueError),
multiprocessing catches Exception; raising OSError for everything rescues
asyncio and breaks ProcessPoolExecutor. Regression tests run in fresh
interpreters, since in-process tests cannot see it: by the time a test body
runs, asyncio is already imported.
-
sendmsg()failed on every connected stream socket on Windows.WSASendMsgrefusesSOCK_STREAMoutright withWSAEINVAL-- measured on Windows 11 build 28000, where a connectedSOCK_DGRAMis accepted, so the refusal is about the socket type and not about being connected. The buffers-only path now usesWSASend, Windows' own scatter-gather send, which works there and showed no smallIOV_MAX-like cap (500 buffers in one call, verified). A destination or a control buffer still routes throughWSASendMsg, the only call that carries either, soUdpEndpoint.send(src=)is unchanged. Ancillary data on a stream socket still fails, which is correct: Windows has no per-packet information to attach to one. -
UdpEndpointreported nothing when a datagram was too large forbufsize.Datagramnow carriestruncated, fromMSG_TRUNC, beside the existingcontrol_truncated. The flag was always inmsg_flagsand was being dropped, so a caller had no way to tell a complete datagram from the leading fragment of a longer one -- and a protocol parser handed a message cut mid-field reports a malformed packet rather than a short read. Measured on Linux withbufsize=576: a 1102-octet datagram arrived cut to 576 with the flag set and discarded. Reported, not raised: deciding that a short datagram is fatal belongs to the protocol.
It is reported on the no-pktinfo path too, which now goes through recvmsg
with a zero-length control buffer rather than recvfrom, because recvfrom
cannot report it. Losing the arrival interface is a documented degrade; losing
this is silent data loss, and the two no longer have to be given up together.
- A cancelled
arecv()left its reader registered on the loop, and the notifier stayed bound to the first loop for good. Both reported by a consumer reading the code, and both reproduced here before fixing.
Cancelling the awaiting task is the ordinary server shutdown -- cancel the
receive task, then close the endpoint -- and the registration was removed only
when it fired, so the loop was left watching a socket that then closed and
raised from the selector on its next poll. Measured:
loop.remove_reader(fileno) after a cancelled arecv returned True,
meaning one was still there. A cancelled arecv now unregisters itself, and
the thread path drops its pending future.
Separately, the notifier thread captured its loop for its whole lifetime, so
serving one endpoint from a second loop -- asyncio.run(serve()) twice, or a
server stopped and restarted -- sent readiness to a closed loop. Measured on a
ProactorEventLoop: the second exchange timed out while the daemon thread
raised an unhandled "Event loop is closed" to stderr, where no caller could
see it. A different running loop now retires the old thread and starts
another, joining the old one rather than abandoning it; the only previous
reset was close(), which also closes the socket. The post is additionally
guarded, since a loop can close between the select and the call.
bind(options=[(SOL_SOCKET, SO_REUSEADDR, 1)])failed with a bareWSAEINVALon Windows -- a regression introduced in this same unreleased cycle, caught by a consumer's test suite. Windows refusesSO_REUSEADDRon a socket that already carriesSO_EXCLUSIVEADDRUSE, and oncebind()began setting the latter for both values ofreuse_address, the stdlib-shaped spelling of "share this address" stopped working. The error names neither option, so the caller saw only "An invalid argument was supplied".
An explicit nonzero SO_REUSEADDR in options= is now treated as
allow_address_takeover=True -- it is the caller asking for takeover in so
many words. A zero value stays an explicit opt-out. bind_error_hint learned
WSAEINVAL, since a bare 10022 is undiagnosable. Verified against the
reporting consumer's suite: the three tests that failed now pass, and fail
again when the fix is reverted.
-
A v4 client of a dual-stack listener could not be replied to at all. Measured on Windows 11 ARM64 with an
AF_INETclient on127.0.0.1and abind("::", family=AF_INET6, IPV6_V6ONLY=0)listener, it failed both ways: -
With pktinfo,
reply_socketcorrectly returned anAF_INETsocket, butdatagram.senderwas still the v6 4-tuple -- so thereply.sendto(answer, packet.sender)shown in this library's own docs raisedTypeError: AF_INET address must be a pair (host, port). - Without pktinfo there was no
local_address, so both fallbacks used the listener's family; the resultingAF_INET6socket carriesIPV6_V6ONLY=1on Windows (the platform default, whichbind()does not clear), andsendtoto a mapped address failed withWinError 10049. The exchange silently never started.
Now the sender's family decides the reply socket's, with or without
pktinfo, and the own-address fallback is skipped when it is in the wrong
family. New Datagram.reply_address gives the peer in the family that was
chosen -- a mapped sender as a plain (host, port), everything else unchanged
-- and is what the examples now pass to sendto.
-
is_multicastmissed a v4-mapped group below Python 3.13. The stdlib only began delegating a mapped address'sis_*properties to the embedded v4 address in 3.13, soIPv6Address("::ffff:224.0.0.1").is_multicastisFalseon 3.9 andTrueon 3.14 -- the function's answer depended on the interpreter. It unmaps first now, asis_broadcastalready did.UdpEndpoint._is_repliableinherits this, so on 3.9-3.12 a reply socket could bind a mapped multicast destination. -
reply_socketanswered from the wrong address when the port was taken. The candidate loop caught everyOSErrorand advanced the address, so an explicitport=already held on the arrival address fell through to the endpoint's own address and then the wildcard with the same port -- and where that later bind succeeded, the reply left from an address the client never addressed, which is the single failure this method exists to prevent.
Measured on Windows 11 ARM64 against the pre-fix code: holding
10.6.0.223:57014 and replying to a datagram that arrived there returned a
socket bound to 127.0.0.1:57014.
A held port and an unusable address are different failures and now move in
different directions -- an unbindable address (broadcast, multicast, a wrong
scope) advances the address, a held port advances the port on the same
address. When the ports run out on an otherwise bindable address this raises
AddressInUseError and binds nothing, so a caller can also tell "port
busy, try another" from "this address is unbindable", which the old generic
OSError hid. Both reply_socket and AddressInUseError are new in this
unreleased cycle, so no released behaviour changed.
bind()'s default let a second live UDP socket take the port on Linux.reuse_address=True(the default) setSO_REUSEADDR, and on a datagram socket that is not theTIME_WAITconvenience it is for TCP -- it permits duplicate bindings of live sockets. Measured on WSL: a secondbind()of the same live UDPaddr:portwith default arguments succeeded and the datagram went to the second socket, with the holder getting no error.socket(7)is explicit that the exception is an active listening socket, and a UDP socket never listens.
SO_REUSEADDR is now set on POSIX for stream sockets only. Sharing a UDP
port stays reachable through the names that say so -- reuse_port=True
(SO_REUSEPORT, the option designed for it) or allow_address_takeover=True.
multicast_socket sets what it needs itself and was verified not to rely on
the old default.
This also corrects two documentation passages that stated the opposite: both
said "two live sockets still cannot hold one addr:port", which is true for
TCP and false for UDP on Linux.
- bind(reuse_address=False) left a Windows wildcard bind open to hijack.
It set nothing -- both SO_REUSEADDR and SO_EXCLUSIVEADDRUSE read 0 --
and Windows then lets a more specific SO_REUSEADDR bind take over a
non-exclusive wildcard. Reproduced: a thief binding 127.0.0.1 received the
datagram while the holder on 0.0.0.0 got nothing and no error.
So the flag that reads as "strictest" was the least strict setting available,
which is the worst shape a safety option can have -- the careful caller got
the unsafe behaviour. SO_EXCLUSIVEADDRUSE is now set on Windows whenever
allow_address_takeover is false, for both values of reuse_address;
that option's only effect there is denying the takeover, so it costs nothing.
reuse_address now governs POSIX SO_REUSEADDR only, which is what the name
means everywhere else. allow_address_takeover=True remains the one way to
opt into the old behaviour. Windows-only change, strictly toward safety.
- UdpEndpoint.recv() enumerated every interface on every datagram.
Measured on Windows loopback with 300-octet packets: 1.07 ms per packet
against 0.015 ms with resolve_interface=False -- a 70x cost on the default
path, and one a consuming project measured at 35-42 ms per enumeration on a
host with more adapters. Worse than a bad default: the class docstring's own
example uses the default and reads .interface, so the documented usage was
the slow one, and a server loop is a hot loop by definition since the sender
controls the rate.
Now resolved through a per-endpoint index -> Interface cache, refreshed on a
miss as well as on a 30-second TTL -- a miss means the adapter set changed,
which is both cheap to act on and exactly when it matters. Negative results are
cached too, so a stale or vanished index does not re-enumerate forever.
Measured after: 0.017 ms per packet, and one enumeration for ten datagrams
instead of ten.
- UdpEndpoint.send()'s docstring still claimed Windows could not honour src
at all and that supports_src_pinning was False there. Both stopped being
true earlier in this release.
- IPv6
UdpEndpointwas silently degraded on Windows. The receive option was looked up asIPV6_RECVPKTINFO, which Windows does not export at all; thereIPV6_PKTINFO(19) is both the request and the carrier. The result wassupports_pktinfo == Falsefor everyAF_INET6endpoint on that platform while a rawrecvmsgon the same socket delivered the cmsg perfectly well. The round-trip test passed throughout, because it took its own "no pktinfo here" early-exit branch, and its guard against exactly this consulted the same function that was wrong. - v4 pktinfo was silently off on Python 3.9 for Windows, which exports no
socket.IP_PKTINFOeven though Winsock supports it at the documented value - Measured: 3.14 ran the v4 tests while 3.9 skipped nine of them and still
reported a green suite. The documented literal is now used, with
OSErrorfromsetsockoptas the real capability signal. patch_socket_module()/socket_patched(), and netimps installs the patch on import by default:recvmsg/sendmsgontosocket.socketandCMSG_LEN/CMSG_SPACEonto thesocketmodule, so POSIX-shaped code runs unchanged on Windows. SetNETIMPS_NO_SOCKET_PATCH=1before the first import to decline, or callpatch_socket_module(False)afterwards. It is strictly additive and never replaces a name the platform already provides, so on Linux and macOS it is a verified no-op. All four names are installed rather than just the method, because Windows lacksCMSG_SPACEtoo and the standard idiom is detect, then size a buffer, then receive.
Cmsg payloads are not normalised to Linux's byte layouts. They genuinely
differ -- a v4 IP_PKTINFO cmsg is type 8, 12 bytes,
{ifindex; spec_dst; addr} on Linux against type 19, 8 bytes,
{addr; ifindex} on Windows -- and faking one as the other would make
correct-looking code read a plausible wrong address instead of failing.
UdpEndpoint is the layer that handles the difference for you.
- resolve_wire() -- a DNS client on the standard library alone: one
question over UDP to each nameserver in turn, asked again over TCP when the
reply is truncated, CNAME chains followed, records returned as native types
like the other backends. It joins resolve()'s chain after dnspython,
for an explicit ns= or source= only -- so a named nameserver now works
without dnspython installed.
- source= on resolve(), resolve_dnspython() and resolve_wire(): the
local address the queries leave from (one per address family as a list).
system and nslookup are skipped when it is set, as for ns=.
- resolve_doh() -- DNS over HTTPS (RFC 8484), with an injectable fetch
so a caller's own HTTP stack (proxy, CA bundle) carries the request.
0.3.2 - 2026-09-28
Changed
- The
cliextra'sduhodependency is bounded to>=0.6.0,<0.7(previously unbounded at>=0.3.3, so a barepip install netimps[cli]would have floated onto 0.6.0 unverified). No code change was needed -- every documented 0.6.0 change was checked againstsrc/netimps/cli.pyand the full suite is unaffected (663 passed, 6 skipped, before and after). duho 0.6.0 also adds an opt-in MCP launch feature, triggered by settingNETIMPS_MCP=stdio; netimps does not opt out and leaves it at duho's default.
0.3.1 - 2026-09-21
A same-day follow-up to 0.3.0, fixing three defects that release introduced or
exposed. No documented contract changes: the resolve() fix restores one.
Added
resolve(..., strict=True)-- raise the last backend'sResolutionErrorwhen no backend could ask, instead of returning[]. Off by default. It distinguishes a resolver outage from a name that does not exist; it does not turn an empty answer into an error, so a name that genuinely does not resolve still returns[]at any strictness.
Fixed
resolve()returns[]on a resolver outage again, restoring the pre-0.3.0 contract. 0.3.0 made the chain re-raise the last backend'sResolutionErrorwhen every applicable backend failed to attempt the query, which turnedif not resolve(host):into an uncaught exception wherever DNS was unreachable. The documented contract -- "always alist, empty on a lookup failure, neverNone" -- stands, and callers that need the distinction opt in withstrict=True.bind_error_hintno longer reports a WindowsWSAEACCESas a privilege problem. Windows has no privileged ports -- any user may bind port 80 -- andWSAEACCES(WinError 10013) on a bind means the address is held exclusively by another socket, or refused by a firewall or an excluded port range. Python maps it toPermissionError/EACCES, so the POSIX branch matched first and produced "permission denied binding port 64514" for an unprivileged port, plus the "ports below 1024 need root/Administrator" rider on a low port. The POSIXEACCESreading is unchanged, where that advice is correct.- A release publishes its documentation again.
docs.ymlwas triggered byrelease: published, which never fires: the release is created withGITHUB_TOKEN, and GitHub does not start workflow runs from token-created events. It now keys off the Release workflow completing successfully. The 0.3.0 site was published anyway, by the push-to-main trigger.
0.3.0 - 2026-09-21
Entries marked BREAKING change a documented contract. See
RELEASENOTES.md for migration detail and validation evidence.
Added
ResolutionErroris exported (netimps.ResolutionError, listed in__all__). It is the documented raised type ofresolve,resolve_systemandresolve_nslookup, and catching it by name previously meant importing the privatenetimps._dns.Interface.loopback-- the kernel's own answer, fromIFF_LOOPBACK(POSIX) orIfType == IF_TYPE_SOFTWARE_LOOPBACK(Windows), andNonewhen enumeration reported neither, plus a matching trailingloopback=constructor keyword.Interface.is_loopbackconsults it first and falls back to the address heuristic only when it isNone.UdpEndpoint.supports_src_pinning-- whethersend(src=...)can be honoured at all.Falseon Windows (nosendmsg) and wherever the socket's family has no pktinfo control message.repr(UdpEndpoint)gained a matchingsrc_pinning=field.Datagram.control_truncated--MSG_CTRUNC, i.e. the kernel had more ancillary data than the buffer held. Appended last with aFalsedefault, so positional construction and unpacking of the first five fields is unaffected.ipv6=onmulticast_socket-- forces the address family. The family came fromany(":" in g for g in groups), and a send-only socket has no groups, soany()over an empty list made it IPv4 unconditionally: the documentedmulticast_socket(ttl=32, bind=False)sender could not be given an IPv6 hop limit or used to reach an IPv6 group.None(the default) still infers fromgroup.ipv6=onget_ip,get_source_ip,get_route,hop_countandget_pmtu, matchingping's, andallow_address_takeover=onbind(see the Windows entry under Changed).- Per-octet dot MAC text --
MACAddress("aa.bb.cc.dd.ee.ff")parses. That spelling is exactly whatas_str(".")emits, so the round trip through the package's own output format was broken. Cisco triplets (aabb.ccdd.eeff) remain accepted on input and are still never produced. MACLikeincludesbytearray, which the constructor already accepted. No runtime change; a type checker stops rejecting a valid call.
Changed
-
multicast_socketrejects groups of mixed address families, and anipv6=that contradictsgroup. One socket has one family; picking either and letting the other join fail surfaced as an opaqueOSErrorfrom insidesetsockoptinstead of naming the mistake. -
BREAKING:
MACAddress.__eq__no longer coerces astr.MACAddress("aa:bb:cc:dd:ee:ff") == "aa:bb:cc:dd:ee:ff"is nowFalse-- in both directions -- andmac != textisTrue; anything that is not aMACAddressyieldsNotImplemented. Migration:MACAddress.try_parse(text) == mac. Every spelling of the text compared equal, whilestr.__hash__-- not ours to change -- hashes each of them differently, so equality and hashing disagreed. That is what broke containers, and it is what this buys:mac in {MACAddress(other_spelling)}and{mac: v}[MACAddress(other_spelling)]both work now and did not before. - BREAKING (narrow): mixed MAC separators are rejected.
MACAddress("00-11:22-33:44-55")raisesValueError; a separator form has to be used consistently. - BREAKING (narrow):
MACAddress(True)/MACAddress(False)raiseTypeErrorinstead of building00:00:00:00:00:01/...:00--boolis anintsubclass, and an unguarded int branch took it.is_valid(True)is nowFalseandtry_parse(True)isNone. - BREAKING:
resolve()no longer stops on an empty answer. Only a non-empty answer stops the chain, and[]comes back only when every applicable backend was empty.resolve("localhost")returns127.0.0.1in 0.011s where it returned[]; the same held for hosts-file,.localand NSS-only names on Windows and macOS, wherednspythonraises NXDOMAIN for them and so stopped the chain before the OS resolver was ever asked. (Linux answered, because systemd-resolved replies -- which is why Linux-only CI never saw this.) The cost: a genuinely non-existent name now takes two or three backend calls instead of one, the last of which may spawnnslookup. Passbackends="dnspython"for the old single call. - BREAKING:
resolve_nslookupraisesResolutionErroron a non-zero exit carrying no "no such name" marker -- an unreachable server, a refused connection, a usage error. It used to return[], which made a transport failure indistinguishable from NXDOMAIN and, insideresolve()'s chain, stopped the chain on it. An exit-1 NXDOMAIN with the marker text is still[]. - BREAKING:
resolve_nslookupvalidates its query before spawning anything --ValueErrorfor a query starting with-(nslookuphas no--separator to escape one with), for an empty or whitespace-only query, and for one containing whitespace or control characters. Such a query used to reach the binary, which went interactive and drained the caller's stdin, sending each line to the nameserver as a query name. - BREAKING in effect:
resolve_system(addr, "ptr", timeout=T)honoursT. The PTR branch calledgethostbyaddron the calling thread, bypassing the bounded daemon thread 0.2.2 added for the address path: measured 4.627s against a 0.1s deadline, and 0.106s now. It raisesResolutionErrorat the deadline rather than blocking and then returning[]. resolve()also skips thesystembackend for an explicit non-53port=ortcp=True, not only forns=-- which is what its docstring already promised. Withbackends=["system"]plus one of those, the resultingValueErrornow names the reason.- BREAKING: a port outside 0-65535 raises.
tcp_check,wait_for_port,scan_ports,scan_hosts,get_pmtu,discover_mtu,get_tcp_mssandregister_portraiseValueError("port out of range: 65536 (must be 0-65535)"), and a non-intport raisesTypeError. Ports were masked to 16 bits, sotcp_check(host, p + 65536)confidently answered aboutp. A service-name string (tcp_check(host, "http")) used to reachgetaddrinfoand now raises; the scanners resolve scheme names to numbers first, so they are unaffected. - BREAKING: a negative
timeoutraises fromscan_ports/scan_hosts; it used to be swallowed, and every port then read as closed. Atimeoutof0is floored rather than taken literally -- 1 ms in the scanners, 0.05s intcp_check-- becausesettimeout(0)means non-blocking, sotcp_check(open_port, timeout=0)returnedFalsefor an open port and now returnsTrue. - BREAKING:
scan_hosts(network, ports=[])scans nothing and returns[]. An explicitly empty list used to fall through to the 36-port "common" set -- the opposite of what it says. - BREAKING:
register_port(scheme, new_port)moves the scheme instead of leaving the old reverse entry behind, soget_default_scheme(old_port)no longer names it. The registry used to contradict itself: the scheme's port had changed and the old port still mapped back to that scheme. - BREAKING (Windows):
bind(reuse_address=True)setsSO_EXCLUSIVEADDRUSE, notSO_REUSEADDR. On WindowsSO_REUSEADDRlets an unrelated process take over a live listener's port; the hijack was reproduced and is now a regression test in which the thief socket is refused with errno 13. Code that relied on rebinding a live Windows port must pass the newallow_address_takeover=True. POSIX behaviour is unchanged. - BREAKING:
Route.on_linkisOptional[bool]--Truewhen no router is involved,Falsewhen one is, andNonewhen no next-hop lookup could be made at all. It used to begateway is None, which turned "we never looked" into a confidentTrue: on macOS,get_route("1.1.1.1")reportedon_link=Truefrom a192.168.64.3/24host.Noneis falsy, soif route.on_link:still takes the safe branch;route.on_link is Trueand== Trueare what change.Route.__eq__now also compareson_link, andRouteis hashable -- defining__eq__with no__hash__had set__hash__toNone, sohash(get_route("127.0.0.1"))raised. - BREAKING:
normalize_hostraisesValueErrorfor an unbracketed string with two or more colons that is not an IPv6 address. It used to return the whole string as the host. - BREAKING (narrow):
UdpEndpoint.send(src=...)raisesValueErrorfor a spec naming no local address or interface -- it used to fall back to an unpinnedsendtoand report success, which is the exact failuresrcexists to prevent -- and for an IPv6 source on anAF_INETendpoint, which used to raise an uncaughtOSErrorfrominet_atonon Linux. A v6 control message on a v4 socket is accepted-and-ignored by the kernel, so there is no correct silent behaviour to fall back to. - BREAKING (CLI): the positional argument is required for
ping,resolve,check(both of them),mtu,scan,addrandsplit. It used to default to"", sonetimps pinganswered about the empty string and printeddid not answer (icmp). It is now argparse's usage error on stderr, exit 2. - BREAKING (CLI): every diagnostic moved to stderr --
error: ...,no interface named ...,no route to .... stdout carries the answer alone, so--jsonoutput stays parseable on failure (it is empty, and the exit code carries the verdict). AValueErrorout of the library became a usage error at the command boundary --error: <message>on stderr with exit 2, never a traceback -- as innetimps ping <host> -m tcpwith no--port. Exit codes are otherwise unchanged, including the deliberate split betweenport <unknown>= 1 (a lookup that found no mapping, like an emptyresolve) andcheck <host> <unknown>= 2 (a caller error). MACAddress.is_validis annotatedboolrather thanTypeGuard[MACAddress]. No runtime change; the narrowing was unsound -- the object is still astr-- so code that relied on it now gets checker errors, correctly.get_source_ipis annotatedOptional[IPAddress]instead ofOptional[Any];tcp_check'stimeoutisOptional[float](Noneblocks, which already worked); andDatagram.sender,.local_addressand.interfaceare really typed rather thanAny, so consumers of thispy.typedpackage get checking on them.get_default_port/get_default_schemequery the system services database with an explicit protocol, TCP first then UDP, instead of letting the platform choose. Answers are now identical across platforms; where a name or port differs between TCP and UDP the TCP entry wins. Nothing in the built-in table changes.scan_portsresolves its host once per scan rather than once per port, so a rate-limited resolver can no longer turn open ports into "closed", and an unresolvable name costs one lookup rather than one per port. A name with several addresses is still probed on each.- A port spec holding a Unicode digit that
int()rejects (U+00B2, say) falls through to scheme lookup and raises the intended "unknown port range or scheme" rather thaninvalid literal for int(). The CLI decides "is this a number?" withint()rather thanstr.isdigit()for the same reason. Interface.macreports an all-zero hardware address asNone, so Linuxlomatches macOS and Windows instead of reportingMACAddress("00:00:00:00:00:00").Interfaceis hashable too, soset(get_interfaces())works instead of raising.iter_addresses(family=)validates eagerly, at the call rather than at the firstnext(), and its message names the4/6short form it wants.UdpEndpoint(pktinfo=False)governs receiving only.send(src=...)is honoured on such an endpoint, since pinning a source needs no socket option; the parameter used to disable both. AnOSErrorfrom the kernel on a pinned send (a source address this host cannot send from) now propagates instead of being read as an unsupported platform -- genuine platform incapability still degrades tosendto.get_routeon macOS/BSD spawns a short-livedroute -n get(stdin=DEVNULL, 5s timeout) to learn the next hop; loopback short-circuits without spawning anything. It was subprocess-free there before and returned nothing useful.netimps route's text output gained anon-link <True|False|unknown>line and prints(unknown)rather than(on-link, no router)for the newNone.--json'son_linkcan now benull; the JSON keys are unchanged.- Packaging:
*.local.*is excluded from both the sdist and the wheel -- the sdist's existing/.*covers dotfiles only, andfnmatch("AGENTS.local.md", ".*")isFalse-- and the published metadata now carries an author email,jose-pr <jose-pr@coqui.dev>.
Removed
- The claim that
ping(ttl=...)"behaves the same on every OS" is withdrawn from the README and the docs landing page. It was never true on BSD, where-tis an overall deadline and-mis the hop limit -- the other way round from Linux, whose-mis a firewall mark. What the sentence was really justified by is kept and now stands on its own terms:ping()decides success from the reply, not the exit code, because Windowspingexits0for "TTL expired in transit". - The docs site's "the only runtime dependency is
dnspython" is withdrawn. It has been false since 0.2.0 madednspythonoptional; the package declaresdependencies = []. It outlived the release that made it wrong because the landing page could only be republished by cutting a version -- see the docs workflow note below.
Fixed
netimps.__version__is read from the installed distribution metadata rather than restated as a literal besidepyproject.toml. The literal drifted on the first version bump -- reporting0.2.2from a0.3.0package -- while the shipped header promised the two always carry the same value. A source tree with no installed metadata reports0.0.0+unknown.-
Link-local IPv6 multicast joins work on macOS/BSD.
ff02::/16has no meaning without a scope, and those kernels will not pick one -- somulticast_socket("ff02::fb")raisedOSError 49 (EADDRNOTAVAIL)there while joining fine on Linux and Windows, which both accept index0for "kernel's choice". An index is now supplied only on the platforms that refuse to choose, and only when the caller named nointerface=, which still wins everywhere. -
pingis no longer a Linux-only implementation. Five of the six flags it emitted mean something different, or nothing at all, on BSD/macOS -- measured on a real runner, not inferred.-Wis milliseconds there rather than seconds, so the Linux value gave macOS a 1 ms deadline andtimeout=was inert;-tis an overall deadline rather than the TTL (-mis the TTL);-Iis multicast-only and is rejected for a unicast destination (-Sis the source flag); DF is-Drather than-M do; and-4/-6do not exist at all -- macOSpingexits 64 on them and cannot even take a v6 literal. A three-way_PLATFORM(windows/linux/bsd) emits each grammar, and an IPv6 destination selects the separateping6binary, which disagrees again (-hfor the hop limit, no-Wat all).ping("::1"),ipv6=,src=andtimeout=therefore work on macOS, where each was silently broken. - A BSD reply is matched despite different punctuation.
ping6prints16 bytes from ::1, icmp_seq=0 hlim=64-- a comma rather than a colon, andhlimrather thanttl, so a v6 reply there reported no TTL at all. ping("-?")raises instead of reporting success. Windowsping -?prints usage and exits0; nothing resolved, so the reply-address check was skipped entirely and the bare exit code was taken as proof of a reply --ping("-?")came back truthy for a host that was never contacted. A destination starting with-is aValueErrornow, and success requires positive reply evidence rather than exit status alone.ping("")stays falsy, as it always has.PingResult.rtt_msis0.0for a sub-millisecond reply, which is what its docstring has always said. Windows printstime<1msand the pattern captured the1, so every sub-millisecond reply reported1.0-- up to 100% error, silently, and the documented caution about0.0being falsy guarded nothing.get_pmtureturns a number. It was unreachable code on every platform for the life of the project:socket.IP_MTUis not exported by CPython anywhere (re-measured on Windows and Linux), so the first guard returnedNoneunconditionally and the rest of the body never ran. Linux now uses the documented literals and readsIPV6_PATHMTUas theip6_mtuinfostruct it really returns, rather than as a bare int -- measured 65535 for127.0.0.1and 65536 for::1. Windows genuinely exposes no cached path MTU and still answersNone.discover_mtu(probe=False)inherited the bug and the fix.discover_mtu(method="udp")sets DF. No DF option was set on any platform, so an oversized datagram was fragmented locally, reassembled by the peer and answered: every probe "survived" and the binary search returned its own ceiling --high, 9000 by default -- on Linux and Windows as well as BSD. It sets the per-platform option now and returnsNonewhere it cannot, rather than a number it cannot stand behind. The probe socket was also hardcodedAF_INET, so every IPv6 destination failed insidesendtoand read as "no reply".- An IPv6
UdpEndpointreports the arrival interface. It advertisedsupports_pktinfo=Trueand then returnedinterface_index=0,interface=Noneandlocal_address=Nonefor every datagram, because only the IPv4 option was ever requested.IPV6_RECVPKTINFOand thein6_pktinfolayout are handled now, andsupports_pktinfoisFalse-- rather than optimisticallyTrue-- whenever the option for this socket's family is missing or refused. send(src=...)pins the interface as well as the address. The pktinfo structure carried a hardcodedipi_ifindex=0("kernel's choice"), so it only ever pinned an address despite documenting otherwise. The ancillary buffer is also sized for four control messages rather than exactly one, so an unrelated option enabled on the raw socket no longer silently swallows the pktinfo.Interface.is_loopbackcomes from the interface flags. It was derived from the addresses, and WSL2 binds a routable10.255.255.254/32tolo, so no interface reportedis_loopbackthere -- and two tests took a skip branch marked# pragma: no coverbecause the author believed it unreachable.IFF_LOOPBACK/IfTypewere already being read intorawand ignored. WSL2 now reports exactly one loopback interface where it reported none; the same applies to any keepalived/anycast/VIP host.- Zone-qualified IPv6 is recognised.
is_local_address,interface_forandinterfaces_foranswered falsy for the%zonespelling of an address they called local unscoped, andinterface_index()failed for every scoped literal, becauseipaddresskeeps the zone andfe80::1%12matches no enumerated address. A numeric zone is taken as the index (that is the OS's own spelling) and a named one resolves throughget_interfaces(). A zone naming a different adapter still does not match, which is the point. - The IPv4-only
gethostbynameis gone fromget_ip,get_routeandhop_count, replaced bygetaddrinfowith an explicit family.get_routegained real IPv6 next-hop lookups:GetBestRoute2on Windows (both families),/proc/net/ipv6_routeon Linux,route -n geton BSD. The Linux parser honoursRTF_UP/RTF_REJECT, because WSL2 carries a::/0reject route onloand matching it would have made every global IPv6 address "on-link via loopback". get_source_ipno longer picks the address family with":" in dst. A hostname never contains a colon, so every hostname was probed as IPv4: an IPv6-only name returnedNone, and a dual-stack name returned the v4 source even where traffic would leave over v6.tcp_check(timeout=T)bounds the whole call.socket.create_connectionappliestimeoutper resolved address, so a name with N addresses cost up to N x T.- No subprocess inherits the caller's stdin. All three call sites --
nslookup,pingandtraceroute-- passstdin=DEVNULL.capture_outputredirects stdout and stderr only, so the child kept the parent's stdin, andnslookupin particular reads it. is_multicastaccepts the interface objects its neighbours do.is_multicast(IPv4Interface("239.1.2.3/32"))wasFalse, and it is the gatekeeperjoin_group/leave_groupconsult, so a genuine group passed that way was rejected as "not a multicast group".pip install netimpsno longer ships a command that dies with a traceback. Thenetimpsconsole script installs unconditionally whileduholives in thecliextra, so the entry point raisedImportErrorat import time. The import moved insiderun(): the command now printsnetimps: the CLI needs the 'cli' extra -- pip install 'netimps[cli]'and exits 1, andimport netimps.clisucceeds without duho installed.- A latent 34-byte over-read.
_SockaddrDldeclaredsdl_dataat 46 bytes (54 in total) where the real BSDstruct sockaddr_dlis 20; the read is bounded bysdl_lennow. It did not fault in practice, becausegetifaddrsreturns one contiguous arena, but it was undefined behaviour at a page boundary.
Documentation
- The shipped API header (
src/netimps/AGENTS.md), the README, the docs landing page and the repo-rootAGENTS.mdare back in line with the code; several of the contracts above had been documented as something else. - CI runs the gate the repo has always claimed. A
lintjob runsblack --check src/ tests/,mypy src/netimps(27 errors when this campaign started, 0 now) andmypy tests/typing/api.py-- the file that proves the shipped header's static-typing promises, and that until now was executed by nothing at all. Pull requests are gated too, which is the one moment the check is worth having. - Pages deploys moved out of
release.ymlinto their owndocs.yml. The site could previously change only by cutting a PyPI release, so a wrong sentence on the landing page stayed wrong until the next version. A release now gates on a strict docs build and deploys onrelease: published, and a docs-only correction ships on its own.publish-pypialso passesskip-existing: true, so a release that fails after some files upload can be finished rather than being stuck. - "Tests must never hit the network" is enforced rather than trusted
(
tests/conftest.py). A review measured eleven off-host lookups, and simulating a wildcard resolver -- the kind many ISP and corporate networks run -- turned the suite red, because several tests assert that a name does not resolve. The suite grew from 432 to 640 tests, includingtests/test_platform_smoke.py, which runs the real platform binaries against loopback: every other ping test fakessubprocess.runand asserts the argv the library builds, which is exactly how the macOS defects above shipped while CI stayed green. - A
benchmarks/suite (run on demand, deliberately not in CI, results committed per platform) and two runnableexamples/were added.
0.2.2 - 2026-08-16
Fixed
resolve_system(timeout=)now bounds wall time. The lookup ran inside awith ThreadPoolExecutor(...)block, whose__exit__joins the worker still blocked ingetaddrinfo-- so the timeout changed what was raised but not when, and a 30s resolver hang still cost the caller 30s despitetimeout=5.0.resolve()'s chain consequently never reachednslookupat the promised deadline either.interface=is honoured for IPv6 multicast. The spec was reduced to an address and then passed toif_nametoindex(), which always fails for an address string, so theIPV6_JOIN_GROUPindex silently stayed0-- "kernel's choice", the exact defaultinterface=exists to override.IPV6_MULTICAST_IFwas never set at all, so sends left by the default route while joins listened elsewhere. Both now resolve the adapter to its interface index. IPv4 behaviour is unchanged.ping(hostname, ipv6=True)is no longer always false. The reply address was resolved with the IPv4-onlygethostbyname, so a v6 reply was checked against a v4 expectation and never matched. The expectation now comes fromgetaddrinfohonouringipv6=, and a name resolving to several addresses counts as answered if the reply came from any of them.ping(..., method="tcp"/"udp")reaches IPv6 destinations. Both probes openedAF_INETsockets unconditionally, so a v6 destination failed insideconnect/sendtoand was reported as unreachable -- a wrong falsy answer rather than an error.ipv6=now applies to all three methods.ping(..., method="udp")detects ICMP port-unreachable on POSIX. The probe socket was never connected, and POSIX delivers asynchronous ICMP errors only to connected UDP sockets -- so the documented "host answered, nothing listening" signal worked on Windows alone and the probe just timed out on Linux/macOS.ECONNREFUSEDandECONNRESETboth now count.
Documentation
- The shipped API header named
PingResult.sourceandRoute.source; both attributes are spelled.src(andPingResult.hostwas unlisted). - Recorded the per-family multicast interface selection,
ping'sipv6=reach across all three methods, andresolve_system's real wall-time deadline in the shipped header. - Added the known, still-unverified macOS/BSD
ping6reply-shape gap (from <addr>,rather thanfrom <addr>:) to the header rather than guessing a parser change without a macOS runner.
0.2.1 - 2026-07-30
Added
AddressLike, a new type alias (str | IPv4Address | IPv6Address | IPv4Interface | IPv6Interface) accepted by everydst-typed parameter:ping,tcp_check,wait_for_port,get_route,hop_count,get_pmtu,discover_mtu,get_tcp_mss,scan_ports(host),get_ip,UdpEndpoint.send, andresolve's (and its backends')query. AnIPv4Interface/IPv6Interfaceunwraps to its.ip-- previously passing one stringified with its/prefixintact, which every consumer (subprocess argument, socket call, DNS query) read as garbage. A network (IPv4Network/IPv6Network) raisesTypeError, since it has no single address to use.resolve()(and all three backends) auto-selectrdtype. It now defaults toNone, which picks"ptr"whenqueryis an address literal and"a"otherwise --resolve("8.8.8.8")now returns['dns.google']instead of attempting a nonsensical A lookup on a literal address. Pass an explicitrdtypeto opt out.resolve_system()gains"ptr"support (viasocket.gethostbyaddr()) to make this work across every backend.
Fixed
ping(src=...)crashed withNameErrorinstead of returning a falsy result whensrcnamed an interface with no usable address (e.g. an unknown adapter name, or a MAC not currently present) -- a leftover reference to an undefinedhostnamevariable instead ofdst. Found via amypypass while auditing type annotations; a regression test now covers the path.
Changed
- Public functions across the package now carry complete parameter and
return type annotations (previously missing on, among others,
collapse,subtract,get_ip,PingResult,Route,scan_hosts,is_multicast,join_group/leave_group,multicast_socket,UdpEndpoint, andbind). The recurring "loose interface spec" parameter (ping(src=),bind(interface=),discover_mtu(src=),multicast_socket(interface=), etc.) now shares one internal type alias instead of being unannotated at each call site.
0.2.0 - 2026-07-29
Added
- Three independently callable DNS backends, plus
resolve()chaining them:resolve_dnspython()(the originaldnspython-backed implementation, every record type),resolve_system()(socket.getaddrinfo()-- hosts file, NSS, OS resolver cache, address records only), andresolve_nslookup()(shells out tonslookup, parses both BIND-style and Windows-style output, address records only).resolve()now tries["dnspython", "system", "nslookup"]in order by default and returns the first definitive answer, skipping/falling through backends that can't serve the request (non-addressrdtype, missing binary,dnspythonnot installed). A custom order/subset is available viaresolve(..., backends=[...])(or a single name as a plain string). resolve()(and all three backends) gain asearchparameter for the system resolver's search list (resolv.conf'ssearch/domaindirective, or the Windows per-adapter DNS suffix list). It defaults toTrue, so an unqualified name likeresolve("db1")is expanded the wayping db1would be;search=Falselooks the name up literally (as a fully-qualified name, so the OS resolver's own search-list logic doesn't kick in either), and a list of domain names tries exactly those suffixes instead of the system list.dnspythonis now an optional dependency (pip install netimps[dns]), sinceresolve()can fall back toresolve_system()/resolve_nslookup()without it. The CLI'sresolvesubcommand now reports a missing-backend failure as a clean CLI error rather than an uncaught exception.
Changed
resolve()'s default behavior for unqualified names. Previously an unqualifiedquerywas only ever looked up as-is; it now also tries the system resolver's search list first (seesearchabove). Passsearch=Falseto keep the old literal-only behavior.ns=Nonealready used the system resolver's nameservers; an invalidns=now raises before any query is attempted rather than silently falling back to the system default.resolve()is no longer purelydnspython-backed. Behavior should be unchanged for existing callers whendnspythonis installed (it's still tried first), but a lookup that previously raised or returned[]becausednspythonfailed for a reason unrelated to the DNS answer itself (e.g. a malformed system resolver config) may now succeed via thesystemornslookupfallback instead.
0.1.0 - 2026-07-25
Added
- Complete local-interface membership lookups.
interfaces_for()yields every adapter matching an interface, exact address/IPInterface, network, orMACAddress, whileinterface_for()keeps the first-match scalar contract.is_local_address()distinguishes an assigned or loopback address from one that is merely private, link-local, on-link or reachable. - Static parser contracts.
TypeFormoverloads preserve union and concrete result types, callable builders, and explicittry_parse(default=...)fallbacks.IPInterfaceLikenow complements the existing input aliases.
Changed
interface_for()accepts networks and MAC addresses, including MAC text and 6-byte packed values. Integer MACs remain explicitMACAddressvalues. Its legacystrict=Falsesynthetic fallback remains address-only because a missing network or MAC has no honest single-interface representation.- The IP input aliases now include packed bytes plus the exact stdlib
two-tuple and existing-interface forms accepted by the interface/network
factories.
is_valid()is documented as a boolean convertibility check rather than an unsound type guard for the original object.
0.0.2 - 2026-07-23
Added
ws/wssin the built-in scheme→port table (80/443). WebSocket schemes (RFC 6455) ride the HTTP/HTTPS ports but are absent from/etc/services, soget_default_port("wss")previously returnedNoneand every websocket consumer had toregister_portthem.http/httpsremain canonical for 80/443.
0.0.1 - 2026-07-22
Added
- Command-line interface (
netimps .../python -m netimps), built on duho and installed by the newcliextra. Eleven subcommands cover the diagnostic surface:interfaces,ping,resolve,check,route,mtu,scan,addr,source,port,split. Every one takes--json, and exit codes distinguish success from "the answer was no" from a caller error.duhois CLI-only -- importing the library does not require it.
0.0.0 - 2026-07-22
Initial release.
Earlier version numbers appear in this project's git history but were never tagged or published, so there is no upgrade path to describe -- everything below is simply what the package contains.
Added
- Interface discovery --
get_interfaces()reports adapter names, MACs, MTU and real prefix lengths on Linux, macOS/BSD and Windows, viactypesbindings togetifaddrs(3)andGetAdaptersAddresses. No third-party dependency.Interface.is_loopbackis derived from the addresses rather than the name, sincelo,lo0andLoopback Pseudo-Interface 1share no spelling.Interface.primary_ip()picks one entry;iter_addresses()is the flattened per-address view. - Types and parsing --
IPAddress/IPInterface/IPNetworkunion aliases to annotate with, and oneparse(value, type, **kwargs)entry point with non-raisingtry_parseand booleanis_validsiblings. Concrete types are strict about family; networks are non-strict about host bits by default. MACAddress-- colon/hyphen/dot/bare plusint/bytes, hashable and ordered, with.packed,.oui,.is_multicast,.is_localand case-selectable rendering. A value type exposing.packed, not abytessubclass, matching howipaddressmodels addresses.- Socket helpers --
bind(),bind_error_hint(),interface_for(),get_source_ip(),get_free_port(),tcp_check(),wait_for_port(). UdpEndpoint-- UDP receive reporting which interface a datagram arrived on viaIP_PKTINFO, degrading whererecvmsgdoes not exist.- Routing and MTU --
get_route()(first hop, unprivileged),hop_count()(raw sockets or a traceroute fallback, so it works without elevation),discover_mtu()(measures the real path --method="icmp"with DF-flagged pings,"udp"with datagrams, or"tcp"deriving from the negotiated MSS since TCP cannot be probed),get_pmtu()(the kernel's cached answer, usuallyNone),get_tcp_mss(), andInterface.mtu. Header arithmetic is family-aware: IPv6 adds 20 bytes over IPv4, and assuming v4 on a v6 path under-reports by exactly that. - CIDR maths and host parsing --
collapse(),subtract()(absent fromipaddress), andnormalize_host(), which keeps"::1"an address rather than host"::"port1. - Scheme/port registry --
get_default_port(),get_default_scheme(),register_port(). - DNS --
resolve()returning native types (A/AAAAasipaddressobjects),[]on a genuine lookup failure, andValueErrorfor a malformed query rather than a silent empty result. ping()-- returns aPingResultwith round-trip time and TTL that stays truthy.method="icmp"|"tcp"|"udp"reaches hosts through firewalls that drop echo; all three ask "is the host up?", so a TCP refusal or an ICMP port-unreachable counts as success.tcp_checkremains the "is the service up?" question, where a refusal is a failure.ttl=behaves identically on every platform, because Windowspingexits 0 for "TTL expired in transit" and the reply address is verified instead of the exit code.- Scanning -- concurrent
scan_ports()/scan_hosts(), ports addressable by scheme name. - Multicast --
multicast_socket(),join_group(),leave_group(), wrapping a setup whose failure modes are otherwise silent. Host,retry()/backoff_delays(), and the named networksAPIPA,LOOPBACK_V4,LOOPBACK_V6,LINK_LOCAL_V6.