netimps 0.0.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {netimps-0.0.2 → netimps-0.2.0}/AGENTS.md +10 -9
  2. {netimps-0.0.2 → netimps-0.2.0}/CHANGELOG.md +71 -1
  3. {netimps-0.0.2 → netimps-0.2.0}/PKG-INFO +34 -15
  4. {netimps-0.0.2 → netimps-0.2.0}/README.md +29 -13
  5. {netimps-0.0.2 → netimps-0.2.0}/pyproject.toml +11 -3
  6. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/AGENTS.md +120 -18
  7. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/__init__.py +68 -28
  8. netimps-0.2.0/src/netimps/_dns.py +678 -0
  9. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/_iface_spec.py +2 -2
  10. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/_ifaddrs.py +14 -3
  11. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/_ip.py +42 -4
  12. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/_sockets.py +125 -21
  13. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/cli.py +6 -1
  14. {netimps-0.0.2 → netimps-0.2.0}/tests/test_centralized.py +170 -7
  15. {netimps-0.0.2 → netimps-0.2.0}/tests/test_ip.py +42 -1
  16. netimps-0.2.0/tests/test_net.py +1086 -0
  17. netimps-0.2.0/tests/typing/api.py +92 -0
  18. netimps-0.0.2/src/netimps/_dns.py +0 -160
  19. netimps-0.0.2/tests/test_net.py +0 -487
  20. {netimps-0.0.2 → netimps-0.2.0}/.gitignore +0 -0
  21. {netimps-0.0.2 → netimps-0.2.0}/LICENSE +0 -0
  22. {netimps-0.0.2 → netimps-0.2.0}/docs/api/reference.md +0 -0
  23. {netimps-0.0.2 → netimps-0.2.0}/docs/changelog.md +0 -0
  24. {netimps-0.0.2 → netimps-0.2.0}/docs/index.md +0 -0
  25. {netimps-0.0.2 → netimps-0.2.0}/mkdocs.yml +0 -0
  26. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/__main__.py +0 -0
  27. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/_mac.py +0 -0
  28. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/_multicast.py +0 -0
  29. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/_ping.py +0 -0
  30. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/_retry.py +0 -0
  31. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/_scan.py +0 -0
  32. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/_scheme.py +0 -0
  33. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/_udp.py +0 -0
  34. {netimps-0.0.2 → netimps-0.2.0}/src/netimps/py.typed +0 -0
  35. {netimps-0.0.2 → netimps-0.2.0}/tests/test_cli.py +0 -0
  36. {netimps-0.0.2 → netimps-0.2.0}/tests/test_interfaces.py +0 -0
  37. {netimps-0.0.2 → netimps-0.2.0}/tests/test_mac.py +0 -0
  38. {netimps-0.0.2 → netimps-0.2.0}/tests/test_scan.py +0 -0
  39. {netimps-0.0.2 → netimps-0.2.0}/tests/test_sockets.py +0 -0
@@ -2,9 +2,10 @@
2
2
 
3
3
  A **small, self-contained network-utilities library** — a thin, typed layer over
4
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, one
6
- runtime dependency (`dnspython`, used only by `resolve`), and behaviour that
7
- stays faithful to the stdlib.
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.
8
9
 
9
10
  ```python
10
11
  import netimps
@@ -46,11 +47,11 @@ netimps.ping("8.8.8.8").rtt_ms # 9.0
46
47
  ## Install
47
48
 
48
49
  ```bash
49
- pip install netimps
50
+ pip install netimps # no hard dependencies
51
+ pip install netimps[dns] # plus dnspython, for resolve()'s fullest backend
50
52
  ```
51
53
 
52
- Requires Python 3.9+. Runtime dependency: `dnspython` (used only inside
53
- `resolve`).
54
+ Requires Python 3.9+. No hard runtime dependencies.
54
55
 
55
56
  > **This file is development documentation** — layout, testing, CI, release.
56
57
  > It is deliberately **not shipped** in the wheel. The library-usage reference
@@ -71,7 +72,7 @@ src/netimps/
71
72
  ├── _multicast.py # private: group membership and socket setup
72
73
  ├── _ifaddrs.py # private: ctypes getifaddrs/GetAdaptersAddresses bindings
73
74
  ├── _sockets.py # private: source IP, free port, tcp/wait, route, hops, MTU
74
- ├── _dns.py # private: resolve() over dnspython
75
+ ├── _dns.py # private: resolve() chaining dnspython/system/nslookup backends
75
76
  ├── _ping.py # private: ping() over the platform binary
76
77
  └── py.typed # PEP 561 marker — the package ships inline type hints
77
78
  ```
@@ -94,7 +95,7 @@ map:
94
95
  | Name | Purpose |
95
96
  | --- | --- |
96
97
  | `IPAddress`, `IPInterface`, `IPNetwork` | v4/v6 **union aliases** for annotations |
