Repository navigation
v0.3.0
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_system
andresolve_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 matching
src_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_countand
get_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 an
ipv6=that contradictsgroup. One socket has one family; picking either
and letting the other join fail surfaced as an opaqueOSErrorfrom inside
setsockoptinstead 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 a
MACAddressyieldsNotImplemented.
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)raise
TypeErrorinstead 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=or
tcp=True, not only forns=-- which is what its docstring already
promised. Withbackends=["system"]plus one of those, the resulting
ValueErrornow 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_mssand
register_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 reachgetaddrinfo
and 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. Atimeoutof
0is floored rather than taken literally -- 1 ms in the scanners, 0.05s in
tcp_check-- becausesettimeout(0)means non-blocking, so
tcp_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 new
allow_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")reported
on_link=Truefrom a192.168.64.3/24host.Noneis falsy, so
if route.on_link:still takes the safe branch;route.on_link is Trueand
== Trueare what change.Route.__eq__now also compareson_link, and
Routeis 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 between
port <unknown>= 1 (a lookup that found no mapping, like an empty
resolve) andcheck <host> <unknown>= 2 (a caller error). -
MACAddress.is_validis annotatedboolrather than
TypeGuard[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 of
Optional[Any];tcp_check'stimeoutisOptional[float](Noneblocks,
which already worked); andDatagram.sender,.local_addressand
.interfaceare really typed rather thanAny, so consumers of this
py.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 Linuxlo
matches macOS and Windows instead of reporting
MACAddress("00:00:00:00:00:00").Interfaceis hashable too, so
set(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, and
fnmatch("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 Windowspingexits0
for "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.0
package -- 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 -- so
multicast_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.
ping6prints
16 bytes from ::1, icmp_seq=0 hlim=64-- a comma rather than a colon, and
hlimrather 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 returned
Noneunconditionally 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.1
and 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 advertised
supports_pktinfo=Trueand then returnedinterface_index=0,
interface=Noneandlocal_address=Nonefor every datagram, because only
the IPv4 option was ever requested.IPV6_RECVPKTINFOand thein6_pktinfo
layout 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_for
andinterfaces_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_routeand
hop_count, replaced bygetaddrinfowith an explicit family.get_route
gained 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_connection
appliestimeoutper 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 while
duholives in thecliextra, so the entry point raisedImportErrorat
import time. The import moved insiderun(): the command now prints
netimps: 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, becausegetifaddrs
returns 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 runs
black --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 passes
skip-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, including
tests/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.
Full Changelog: v0.2.2...v0.3.0