netimps 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {netimps-0.2.2 → netimps-0.3.0}/.gitignore +8 -1
  2. netimps-0.3.0/AGENTS.md +379 -0
  3. netimps-0.3.0/CHANGELOG.md +611 -0
  4. {netimps-0.2.2 → netimps-0.3.0}/PKG-INFO +22 -16
  5. {netimps-0.2.2 → netimps-0.3.0}/README.md +20 -14
  6. netimps-0.3.0/RELEASENOTES.md +133 -0
  7. netimps-0.3.0/benchmarks/README.md +88 -0
  8. netimps-0.3.0/benchmarks/results/windows-py3.14.6-arm64.json +118 -0
  9. netimps-0.3.0/benchmarks/run.py +203 -0
  10. {netimps-0.2.2 → netimps-0.3.0}/docs/index.md +16 -11
  11. netimps-0.3.0/examples/describe_host.py +79 -0
  12. netimps-0.3.0/examples/subnet_planner.py +70 -0
  13. {netimps-0.2.2 → netimps-0.3.0}/mkdocs.yml +0 -1
  14. {netimps-0.2.2 → netimps-0.3.0}/pyproject.toml +11 -3
  15. netimps-0.3.0/src/netimps/AGENTS.md +998 -0
  16. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/__init__.py +67 -9
  17. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/__main__.py +3 -0
  18. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_dns.py +217 -89
  19. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_iface_spec.py +112 -7
  20. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_ifaddrs.py +171 -44
  21. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_ip.py +69 -9
  22. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_mac.py +85 -27
  23. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_multicast.py +116 -4
  24. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_ping.py +325 -82
  25. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_scan.py +143 -21
  26. netimps-0.3.0/src/netimps/_scheme.py +249 -0
  27. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_sockets.py +799 -145
  28. netimps-0.3.0/src/netimps/_udp.py +428 -0
  29. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/cli.py +160 -38
  30. netimps-0.3.0/tests/conftest.py +157 -0
  31. {netimps-0.2.2 → netimps-0.3.0}/tests/test_centralized.py +207 -17
  32. netimps-0.3.0/tests/test_cli.py +427 -0
  33. netimps-0.3.0/tests/test_interfaces.py +603 -0
  34. {netimps-0.2.2 → netimps-0.3.0}/tests/test_ip.py +144 -0
  35. netimps-0.3.0/tests/test_mac.py +370 -0
  36. {netimps-0.2.2 → netimps-0.3.0}/tests/test_net.py +474 -40
  37. netimps-0.3.0/tests/test_platform_smoke.py +155 -0
  38. netimps-0.3.0/tests/test_scan.py +897 -0
  39. {netimps-0.2.2 → netimps-0.3.0}/tests/test_sockets.py +660 -39
  40. netimps-0.3.0/tests/typing/api.py +188 -0
  41. netimps-0.3.0/tests/typing/consumer.ini +32 -0
  42. netimps-0.2.2/AGENTS.md +0 -208
  43. netimps-0.2.2/CHANGELOG.md +0 -249
  44. netimps-0.2.2/src/netimps/AGENTS.md +0 -619
  45. netimps-0.2.2/src/netimps/_scheme.py +0 -145
  46. netimps-0.2.2/src/netimps/_udp.py +0 -198
  47. netimps-0.2.2/tests/test_cli.py +0 -231
  48. netimps-0.2.2/tests/test_interfaces.py +0 -310
  49. netimps-0.2.2/tests/test_mac.py +0 -177
  50. netimps-0.2.2/tests/test_scan.py +0 -412
  51. netimps-0.2.2/tests/typing/api.py +0 -92
  52. {netimps-0.2.2 → netimps-0.3.0}/LICENSE +0 -0
  53. {netimps-0.2.2 → netimps-0.3.0}/docs/api/reference.md +0 -0
  54. {netimps-0.2.2 → netimps-0.3.0}/docs/changelog.md +0 -0
  55. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_retry.py +0 -0
  56. {netimps-0.2.2 → netimps-0.3.0}/src/netimps/py.typed +0 -0