97
- | `IPAddressLike`, `IPNetworkLike`, `MACLike` | accepted-input unions |
98
+ | `IPAddressLike`, `IPInterfaceLike`, `IPNetworkLike`, `MACLike` | accepted-input unions |
98
99
  | `IPv4Address`, `IPv4Interface`, `IPv4Network`, `IPv6Address`, `IPv6Interface`, `IPv6Network` | stdlib concrete-type re-exports |
99
100
  | `parse`, `try_parse`, `is_valid` | build a type from a value (raising / `None` / `bool`) |
100
101
  | `MACAddress` | parse / classify / render MAC addresses |
@@ -105,7 +106,7 @@ map:
105
106
  | `get_default_port`, `get_default_scheme`, `register_port` | scheme ↔ port registry |
106
107
  | `resolve` | DNS lookup → native records (`[]` on failure) |
107
108
  | `ping`, `PingResult` | reachability with RTT and TTL |
108
- | `bind`, `bind_error_hint`, `interface_for` | socket creation and diagnosis |
109
+ | `bind`, `bind_error_hint`, `interface_for`, `interfaces_for`, `is_local_address` | socket creation and local membership |
109
110
  | `get_source_ip`, `get_free_port`, `tcp_check`, `wait_for_port` | socket helpers |
110
111
  | `UdpEndpoint`, `Datagram` | UDP receive with arrival interface (`IP_PKTINFO`) |
111
112
  | `Host` | hostname-or-address value type |
@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-07-29
11
+
12
+ ### Added
13
+
14
+ - **Three independently callable DNS backends**, plus `resolve()` chaining
15
+ them: `resolve_dnspython()` (the original `dnspython`-backed
16
+ implementation, every record type), `resolve_system()`
17
+ (`socket.getaddrinfo()` -- hosts file, NSS, OS resolver cache, address
18
+ records only), and `resolve_nslookup()` (shells out to `nslookup`, parses
19
+ both BIND-style and Windows-style output, address records only). `resolve()`
20
+ now tries `["dnspython", "system", "nslookup"]` in order by default and
21
+ returns the first definitive answer, skipping/falling through backends that
22
+ can't serve the request (non-address `rdtype`, missing binary, `dnspython`
23
+ not installed). A custom order/subset is available via
24
+ `resolve(..., backends=[...])` (or a single name as a plain string).
25
+ - `resolve()` (and all three backends) gain a `search` parameter for the
26
+ system resolver's search list (`resolv.conf`'s `search`/`domain`
27
+ directive, or the Windows per-adapter DNS suffix list). It defaults to
28
+ `True`, so an unqualified name like `resolve("db1")` is expanded the way
29
+ `ping db1` would be; `search=False` looks the name up literally (as a
30
+ fully-qualified name, so the OS resolver's own search-list logic doesn't
31
+ kick in either), and a list of domain names tries exactly those suffixes
32
+ instead of the system list.
33
+ - `dnspython` is now an **optional** dependency (`pip install netimps[dns]`),
34
+ since `resolve()` can fall back to `resolve_system()`/`resolve_nslookup()`
35
+ without it. The CLI's `resolve` subcommand now reports a missing-backend
36
+ failure as a clean CLI error rather than an uncaught exception.
37
+
38
+ ### Changed
39
+
40
+ - **`resolve()`'s default behavior for unqualified names.** Previously an
41
+ unqualified `query` was only ever looked up as-is; it now also tries the
42
+ system resolver's search list first (see `search` above). Pass
43
+ `search=False` to keep the old literal-only behavior. `ns=None` already
44
+ used the system resolver's nameservers; an invalid `ns=` now raises before
45
+ any query is attempted rather than silently falling back to the system
46
+ default.
47
+ - **`resolve()` is no longer purely `dnspython`-backed.** Behavior should be
48
+ unchanged for existing callers when `dnspython` is installed (it's still
49
+ tried first), but a lookup that previously raised or returned `[]` because
50
+ `dnspython` failed for a reason unrelated to the DNS answer itself (e.g. a
51
+ malformed system resolver config) may now succeed via the `system` or
52
+ `nslookup` fallback instead.
53
+
54
+ ## [0.1.0] - 2026-07-25
55
+
56
+ ### Added
57
+
58
+ - **Complete local-interface membership lookups.** `interfaces_for()` yields
59
+ every adapter matching an interface, exact address/`IPInterface`, network,
60
+ or `MACAddress`, while `interface_for()` keeps the first-match scalar
61
+ contract. `is_local_address()` distinguishes an assigned or loopback address
62
+ from one that is merely private, link-local, on-link or reachable.
63
+ - **Static parser contracts.** `TypeForm` overloads preserve union and concrete
64
+ result types, callable builders, and explicit `try_parse(default=...)`
65
+ fallbacks. `IPInterfaceLike` now complements the existing input aliases.
66
+
67
+ ### Changed
68
+
69
+ - `interface_for()` accepts networks and MAC addresses, including MAC text and
70
+ 6-byte packed values. Integer MACs remain explicit `MACAddress` values. Its legacy
71
+ `strict=False` synthetic fallback remains address-only because a missing
72
+ network or MAC has no honest single-interface representation.
73
+ - The IP input aliases now include packed bytes plus the exact stdlib
74
+ two-tuple and existing-interface forms accepted by the interface/network
75
+ factories. `is_valid()` is documented as a boolean convertibility check
76
+ rather than an unsound type guard for the original object.
77
+
10
78
  ## [0.0.2] - 2026-07-23
