netimps 0.2.1__tar.gz → 0.2.2__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.1 → netimps-0.2.2}/AGENTS.md +17 -0
- {netimps-0.2.1 → netimps-0.2.2}/CHANGELOG.md +47 -1
- {netimps-0.2.1 → netimps-0.2.2}/PKG-INFO +3 -2
- {netimps-0.2.1 → netimps-0.2.2}/pyproject.toml +2 -1
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/AGENTS.md +29 -4
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/__init__.py +1 -1
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_dns.py +35 -13
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_iface_spec.py +69 -1
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_multicast.py +52 -29
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_ping.py +147 -51
- {netimps-0.2.1 → netimps-0.2.2}/tests/test_net.py +185 -8
- {netimps-0.2.1 → netimps-0.2.2}/tests/test_scan.py +133 -0
- {netimps-0.2.1 → netimps-0.2.2}/tests/test_sockets.py +151 -0
- {netimps-0.2.1 → netimps-0.2.2}/.gitignore +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/LICENSE +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/README.md +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/docs/api/reference.md +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/docs/changelog.md +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/docs/index.md +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/mkdocs.yml +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/__main__.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_ifaddrs.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_ip.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_mac.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_retry.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_scan.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_scheme.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_sockets.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_udp.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/cli.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/src/netimps/py.typed +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/tests/test_centralized.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/tests/test_cli.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/tests/test_interfaces.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/tests/test_ip.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/tests/test_mac.py +0 -0
- {netimps-0.2.1 → netimps-0.2.2}/tests/typing/api.py +0 -0
|
@@ -148,6 +148,23 @@ map:
|
|
|
148
148
|
- **Check for silent platform gaps before adding a socket option.** `IP_MTU`,
|
|
149
149
|
`IP_MTU_DISCOVER`, `IP_DONTFRAG` and `SO_REUSEPORT` do not exist on Windows;
|
|
150
150
|
binding a multicast socket to the group address fails there too.
|
|
151
|
+
- **IPv6 multicast names an adapter by *index*, IPv4 by *address*.** They are
|
|
152
|
+
not two spellings of one thing: feeding an address to the v6 side does not
|
|
153
|
+
raise, it lands as index `0`, which is "kernel's choice". Use
|
|
154
|
+
`_iface_spec.interface_index()` for anything v6, `interface_address()` for
|
|
155
|
+
v4.
|
|
156
|
+
- **POSIX delivers asynchronous ICMP errors only to *connected* UDP sockets.**
|
|
157
|
+
An unconnected probe never sees a port-unreachable and just times out, while
|
|
158
|
+
Windows reports it either way — so the unconnected version tests green here
|
|
159
|
+
and under-reports on Linux CI. Also note `_discover_mtu_udp` is still
|
|
160
|
+
unconnected by design; it treats such errors as failure anyway.
|
|
161
|
+
- **`ThreadPoolExecutor.__exit__` calls `shutdown(wait=True)`,** and its atexit
|
|
162
|
+
hook joins worker threads too. It is therefore the wrong tool for bounding a
|
|
163
|
+
blocking call: a daemon `threading.Thread` joined through a queue is what
|
|
164
|
+
`_dns._resolve_system_once` uses, and why.
|
|
165
|
+
- **Match a ping reply by address token, and remember hostnames are plural.**
|
|
166
|
+
`gethostbyname` is IPv4-only — use `getaddrinfo` with an explicit family, or
|
|
167
|
+
`ipv6=` silently does nothing.
|
|
151
168
|
- **Windows exposes no cached path MTU.** Already investigated, so do not
|
|
152
169
|
re-derive it: `MIB_IPFORWARDROW.dwForwardMtu` reads 0 (unsupported), and
|
|
153
170
|
`MIB_IPFORWARD_ROW2` has no MTU field. `Interface.mtu` is the link MTU;
|
|
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.2] - 2026-08-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`resolve_system(timeout=)` now bounds wall time.** The lookup ran inside a
|
|
15
|
+
`with ThreadPoolExecutor(...)` block, whose `__exit__` joins the worker
|
|
16
|
+
still blocked in `getaddrinfo` -- so the timeout changed *what* was raised
|
|
17
|
+
but not *when*, and a 30s resolver hang still cost the caller 30s despite
|
|
18
|
+
`timeout=5.0`. `resolve()`'s chain consequently never reached `nslookup` at
|
|
19
|
+
the promised deadline either.
|
|
20
|
+
- **`interface=` is honoured for IPv6 multicast.** The spec was reduced to an
|
|
21
|
+
address and then passed to `if_nametoindex()`, which always fails for an
|
|
22
|
+
address string, so the `IPV6_JOIN_GROUP` index silently stayed `0` --
|
|
23
|
+
"kernel's choice", the exact default `interface=` exists to override.
|
|
24
|
+
`IPV6_MULTICAST_IF` was never set at all, so sends left by the default
|
|
25
|
+
route while joins listened elsewhere. Both now resolve the adapter to its
|
|
26
|
+
interface index. IPv4 behaviour is unchanged.
|
|
27
|
+
- **`ping(hostname, ipv6=True)` is no longer always false.** The reply address
|
|
28
|
+
was resolved with the IPv4-only `gethostbyname`, so a v6 reply was checked
|
|
29
|
+
against a v4 expectation and never matched. The expectation now comes from
|
|
30
|
+
`getaddrinfo` honouring `ipv6=`, and a name resolving to several addresses
|
|
31
|
+
counts as answered if the reply came from any of them.
|
|
32
|
+
- **`ping(..., method="tcp"/"udp")` reaches IPv6 destinations.** Both probes
|
|
33
|
+
opened `AF_INET` sockets unconditionally, so a v6 destination failed inside
|
|
34
|
+
`connect`/`sendto` and was reported as unreachable -- a wrong falsy answer
|
|
35
|
+
rather than an error. `ipv6=` now applies to all three methods.
|
|
36
|
+
- **`ping(..., method="udp")` detects ICMP port-unreachable on POSIX.** The
|
|
37
|
+
probe socket was never connected, and POSIX delivers asynchronous ICMP
|
|
38
|
+
errors only to connected UDP sockets -- so the documented "host answered,
|
|
39
|
+
nothing listening" signal worked on Windows alone and the probe just timed
|
|
40
|
+
out on Linux/macOS. `ECONNREFUSED` and `ECONNRESET` both now count.
|
|
41
|
+
|
|
42
|
+
### Documentation
|
|
43
|
+
|
|
44
|
+
- The shipped API header named `PingResult.source` and `Route.source`; both
|
|
45
|
+
attributes are spelled `.src` (and `PingResult.host` was unlisted).
|
|
46
|
+
- Recorded the per-family multicast interface selection, `ping`'s `ipv6=`
|
|
47
|
+
reach across all three methods, and `resolve_system`'s real wall-time
|
|
48
|
+
deadline in the shipped header.
|
|
49
|
+
- Added the known, still-unverified macOS/BSD `ping6` reply-shape gap
|
|
50
|
+
(`from <addr>,` rather than `from <addr>:`) to the header rather than
|
|
51
|
+
guessing a parser change without a macOS runner.
|
|
52
|
+
|
|
10
53
|
## [0.2.1] - 2026-07-30
|
|
11
54
|
|
|
12
55
|
### Added
|
|
@@ -196,7 +239,10 @@ below is simply what the package contains.
|
|
|
196
239
|
- **`Host`**, **`retry()`/`backoff_delays()`**, and the named networks `APIPA`,
|
|
197
240
|
`LOOPBACK_V4`, `LOOPBACK_V6`, `LINK_LOCAL_V6`.
|
|
198
241
|
|
|
199
|
-
[Unreleased]: https://github.com/jose-pr/netimps/compare/v0.
|
|
242
|
+
[Unreleased]: https://github.com/jose-pr/netimps/compare/v0.2.2...HEAD
|
|
243
|
+
[0.2.2]: https://github.com/jose-pr/netimps/compare/v0.2.1...v0.2.2
|
|
244
|
+
[0.2.1]: https://github.com/jose-pr/netimps/compare/v0.2.0...v0.2.1
|
|
245
|
+
[0.2.0]: https://github.com/jose-pr/netimps/compare/v0.1.0...v0.2.0
|
|
200
246
|
[0.1.0]: https://github.com/jose-pr/netimps/compare/v0.0.2...v0.1.0
|
|
201
247
|
[0.0.2]: https://github.com/jose-pr/netimps/releases/tag/v0.0.2
|
|
202
248
|
[0.0.1]: https://github.com/jose-pr/netimps/releases/tag/v0.0.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: netimps
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Small, self-contained network utilities: IP/MAC types, DNS lookup, ping, NIC discovery
|
|
5
5
|
Project-URL: Homepage, https://github.com/jose-pr/netimps
|
|
6
6
|
Project-URL: Documentation, https://jose-pr.github.io/netimps/
|
|
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
20
|
Classifier: Topic :: System :: Networking
|
|
20
21
|
Classifier: Typing :: Typed
|
|
21
22
|
Requires-Python: >=3.9
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "netimps"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
authors = [{ name = "Jose A." }]
|
|
9
9
|
description = "Small, self-contained network utilities: IP/MAC types, DNS lookup, ping, NIC discovery"
|
|
10
10
|
readme = "README.md"
|
|
@@ -19,6 +19,7 @@ classifiers = [
|
|
|
19
19
|
"Programming Language :: Python :: 3.11",
|
|
20
20
|
"Programming Language :: Python :: 3.12",
|
|
21
21
|
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
22
23
|
"Operating System :: OS Independent",
|
|
23
24
|
"Topic :: System :: Networking",
|
|
24
25
|
"Typing :: Typed",
|
|
@@ -16,7 +16,7 @@ is self-contained: it references nothing outside the installed distribution.
|
|
|
16
16
|
testing, releasing) is not shipped; it lives with the source at
|
|
17
17
|
<https://github.com/jose-pr/netimps>.
|
|
18
18
|
|
|
19
|
-
`netimps.__version__` — the package version string (currently `"0.2.
|
|
19
|
+
`netimps.__version__` — the package version string (currently `"0.2.2"`).
|
|
20
20
|
|
|
21
21
|
## Argument naming
|
|
22
22
|
|
|
@@ -309,6 +309,12 @@ cannot see (its own DNS query bypasses all of that). Same `AddressLike`
|
|
|
309
309
|
trick `host`/`getent` scripts use. A **list of domain names** tries `query`
|
|
310
310
|
qualified with each, in order, one `getaddrinfo` call per candidate,
|
|
311
311
|
independent of (and untouched by) the OS's own search list.
|
|
312
|
+
- **`timeout` bounds wall time, per candidate name tried.** `getaddrinfo` has
|
|
313
|
+
no timeout of its own, so each attempt runs in a daemon helper thread that
|
|
314
|
+
is abandoned at the deadline; the underlying call is not cancelled, but
|
|
315
|
+
neither the caller nor interpreter exit waits for it. A broken resolver
|
|
316
|
+
therefore costs `timeout`, not however long it takes to give up — which is
|
|
317
|
+
also what lets `resolve()`'s chain reach `nslookup` on schedule.
|
|
312
318
|
|
|
313
319
|
**`resolve_nslookup(query, rdtype=None, ns=None, timeout=5.0, search=True)`**
|
|
314
320
|
|
|
@@ -338,8 +344,8 @@ path is usable. Address records only: `rdtype` must be `"a"`, `"aaaa"` or
|
|
|
338
344
|
**`ping(dst, tries=1, timeout=1.0, ipv6=None, src=None, size=None, ttl=None, dont_fragment=False, method="icmp", port=None) -> PingResult`**
|
|
339
345
|
|
|
340
346
|
`PingResult` is **truthy on success** and compares equal to `bool`, so
|
|
341
|
-
`if ping(host):` and `== True` keep working, while carrying `.ok`, `.
|
|
342
|
-
`.ttl`, `.
|
|
347
|
+
`if ping(host):` and `== True` keep working, while carrying `.ok`, `.host`,
|
|
348
|
+
`.rtt_ms`, `.ttl`, `.src`, `.attempts`.
|
|
343
349
|
|
|
344
350
|
| Argument | Notes |
|
|
345
351
|
| --- | --- |
|
|
@@ -360,6 +366,19 @@ failure. `tcp` and `udp` also report `rtt_ms`; only ICMP reports `ttl`.
|
|
|
360
366
|
"TTL expired in transit", so the reply address is verified rather than
|
|
361
367
|
trusting the exit code. Locale-independent — it matches on addresses, never
|
|
362
368
|
prose.
|
|
369
|
+
- **`ipv6=` applies to all three methods**, not just the ICMP binary: it picks
|
|
370
|
+
the `-6`/`-4` flag, the family the reply address is resolved in, and the
|
|
371
|
+
family the `tcp`/`udp` probe sockets use. `ipv6=None` accepts either. A
|
|
372
|
+
hostname with several addresses counts as answered if the reply came from
|
|
373
|
+
any of them.
|
|
374
|
+
- **The `udp` probe connects its socket before sending**, which is why the ICMP
|
|
375
|
+
port-unreachable is seen on POSIX and not only on Windows — an unconnected
|
|
376
|
+
UDP socket is never delivered an asynchronous ICMP error on Linux/BSD.
|
|
377
|
+
`ECONNREFUSED` and `ECONNRESET` both count as "the host answered".
|
|
378
|
+
- **Known gap: macOS/BSD `ping6` reply lines.** Verification looks for
|
|
379
|
+
`from <addr>:`; BSD `ping6` is documented as printing `from <addr>,`. If
|
|
380
|
+
that holds, a v6 *literal* ping on macOS verifies as falsy despite a healthy
|
|
381
|
+
reply. Unverified on real hardware, so deliberately not "fixed" by guess.
|
|
363
382
|
- An unusable `src` (unknown MAC, adapter with no address, foreign address)
|
|
364
383
|
gives a falsy result — it **never silently falls back** to the default route.
|
|
365
384
|
- Never raises: missing binary, hung subprocess and non-zero exit are all falsy.
|
|
@@ -417,7 +436,7 @@ failure. `tcp` and `udp` also report `rtt_ms`; only ICMP reports `ttl`.
|
|
|
417
436
|
|
|
418
437
|
## Routing, hops and MTU
|
|
419
438
|
|
|
420
|
-
- **`get_route(dst="8.8.8.8") -> Route`** — `.
|
|
439
|
+
- **`get_route(dst="8.8.8.8") -> Route`** — `.dst`, `.src`, `.gateway`,
|
|
421
440
|
`.interface_index`, `.on_link`. **First hop only, deliberately** — that is
|
|
422
441
|
available unprivileged everywhere, unlike the full path. Never raises;
|
|
423
442
|
unknown pieces are `None`/`0`. The gateway resolves on Windows and Linux only.
|
|
@@ -501,6 +520,12 @@ receives nothing, and looks fine:
|
|
|
501
520
|
*both* send and receive. Without it the kernel picks by routing table, which
|
|
502
521
|
on a multi-homed host is regularly the wrong adapter. An unknown interface
|
|
503
522
|
**raises** rather than falling back.
|
|
523
|
+
- **The two families identify an adapter differently**, and the spec is
|
|
524
|
+
resolved accordingly: IPv4 by local *address* (`IP_ADD_MEMBERSHIP`,
|
|
525
|
+
`IP_MULTICAST_IF`), IPv6 by interface *index* (`IPV6_JOIN_GROUP`,
|
|
526
|
+
`IPV6_MULTICAST_IF`). An adapter the platform reports **no index** for
|
|
527
|
+
raises for an IPv6 group, because index `0` means "kernel's choice" — the
|
|
528
|
+
default `interface=` was passed to override.
|
|
504
529
|
|
|
505
530
|
## UDP with arrival interface
|
|
506
531
|
|
|
@@ -246,18 +246,36 @@ def _resolve_system_once(
|
|
|
246
246
|
except _socket.gaierror:
|
|
247
247
|
return []
|
|
248
248
|
else:
|
|
249
|
-
import
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
249
|
+
import queue as _queue
|
|
250
|
+
import threading as _threading
|
|
251
|
+
|
|
252
|
+
# A *daemon* thread, joined through a queue rather than a
|
|
253
|
+
# ThreadPoolExecutor. The executor looks like the obvious fit and is
|
|
254
|
+
# the wrong one: `__exit__` calls `shutdown(wait=True)` on every
|
|
255
|
+
# supported Python, so raising out of the `with` block joins the
|
|
256
|
+
# worker still stuck inside getaddrinfo() and the caller waits out the
|
|
257
|
+
# whole hang anyway -- `timeout` would change *what* is raised but not
|
|
258
|
+
# *when*. Its atexit hook joins pool threads too, so even
|
|
259
|
+
# `shutdown(wait=False)` would move the hang to interpreter exit.
|
|
260
|
+
# A daemon thread is abandoned at both points, which is the contract.
|
|
261
|
+
outcome: "_queue.Queue" = _queue.Queue(maxsize=1)
|
|
262
|
+
|
|
263
|
+
def _run_lookup() -> None:
|
|
253
264
|
try:
|
|
254
|
-
|
|
255
|
-
except
|
|
265
|
+
outcome.put(("ok", _lookup()))
|
|
266
|
+
except BaseException as exc: # relayed to the caller verbatim
|
|
267
|
+
outcome.put(("error", exc))
|
|
268
|
+
|
|
269
|
+
_threading.Thread(target=_run_lookup, daemon=True).start()
|
|
270
|
+
try:
|
|
271
|
+
kind, payload = outcome.get(timeout=timeout)
|
|
272
|
+
except _queue.Empty:
|
|
273
|
+
raise ResolutionError("resolve_system timed out after %.1fs" % (timeout,))
|
|
274
|
+
if kind == "error":
|
|
275
|
+
if isinstance(payload, _socket.gaierror):
|
|
256
276
|
return []
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
"resolve_system timed out after %.1fs" % (timeout,)
|
|
260
|
-
) from exc
|
|
277
|
+
raise payload
|
|
278
|
+
infos = payload
|
|
261
279
|
|
|
262
280
|
seen = []
|
|
263
281
|
for info in infos:
|
|
@@ -308,9 +326,13 @@ def resolve_system(
|
|
|
308
326
|
otherwise. Anything else raises.
|
|
309
327
|
:param timeout: seconds to wait *per candidate name tried* (see
|
|
310
328
|
``search``). There is no native per-call timeout for
|
|
311
|
-
``getaddrinfo``/``gethostbyaddr``, so each attempt runs in a
|
|
312
|
-
thread and is abandoned (without cancelling the underlying
|
|
313
|
-
call) past the deadline.
|
|
329
|
+
``getaddrinfo``/``gethostbyaddr``, so each attempt runs in a daemon
|
|
330
|
+
helper thread and is abandoned (without cancelling the underlying
|
|
331
|
+
blocking call) past the deadline. The deadline bounds **wall time**:
|
|
332
|
+
a resolver that hangs for a minute still raises
|
|
333
|
+
:class:`ResolutionError` at ``timeout``, and the abandoned thread
|
|
334
|
+
holds up neither the caller nor interpreter exit. ``None`` waits
|
|
335
|
+
indefinitely.
|
|
314
336
|
:param search: how to expand an unqualified ``query``. Ignored for
|
|
315
337
|
``rdtype="ptr"`` -- an address has no search-list suffix to try.
|
|
316
338
|
There is no per-call search-list override on
|
|
@@ -24,7 +24,7 @@ from ._ifaddrs import Interface
|
|
|
24
24
|
from ._ip import IPAddress
|
|
25
25
|
from ._mac import MACAddress
|
|
26
26
|
|
|
27
|
-
__all__ = ["interface_address"]
|
|
27
|
+
__all__ = ["interface_address", "interface_index"]
|
|
28
28
|
|
|
29
29
|
#: The loose "which interface?" spec every ``src=``/``interface=`` parameter
|
|
30
30
|
#: in the package accepts: an :class:`Interface`, a :class:`MACAddress` (or
|
|
@@ -109,3 +109,71 @@ def interface_address(
|
|
|
109
109
|
if parsed is None:
|
|
110
110
|
return _fail("cannot resolve %r to a local address" % (interface,))
|
|
111
111
|
return parsed
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def interface_index(interface: "InterfaceSpec", strict: bool = True) -> "Optional[int]":
|
|
115
|
+
"""Reduce an interface spec to its OS interface index.
|
|
116
|
+
|
|
117
|
+
The index-shaped sibling of :func:`interface_address`, for the OS
|
|
118
|
+
interfaces that identify an adapter by number rather than by address:
|
|
119
|
+
``IPV6_JOIN_GROUP``/``IPV6_LEAVE_GROUP``'s ``mreq`` and
|
|
120
|
+
``IPV6_MULTICAST_IF`` all take an index. Reducing such a spec to an
|
|
121
|
+
address first is not a lossy shortcut but an outright wrong answer --
|
|
122
|
+
``if_nametoindex("2001:db8::5")`` raises, leaving index ``0``, which the
|
|
123
|
+
kernel reads as "choose by routing table".
|
|
124
|
+
|
|
125
|
+
:param interface: an :class:`Interface`, a :class:`MACAddress` (or MAC
|
|
126
|
+
string), an adapter name, an address held by a local interface, or
|
|
127
|
+
``None``.
|
|
128
|
+
:param strict: when True (the default) a spec that names no local
|
|
129
|
+
interface -- or one the platform reports no index for -- raises
|
|
130
|
+
:class:`ValueError`; when False it returns ``None``.
|
|
131
|
+
|
|
132
|
+
``None`` in gives ``None`` out -- "no preference", which callers translate
|
|
133
|
+
into leaving the index at ``0`` and letting the kernel pick.
|
|
134
|
+
|
|
135
|
+
An index of ``0`` is never returned as a value: ``0`` *is* the kernel's
|
|
136
|
+
"pick for me", so reporting it for an adapter the caller explicitly named
|
|
137
|
+
would recreate the silent wrong-adapter failure this exists to prevent.
|
|
138
|
+
"""
|
|
139
|
+
from . import IPAddress, MACAddress, interface_for, is_valid, try_parse
|
|
140
|
+
from ._ifaddrs import Interface, get_interfaces
|
|
141
|
+
|
|
142
|
+
if interface is None:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def _fail(message: str):
|
|
146
|
+
if strict:
|
|
147
|
+
raise ValueError(message)
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
match: "Optional[Interface]" = None
|
|
151
|
+
if isinstance(interface, Interface):
|
|
152
|
+
match = interface
|
|
153
|
+
elif isinstance(interface, MACAddress) or (
|
|
154
|
+
isinstance(interface, str) and is_valid(interface, MACAddress)
|
|
155
|
+
):
|
|
156
|
+
wanted = MACAddress(interface)
|
|
157
|
+
match = interface_for(wanted)
|
|
158
|
+
if match is None:
|
|
159
|
+
return _fail("no interface with MAC %s" % (wanted,))
|
|
160
|
+
elif isinstance(interface, str) and not is_valid(interface, IPAddress):
|
|
161
|
+
# An adapter name. Resolved through get_interfaces() rather than
|
|
162
|
+
# socket.if_nametoindex() so the Windows *friendly* name works too --
|
|
163
|
+
# if_nametoindex there wants the adapter's GUID-ish system name.
|
|
164
|
+
match = next(
|
|
165
|
+
(iface for iface in get_interfaces() if iface.name == interface), None
|
|
166
|
+
)
|
|
167
|
+
if match is None:
|
|
168
|
+
return _fail("no interface named %r" % (interface,))
|
|
169
|
+
else:
|
|
170
|
+
address = try_parse(str(interface).strip(), IPAddress)
|
|
171
|
+
if address is None:
|
|
172
|
+
return _fail("cannot resolve %r to a local interface" % (interface,))
|
|
173
|
+
match = interface_for(address)
|
|
174
|
+
if match is None:
|
|
175
|
+
return _fail("no local interface holds address %s" % (address,))
|
|
176
|
+
|
|
177
|
+
if not match.index:
|
|
178
|
+
return _fail("interface %r reports no index on this platform" % (match.name,))
|
|
179
|
+
return match.index
|
|
@@ -15,6 +15,13 @@ The parts people get wrong
|
|
|
15
15
|
routing table -- which on a host with VMs, containers or a VPN is regularly
|
|
16
16
|
the wrong adapter, and the socket then receives nothing at all. Pass one when
|
|
17
17
|
it matters.
|
|
18
|
+
* **The two families name an adapter differently.** IPv4 identifies it by a
|
|
19
|
+
local *address* (``IP_ADD_MEMBERSHIP``'s ``imr_interface``,
|
|
20
|
+
``IP_MULTICAST_IF``); IPv6 identifies it by interface *index*
|
|
21
|
+
(``IPV6_JOIN_GROUP``'s ``ipv6mr_interface``, ``IPV6_MULTICAST_IF``).
|
|
22
|
+
Feeding an address to the v6 side does not fail loudly -- it lands as index
|
|
23
|
+
``0``, which means "kernel's choice", i.e. the default this module exists
|
|
24
|
+
to let you override.
|
|
18
25
|
* **``SO_REUSEPORT`` does not exist on Windows.** Code that sets it
|
|
19
26
|
unconditionally raises there, so it is applied only where present.
|
|
20
27
|
* **Default TTL is 1**, confining traffic to the local link. Raising it is a
|
|
@@ -25,9 +32,10 @@ from __future__ import annotations
|
|
|
25
32
|
|
|
26
33
|
import socket as _socket
|
|
27
34
|
import struct as _struct
|
|
28
|
-
from typing import List,
|
|
35
|
+
from typing import List, Union
|
|
29
36
|
|
|
30
37
|
from ._iface_spec import InterfaceSpec, interface_address as _interface_address
|
|
38
|
+
from ._iface_spec import interface_index as _interface_index
|
|
31
39
|
from ._ip import AddressLike
|
|
32
40
|
|
|
33
41
|
__all__ = ["multicast_socket", "join_group", "leave_group", "is_multicast"]
|
|
@@ -50,18 +58,19 @@ def is_multicast(address: "AddressLike") -> bool:
|
|
|
50
58
|
return bool(parsed is not None and parsed.is_multicast)
|
|
51
59
|
|
|
52
60
|
|
|
53
|
-
def _membership_request(group: str,
|
|
54
|
-
"""Build the mreq structure for IP_ADD_MEMBERSHIP / IPV6_JOIN_GROUP.
|
|
61
|
+
def _membership_request(group: str, interface: "InterfaceSpec", ipv6: bool):
|
|
62
|
+
"""Build the mreq structure for IP_ADD_MEMBERSHIP / IPV6_JOIN_GROUP.
|
|
63
|
+
|
|
64
|
+
Resolving the interface spec lives here rather than in the callers
|
|
65
|
+
because the two families need *different* resolutions of the same spec:
|
|
66
|
+
IPv4 wants a local address, IPv6 wants an interface index.
|
|
67
|
+
"""
|
|
55
68
|
if ipv6:
|
|
56
|
-
index = 0
|
|
57
|
-
if interface_address:
|
|
58
|
-
try:
|
|
59
|
-
index = _socket.if_nametoindex(interface_address)
|
|
60
|
-
except (OSError, AttributeError, ValueError):
|
|
61
|
-
index = 0
|
|
69
|
+
index = _interface_index(interface) or 0
|
|
62
70
|
return _socket.inet_pton(_socket.AF_INET6, group) + _struct.pack("@I", index)
|
|
63
71
|
|
|
64
|
-
|
|
72
|
+
address = _interface_address(interface, want_ipv6=False)
|
|
73
|
+
local = "0.0.0.0" if address is None else str(address)
|
|
65
74
|
return _struct.pack("4s4s", _socket.inet_aton(group), _socket.inet_aton(local))
|
|
66
75
|
|
|
67
76
|
|
|
@@ -75,17 +84,19 @@ def join_group(
|
|
|
75
84
|
host with VMs or a VPN is often the wrong adapter -- and the failure is
|
|
76
85
|
silent: the socket simply never receives.
|
|
77
86
|
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
The spec is resolved per family: to a local **address** for an IPv4
|
|
88
|
+
group, to an interface **index** for an IPv6 one, since that is what each
|
|
89
|
+
``mreq`` carries.
|
|
90
|
+
|
|
91
|
+
Raises :class:`ValueError` for a non-multicast group, or for an interface
|
|
92
|
+
that resolves to no usable address (IPv4) or no index (IPv6), and
|
|
93
|
+
:class:`OSError` if the kernel rejects the join.
|
|
80
94
|
"""
|
|
81
95
|
if not is_multicast(group):
|
|
82
96
|
raise ValueError("%r is not a multicast group" % (group,))
|
|
83
97
|
|
|
84
98
|
ipv6 = ":" in group
|
|
85
|
-
|
|
86
|
-
request = _membership_request(
|
|
87
|
-
group, None if address is None else str(address), ipv6
|
|
88
|
-
)
|
|
99
|
+
request = _membership_request(group, interface, ipv6)
|
|
89
100
|
if ipv6:
|
|
90
101
|
sock.setsockopt(_socket.IPPROTO_IPV6, _socket.IPV6_JOIN_GROUP, request)
|
|
91
102
|
else:
|
|
@@ -104,10 +115,7 @@ def leave_group(
|
|
|
104
115
|
raise ValueError("%r is not a multicast group" % (group,))
|
|
105
116
|
|
|
106
117
|
ipv6 = ":" in group
|
|
107
|
-
|
|
108
|
-
request = _membership_request(
|
|
109
|
-
group, None if address is None else str(address), ipv6
|
|
110
|
-
)
|
|
118
|
+
request = _membership_request(group, interface, ipv6)
|
|
111
119
|
if ipv6:
|
|
112
120
|
sock.setsockopt(_socket.IPPROTO_IPV6, _socket.IPV6_LEAVE_GROUP, request)
|
|
113
121
|
else:
|
|
@@ -139,7 +147,10 @@ def multicast_socket(
|
|
|
139
147
|
pass the port the senders use.
|
|
140
148
|
:param interface: :class:`Interface`, MAC, adapter name or local address to
|
|
141
149
|
join through. **Strongly recommended on multi-homed hosts** -- the
|
|
142
|
-
routing-table default is frequently the wrong adapter.
|
|
150
|
+
routing-table default is frequently the wrong adapter. Pins outgoing
|
|
151
|
+
traffic as well as the joins (``IP_MULTICAST_IF`` for IPv4,
|
|
152
|
+
``IPV6_MULTICAST_IF`` for IPv6), so sends and receives cannot end up
|
|
153
|
+
on different adapters.
|
|
143
154
|
:param ttl: hop limit for outgoing datagrams. The default of **1 keeps
|
|
144
155
|
traffic on the local link**; raise it deliberately to cross routers.
|
|
145
156
|
:param loop: whether this host receives its own transmissions. ``True``
|
|
@@ -180,14 +191,26 @@ def multicast_socket(
|
|
|
180
191
|
sock.setsockopt(_socket.IPPROTO_IP, _socket.IP_MULTICAST_LOOP, int(loop))
|
|
181
192
|
|
|
182
193
|
# Pin outgoing traffic to the chosen adapter as well as incoming, or
|
|
183
|
-
# sends leave by the default route while joins listen elsewhere.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
194
|
+
# sends leave by the default route while joins listen elsewhere. The
|
|
195
|
+
# option is per-family: IPv4 names the adapter by local address, IPv6
|
|
196
|
+
# by interface index.
|
|
197
|
+
if interface is not None:
|
|
198
|
+
if ipv6:
|
|
199
|
+
index = _interface_index(interface)
|
|
200
|
+
if index:
|
|
201
|
+
sock.setsockopt(
|
|
202
|
+
_socket.IPPROTO_IPV6,
|
|
203
|
+
_socket.IPV6_MULTICAST_IF,
|
|
204
|
+
_struct.pack("@I", index),
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
outgoing = _interface_address(interface, want_ipv6=False)
|
|
208
|
+
if outgoing is not None:
|
|
209
|
+
sock.setsockopt(
|
|
210
|
+
_socket.IPPROTO_IP,
|
|
211
|
+
_socket.IP_MULTICAST_IF,
|
|
212
|
+
_socket.inet_aton(str(outgoing)),
|
|
213
|
+
)
|
|
191
214
|
|
|
192
215
|
if bind:
|
|
193
216
|
# "" rather than the group address: binding to the group works on
|