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.
- {netimps-0.2.2 → netimps-0.3.0}/.gitignore +8 -1
- netimps-0.3.0/AGENTS.md +379 -0
- netimps-0.3.0/CHANGELOG.md +611 -0
- {netimps-0.2.2 → netimps-0.3.0}/PKG-INFO +22 -16
- {netimps-0.2.2 → netimps-0.3.0}/README.md +20 -14
- netimps-0.3.0/RELEASENOTES.md +133 -0
- netimps-0.3.0/benchmarks/README.md +88 -0
- netimps-0.3.0/benchmarks/results/windows-py3.14.6-arm64.json +118 -0
- netimps-0.3.0/benchmarks/run.py +203 -0
- {netimps-0.2.2 → netimps-0.3.0}/docs/index.md +16 -11
- netimps-0.3.0/examples/describe_host.py +79 -0
- netimps-0.3.0/examples/subnet_planner.py +70 -0
- {netimps-0.2.2 → netimps-0.3.0}/mkdocs.yml +0 -1
- {netimps-0.2.2 → netimps-0.3.0}/pyproject.toml +11 -3
- netimps-0.3.0/src/netimps/AGENTS.md +998 -0
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/__init__.py +67 -9
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/__main__.py +3 -0
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_dns.py +217 -89
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_iface_spec.py +112 -7
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_ifaddrs.py +171 -44
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_ip.py +69 -9
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_mac.py +85 -27
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_multicast.py +116 -4
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_ping.py +325 -82
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_scan.py +143 -21
- netimps-0.3.0/src/netimps/_scheme.py +249 -0
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_sockets.py +799 -145
- netimps-0.3.0/src/netimps/_udp.py +428 -0
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/cli.py +160 -38
- netimps-0.3.0/tests/conftest.py +157 -0
- {netimps-0.2.2 → netimps-0.3.0}/tests/test_centralized.py +207 -17
- netimps-0.3.0/tests/test_cli.py +427 -0
- netimps-0.3.0/tests/test_interfaces.py +603 -0
- {netimps-0.2.2 → netimps-0.3.0}/tests/test_ip.py +144 -0
- netimps-0.3.0/tests/test_mac.py +370 -0
- {netimps-0.2.2 → netimps-0.3.0}/tests/test_net.py +474 -40
- netimps-0.3.0/tests/test_platform_smoke.py +155 -0
- netimps-0.3.0/tests/test_scan.py +897 -0
- {netimps-0.2.2 → netimps-0.3.0}/tests/test_sockets.py +660 -39
- netimps-0.3.0/tests/typing/api.py +188 -0
- netimps-0.3.0/tests/typing/consumer.ini +32 -0
- netimps-0.2.2/AGENTS.md +0 -208
- netimps-0.2.2/CHANGELOG.md +0 -249
- netimps-0.2.2/src/netimps/AGENTS.md +0 -619
- netimps-0.2.2/src/netimps/_scheme.py +0 -145
- netimps-0.2.2/src/netimps/_udp.py +0 -198
- netimps-0.2.2/tests/test_cli.py +0 -231
- netimps-0.2.2/tests/test_interfaces.py +0 -310
- netimps-0.2.2/tests/test_mac.py +0 -177
- netimps-0.2.2/tests/test_scan.py +0 -412
- netimps-0.2.2/tests/typing/api.py +0 -92
- {netimps-0.2.2 → netimps-0.3.0}/LICENSE +0 -0
- {netimps-0.2.2 → netimps-0.3.0}/docs/api/reference.md +0 -0
- {netimps-0.2.2 → netimps-0.3.0}/docs/changelog.md +0 -0
- {netimps-0.2.2 → netimps-0.3.0}/src/netimps/_retry.py +0 -0
- {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
|
|
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/
|
netimps-0.3.0/AGENTS.md
ADDED
|
@@ -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).
|