11
79
 
12
80
  ### Added
@@ -87,6 +155,8 @@ below is simply what the package contains.
87
155
  - **`Host`**, **`retry()`/`backoff_delays()`**, and the named networks `APIPA`,
88
156
  `LOOPBACK_V4`, `LOOPBACK_V6`, `LINK_LOCAL_V6`.
89
157
 
90
- [Unreleased]: https://github.com/jose-pr/netimps/compare/v0.0.1...HEAD
158
+ [Unreleased]: https://github.com/jose-pr/netimps/compare/v0.1.0...HEAD
159
+ [0.1.0]: https://github.com/jose-pr/netimps/compare/v0.0.2...v0.1.0
160
+ [0.0.2]: https://github.com/jose-pr/netimps/releases/tag/v0.0.2
91
161
  [0.0.1]: https://github.com/jose-pr/netimps/releases/tag/v0.0.1
92
162
  [0.0.0]: https://github.com/jose-pr/netimps/releases/tag/v0.0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netimps
3
- Version: 0.0.2
3
+ Version: 0.2.0
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/
@@ -19,16 +19,19 @@ Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Topic :: System :: Networking
20
20
  Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.9
22
- Requires-Dist: dnspython
23
22
  Provides-Extra: cli
24
23
  Requires-Dist: duho>=0.3.3; extra == 'cli'
25
24
  Provides-Extra: dev
26
25
  Requires-Dist: black; extra == 'dev'
27
26
  Requires-Dist: build; extra == 'dev'
27
+ Requires-Dist: dnspython; extra == 'dev'
28
28
  Requires-Dist: duho>=0.3.3; extra == 'dev'
29
29
  Requires-Dist: hatchling; extra == 'dev'
30
+ Requires-Dist: mypy<2; extra == 'dev'
30
31
  Requires-Dist: pytest; extra == 'dev'
31
32
  Requires-Dist: twine; extra == 'dev'
33
+ Provides-Extra: dns
34
+ Requires-Dist: dnspython; extra == 'dns'
32
35
  Provides-Extra: docs
33
36
  Requires-Dist: mkdocs; extra == 'docs'
34
37
  Requires-Dist: mkdocs-material; extra == 'docs'
@@ -47,10 +50,13 @@ Description-Content-Type: text/markdown
47
50
  "which is my IP", reachability checks, CIDR maths, host:port parsing, DNS,
48
51
  ping, scanning and multicast — as one typed, flat-import library.
49
52
 
50
- Built on the standard library: the only runtime dependency is `dnspython`, and
51
- only `resolve()` uses it. Interface enumeration, routing and multicast are
52
- `ctypes` bindings to the platform's own APIs, so there is nothing to compile
53
- and no wheel to miss for your platform.
53
+ Built on the standard library, with **no hard runtime dependencies**.
54
+ `resolve()` tries `dnspython`, the OS resolver and `nslookup`, in that order,
55
+ using whichever is available (`pip install netimps[dns]` for `dnspython`
56
+ itself, the fullest of the three: structured records, every record type,
57
+ explicit nameserver/search-list control). Interface enumeration, routing and
58
+ multicast are `ctypes` bindings to the platform's own APIs, so there is
59
+ nothing to compile and no wheel to miss for your platform.
54
60
 
55
61
  ## Features
56
62
 
@@ -58,13 +64,16 @@ and no wheel to miss for your platform.
58
64
  names, MACs, MTU and **real prefix lengths** on Linux, macOS/BSD and Windows,
59
65
  via `getifaddrs(3)` / `GetAdaptersAddresses`. No `ifaddr` required.
60
66
  - **One parsing entry point** — `parse(value, type)` with non-raising
61
- `try_parse` and boolean `is_valid` siblings, all typed so a checker narrows
62
- the result.
67
+ `try_parse` and boolean `is_valid` siblings. Type-form overloads preserve the
68
+ parsed result, including v4/v6 union aliases and caller-supplied defaults.
63
69
  - **`MACAddress`** — colon/hyphen/dot/bare plus `int`/`bytes`, hashable and
64
70
  ordered, with `.oui`, `.is_multicast`, `.is_local` and case-selectable
65
71
  rendering.
66
72
  - **The socket helpers everyone rewrites** — `get_source_ip`, `get_free_port`,
67
73
  `tcp_check`, `wait_for_port`.
74
+ - **Local membership lookups** — `interface_for()` gives the first adapter for
75
+ an address, interface, network or MAC; `interfaces_for()` yields every match,
76
+ and `is_local_address()` distinguishes assignment from mere address scope.
68
77
  - **Routing and MTU** — `get_route` (first hop, unprivileged), `hop_count`
69
78
  (raw sockets *or* traceroute fallback), `discover_mtu` / `get_pmtu`, `Interface.mtu`.