@@ -1,6 +1,9 @@
1
1
  # Agent configs / private notes — never tracked in this repo
2
+ # `.agents` is deliberately slashless: it is usually a symlink, which a
3
+ # directory-only `.agents/` pattern would not match.
4
+ # `*.local.*` covers every extension, not just `.local.md`.
2
5
  .agents
3
- *.local.md
6
+ *.local.*
4
7
  CLAUDE*
5
8
  .claude/
6
9
 
@@ -22,6 +25,10 @@ dist/
22
25
  *.egg
23
26
  .eggs/
24
27
 
28
+ # Probe captures. .github/probe/capture.py writes one per run; they hold a
29
+ # transcript of the host's real behaviour and are never meant to be committed.
30
+ probe*.json
31
+
25
32
  # Test / coverage / type-checker caches
26
33
  .pytest_cache/
27
34
  .mypy_cache/
@@ -0,0 +1,379 @@
1
+ # netimps
2
+
3
+ A **small, self-contained network-utilities library** — a thin, typed layer over
4
+ the standard library's `ipaddress`, native cross-platform interface discovery,
5
+ and a handful of host helpers (DNS lookup, ping). One flat import surface, no
6
+ hard runtime dependencies (`resolve()` chains `dnspython`/OS-resolver/
7
+ `nslookup` backends, using whichever is available -- `dnspython` is optional,
8
+ via the `dns` extra), and behaviour that stays faithful to the stdlib.
9
+
10
+ ```python
11
+ import netimps
12
+ from netimps import IPNetwork, MACAddress, parse
13
+
14
+ for iface in netimps.get_interfaces():
15
+ print(iface.name, iface.mac, iface.mtu, [str(ip) for ip in iface.ips])
16
+
17
+ parse("10.0.0.5/24", IPNetwork) # IPv4Network('10.0.0.0/24')
18
+ netimps.get_source_ip("8.8.8.8") # the address that actually reaches it
19
+ netimps.tcp_check("example.com", 443) # True
20
+ netimps.resolve("example.com") # [IPv4Address(...)] ([] on failure)
21
+ netimps.ping("8.8.8.8").rtt_ms # 9.0
22
+ ```
23
+
24
+ - **Interface discovery, no dependencies** — `get_interfaces()` gives adapter
25
+ names, MACs, MTU and *real* prefix lengths on Linux, macOS/BSD and Windows,
26
+ via `ctypes` bindings to `getifaddrs(3)` / `GetAdaptersAddresses`. Results are
27
+ normalised across platforms; native leftovers are opt-in via `raw=True`.
28
+ - **One parsing entry point** — `parse(value, type)`, plus non-raising
29
+ `try_parse` and boolean `is_valid`. `IPAddress`/`IPInterface`/`IPNetwork` are
30
+ the v4/v6 unions you annotate with *and* the types you parse into.
31
+ - **`MACAddress`** — colon/hyphen/dot/bare plus `int`/`bytes`, hashable and
32
+ ordered, with `.packed`, `.oui`, `.is_multicast`, `.is_local`,
33
+ `.as_str(sep, upper=)` and `is_valid`/`try_parse` classmethods.
34
+ - **Socket helpers** — `get_source_ip`, `get_free_port`, `tcp_check`,
35
+ `wait_for_port`: the four every network tool rewrites.
36
+ - **Routing and MTU** — `get_route` (first hop, unprivileged), `hop_count`
37
+ (raw sockets or traceroute fallback), `discover_mtu` / `get_pmtu`, `Interface.mtu`.
38
+ - **CIDR maths and host parsing** — `collapse`, `subtract` (absent from
39
+ `ipaddress`), and `normalize_host` with correct IPv6 bracket handling.
40
+ - **Scanning** — concurrent `scan_ports` / `scan_hosts`, ports addressable by
41
+ scheme name.
42
+ - **Multicast** — `multicast_socket`, `join_group`, `leave_group`, wrapping the
43
+ setup whose failure modes are silent.
44
+ - **DNS and ping** — `resolve()` returning native types; `ping()` returning a
45
+ `PingResult` with RTT and TTL that stays truthy.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install netimps # no hard dependencies
51
+ pip install netimps[dns] # plus dnspython, for resolve()'s fullest backend
52
+ ```
53
+
54
+ Requires Python 3.9+. No hard runtime dependencies.
55
+
56
+ > **This file is development documentation** — layout, testing, CI, release.
57
+ > It is deliberately **not shipped** in the wheel. The library-usage reference
58
+ > is `src/netimps/AGENTS.md`, which *is* shipped and must stay self-contained
59
+ > (no repo-relative links, since an installed consumer has no repo).
60
+
61
+ ## Code layout
62
+
63
+ ```
64
+ src/netimps/
65
+ ├── __init__.py # the public surface: generic parse/try_parse/is_valid
66
+ ├── _ip.py # private: IP type aliases, builder tables, IP helpers
67
+ ├── _mac.py # private: MACAddress value type
68
+ ├── _scheme.py # private: scheme <-> port registry, shared port coercion
69
+ ├── cli.py # public: duho-backed CLI (needs the `cli` extra)
70
+ ├── __main__.py # `python -m netimps`
71
+ ├── _scan.py # private: concurrent port/host scanning
72
+ ├── _multicast.py # private: group membership and socket setup
73
+ ├── _ifaddrs.py # private: ctypes getifaddrs/GetAdaptersAddresses bindings
74
+ ├── _sockets.py # private: source IP, free port, tcp/wait, route, hops, MTU
75
+ ├── _dns.py # private: resolve() chaining dnspython/system/nslookup backends
76
+ ├── _ping.py # private: ping() over the platform binary
77
+ ├── _retry.py # private: bounded retry with exponential backoff
78
+ ├── _udp.py # private: UDP receive with arrival interface (pktinfo)
79
+ ├── _iface_spec.py # private: shared InterfaceSpec coercion (MAC/name/Interface -> address)
80
+ └── py.typed # PEP 561 marker — the package ships inline type hints
81
+ ```
82
+
83
+ Beside the package: `tests/` (see **Develop**), `docs/` + `mkdocs.yml` for the
84
+ published site, `benchmarks/` (run on demand, never in CI) and `examples/`
85
+ (two runnable scripts; local and read-only).
86
+
87
+ **The import surface is still flat** — everything is re-exported from
88
+ `netimps`, and the `_`-prefixed modules are implementation detail. Do not
89
+ import them directly from outside the package.
90
+
91
+ `__init__` imports the submodules **last**, because several of them call back
92
+ into it (`parse`, `try_parse`, `MACAddress`); those back-references are
93
+ function-local imports for the same reason. Everything importable from
94
+ `netimps` is declared in `__all__` at the top of `__init__.py`.
95
+
96
+ ## Entry points
97
+
98
+ See **`src/netimps/AGENTS.md`** for the header-file-style public API (every
99
+ export with its signature, arguments, return contract, and gotchas). Quick
100
+ map:
101
+
102
+ | Name | Purpose |
103
+ | --- | --- |
104
+ | `IPAddress`, `IPInterface`, `IPNetwork` | v4/v6 **union aliases** for annotations |
105
+ | `IPAddressLike`, `IPInterfaceLike`, `IPNetworkLike`, `MACLike` | accepted-input unions |
106
+ | `AddressLike` | accepted-input union for a single destination (hostname, address, or interface object) |
107
+ | `IPv4Address`, `IPv4Interface`, `IPv4Network`, `IPv6Address`, `IPv6Interface`, `IPv6Network` | stdlib concrete-type re-exports |
108
+ | `parse`, `try_parse`, `is_valid` | build a type from a value (raising / `None` / `bool`) |
109
+ | `MACAddress` | parse / classify / render MAC addresses |
110
+ | `get_interfaces`, `Interface`, `iter_addresses` | native cross-platform NIC discovery |
111
+ | `get_ip`, `is_link_scoped` | address resolution and scope classification |
112
+ | `collapse`, `subtract` | CIDR set maths |
113
+ | `normalize_host` | `host:port` splitting, IPv6-aware |
114
+ | `get_default_port`, `get_default_scheme`, `register_port` | scheme ↔ port registry |
115
+ | `resolve`, `resolve_dnspython`, `resolve_system`, `resolve_nslookup` | DNS lookup → native records; `resolve` chains the three backends, each independently callable, and returns `[]` only when every applicable backend answered empty |
116
+ | `ResolutionError` | raised by the three resolvers when a backend could not even ask (missing binary, unreachable server, deadline) — as opposed to an empty answer |
117
+ | `ping`, `PingResult` | reachability with RTT and TTL |
118
+ | `bind`, `bind_error_hint`, `interface_for`, `interfaces_for`, `is_local_address` | socket creation and local membership |
119
+ | `get_source_ip`, `get_free_port`, `tcp_check`, `wait_for_port` | socket helpers |
120
+ | `UdpEndpoint`, `Datagram` | UDP receive with arrival interface (`IP_PKTINFO` / `IPV6_RECVPKTINFO`, per family) |
121
+ | `Host` | hostname-or-address value type |
122
+ | `retry`, `backoff_delays` | bounded retry with exponential backoff |
123
+ | `APIPA`, `LOOPBACK_V4`, `LOOPBACK_V6`, `LINK_LOCAL_V6` | named networks |
124
+ | `get_route`, `Route`, `hop_count` | routing and distance |
125
+ | `discover_mtu`, `get_pmtu`, `get_tcp_mss` | path MTU by ICMP/UDP/TCP, the kernel's cached guess, or the negotiated MSS |
126
+ | `scan_ports`, `scan_hosts`, `PORT_RANGES` | concurrent scanning |
127
+ | `multicast_socket`, `join_group`, `leave_group`, `is_multicast` | multicast |
128
+ | `HOST_DN` | `platform.node()` of the running host, captured at import time |
129
+
130
+ ## Working here
131
+
132
+ - **A green suite proves nothing about this package's platform behaviour.**
133
+ Nearly every test that touches `ping`, `traceroute` or `nslookup` fakes
134
+ `subprocess.run` and then asserts the argv the library *builds* — which can
135
+ never catch a flag the platform does not have. That is not hypothetical: CI
136
+ happily confirmed that `ping(ipv6=True)` puts `-6` in the argv for months
137
+ while macOS `ping` answered `invalid option -- 6` and exited 64.
138
+ `tests/test_platform_smoke.py` is the one file that runs the real binaries,
139
+ loopback only, and **nothing in it may be mocked**. A claim about another
140
+ platform needs a measurement on that platform (a `ci-*` tag runs the matrix),
141
+ not a passing test here.
142
+ - **Don't collapse the per-platform `sockaddr` layouts** in `_ifaddrs.py`.
143
+ macOS/BSD have a leading `sa_len` byte Linux lacks; using the Linux layout on
144
+ BSD decodes `AF_INET` as `512` and *silently* drops every address instead of
145
+ raising — a Linux-only CI stays green while Mac users lose data.
146
+ - **`is_loopback` comes from the interface *flags*, never the name**, with the
147
+ address heuristic only as a fallback when the OS reported no flag.
148
+ `IFF_LOOPBACK` (POSIX) and `IfType == IF_TYPE_SOFTWARE_LOOPBACK` (Windows)
149
+ were already being read into `raw`. Names (`lo` / `lo0` / `Loopback
150
+ Pseudo-Interface 1`) share no spelling, and addresses are not authoritative
151
+ either: WSL2 binds a routable `10.255.255.254/32` to `lo`, so the address
152
+ heuristic found **no** loopback interface at all there — and two tests
153
+ silently took a skip branch marked `# pragma: no cover`.
154
+ - **`_ping._PLATFORM` is a three-way split** — `windows` / `linux` / `bsd` —
155
+ not `os.name == "nt"`. Of the six flags the module emits, *five* mean
156
+ something different or nothing at all on BSD: `-W` is milliseconds rather
157
+ than seconds, `-t` is an overall deadline rather than the TTL (`-m` is the
158
+ TTL there, while Linux's `-m` is a firewall mark), `-I` is multicast-only and
159
+ is rejected for a unicast destination (`-S` is the source flag), and
160
+ `-4`/`-6` do not exist at all — IPv6 is a separate **`ping6`** binary with
161
+ its own grammar again (`-h` for the hop limit, no `-W`). Anything that is
162
+ neither Windows nor Linux is treated as BSD deliberately: a flag we fail to
163
+ emit is a missing feature, a flag that means something else is a wrong
164
+ answer.
165
+ - **BSD `ping` does have a DF flag: `-D`.** A long-standing comment here said
166
+ it did not, which is why `discover_mtu(method="icmp")` was reported as
167
+ unfixable there. `ping6` is the one combination with no verified flag, so
168
+ `dont_fragment=True` is rejected for it rather than silently sent without DF.
169
+ - The ctypes paths can't be asserted against fixed values, so
170
+ `tests/test_interfaces.py` checks invariants plus the pure helpers and the
171
+ fallback, which *are* exactly testable.
172
+ - **`duho` is a CLI-only dependency.** `cli.py` and `__main__.py` may import it;
173
+ nothing else may, and `cli.py` imports it inside `run()` so that a
174
+ no-extra install gets a message rather than an `ImportError` traceback.
175
+ `tests/test_cli.py` skips itself when the extra is absent, and asserts the
176
+ library still imports with duho blocked.
177
+ - **Tests must never hit the network, and `tests/conftest.py` now enforces
178
+ it** rather than trusting it. An autouse fixture fails any off-host name
179
+ resolution at the point of the call, naming the test; eleven such lookups
180
+ existed when it was added, and simulating a wildcard resolver (the kind many
181
+ ISP and corporate networks run) turned the suite red, because several tests
182
+ assert that a name does *not* resolve. Two escape hatches, both
183
+ self-documenting:
184
+ - `no_such_host` — makes every off-host name fail deterministically. Use it
185
+ whenever the precondition is "given a name that does not resolve"; picking
186
+ something in `.invalid` and trusting the resolver is the flake itself.
187
+ - `allow_resolver` — lifts the guard for one test, marking it as one whose
188
+ failures may be the network's fault.
189
+
190
+ The guard deliberately **allows address literals**:
191
+ `getaddrinfo("1.1.1.1", ...)` parses four numbers and returns, no packet
192
+ leaves the machine, and blocking it would push tests into mocking things that
193
+ were never remote. `test_net.py` fakes `dns.resolver` and `subprocess.run`
194
+ throughout; `test_scan.py`, `test_sockets.py`, `test_centralized.py` and
195
+ `test_platform_smoke.py` use loopback only.
196
+ - **`_ip` is imported *before* the definitions** in `__init__`, unlike the other
197
+ submodules which are imported last. `parse()` uses `IPAddress` as a default
198
+ argument, and defaults evaluate at definition time.
199
+ - **Windows `ping` exits 0 for "TTL expired in transit."** Anything inferring
200
+ success from the exit code alone is wrong; match the reply address instead,
201
+ never the localised prose. Windows `ping -?` also exits 0, which is how
202
+ `ping("-?")` used to come back truthy for a host that was never contacted.
203
+ - **Check for silent platform gaps before adding a socket option — and check
204
+ *every* platform, not just Windows.** Measured on Windows and Linux (3.9
205
+ through 3.14): `IP_MTU`, `IP_MTU_DISCOVER` and `IP_DONTFRAG` are exported by
206
+ CPython on **neither**, so a `getattr(socket, "IP_MTU", None)` guard disables
207
+ the code everywhere — which is exactly what silently killed `get_pmtu` for
208
+ the life of the project. `SO_REUSEPORT`, `IPV6_PATHMTU`, `IPV6_RECVPATHMTU`
209
+ and `IPV6_RECVPKTINFO` are missing on Windows but present on Linux; Windows
210
+ has `IPV6_DONTFRAG` (14) and no `IP_DONTFRAGMENT`. Binding a multicast socket
211
+ to the group address fails there too. Where the constant is documented and
212
+ stable, use the literal and let `OSError` from the `set`/`getsockopt` be the
213
+ "unsupported" signal.
214
+ - **IPv6 multicast names an adapter by *index*, IPv4 by *address*.** They are
215
+ not two spellings of one thing: feeding an address to the v6 side does not
216
+ raise, it lands as index `0`, which is "kernel's choice". Use
217
+ `_iface_spec.interface_index()` for anything v6, `interface_address()` for
218
+ v4. Both honour a `%zone` suffix.
219
+ - **POSIX delivers asynchronous ICMP errors only to *connected* UDP sockets.**
220
+ An unconnected probe never sees a port-unreachable and just times out, while
221
+ Windows reports it either way — so the unconnected version tests green here
222
+ and under-reports on Linux CI. Also note `_discover_mtu_udp` is still
223
+ unconnected by design; it treats such errors as failure anyway.
224
+ - **`ThreadPoolExecutor.__exit__` calls `shutdown(wait=True)`,** and its atexit
225
+ hook joins worker threads too. It is therefore the wrong tool for bounding a
226
+ blocking call: a daemon `threading.Thread` joined through a queue is what
227
+ `_dns._bounded_lookup` uses, and why. Every blocking resolver call goes
228
+ through it — the `ptr` branch called `gethostbyaddr` directly and was
229
+ measured at 4.6s against a 0.1s deadline.
230
+ - **Match a ping reply by address token, and remember hostnames are plural.**
231
+ `gethostbyname` is IPv4-only — use `getaddrinfo` with an explicit family, or
232
+ `ipv6=` silently does nothing. The reply needle also has to tolerate BSD's
233
+ punctuation: `ping6` prints `16 bytes from ::1, icmp_seq=0 hlim=64` — comma,
234
+ and `hlim` rather than `ttl`.
235
+ - **Windows exposes no cached path MTU.** Already investigated, so do not
236
+ re-derive it: `MIB_IPFORWARDROW.dwForwardMtu` reads 0 (unsupported), and
237
+ `MIB_IPFORWARD_ROW2` has no MTU field. `Interface.mtu` is the link MTU;
238
+ `discover_mtu` probing is the only way to get a path MTU there. Linux *does*
239
+ answer — `get_pmtu` reads the `IP_MTU` literal for v4 and `IPV6_PATHMTU` for
240
+ v6, the latter returning an `ip6_mtuinfo` struct rather than a bare int.
241
+ - **`GetBestRoute2`, not `GetIpForwardTable`.** It asks Windows which route it
242
+ would pick, so the kernel does longest-prefix matching, and unlike
243
+ `GetBestRoute` it serves both families. The POSIX side has no equivalent and
244
+ parses `/proc/net/route` and `/proc/net/ipv6_route` by hand — which is where
245
+ the loopback bug came from, since the v4 file omits loopback entirely, and
246
+ where the v6 parser has to honour `RTF_UP`/`RTF_REJECT`, since WSL2 carries a
247
+ `::/0` reject route on `lo` that would otherwise make every global IPv6
248
+ address "on-link via loopback". BSD has neither file and shells out to
249
+ `route -n get`.
250
+ - **`tests/typing/api.py` is checked with a *consumer's* mypy config**,
251
+ `tests/typing/consumer.ini`, not the package's own — for two reasons, and
252
+ the second one bites.
253
+ 1. The package sets `enable_incomplete_feature = ["TypeForm"]` so mypy will
254
+ type-check `__init__.py`'s own overload definitions. Nobody downstream
255
+ sets it, so checking `api.py` with it on is not the check that matters.
256
+ 2. It keeps the two `lint`-job invocations on different option sets, which
257
+ is what stops the first from poisoning the second through `.mypy_cache`.
258
+ Measured here with mypy 1.20.2: from a cold cache, `mypy
259
+ tests/typing/api.py` alone is **clean**, but `mypy src/netimps` followed
260
+ by `mypy tests/typing/api.py` under the *same* config reports **25**
261
+ `call-overload` errors. `TypeForm[_T]` serialises into the cache as plain
262
+ `type[_T]`, so the second run reads a degraded `netimps` and loses every
263
+ union-alias overload. `--no-incremental` restores it.
264
+
265
+ So: do not collapse the two invocations onto one config without passing
266
+ `--no-incremental`, and do not read a `call-overload` error in `api.py` as a
267
+ contract regression before re-running it from a cold cache.
268
+ - **`.github/probe/` answers platform questions with captured bytes.** Push a
269
+ `probe-*` tag (not `ci-*` — that is test.yml's) or dispatch it, then read the
270
+ uploaded artifact. It has already settled several things it would be a waste
271
+ to re-derive: macOS `ping` has no `-4`/`-6`, BSD `-W` is milliseconds,
272
+ `IP_DONTFRAG` is 28 on Darwin and 67 on FreeBSD, and CPython exports
273
+ `socket.IP_MTU` on no platform at all. Output is redacted by default
274
+ (`--raw` to keep MACs and addresses for local diagnosis) because the
275
+ transcript is uploaded and pasted into findings.
276
+ - **Type-check for every platform, not just yours.** `mypy` checks every
277
+ per-platform branch whatever host it runs on, but resolves names against the
278
+ platform it *thinks* it is targeting — so `ctypes.WinDLL`,
279
+ `socket.SIO_RCVALL` and `socket.ioctl` type fine on Windows and fail on the
280
+ Linux runner. Run `mypy --platform linux`, `--platform darwin` and
281
+ `--platform win32`; CI runs all three. A clean local run on one platform
282
+ proved nothing and let five `attr-defined` errors reach CI.
283
+ - **Constants differ between the BSDs, not just between BSD and Linux.**
284
+ `IP_DONTFRAG` is 67 on FreeBSD and **28 on Darwin**; one number for "BSD"
285
+ made `_set_dont_fragment` fail silently on macOS, which for a DF option means
286
+ the MTU search loses its whole point. `IPV6_DONTFRAG` (62) they do agree on.
287
+ - Run `black src/ tests/` before committing. CI's `lint` job runs
288
+ `black --check src/ tests/`, `mypy src/netimps`, and the consumer-config
289
+ check above; all three must pass.
290
+
291
+ ## Develop
292
+
293
+ Venvs are named `.venv/<version>-<os>-<arch>/`, one per interpreter this
294
+ project is tested against. The suffix is not decoration: this repo tests two
295
+ Pythons, and on a machine that can run more than one architecture the name is
296
+ the only thing distinguishing them.
297
+
298
+ `<arch>` is what the interpreter was **built for**
299
+ (`sysconfig.get_platform()`), not what the host is (`platform.machine()`).
300
+ They differ: an ARM64 Windows box runs emulated x64 CPython perfectly happily
301
+ and reports `ARM64` for the machine while the interpreter is `win-amd64`.
302
+ Prefer a native build where one exists — the emulated one is slower and can
303
+ diverge on exactly the low-level behaviour this package pokes at.
304
+
305
+ ```bash
306
+ # Latest (development), and the floor (what CI's oldest job runs).
307
+ py -3.14-arm64 -m venv .venv/3.14-nt-arm64
308
+ py -3.9-arm64 -m venv .venv/3.9-nt-arm64
309
+
310
+ .venv/3.14-nt-arm64/Scripts/pip install -e ".[dev,docs]"
311
+ .venv/3.9-nt-arm64/Scripts/pip install -e ".[dev]"
312
+
313
+ .venv/3.14-nt-arm64/Scripts/pytest -q
314
+ .venv/3.9-nt-arm64/Scripts/pytest -q # the floor -- run it before pushing
315
+ ```
316
+
317
+ On POSIX the scripts live in `bin/` rather than `Scripts/`, and the name is
318
+ e.g. `.venv/3.14-posix-x86_64`.
319
+
320
+ Tests live in `tests/` and run via `pytest -q` from a checkout;
321
+ `pyproject.toml` puts `src/` on the path.
322
+
323
+ | File | Covers |
324
+ | --- | --- |
325
+ | `conftest.py` | the suite-wide network guard and its `no_such_host` / `allow_resolver` opt-outs |
326
+ | `test_ip.py` | `parse`/`try_parse`/`is_valid`, the aliases, CIDR maths |
327
+ | `test_mac.py` | `MACAddress` parsing, ordering, and the hash/eq law across every accepted spelling |
328
+ | `test_net.py` | DNS and ping, with `dns.resolver` and `subprocess.run` faked throughout |
329
+ | `test_interfaces.py` | `get_interfaces` invariants, the pure helpers, the degraded fallback |
330
+ | `test_sockets.py` | bind / `tcp_check` / route / MTU; loopback, or assertions about shape |
331
+ | `test_scan.py` | `scan_ports` / `scan_hosts` and the multicast helpers, loopback only |
332
+ | `test_centralized.py` | the helpers centralised from sibling repos: `bind`, `interface_for`, `UdpEndpoint`, `Host`, `retry` |
333
+ | `test_cli.py` | the CLI; skips itself when the `cli` extra is absent |
334
+ | `test_platform_smoke.py` | the **only** non-mocked tests — the real `ping`/`ping6` binary and real loopback sockets |
335
+ | `typing/api.py` | the static-typing contract; never executed, checked by mypy with `typing/consumer.ini` |
336
+
337
+ `tests/test_platform_smoke.py` must stay unmocked. Every other ping test
338
+ asserts the argv the library builds, which cannot catch a flag the platform
339
+ rejects; this file is what turns "CI is green" into evidence about the
340
+ platform. If one of its assertions fails, the library is broken there — do not
341
+ mock it to make it pass.
342
+
343
+ Run against **3.9 and 3.14** — 3.9 is the floor, so no unquoted `X | Y` unions
344
+ at runtime. CI runs the full 3.9–3.14 matrix on ubuntu plus both edges on
345
+ windows and macos, on a push to `main`, on a pull request against `main`, on a
346
+ `ci-*` tag (a throwaway tag, so an agent without dashboard access can trigger
347
+ and poll a run), and on `workflow_dispatch`. The docs site is built and
348
+ deployed by a separate `docs.yml` — on a docs-affecting push to `main`, on
349
+ `workflow_dispatch`, and on `release: published` — so a wrong sentence on the
350
+ landing page can be corrected without cutting a version.
351
+
352
+ Code is formatted with **black** (`target-version = py39`, configured in
353
+ `pyproject.toml`; installed by the `dev` extra):
354
+
355
+ ```bash
356
+ .venv/3.14-nt-arm64/Scripts/black src/ tests/ # format
357
+ .venv/3.14-nt-arm64/Scripts/black --check src/ tests/ # verify, as CI does
358
+ ```
359
+
360
+ `benchmarks/run.py` is a perf suite run **on demand**, never per push — shared
361
+ runners are too noisy for the numbers to mean anything. `python
362
+ benchmarks/run.py --save` writes one JSON per (platform, interpreter,
363
+ architecture) into `benchmarks/results/`, which is tracked so a before/after
364
+ comparison stays recoverable.
365
+
366
+ ### Releasing
367
+
368
+ This project follows [Semantic Versioning](https://semver.org/) and keeps a
369
+ [`CHANGELOG.md`](CHANGELOG.md). Pre-1.0, MINOR means "the documented API
370
+ broke" and nothing else — additions and fixes are PATCH. Pushing a tag matching
371
+ `v*` triggers the release workflow: test gate → build → strict docs build
372
+ (a *gate*, not a deploy) → GitHub release → publish to PyPI with
373
+ `skip-existing: true`, so a run that fails partway through can be re-run.
374
+ Creating the release fires `docs.yml`, which owns every Pages deploy. Package
375
+ builds locally with `hatchling`.
376
+
377
+ ## License
378
+
379
+ MIT — see [LICENSE](LICENSE).