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.
Files changed (37) hide show
  1. {netimps-0.2.1 → netimps-0.2.2}/AGENTS.md +17 -0
  2. {netimps-0.2.1 → netimps-0.2.2}/CHANGELOG.md +47 -1
  3. {netimps-0.2.1 → netimps-0.2.2}/PKG-INFO +3 -2
  4. {netimps-0.2.1 → netimps-0.2.2}/pyproject.toml +2 -1
  5. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/AGENTS.md +29 -4
  6. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/__init__.py +1 -1
  7. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_dns.py +35 -13
  8. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_iface_spec.py +69 -1
  9. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_multicast.py +52 -29
  10. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_ping.py +147 -51
  11. {netimps-0.2.1 → netimps-0.2.2}/tests/test_net.py +185 -8
  12. {netimps-0.2.1 → netimps-0.2.2}/tests/test_scan.py +133 -0
  13. {netimps-0.2.1 → netimps-0.2.2}/tests/test_sockets.py +151 -0
  14. {netimps-0.2.1 → netimps-0.2.2}/.gitignore +0 -0
  15. {netimps-0.2.1 → netimps-0.2.2}/LICENSE +0 -0
  16. {netimps-0.2.1 → netimps-0.2.2}/README.md +0 -0
  17. {netimps-0.2.1 → netimps-0.2.2}/docs/api/reference.md +0 -0
  18. {netimps-0.2.1 → netimps-0.2.2}/docs/changelog.md +0 -0
  19. {netimps-0.2.1 → netimps-0.2.2}/docs/index.md +0 -0
  20. {netimps-0.2.1 → netimps-0.2.2}/mkdocs.yml +0 -0
  21. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/__main__.py +0 -0
  22. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_ifaddrs.py +0 -0
  23. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_ip.py +0 -0
  24. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_mac.py +0 -0
  25. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_retry.py +0 -0
  26. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_scan.py +0 -0
  27. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_scheme.py +0 -0
  28. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_sockets.py +0 -0
  29. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/_udp.py +0 -0
  30. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/cli.py +0 -0
  31. {netimps-0.2.1 → netimps-0.2.2}/src/netimps/py.typed +0 -0
  32. {netimps-0.2.1 → netimps-0.2.2}/tests/test_centralized.py +0 -0
  33. {netimps-0.2.1 → netimps-0.2.2}/tests/test_cli.py +0 -0
  34. {netimps-0.2.1 → netimps-0.2.2}/tests/test_interfaces.py +0 -0
  35. {netimps-0.2.1 → netimps-0.2.2}/tests/test_ip.py +0 -0
  36. {netimps-0.2.1 → netimps-0.2.2}/tests/test_mac.py +0 -0
  37. {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.1.0...HEAD
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: netimps
3
- Version: 0.2.1
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.1"
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.1"`).
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`, `.rtt_ms`,
342
- `.ttl`, `.source`, `.attempts`.
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`** — `.source`, `.gateway`,
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
 
@@ -163,7 +163,7 @@ __all__ = [
163
163
  "HOST_DN",
164
164
  ]
165
165
 
166
- __version__ = "0.2.1"
166
+ __version__ = "0.2.2"
167
167
 
168
168
  #: Fully-qualified (or short) name of the host running this process.
169
169
  HOST_DN = _platform.node()
@@ -246,18 +246,36 @@ def _resolve_system_once(
246
246
  except _socket.gaierror:
247
247
  return []
248
248
  else:
249
- import concurrent.futures as _futures
250
-
251
- with _futures.ThreadPoolExecutor(max_workers=1) as pool:
252
- future = pool.submit(_lookup)
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
- infos = future.result(timeout=timeout)
255
- except _socket.gaierror:
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
- except _futures.TimeoutError as exc:
258
- raise ResolutionError(
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 helper
312
- thread and is abandoned (without cancelling the underlying blocking
313
- call) past the deadline. ``None`` waits indefinitely.
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, Optional, Union
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, interface_address: "Optional[str]", ipv6: bool):
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
- local = interface_address or "0.0.0.0"
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
- Raises :class:`ValueError` for a non-multicast group or an interface with no
79
- usable address, and :class:`OSError` if the kernel rejects the join.
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
- address = _interface_address(interface, want_ipv6=ipv6)
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
- address = _interface_address(interface, want_ipv6=ipv6)
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
- outgoing = _interface_address(interface, want_ipv6=ipv6)
185
- if outgoing is not None and not ipv6:
186
- sock.setsockopt(
187
- _socket.IPPROTO_IP,
188
- _socket.IP_MULTICAST_IF,
189
- _socket.inet_aton(str(outgoing)),
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