70
79
  - **CIDR set maths** — `collapse` and `subtract`, the latter missing from
@@ -76,18 +85,20 @@ and no wheel to miss for your platform.
76
85
  `retry()` for the backoff loop everyone writes without jitter.
77
86
  - **Multicast** — `multicast_socket` handling the join dance whose failure
78
87
  modes are otherwise silent.
79
- - **DNS and ping** — `resolve()` returning native types; `ping()` returning
80
- round-trip time and TTL, not just a boolean.
88
+ - **DNS and ping** — `resolve()` chaining `dnspython`/OS resolver/`nslookup`
89
+ backends and returning native types; `ping()` returning round-trip time and
90
+ TTL, not just a boolean.
81
91
 
82
92
  ## Installation
83
93
 
84
94
  ```bash
85
- pip install netimps # the library
95
+ pip install netimps # the library -- no hard dependencies
96
+ pip install netimps[dns] # plus dnspython, for resolve()'s fullest backend
86
97
  pip install netimps[cli] # plus the `netimps` command
87
98
  ```
88
99
 
89
- Requires Python 3.9+. The CLI extra adds `duho`; importing the library never
90
- requires it.
100
+ Requires Python 3.9+. Both extras are additive and independent; importing the
101
+ library never requires either.
91
102
 
92
103
  ## Command line
93
104
 
@@ -128,6 +139,11 @@ netimps.is_valid("::1", IPAddress) # True
128
139
  netimps.get_source_ip("8.8.8.8") # IPv4Address('192.0.2.10')
129
140
  netimps.get_route("8.8.8.8").gateway # IPv4Address('192.0.2.1')
130
141
 
142
+ # Exact assignment, subnet membership, or MAC ownership
143
+ netimps.interface_for("192.0.2.10") # first matching Interface, or None
144
+ list(netimps.interfaces_for(parse("192.0.2.0/24", IPNetwork)))
145
+ netimps.is_local_address("127.0.0.1") # True without interface discovery
146
+
131
147
  # Honest reachability, and waiting for a service
132
148
  netimps.tcp_check("example.com", 443) # True
133
149
  netimps.wait_for_port("localhost", 5432, timeout=60)
@@ -176,7 +192,7 @@ netimps.retry(lambda: netimps.tcp_check("example.com", 443), attempts=3)
176
192
  | Name | Purpose |
177
193
  | --- | --- |
178
194
  | `IPAddress`, `IPInterface`, `IPNetwork` | v4/v6 **union aliases** for annotations |
179
- | `IPAddressLike`, `IPNetworkLike`, `MACLike` | accepted-input unions |
195
+ | `IPAddressLike`, `IPInterfaceLike`, `IPNetworkLike`, `MACLike` | accepted-input unions |
180
196
  | `IPv4Address`, `IPv4Interface`, ... | stdlib concrete-type re-exports |
181
197
  | `parse`, `try_parse`, `is_valid` | build a type from a value (raising / `None` / `bool`) |
182
198
  | `MACAddress` | parse / classify / render MAC addresses |
@@ -187,7 +203,7 @@ netimps.retry(lambda: netimps.tcp_check("example.com", 443), attempts=3)
187
203
  | `get_default_port`, `get_default_scheme`, `register_port` | scheme ↔ port registry |
188
204
  | `resolve` | DNS lookup → native records (`[]` on failure) |
189
205
  | `ping`, `PingResult` | reachability with RTT and TTL |
190
- | `bind`, `bind_error_hint`, `interface_for` | socket creation and diagnosis |
206
+ | `bind`, `bind_error_hint`, `interface_for`, `interfaces_for`, `is_local_address` | socket creation and local membership |
191
207
  | `get_source_ip`, `get_free_port`, `tcp_check`, `wait_for_port` | socket helpers |
192
208
  | `UdpEndpoint`, `Datagram` | UDP receive with arrival interface (`IP_PKTINFO`) |
193
209
  | `Host` | hostname-or-address value type |
@@ -212,6 +228,9 @@ A few behaviours are deliberate and worth knowing:
212
228
  `parse("::1", IPv4Address)` raises rather than quietly returning v6.
213
229
  - **Networks parse non-strict by default**, so `10.0.0.5/24` normalises instead
214
230
  of raising. Pass `strict=True` for stdlib behaviour.
231
+ - **Addresses can belong to more than one interface.** `interface_for()` keeps
232
+ the historical first-match result; use `interfaces_for()` when duplicates
233
+ such as unscoped IPv6 link-local assignments matter.
215
234
  - **`resolve` raises on a malformed query** rather than returning `[]` — a
216
235
  typo'd record type should not look like "no such record".
217
236
  - **`ping(ttl=...)` behaves the same on every OS.** Windows `ping` exits `0`
@@ -10,10 +10,13 @@
10
10
  "which is my IP", reachability checks, CIDR maths, host:port parsing, DNS,
11
11
  ping, scanning and multicast — as one typed, flat-import library.
12
12
 
