netimps 0.0.0__py3-none-any.whl

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/AGENTS.md ADDED
@@ -0,0 +1,424 @@
1
+ # `netimps` — public API header
2
+
3
+ Header-file-style reference for the `netimps` package: every `__all__` export
4
+ with its signature, arguments, contract, and gotchas, so this module can be
5
+ consumed without reading its source.
6
+
7
+ Everything is imported from `netimps` directly. The `_`-prefixed submodules
8
+ (`_ip`, `_mac`, `_ifaddrs`, `_sockets`, `_dns`, `_ping`, `_scan`, `_multicast`,
9
+ `_scheme`, `_retry`, `_udp`, `_iface_spec`) are implementation detail —
10
+ **do not import them**.
11
+
12
+ **This file documents using the library**, and ships inside the package, so it
13
+ is self-contained: it references nothing outside the installed distribution.
14
+ `README.md` ships alongside it as the overview -- read either with
15
+ `importlib.resources.files("netimps")`. Development documentation (building,
16
+ testing, releasing) is not shipped; it lives with the source at
17
+ <https://github.com/jose-pr/netimps>.
18
+
19
+ `netimps.__version__` — the package version string (currently `"0.0.0"`).
20
+
21
+ ## Argument naming
22
+
23
+ The package is consistent about what the first argument means:
24
+
25
+ | Name | Meaning | Examples |
26
+ | --- | --- | --- |
27
+ | `dst` | where traffic is **sent** | `ping`, `tcp_check`, `wait_for_port`, `get_route`, `hop_count`, `discover_mtu` |
28
+ | `src` | where traffic is **sent from** | `ping(src=)`, `get_free_port(src=)`, `discover_mtu(src=)` |
29
+ | `host` / `network` | the thing being **examined** | `scan_ports(host)`, `scan_hosts(network)` |
30
+ | `address` / `ip` | an address being **classified** (no DNS) | `get_ip`, `interface_for`, `is_multicast`, `is_link_scoped` |
31
+
32
+ `dst`/`src` are abbreviated symmetrically, matching packet-header convention.
33
+ A `dst` accepts a hostname; an `address` does not.
34
+
35
+ ## Types vs parsing — read this first
36
+
37
+ The noun names are **types you annotate with**; you turn values into them with
38
+ one function:
39
+
40
+ ```python
41
+ def route(dst: netimps.IPAddress, via: netimps.IPNetwork) -> None: ... # type
42
+
43
+ addr = parse("10.0.0.5") # -> IPv4Address
44
+ net = parse("10.0.0.5/24", IPNetwork) # -> IPv4Network('10.0.0.0/24')
45
+ ```
46
+
47
+ The union aliases are **not callable** — `IPAddress("10.0.0.5")` is a
48
+ `TypeError`. Use `parse`.
49
+
50
+ ## Type aliases
51
+
52
+ | Name | Meaning |
53
+ | --- | --- |
54
+ | `IPAddress` | `IPv4Address \| IPv6Address` |
55
+ | `IPInterface` | `IPv4Interface \| IPv6Interface` (address + prefix) |
56
+ | `IPNetwork` | `IPv4Network \| IPv6Network` |
57
+ | `IPAddressLike` | anything accepted *as input* for an address |
58
+ | `IPNetworkLike` | anything accepted *as input* for a network |
59
+ | `MACLike` | `str \| int \| bytes \| MACAddress` |
60
+
61
+ Plus the stdlib concretes re-exported so callers need not import `ipaddress`:
62
+ `IPv4Address`, `IPv4Interface`, `IPv4Network`, `IPv6Address`, `IPv6Interface`,
63
+ `IPv6Network`.
64
+
65
+ **The `*Like` aliases are input-only** and are rejected in a `type` position —
66
+ they describe what goes in, not what to build.
67
+
68
+ ## Parsing
69
+
70
+ - **`parse(value, type=IPAddress, **kwargs)`** — build `type` from `value`,
71
+ raising on bad input. `type` is a union alias, a concrete class, or any
72
+ callable. Extra `kwargs` pass to the underlying builder.
73
+ - **`try_parse(value, type=IPAddress, default=None, **kwargs)`** — same, but
74
+ returns `default` instead of raising.
75
+ - **`is_valid(value, type=IPAddress, **kwargs)`** — same, returning `bool`.
76
+
77
+ All three spell the second argument `type`, so it works positionally or by
78
+ keyword. Key behaviours:
79
+
80
+ - **Every type accepts the full stdlib input range** — `str`, `int`, packed
81
+ `bytes`, or an existing object — because the builders are `ipaddress.ip_*`,
82
+ not the concrete constructors.
83
+ - **Unions accept either family; concrete types are strict.**
84
+ `parse("::1", IPAddress)` works; `parse("::1", IPv4Address)` raises, because
85
+ asking for v4 and receiving v6 would defeat the request.
86
+ - **Networks are non-strict by default**, unlike the stdlib:
87
+ `parse("10.0.0.5/24", IPNetwork)` normalises to `10.0.0.0/24` instead of
88
+ raising. Pass `strict=True` for stdlib behaviour.
89
+ - **Only `ValueError`/`TypeError` count as "invalid".** Anything else (an
90
+ `OSError` from a network-touching builder, a bug in it) propagates rather
91
+ than being disguised as a rejected value.
92
+ - An unusable `type` raises `TypeError` **even from `try_parse`** — a caller
93
+ bug is not a rejected value.
94
+
95
+ > **Gotcha:** `is_valid` uses an internal sentinel rather than testing
96
+ > `try_parse(...) is not None`, so a builder that legitimately returns `None`
97
+ > for valid input still counts as valid. Do not "simplify" that away.
98
+
99
+ ## `MACAddress`
100
+
101
+ **`MACAddress(value)`** — an IEEE 802 hardware address. Accepts colon
102
+ (`AA:BB:CC:DD:EE:FF`), hyphen, dot/Cisco (`aabb.ccdd.eeff`) or bare
103
+ (`AABBCCDDEEFF`) text, a 48-bit `int`, 6 raw `bytes`, or another `MACAddress`.
104
+
105
+ Normalised to lowercase, compared and hashed by canonical bytes, so it works as
106
+ a dict key and two values differing only in parsed case are equal.
107
+
108
+ | Member | Meaning |
109
+ | --- | --- |
110
+ | `.as_str(sep=":", upper=False)` | render with any separator; `sep=""` for bare form |
111
+ | `.packed` | the 6 raw bytes |
112
+ | `.oui` | 3-byte vendor prefix |
113
+ | `.is_multicast` | group bit (low bit of octet 0) |
114
+ | `.is_local` / `.is_universal` | the U/L bit |
115
+ | `int(mac)`, `str(mac)` | integer / colon form |
116
+ | `<`, `<=`, `>`, `>=` | ordering, so MACs sort |
117
+ | `MACAddress.is_valid(v)` / `.try_parse(v)` | classmethods; the type-local spelling |
118
+
119
+ - **Not a `bytes` subclass** — deliberately, matching how `ipaddress` models
120
+ addresses. Use `.packed` at wire boundaries.
121
+ - Case is presentational only: `upper=True` never affects equality or hashing.
122
+ - `.is_local` means *locally administered* (VMs, containers, MAC randomisation),
123
+ so such addresses are **not stable identifiers**.
124
+ - The classmethods are `classmethod`, not `staticmethod`, so a subclass
125
+ validates against itself.
126
+
127
+ ## Interface discovery
128
+
129
+ **`get_interfaces(raw=False) -> List[Interface]`** — adapter names, MACs, MTU
130
+ and **real prefix lengths**, via `ctypes` bindings to `getifaddrs(3)` (POSIX)
131
+ and `GetAdaptersAddresses` (Windows). **No third-party dependency**; `ifaddr`
132
+ is deliberately not used.
133
+
134
+ **`Interface`** — normalised identically across platforms:
135
+
136
+ | Attribute | Meaning |
137
+ | --- | --- |
138
+ | `.name` | human-usable name (`eth0`, `en0`, Windows *friendly* name — never a GUID) |
139
+ | `.index` | `if_nametoindex` value, `0` if unknown |
140
+ | `.mac` | `MACAddress` or `None` |
141
+ | `.ips` | every address with its real prefix |
142
+ | `.ipv4` / `.ipv6` | the split views |
143
+ | `.mtu` | link MTU in bytes, or `None` |
144
+ | `.primary_ip(ipv6=False, loopback_ok=True)` | pick **one** entry from `.ips` (non-loopback preferred), or `None` |
145
+ | `.is_loopback` | **computed from the addresses, not the name** |
146
+ | `.raw` | `None` unless `raw=True`; platform-specific leftovers |
147
+
148
+ - **`is_loopback` never matches on names.** `lo`, `lo0` and
149
+ `Loopback Pseudo-Interface 1` share no spelling; `127.0.0.0/8` and `::1` do.
150
+ - **`.raw` is not portable** and sits outside the stability guarantee — the
151
+ escape hatch for adapter GUIDs, `IFF_*` flags, WMI correlation.
152
+ - **Never raises for enumeration failure.** If the native call is unavailable it
153
+ degrades to hostname resolution, where **prefixes are fiction** (every address
154
+ becomes `/32` or `/128` under an interface named `"<unknown>"`). Check
155
+ `iface.name == "<unknown>"` to detect it.
156
+ - **`primary_ip()` is a selection, not "the" address** — an adapter routinely
157
+ has several. It returns the **same element type as `.ips`** (an
158
+ `ip_interface`, carrying the prefix) and the result *is* one of them; use
159
+ `.ip` for the bare address that socket options take.
160
+
161
+ **`iter_addresses(interfaces=None, family=None)`** — the flattened
162
+ `(interface, address)` view, yielded once per address rather than per adapter,
163
+ for consumers that filter or act per address. The full `Interface` comes along,
164
+ so nothing is lost. Pass an existing enumeration in a loop; it is a syscall.
165
+
166
+ ## Address and network helpers
167
+
168
+ - **`get_ip(address) -> IPAddress | None`** — literal *or hostname* to an
169
+ address. **May block on DNS**, unlike `try_parse`, which never touches the
170
+ network.
171
+ - **`is_link_scoped(ip) -> bool`** — loopback (host scope) or link-local (link
172
+ scope): confined to this host or link. **Not "is private"** — RFC 1918 ranges
173
+ are globally scoped and return `False`.
174
+ - **`collapse(networks) -> List[IPNetwork]`** — merge adjacent/overlapping
175
+ networks into the minimal equivalent list. Mixed families collapse
176
+ independently.
177
+ - **`subtract(networks, remove) -> List[IPNetwork]`** — set difference, which
178
+ `ipaddress` omits (it ships `collapse_addresses` but nothing to punch holes).
179
+ Result is collapsed.
180
+ - **`normalize_host(text, default_port=None) -> (host, port)`** — split
181
+ `host:port`, handling IPv6 brackets. **`"::1"` stays an address**, never host
182
+ `"::"` port `1` — the mistake hand-rolled splitters make. Only a bracketed v6
183
+ address may carry a port; scope ids are preserved.
184
+
185
+ ## Scheme ↔ port registry
186
+
187
+ - **`get_default_port(scheme) -> int | None`** — built-in table (~30 entries,
188
+ including the socks variants absent from `/etc/services`), then
189
+ `getservbyname`. Case-insensitive.
190
+ - **`get_default_scheme(port) -> str | None`** — the inverse, then
191
+ `getservbyport`.
192
+ - **`register_port(scheme, port, canonical=False)`** — extend or override.
193
+
194
+ Where several schemes share a port (1080 → socks/socks4/socks5) the **canonical**
195
+ one is returned. Registering an alias does not steal that slot unless
196
+ `canonical=True`.
197
+
198
+ ## DNS
199
+
200
+ **`resolve(query, rdtype="a", ns=None, timeout=5.0, port=53, tcp=False)`**
201
+
202
+ Returns a `list`, **empty on any genuine lookup failure** (NXDOMAIN, no answer,
203
+ all nameservers failed, timeout) — never `None`, so `if result:` and
204
+ `result[0]` are safe.
205
+
206
+ **Records are native types**: `A`/`AAAA` are `ipaddress` objects, everything
207
+ else is `str` with the trailing root dot stripped and TXT strings unquoted.
208
+
209
+ - **A malformed query or unknown record type raises `ValueError`** — a caller
210
+ bug, not a DNS result. A broad `except` here would make a typo'd record type
211
+ indistinguishable from "no such record".
212
+ - `timeout` bounds the **whole resolution including retries**, so a list of
213
+ dead nameservers cannot run past it.
214
+ - Requires `dnspython` — the package's only runtime dependency.
215
+
216
+ ## Reachability
217
+
218
+ **`ping(dst, tries=1, timeout=1.0, ipv6=None, src=None, size=None, ttl=None, dont_fragment=False, method="icmp", port=None) -> PingResult`**
219
+
220
+ `PingResult` is **truthy on success** and compares equal to `bool`, so
221
+ `if ping(host):` and `== True` keep working, while carrying `.ok`, `.rtt_ms`,
222
+ `.ttl`, `.source`, `.attempts`.
223
+
224
+ | Argument | Notes |
225
+ | --- | --- |
226
+ | `src` | `Interface`, address, **MAC**, adapter name or string. A MAC is resolved to the adapter holding it. |
227
+ | `size` | ICMP payload bytes. The wire packet is **28 bytes larger** (20 IP + 8 ICMP). |
228
+ | `ttl` | initial hop limit — `-i` on Windows, `-t` on POSIX (the letters are **swapped**). |
229
+ | `dont_fragment` | DF bit. With `size`, the manual MTU probe: largest passing `size` + 28 = path MTU. Ignored on macOS/BSD. |
230
+ | `method` | `"icmp"` (default), `"tcp"` or `"udp"`. The latter two reach hosts through firewalls that drop echo. |
231
+ | `port` | required for `tcp`/`udp`, ignored for ICMP. |
232
+
233
+ **All three methods ask "is the *host* up?"** — so a TCP refusal counts as
234
+ success (the RST proves something answered), as does an ICMP port-unreachable
235
+ for UDP. Use `tcp_check` for "is the *service* up?", where a refusal is a
236
+ failure. `tcp` and `udp` also report `rtt_ms`; only ICMP reports `ttl`.
237
+
238
+ - **`ttl` behaves identically on every platform.** Windows `ping` exits `0` for
239
+ "TTL expired in transit", so the reply address is verified rather than
240
+ trusting the exit code. Locale-independent — it matches on addresses, never
241
+ prose.
242
+ - An unusable `src` (unknown MAC, adapter with no address, foreign address)
243
+ gives a falsy result — it **never silently falls back** to the default route.
244
+ - Never raises: missing binary, hung subprocess and non-zero exit are all falsy.
245
+ - **ICMP echo is not "is the host up"** — most cloud firewalls drop it. Prefer
246
+ `tcp_check`.
247
+
248
+ ## Socket helpers
249
+
250
+ - **`bind(address="", port=0, *, family, kind, reuse_address=True, reuse_port=False, broadcast=False, interface=None, options=(), listen=None)`**
251
+ — create, configure and bind in one call. `interface` accepts the usual union
252
+ (`Interface`, MAC, adapter name, address) and **raises** if unresolvable
253
+ rather than silently binding the wildcard. `reuse_port` is a **no-op where
254
+ `SO_REUSEPORT` does not exist** (Windows), not an error. The socket is closed
255
+ before any exception propagates, so a failed call leaks nothing.
256
+ - **`bind_error_hint(exc, port=None) -> str | None`** — an actionable sentence
257
+ for a bind failure, recognising POSIX errnos *and* Windows `10013`/`10048`.
258
+ Returns `None` for anything unrecognised, so the caller keeps the original
259
+ error. **Does not raise** — what to do with a failure is the caller's call.
260
+ - **`interface_for(address, strict=True) -> Interface | None`** — reverse
261
+ lookup. `strict=False` synthesizes a host-route interface named
262
+ `"<unknown>"` instead of returning `None`.
263
+ - **`get_source_ip(dst="8.8.8.8", port=80)`** — which local address the kernel
264
+ would use to reach `dst`. **Sends no packets.** The answer depends on
265
+ `dst`: with a VPN up, a public probe returns the tunnel address and a LAN
266
+ probe the physical one. Correct where hostname resolution picks a VM adapter.
267
+ - **`get_free_port(src="127.0.0.1", family=AF_INET) -> int`** — bind port 0 and
268
+ read it back. **Inherently racy** — the port frees the instant it returns; if
269
+ you can, bind port 0 in the server itself instead. `SO_REUSEADDR` is
270
+ deliberately *not* set (it would hand back a `TIME_WAIT` port).
271
+ - **`tcp_check(dst, port, timeout=3.0) -> bool`** — the honest reachability
272
+ test. Never raises. Proves the handshake completed, not that the service is
273
+ healthy; a filtered port is indistinguishable from a closed one.
274
+ - **`wait_for_port(dst, port, timeout=30.0, interval=0.1, connect_timeout=None)`**
275
+ — poll until it answers. Backs off to 1s; honours the overall deadline even
276
+ when individual connects block.
277
+
278
+ ## Routing, hops and MTU
279
+
280
+ - **`get_route(dst="8.8.8.8") -> Route`** — `.source`, `.gateway`,
281
+ `.interface_index`, `.on_link`. **First hop only, deliberately** — that is
282
+ available unprivileged everywhere, unlike the full path. Never raises;
283
+ unknown pieces are `None`/`0`. The gateway resolves on Windows and Linux only.
284
+ - **`hop_count(dst, max_hops=30, timeout=1.0, allow_traceroute=True)`** — uses
285
+ raw-socket probes when permitted, otherwise drives the system
286
+ `traceroute`/`tracert`, so it **works unprivileged**. Only the hop number and
287
+ destination address are parsed, never localised prose.
288
+ `allow_traceroute=False` requires the in-process path and raises
289
+ `PermissionError` instead. **`None` means "no answer", never "unreachable"** —
290
+ firewalls routinely drop ICMP even for an elevated process.
291
+ - **`discover_mtu(dst, low=576, high=9000, timeout=1.0, src=None, port=80, probe=True, method="icmp", **ping_kwargs)`**
292
+ — **measures** the path MTU by binary-searching probes, so packets really
293
+ traverse the path. Returns the MTU **including headers**, comparable with
294
+ `Interface.mtu`. `None` means the destination never answered —
295
+ indistinguishable from "every size was too big".
296
+
297
+ | `method` | How |
298
+ | --- | --- |
299
+ | `"icmp"` | DF-flagged echo. Default; works anywhere `ping` does. |
300
+ | `"udp"` | Datagrams of growing size to `port`. Needs something there that replies. Measures what a **UDP application** can actually push, which a middlebox may cap below the ICMP figure. |
301
+ | `"tcp"` | **Does not probe** — TCP is a stream and the kernel segments it, so a large `send()` becomes many packets. Reads the negotiated MSS and adds the header back. |
302
+
303
+ Extra `**ping_kwargs` reach `ping` for the ICMP method (`ipv6=`, `tries=`).
304
+ `size` and `dont_fragment` are what the search varies, so passing them raises.
305
+ - **`get_tcp_mss(dst, port, timeout=3.0) -> int | None`** — the negotiated TCP
306
+ maximum segment size. Opens a real connection to read it. A reduced value
307
+ signals a tunnel shrinking the path; `None` where `TCP_MAXSEG` is
308
+ unavailable.
309
+ - **`get_pmtu(dst, port=80) -> int | None`** — a **lookup**, not a
310
+ measurement: reads the `IP_MTU` the kernel has *already* learned, and sends
311
+ nothing. Usually `None`, because the kernel only knows a path MTU once prior
312
+ traffic forced it to learn one; **always `None` on Windows**, which has no
313
+ `IP_MTU`. `discover_mtu(..., probe=False)` is exactly this.
314
+
315
+ > **These answer different questions.** On one real host the local link was
316
+ > 9000, `get_pmtu` returned `None`, and `discover_mtu` found the true 1500 — a
317
+ > bottleneck several hops away that nothing local could reveal. Use
318
+ > `Interface.mtu` for the local link, `discover_mtu` for the path.
319
+ >
320
+ > **Header sizes are family-aware.** IPv4 overhead is 20+8 (ICMP/UDP) or 20+20
321
+ > (TCP); IPv6 is 40+8 and 40+20. Assuming IPv4 on a v6 path under-reports by
322
+ > exactly 20 bytes.
323
+
324
+ ## Scanning
325
+
326
+ - **`scan_ports(host, ports="common", timeout=1.0, workers=100) -> List[int]`**
327
+ - **`scan_hosts(network, port=None, ports=None, timeout=1.0, workers=100)`** —
328
+ returns `[(address, [open_ports]), ...]`, hosts with nothing open omitted.
329
+
330
+ `ports` accepts a **`PORT_RANGES` name** (`"common"`, `"well-known"`, `"all"`),
331
+ a **scheme name** resolved via `get_default_port` (`"https"` → 443), a number, a
332
+ numeric string, or any iterable mixing those. Range names win over scheme names
333
+ where they collide.
334
+
335
+ - **`scan_hosts` refuses anything larger than /16** (IPv6 /112): a /8 sweep is
336
+ 16M addresses, a mistake rather than an intention.
337
+ - A **TCP** sweep, so a host answering on none of the probed ports does not
338
+ appear — it is not ARP/ICMP discovery, and a firewalled host is
339
+ indistinguishable from an absent one.
340
+ - Ordinary full connects: **no SYN/stealth scanning, no fingerprinting**.
341
+ Connections are logged by the target like any other. Use on hosts you are
342
+ responsible for.
343
+
344
+ ## Multicast
345
+
346
+ - **`multicast_socket(group=None, port=0, interface=None, ttl=1, loop=True, bind=True, reuse=True)`**
347
+ — a UDP socket configured and joined in one call. `group=None` gives a
348
+ send-only socket.
349
+ - **`join_group(sock, group, interface=None)`** / **`leave_group(...)`**
350
+ - **`is_multicast(address) -> bool`** — `224.0.0.0/4` or `ff00::/8`; never raises.
351
+
352
+ The failure modes this exists to prevent are all **silent** — the socket binds,
353
+ receives nothing, and looks fine:
354
+
355
+ - Binds to `""`, not the group address: **binding to the group fails on Windows**.
356
+ - `SO_REUSEPORT` **does not exist on Windows** and is skipped there rather than
357
+ raising.
358
+ - **`ttl=1` by default**, keeping traffic on the local link; raise it
359
+ deliberately.
360
+ - `interface` accepts an `Interface`, MAC, adapter name or address, and pins
361
+ *both* send and receive. Without it the kernel picks by routing table, which
362
+ on a multi-homed host is regularly the wrong adapter. An unknown interface
363
+ **raises** rather than falling back.
364
+
365
+ ## UDP with arrival interface
366
+
367
+ **`UdpEndpoint(sock, pktinfo=True)`** — wraps a bound UDP socket so each
368
+ datagram reports which interface it arrived on, via `IP_PKTINFO`. Essential for
369
+ broadcast protocols, where a wildcard-bound server otherwise cannot tell which
370
+ network a request came from.
371
+
372
+ `recv(bufsize, resolve_interface=True) -> Datagram`, with `.data`, `.sender`,
373
+ `.local_address`, `.interface_index` and `.interface`.
374
+ `send(data, address, port, source=None)` pins the outgoing interface.
375
+
376
+ - **Degrades rather than failing.** `recvmsg` does not exist on Windows and
377
+ `IP_PKTINFO` is not universal; there the interface fields are simply empty.
378
+ Check `.supports_pktinfo` to know which mode you are in.
379
+ - Pass `resolve_interface=False` in a hot loop and use `.interface_index` —
380
+ enumeration is a syscall.
381
+ - Wraps rather than subclasses the socket; the raw one stays on `.socket`.
382
+
383
+ ## Retry
384
+
385
+ **`retry(func, attempts=3, delay=0.5, multiplier=2.0, max_delay=30.0, jitter=0.1, retryable=(OSError,), on_retry=None)`**
386
+
387
+ Calls `func()`, retrying transient failures with exponential backoff. Returns
388
+ whatever `func` returns; if every attempt fails **the last exception is
389
+ re-raised unwrapped**, so the traceback still points at the real problem.
390
+
391
+ - **Only `OSError` is retried by default** — that covers the socket family. A
392
+ `ValueError` means the call is malformed and will fail identically, so it
393
+ propagates immediately.
394
+ - `attempts` counts *total* calls: `attempts=1` calls once and never sleeps.
395
+ - `jitter` spreads retries so simultaneous failures do not resynchronise into a
396
+ thundering herd. Applied **after** the cap and only ever shortens, so
397
+ `max_delay` is a real ceiling.
398
+ - `on_retry(attempt, exc, next_delay)` is the logging hook; this logs nothing
399
+ itself.
400
+ - Synchronous — it blocks. For async, drive **`backoff_delays(...)`** from your
401
+ own loop; it yields the same schedule.
402
+
403
+ ## Host
404
+
405
+ **`Host(value)`** — a host named by either an address or a hostname.
406
+
407
+ `str(host)` is **always the original text**, so a URL can still be rebuilt when
408
+ resolution fails — the case a bare `get_ip()` handles badly, since it returns
409
+ `None` and loses the name.
410
+
411
+ - `.is_address` — already a literal, no DNS needed.
412
+ - `.ip(refresh=False)` — resolve to an address or `None`. **Cached, including
413
+ failure**, since the common use is several lookups on one object; pass
414
+ `refresh=True` to retry.
415
+ - Compares equal to a plain `str`, and hashes by its text.
416
+
417
+ ## Constants
418
+
419
+ - **`HOST_DN`** — `platform.node()`, captured **at import time** (a later
420
+ hostname change is not reflected).
421
+ - **`PORT_RANGES`** — `{"well-known", "common", "all"}` port tuples.
422
+ - **`APIPA`** (`169.254.0.0/16`), **`LOOPBACK_V4`** (`127.0.0.0/8`),
423
+ **`LOOPBACK_V6`** (`::1/128`), **`LINK_LOCAL_V6`** (`fe80::/10`) — named
424
+ networks, so callers stop spelling the literals out.
netimps/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # netimps
2
+
3
+ [![Version](https://img.shields.io/pypi/v/netimps.svg)](https://pypi.org/project/netimps/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/netimps.svg)](https://pypi.org/project/netimps/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/netimps/test.yml)](https://github.com/jose-pr/netimps/actions/workflows/test.yml)
7
+
8
+ **The network utilities every tool ends up rewriting** — interface discovery,
9
+ "which is my IP", reachability checks, CIDR maths, host:port parsing, DNS,
10
+ ping, scanning and multicast — as one typed, flat-import library.
11
+
12
+ Built on the standard library: the only runtime dependency is `dnspython`, and
13
+ only `resolve()` uses it. Interface enumeration, routing and multicast are
14
+ `ctypes` bindings to the platform's own APIs, so there is nothing to compile
15
+ and no wheel to miss for your platform.
16
+
17
+ ## Features
18
+
19
+ - **Interface discovery, no dependencies** — `get_interfaces()` gives adapter
20
+ names, MACs, MTU and **real prefix lengths** on Linux, macOS/BSD and Windows,
21
+ via `getifaddrs(3)` / `GetAdaptersAddresses`. No `ifaddr` required.
22
+ - **One parsing entry point** — `parse(value, type)` with non-raising
23
+ `try_parse` and boolean `is_valid` siblings, all typed so a checker narrows
24
+ the result.
25
+ - **`MACAddress`** — colon/hyphen/dot/bare plus `int`/`bytes`, hashable and
26
+ ordered, with `.oui`, `.is_multicast`, `.is_local` and case-selectable
27
+ rendering.
28
+ - **The socket helpers everyone rewrites** — `get_source_ip`, `get_free_port`,
29
+ `tcp_check`, `wait_for_port`.
30
+ - **Routing and MTU** — `get_route` (first hop, unprivileged), `hop_count`
31
+ (raw sockets *or* traceroute fallback), `discover_mtu` / `get_pmtu`, `Interface.mtu`.
32
+ - **CIDR set maths** — `collapse` and `subtract`, the latter missing from
33
+ `ipaddress` entirely.
34
+ - **`normalize_host`** — `host:port` splitting that gets IPv6 brackets right.
35
+ - **Scanning** — concurrent `scan_ports` / `scan_hosts`.
36
+ - **Socket setup** — `bind()` with the options named, `UdpEndpoint` for UDP
37
+ servers that need to know which interface a datagram arrived on, and
38
+ `retry()` for the backoff loop everyone writes without jitter.
39
+ - **Multicast** — `multicast_socket` handling the join dance whose failure
40
+ modes are otherwise silent.
41
+ - **DNS and ping** — `resolve()` returning native types; `ping()` returning
42
+ round-trip time and TTL, not just a boolean.
43
+
44
+ ## Installation
45
+
46
+ ```bash
47
+ pip install netimps
48
+ ```
49
+
50
+ Requires Python 3.9+.
51
+
52
+ ## Quick start
53
+
54
+ ```python
55
+ import netimps
56
+ from netimps import IPAddress, IPNetwork, MACAddress, parse
57
+
58
+ # Interfaces: names, MACs, MTU and real prefixes on every OS
59
+ for iface in netimps.get_interfaces():
60
+ print(iface.name, iface.mac, iface.mtu, [str(ip) for ip in iface.ips])
61
+ # 'Wi-Fi' 00:00:5e:00:53:01 1500 ['192.0.2.10/24', 'fe80::.../64']
62
+
63
+ # Types annotate; parse builds
64
+ def route(dst: IPAddress, via: IPNetwork) -> None: ...
65
+
66
+ parse("10.0.0.5") # IPv4Address('10.0.0.5')
67
+ parse("10.0.0.5/24", IPNetwork) # IPv4Network('10.0.0.0/24')
68
+ netimps.try_parse("nope", IPAddress) # None
69
+ netimps.is_valid("::1", IPAddress) # True
70
+
71
+ # Which of my addresses actually reaches that host?
72
+ netimps.get_source_ip("8.8.8.8") # IPv4Address('192.0.2.10')
73
+ netimps.get_route("8.8.8.8").gateway # IPv4Address('192.0.2.1')
74
+
75
+ # Honest reachability, and waiting for a service
76
+ netimps.tcp_check("example.com", 443) # True
77
+ netimps.wait_for_port("localhost", 5432, timeout=60)
78
+
79
+ # CIDR set maths
80
+ netimps.subtract(["10.0.0.0/24"], ["10.0.0.64/26"])
81
+ # [IPv4Network('10.0.0.0/26'), IPv4Network('10.0.0.128/25')]
82
+
83
+ # host:port, including the IPv6 case people get wrong
84
+ netimps.normalize_host("[::1]:8080") # ('::1', 8080)
85
+ netimps.normalize_host("::1") # ('::1', None) -- not port 1
86
+
87
+ # MAC addresses
88
+ mac = MACAddress("AA-BB-CC-DD-EE-FF")
89
+ mac.as_str("-", upper=True) # 'AA-BB-CC-DD-EE-FF'
90
+ mac.is_local, mac.oui.hex() # (True, 'aabbcc') -- AA has the U/L bit
91
+
92
+ # DNS returns native types
93
+ netimps.resolve("example.com")[0].is_global # an IPv4Address, not a str
94
+ netimps.resolve("example.com", "txt") # ['v=spf1 -all'] -- unquoted
95
+
96
+ # ping carries the details, and can use TCP or UDP where ICMP is blocked
97
+ result = netimps.ping("8.8.8.8")
98
+ result.ok, result.rtt_ms, result.ttl # (True, 9.0, 119)
99
+ netimps.ping("8.8.8.8", method="tcp", port=53) # times the handshake
100
+
101
+ # Path MTU, measured rather than guessed
102
+ netimps.discover_mtu("8.8.8.8") # 1500
103
+ netimps.discover_mtu("10.0.0.5", method="udp", port=9999)
104
+ netimps.get_tcp_mss("example.com", 443) # 1460
105
+
106
+ # Scanning and multicast
107
+ netimps.scan_ports("192.168.1.1", ["ssh", "https"]) # [22, 443]
108
+ sock = netimps.multicast_socket("224.0.0.251", 5353) # mDNS listener
109
+
110
+ # Server-side: bind with the options named, and know where packets came from
111
+ server = netimps.bind("", 6767, broadcast=True)
112
+ endpoint = netimps.UdpEndpoint(server)
113
+
114
+ # Retry with backoff and jitter
115
+ netimps.retry(lambda: netimps.tcp_check("example.com", 443), attempts=3)
116
+ ```
117
+
118
+ ## API overview
119
+
120
+ | Name | Purpose |
121
+ | --- | --- |
122
+ | `IPAddress`, `IPInterface`, `IPNetwork` | v4/v6 **union aliases** for annotations |
123
+ | `IPAddressLike`, `IPNetworkLike`, `MACLike` | accepted-input unions |
124
+ | `IPv4Address`, `IPv4Interface`, ... | stdlib concrete-type re-exports |
125
+ | `parse`, `try_parse`, `is_valid` | build a type from a value (raising / `None` / `bool`) |
126
+ | `MACAddress` | parse / classify / render MAC addresses |
127
+ | `get_interfaces`, `Interface`, `iter_addresses` | native cross-platform NIC discovery |
128
+ | `get_ip`, `is_link_scoped` | address resolution and scope classification |
129
+ | `collapse`, `subtract` | CIDR set maths |
130
+ | `normalize_host` | `host:port` splitting, IPv6-aware |
131
+ | `get_default_port`, `get_default_scheme`, `register_port` | scheme ↔ port registry |
132
+ | `resolve` | DNS lookup → native records (`[]` on failure) |
133
+ | `ping`, `PingResult` | reachability with RTT and TTL |
134
+ | `bind`, `bind_error_hint`, `interface_for` | socket creation and diagnosis |
135
+ | `get_source_ip`, `get_free_port`, `tcp_check`, `wait_for_port` | socket helpers |
136
+ | `UdpEndpoint`, `Datagram` | UDP receive with arrival interface (`IP_PKTINFO`) |
137
+ | `Host` | hostname-or-address value type |
138
+ | `retry`, `backoff_delays` | bounded retry with exponential backoff |
139
+ | `APIPA`, `LOOPBACK_V4`, `LOOPBACK_V6`, `LINK_LOCAL_V6` | named networks |
140
+ | `get_route`, `Route`, `hop_count` | routing and distance |
141
+ | `discover_mtu`, `get_pmtu`, `get_tcp_mss` | path MTU by ICMP/UDP/TCP, the kernel's cached guess, or the negotiated MSS |
142
+ | `scan_ports`, `scan_hosts`, `PORT_RANGES` | concurrent scanning |
143
+ | `multicast_socket`, `join_group`, `leave_group`, `is_multicast` | multicast |
144
+ | `HOST_DN` | `platform.node()`, captured at import time |
145
+
146
+ Full per-export reference, with contracts and gotchas, lives in
147
+ [`src/netimps/AGENTS.md`](src/netimps/AGENTS.md).
148
+
149
+ ## Design notes
150
+
151
+ A few behaviours are deliberate and worth knowing:
152
+
153
+ - **`Interface.is_loopback` is computed from addresses, not names** — `lo`,
154
+ `lo0` and `Loopback Pseudo-Interface 1` share no spelling.
155
+ - **Concrete types are strict about family.** `parse("::1", IPAddress)` works;
156
+ `parse("::1", IPv4Address)` raises rather than quietly returning v6.
157
+ - **Networks parse non-strict by default**, so `10.0.0.5/24` normalises instead
158
+ of raising. Pass `strict=True` for stdlib behaviour.
159
+ - **`resolve` raises on a malformed query** rather than returning `[]` — a
160
+ typo'd record type should not look like "no such record".
161
+ - **`ping(ttl=...)` behaves the same on every OS.** Windows `ping` exits `0`
162
+ for "TTL expired in transit", so the reply address is verified instead of
163
+ trusting the exit code.
164
+ - **`hop_count` works unprivileged**, falling back to the system traceroute
165
+ when a raw socket is unavailable.
166
+ - **`discover_mtu` measures; `get_pmtu` only reports what the kernel cached.**
167
+ The latter is usually `None`, and always `None` on Windows. On one real host
168
+ the local link was 9000 and the true path MTU 1500 — only probing found it.
169
+
170
+ ## Development
171
+
172
+ ```bash
173
+ python -m venv .venv/dev
174
+ .venv/dev/Scripts/pip install -e ".[dev]" # POSIX: .venv/dev/bin/pip
175
+ .venv/dev/Scripts/pytest -q
176
+ .venv/dev/Scripts/black src/ tests/
177
+ ```
178
+
179
+ Tested on Python 3.9 (the floor) and 3.14.
180
+
181
+ ### Releasing
182
+
183
+ This project follows [Semantic Versioning](https://semver.org/) and keeps a
184
+ [`CHANGELOG.md`](CHANGELOG.md). Pushing a tag matching `v*` triggers the release
185
+ workflow: test gate → build → publish → docs deploy.
186
+
187
+ ## License
188
+
189
+ MIT — see [LICENSE](LICENSE).