13
- Built on the standard library: the only runtime dependency is `dnspython`, and
14
- only `resolve()` uses it. Interface enumeration, routing and multicast are
15
- `ctypes` bindings to the platform's own APIs, so there is nothing to compile
16
- and no wheel to miss for your platform.
13
+ Built on the standard library, with **no hard runtime dependencies**.
14
+ `resolve()` tries `dnspython`, the OS resolver and `nslookup`, in that order,
15
+ using whichever is available (`pip install netimps[dns]` for `dnspython`
16
+ itself, the fullest of the three: structured records, every record type,
17
+ explicit nameserver/search-list control). Interface enumeration, routing and
18
+ multicast are `ctypes` bindings to the platform's own APIs, so there is
19
+ nothing to compile and no wheel to miss for your platform.
17
20
 
18
21
  ## Features
19
22
 
@@ -21,13 +24,16 @@ and no wheel to miss for your platform.
21
24
  names, MACs, MTU and **real prefix lengths** on Linux, macOS/BSD and Windows,
22
25
  via `getifaddrs(3)` / `GetAdaptersAddresses`. No `ifaddr` required.
23
26
  - **One parsing entry point** — `parse(value, type)` with non-raising
24
- `try_parse` and boolean `is_valid` siblings, all typed so a checker narrows
25
- the result.
27
+ `try_parse` and boolean `is_valid` siblings. Type-form overloads preserve the
28
+ parsed result, including v4/v6 union aliases and caller-supplied defaults.
26
29
  - **`MACAddress`** — colon/hyphen/dot/bare plus `int`/`bytes`, hashable and
27
30
  ordered, with `.oui`, `.is_multicast`, `.is_local` and case-selectable
28
31
  rendering.
29
32
  - **The socket helpers everyone rewrites** — `get_source_ip`, `get_free_port`,
30
33
  `tcp_check`, `wait_for_port`.
34
+ - **Local membership lookups** — `interface_for()` gives the first adapter for
35
+ an address, interface, network or MAC; `interfaces_for()` yields every match,
36
+ and `is_local_address()` distinguishes assignment from mere address scope.
31
37
  - **Routing and MTU** — `get_route` (first hop, unprivileged), `hop_count`
32
38
  (raw sockets *or* traceroute fallback), `discover_mtu` / `get_pmtu`, `Interface.mtu`.
33
39
  - **CIDR set maths** — `collapse` and `subtract`, the latter missing from
@@ -39,18 +45,20 @@ and no wheel to miss for your platform.
39
45
  `retry()` for the backoff loop everyone writes without jitter.
40
46
  - **Multicast** — `multicast_socket` handling the join dance whose failure
41
47
  modes are otherwise silent.
42
- - **DNS and ping** — `resolve()` returning native types; `ping()` returning
43
- round-trip time and TTL, not just a boolean.
48
+ - **DNS and ping** — `resolve()` chaining `dnspython`/OS resolver/`nslookup`
49
+ backends and returning native types; `ping()` returning round-trip time and
50
+ TTL, not just a boolean.
44
51
 
45
52
  ## Installation
46
53
 
47
54
  ```bash
48
- pip install netimps # the library
55
+ pip install netimps # the library -- no hard dependencies
56
+ pip install netimps[dns] # plus dnspython, for resolve()'s fullest backend
49
57
  pip install netimps[cli] # plus the `netimps` command
50
58
  ```
51
59
 
52
- Requires Python 3.9+. The CLI extra adds `duho`; importing the library never
53
- requires it.
60
+ Requires Python 3.9+. Both extras are additive and independent; importing the
61
+ library never requires either.
54
62
 
55
63
  ## Command line
56
64
 
@@ -91,6 +99,11 @@ netimps.is_valid("::1", IPAddress) # True
91
99
  netimps.get_source_ip("8.8.8.8") # IPv4Address('192.0.2.10')
92
100
  netimps.get_route("8.8.8.8").gateway # IPv4Address('192.0.2.1')
93
101
 
102
+ # Exact assignment, subnet membership, or MAC ownership
103
+ netimps.interface_for("192.0.2.10") # first matching Interface, or None
104
+ list(netimps.interfaces_for(parse("192.0.2.0/24", IPNetwork)))
105
+ netimps.is_local_address("127.0.0.1") # True without interface discovery
106
+
94
107
  # Honest reachability, and waiting for a service
95
108
  netimps.tcp_check("example.com", 443) # True
96
109
  netimps.wait_for_port("localhost", 5432, timeout=60)
@@ -139,7 +152,7 @@ netimps.retry(lambda: netimps.tcp_check("example.com", 443), attempts=3)
139
152
  | Name | Purpose |
140
153
  | --- | --- |
141
154
  | `IPAddress`, `IPInterface`, `IPNetwork` | v4/v6 **union aliases** for annotations |
142
- | `IPAddressLike`, `IPNetworkLike`, `MACLike` | accepted-input unions |
155
+ | `IPAddressLike`, `IPInterfaceLike`, `IPNetworkLike`, `MACLike` | accepted-input unions |
143
156
  | `IPv4Address`, `IPv4Interface`, ... | stdlib concrete-type re-exports |
144
157
  | `parse`, `try_parse`, `is_valid` | build a type from a value (raising / `None` / `bool`) |
145
158
  | `MACAddress` | parse / classify / render MAC addresses |
@@ -150,7 +163,7 @@ netimps.retry(lambda: netimps.tcp_check("example.com", 443), attempts=3)
150
163
  | `get_default_port`, `get_default_scheme`, `register_port` | scheme ↔ port registry |
151
164
  | `resolve` | DNS lookup → native records (`[]` on failure) |
152
165
  | `ping`, `PingResult` | reachability with RTT and TTL |
153
- | `bind`, `bind_error_hint`, `interface_for` | socket creation and diagnosis |
166
+ | `bind`, `bind_error_hint`, `interface_for`, `interfaces_for`, `is_local_address` | socket creation and local membership |
154
167
  | `get_source_ip`, `get_free_port`, `tcp_check`, `wait_for_port` | socket helpers |
155
168
  | `UdpEndpoint`, `Datagram` | UDP receive with arrival interface (`IP_PKTINFO`) |
156
169
  | `Host` | hostname-or-address value type |
@@ -175,6 +188,9 @@ A few behaviours are deliberate and worth knowing:
175
188
  `parse("::1", IPv4Address)` raises rather than quietly returning v6.
176
189
  - **Networks parse non-strict by default**, so `10.0.0.5/24` normalises instead
177
190
  of raising. Pass `strict=True` for stdlib behaviour.
191
+ - **Addresses can belong to more than one interface.** `interface_for()` keeps
192
+ the historical first-match result; use `interfaces_for()` when duplicates
193
+ such as unscoped IPv6 link-local assignments matter.
178
194
  - **`resolve` raises on a malformed query** rather than returning `[]` — a
179
195
  typo'd record type should not look like "no such record".
180
196
  - **`ping(ttl=...)` behaves the same on every OS.** Windows `ping` exits `0`
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "netimps"
7
- version = "0.0.2"
7
+ version = "0.2.0"
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"
@@ -23,12 +23,16 @@ classifiers = [
23
23
  "Topic :: System :: Networking",
24
24
  "Typing :: Typed",
25
25
  ]
26
- dependencies = ["dnspython"]
26
+ dependencies = []
27
27
 
28
28
  [project.optional-dependencies]
29
+ # dnspython backs resolve_dnspython() only -- resolve_system()/resolve_nslookup()
30
+ # (and resolve()'s fallback chain through them) work without it, so it is not a
31
+ # hard dependency. Importing netimps must never require it.
32
+ dns = ['dnspython']
29
33
  # The CLI is optional: importing netimps must never require duho.
30
34
  cli = ['duho>=0.3.3']
31
- dev = ['build', 'twine', 'hatchling', 'pytest', 'black', 'duho>=0.3.3']
35
+ dev = ['build', 'twine', 'hatchling', 'pytest', 'black', 'mypy<2', 'duho>=0.3.3', 'dnspython']
32
36
  docs = ['mkdocs', 'mkdocs-material', 'mkdocstrings[python]']
33
37
 
34
38
  [project.scripts]
@@ -57,6 +61,10 @@ exclude = ["/.*"]
57
61
  # 3.9 is the floor, so don't let black emit syntax newer than that.
58
62
  target-version = ["py39"]
59
63
 
64
+ [tool.mypy]
65
+ python_version = "3.9"
66
+ enable_incomplete_feature = ["TypeForm"]
67
+
60
68
  [tool.pytest.ini_options]
61
69
  pythonpath = ["src"]
62
70
  testpaths = ["tests"]
@@ -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.0.2"`).
19
+ `netimps.__version__` — the package version string (currently `"0.2.0"`).
20
20
 
21
21
  ## Argument naming
22
22
 
@@ -27,7 +27,7 @@ The package is consistent about what the first argument means:
27
27
  | `dst` | where traffic is **sent** | `ping`, `tcp_check`, `wait_for_port`, `get_route`, `hop_count`, `discover_mtu` |
28
28
  | `src` | where traffic is **sent from** | `ping(src=)`, `get_free_port(src=)`, `discover_mtu(src=)` |
29
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` |
30
+ | `address` / `ip` | an address being **classified** (no DNS) | `get_ip`, `interface_for`, `interfaces_for`, `is_local_address`, `is_multicast`, `is_link_scoped` |
31
31
 
32
32
  `dst`/`src` are abbreviated symmetrically, matching packet-header convention.
33
33
  A `dst` accepts a hostname; an `address` does not.
@@ -55,6 +55,7 @@ The union aliases are **not callable** — `IPAddress("10.0.0.5")` is a
55
55
  | `IPInterface` | `IPv4Interface \| IPv6Interface` (address + prefix) |
56
56
  | `IPNetwork` | `IPv4Network \| IPv6Network` |
57
57
  | `IPAddressLike` | anything accepted *as input* for an address |
58
+ | `IPInterfaceLike` | anything accepted *as input* for an address + prefix |
58
59
  | `IPNetworkLike` | anything accepted *as input* for a network |
59
60
  | `MACLike` | `str \| int \| bytes \| MACAddress` |
60
61
 
@@ -80,6 +81,9 @@ keyword. Key behaviours:
80
81
  - **Every type accepts the full stdlib input range** — `str`, `int`, packed
81
82
  `bytes`, or an existing object — because the builders are `ipaddress.ip_*`,
82
83
  not the concrete constructors.
84
+ - **`IPInterface` and non-strict `IPNetwork` accept the stdlib two-tuple
85
+ form**, such as `("10.0.0.5", 24)`. `IPNetwork` also accepts an existing
86
+ `IPInterface` and normalises its host bits. `IPAddress` accepts neither.
83
87
  - **Unions accept either family; concrete types are strict.**
84
88
  `parse("::1", IPAddress)` works; `parse("::1", IPv4Address)` raises, because
85
89
  asking for v4 and receiving v6 would defeat the request.
@@ -91,6 +95,11 @@ keyword. Key behaviours:
91
95
  than being disguised as a rejected value.
92
96
  - An unusable `type` raises `TypeError` **even from `try_parse`** — a caller
93
97
  bug is not a rejected value.
98
+ - Static type checkers preserve the selected result type, including the
99
+ `IPAddress`/`IPInterface`/`IPNetwork` unions, concrete classes, callable
100
+ builders, and the union with an explicit `try_parse(default=...)`.
101
+ - **`is_valid` returns a plain `bool`; it does not narrow the original input.**
102
+ It proves convertibility, and parsing can create a different object.
94
103
 
95
104
  > **Gotcha:** `is_valid` uses an internal sentinel rather than testing
96
105
  > `try_parse(...) is not None`, so a builder that legitimately returns `None`
@@ -197,21 +206,97 @@ Where several schemes share a port the **canonical** one is returned (1080 →
197
206
 
198
207
  ## DNS
199
208
 
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".
209
+ Three independently callable backends, plus `resolve()`, which chains them.
210
+ All four share one contract: a `list`, **empty on any genuine lookup
211
+ failure** (NXDOMAIN, NODATA, timeout) — never `None` — with **native
212
+ types**: `A`/`AAAA` records are `ipaddress` objects, everything else is
213
+ `str` (trailing root dot stripped, TXT strings unquoted).
214
+
215
+ **`resolve(query, rdtype="a", ns=None, timeout=5.0, port=53, tcp=False, search=True, backends=None)`**
216
+
217
+ Tries each backend in `backends` (default `["dnspython", "system",
218
+ "nslookup"]`) until one gives a **definitive** answer — records, or a real
219
+ empty result (NXDOMAIN/NODATA) — and returns that. A backend that could not
220
+ even *attempt* the query (missing binary, `dnspython` not installed, a
221
+ `rdtype`/`ns` it structurally can't serve) is skipped or falls through,
222
+ never mistaken for "no records". If every applicable backend fails that way,
223
+ the last such error is raised. `backends` also accepts a single name as a
224
+ plain string (`backends="system"`), or a custom order/subset
225
+ (`backends=["nslookup", "dnspython"]`).
226
+
227
+ - **`system`** is skipped automatically for a non-address `rdtype` or an
228
+ explicit `ns=`/`port=` — it has no per-call nameserver override, so running
229
+ it anyway would silently ignore the caller's choice.
230
+ - A malformed query or unknown record type raises `ValueError` immediately,
231
+ without trying every backend — that's a caller bug, not a resolution
232
+ outcome.
233
+
234
+ **`resolve_dnspython(query, rdtype="a", ns=None, timeout=5.0, port=53, tcp=False, search=True)`**
235
+
236
+ The original backend: `dnspython`, structured records, every `rdtype`.
237
+
238
+ - **`ns=None` (default) uses the system resolver configuration** —
239
+ `/etc/resolv.conf` on POSIX, the registry on Windows. Pass `ns=` (a string
240
+ or list) to query specific nameservers instead; a malformed `ns=` raises
241
+ immediately, before any query is attempted.
242
+ - **`search=True` (default) tries the resolver's search list** (the
243
+ `search`/`domain` directive in `resolv.conf`, or the Windows per-adapter DNS
244
+ suffix list) for an unqualified `query` — e.g. `resolve_dnspython("db1")`
245
+ trying `db1.internal.example.com`, the same as `ping db1` would.
246
+ `search=False` looks up `query` literally. A **list of domain names**
247
+ (`search=["eng.example.com", "example.com"]`) tries exactly those suffixes
248
+ instead of the system list, regardless of `ns`. Ignored for an
249
+ already-qualified (trailing-dot) `query`.
212
250
  - `timeout` bounds the **whole resolution including retries**, so a list of
213
251
  dead nameservers cannot run past it.
214
- - Requires `dnspython` — the package's only runtime dependency.
252
+ - Requires `dnspython` — the package's only runtime dependency. Raises
253
+ `ResolutionError` (not `ValueError`) if it isn't installed, so `resolve()`'s
254
+ chain falls through to the next backend instead of erroring outright.
255
+
256
+ **`resolve_system(query, rdtype="a", timeout=5.0, search=True)`**
257
+
258
+ The OS resolver, via `socket.getaddrinfo()` — **hosts file, NSS
259
+ (`nsswitch.conf`) and DNS, in the order the OS applies them**, including any
260
+ OS-level resolver cache. This is what `resolve_dnspython` cannot see (its own
261
+ DNS query bypasses all of that).
262
+
263
+ - **Address records only**: `rdtype` must be `"a"` or `"aaaa"`; anything else
264
+ raises `ResolutionError` immediately, no query attempted.
265
+ - **No `ns=` override** — `getaddrinfo` always asks whatever resolver the OS
266
+ is configured with; there's no per-call nameserver parameter at that layer
267
+ (not even via `ctypes` — reaching a specific nameserver without shelling
268
+ out means speaking DNS wire protocol yourself, which is what `dnspython`
269
+ already does).
270
+ - **`search`**: `getaddrinfo` itself takes no search-list parameter either, so
271
+ `search=True` (default) just leaves `query` as given and the OS resolver's
272
+ own configured search list (glibc `ndots`/`search`, Windows per-adapter DNS
273
+ suffix) applies as it normally would. `search=False` appends a trailing
274
+ `.`, which every resolver reads as "already fully qualified" — the same
275
+ trick `host`/`getent` scripts use. A **list of domain names** tries `query`
276
+ qualified with each, in order, one `getaddrinfo` call per candidate,
277
+ independent of (and untouched by) the OS's own search list.
278
+
279
+ **`resolve_nslookup(query, rdtype="a", ns=None, timeout=5.0, search=True)`**
280
+
281
+ Shells out to the `nslookup` binary — a fallback for when neither Python-level
282
+ path is usable. Address records only: `rdtype` must be `"a"`, `"aaaa"` or
283
+ `"ptr"`.
284
+
285
+ - Parses **both BIND-style** (`Address: 1.2.3.4`, one line per address) **and
286
+ Windows-style** (`Addresses:` with continuation lines, and NODATA as a bare
287
+ `Name:` line with no address and no error text) output. Windows also prints
288
+ its NXDOMAIN message on **stderr**, not stdout — both streams are checked.
289
+ - `ns=` is passed as `nslookup`'s trailing `server` argument (a single
290
+ nameserver, not a list).
291
+ - **`search`** has the same three-way contract as the other backends, but
292
+ `nslookup` has no built-in search-list handling — this issues one
293
+ `nslookup` call per candidate name, in order, stopping at the first with
294
+ actual records. `search=True` (default) draws the candidate list from the
295
+ system resolver's search config (reusing `dnspython`'s `resolv.conf`/
296
+ registry parsing if it's installed; `[]` — literal name only — if not).
297
+ - Raises `ResolutionError` (not `ValueError`) for a missing binary, a
298
+ timeout, or an unparseable output shape — a genuine "no such name" is
299
+ still `[]`.
215
300
 
216
301
  ## Reachability
217
302
 
@@ -257,9 +342,26 @@ failure. `tcp` and `udp` also report `rtt_ms`; only ICMP reports `ttl`.
257
342
  for a bind failure, recognising POSIX errnos *and* Windows `10013`/`10048`.
258
343
  Returns `None` for anything unrecognised, so the caller keeps the original
259
344
  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`.
345
+ - **`interface_for(query, strict=True) -> Interface | None`** — first matching
346
+ adapter in OS enumeration order. `query` accepts an `Interface`, exact
347
+ `IPAddress`, exact `.ip` from an `IPInterface`, an `IPNetwork` containing at
348
+ least one assigned address, or an exact `MACAddress`. Address-like strings,
349
+ integers and packed bytes remain accepted; a slash-bearing string is a
350
+ network. MAC text and 6-byte packed values are recognised after IP parsing.
351
+ Integer MACs must be wrapped in `MACAddress` because integers are also valid
352
+ IP-address inputs. Invalid input and misses return `None`. With
353
+ `strict=False`, only an address or `IPInterface` miss synthesizes a host-route
354
+ interface named `"<unknown>"`; networks and MACs have no honest synthetic
355
+ result.
356
+ - **`interfaces_for(query) -> Iterator[Interface]`** — every match for the same
357
+ query forms, in OS order and with each adapter yielded once even if several
358
+ assigned addresses match. An `Interface` yields itself without enumeration.
359
+ Addresses need not be unique across adapters (unscoped IPv6 link-local is a
360
+ common example), so use this plural form when every owner matters.
361
+ - **`is_local_address(address) -> bool`** — true only for loopback or an
362
+ address assigned to a local adapter. Private, link-local, on-link, routable
363
+ or reachable alone do not count. Malformed input raises like `parse`;
364
+ loopback answers before interface discovery.
263
365
  - **`get_source_ip(dst="8.8.8.8", port=80)`** — which local address the kernel
264
366
  would use to reach `dst`. **Sends no packets.** The answer depends on
265
367
  `dst`: with a VPN up, a public probe returns the tunnel address and a LAN