ipscout 2.1.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. {ipscout-2.1.0 → ipscout-2.3.0}/CHANGELOG.md +228 -0
  2. {ipscout-2.1.0 → ipscout-2.3.0}/PKG-INFO +6 -2
  3. {ipscout-2.1.0 → ipscout-2.3.0}/README.md +5 -1
  4. {ipscout-2.1.0 → ipscout-2.3.0}/docs/systemdesign/module_reference.md +53 -45
  5. {ipscout-2.1.0 → ipscout-2.3.0}/docs/usage.md +116 -29
  6. {ipscout-2.1.0 → ipscout-2.3.0}/pyproject.toml +11 -4
  7. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/__init__.py +11 -0
  8. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/__init__conf__.py +1 -1
  9. ipscout-2.3.0/src/ipscout/api.py +720 -0
  10. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/arp.py +100 -8
  11. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/banner.py +67 -72
  12. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/bootp.py +96 -34
  13. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/bpf.py +3 -2
  14. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/bsdroute.py +64 -10
  15. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/cli.py +299 -73
  16. ipscout-2.3.0/src/ipscout/deadline.py +70 -0
  17. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/dhcp.py +60 -10
  18. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/dhcp_linux.py +5 -13
  19. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/dhcp_macos.py +21 -12
  20. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/dhcp_windows.py +10 -13
  21. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/errors.py +104 -0
  22. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/factory.py +6 -5
  23. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/interfaces_posix.py +5 -4
  24. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/interfaces_windows.py +73 -33
  25. ipscout-2.3.0/src/ipscout/models.py +1528 -0
  26. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/mtu.py +66 -40
  27. ipscout-2.3.0/src/ipscout/neighbours.py +606 -0
  28. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/neighbours_linux.py +28 -49
  29. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/neighbours_macos.py +54 -33
  30. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/neighbours_windows.py +104 -56
  31. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/netlink.py +10 -0
  32. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/packet.py +132 -25
  33. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/pool.py +49 -5
  34. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/ports.py +34 -3
  35. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/portscan.py +163 -43
  36. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/resolve.py +84 -73
  37. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/routes.py +44 -23
  38. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/routes_linux.py +22 -21
  39. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/routes_macos.py +35 -41
  40. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/routes_windows.py +23 -26
  41. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/scan.py +149 -25
  42. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/service.py +82 -14
  43. ipscout-2.3.0/src/ipscout/snmp.py +458 -0
  44. ipscout-2.3.0/src/ipscout/snmpber.py +385 -0
  45. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/subnet.py +1 -1
  46. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/tcpsyn.py +5 -33
  47. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/traceroute.py +121 -31
  48. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/transport_posix.py +73 -60
  49. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/transport_tcp.py +65 -19
  50. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/transport_windows.py +77 -60
  51. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/winapi.py +296 -62
  52. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/wol.py +56 -11
  53. ipscout-2.3.0/tests/snmp_agent.py +121 -0
  54. ipscout-2.3.0/tests/test_api_resolve_once.py +243 -0
  55. ipscout-2.3.0/tests/test_async_resolution.py +110 -0
  56. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_cli.py +93 -0
  57. ipscout-2.3.0/tests/test_deadline.py +49 -0
  58. ipscout-2.3.0/tests/test_defaults.py +94 -0
  59. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_family_filter.py +1 -1
  60. ipscout-2.3.0/tests/test_family_options.py +193 -0
  61. ipscout-2.3.0/tests/test_family_socket.py +100 -0
  62. ipscout-2.3.0/tests/test_mac_fallback.py +312 -0
  63. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_models.py +52 -14
  64. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_neighbours.py +5 -5
  65. ipscout-2.3.0/tests/test_p3core_bounds.py +150 -0
  66. ipscout-2.3.0/tests/test_p3main_interfaces_windows_family.py +50 -0
  67. ipscout-2.3.0/tests/test_p3win_family_numbering.py +72 -0
  68. ipscout-2.3.0/tests/test_p4a1_bounds.py +431 -0
  69. ipscout-2.3.0/tests/test_p4a2_argument_bounds.py +316 -0
  70. ipscout-2.3.0/tests/test_p4b1_bootp_bounds.py +76 -0
  71. ipscout-2.3.0/tests/test_p4b1_packet_bounds.py +82 -0
  72. ipscout-2.3.0/tests/test_p4b2_bsdroute.py +96 -0
  73. ipscout-2.3.0/tests/test_p4c_windows_data.py +246 -0
  74. ipscout-2.3.0/tests/test_p4main_handoffs.py +42 -0
  75. ipscout-2.3.0/tests/test_pass5_A_record_bounds.py +344 -0
  76. ipscout-2.3.0/tests/test_pass5_B_arguments.py +280 -0
  77. ipscout-2.3.0/tests/test_pass5_C_bounds.py +170 -0
  78. ipscout-2.3.0/tests/test_pass5_D_platform_backends.py +291 -0
  79. ipscout-2.3.0/tests/test_pass5_main_handoffs.py +38 -0
  80. ipscout-2.3.0/tests/test_pass5_main_ping_strict.py +35 -0
  81. ipscout-2.3.0/tests/test_port_bounds.py +94 -0
  82. ipscout-2.3.0/tests/test_route_backends_family.py +159 -0
  83. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_routes.py +6 -4
  84. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_scan_and_subnet.py +25 -3
  85. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_service.py +17 -10
  86. ipscout-2.3.0/tests/test_snmp.py +356 -0
  87. ipscout-2.3.0/tests/test_snmp_device.py +30 -0
  88. ipscout-2.3.0/tests/test_snmp_models.py +68 -0
  89. ipscout-2.3.0/tests/test_snmpber.py +207 -0
  90. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_tcp_fallback.py +31 -0
  91. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_traceroute.py +24 -0
  92. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_transport_posix_logic.py +22 -18
  93. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_ttl_capability_probe.py +1 -4
  94. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_winapi_layout.py +4 -4
  95. ipscout-2.1.0/src/ipscout/api.py +0 -433
  96. ipscout-2.1.0/src/ipscout/models.py +0 -855
  97. ipscout-2.1.0/src/ipscout/neighbours.py +0 -407
  98. {ipscout-2.1.0 → ipscout-2.3.0}/.gitignore +0 -0
  99. {ipscout-2.1.0 → ipscout-2.3.0}/LICENSE +0 -0
  100. {ipscout-2.1.0 → ipscout-2.3.0}/docs/development.md +0 -0
  101. {ipscout-2.1.0 → ipscout-2.3.0}/docs/handover-observe-dhcp.md +0 -0
  102. {ipscout-2.1.0 → ipscout-2.3.0}/docs/installation.md +0 -0
  103. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/__main__.py +0 -0
  104. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/interfaces.py +0 -0
  105. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/leases_linux.py +0 -0
  106. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/py.typed +0 -0
  107. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/serialize.py +0 -0
  108. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/sockerr.py +0 -0
  109. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/static_neighbours.py +0 -0
  110. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/typed_click.py +0 -0
  111. {ipscout-2.1.0 → ipscout-2.3.0}/src/ipscout/unixio.py +0 -0
  112. {ipscout-2.1.0 → ipscout-2.3.0}/tests/capture_support.py +0 -0
  113. {ipscout-2.1.0 → ipscout-2.3.0}/tests/conftest.py +0 -0
  114. {ipscout-2.1.0 → ipscout-2.3.0}/tests/dhcp_capture_fixture.py +0 -0
  115. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_arp.py +0 -0
  116. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_banner.py +0 -0
  117. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_bootp.py +0 -0
  118. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_dhcp_capability_probe.py +0 -0
  119. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_dhcp_macos.py +0 -0
  120. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_dhcp_windows.py +0 -0
  121. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_factory.py +0 -0
  122. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_integration_internet.py +0 -0
  123. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_integration_loopback.py +0 -0
  124. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_interfaces.py +0 -0
  125. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_metadata.py +0 -0
  126. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_module_entry.py +0 -0
  127. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_observe_dhcp.py +0 -0
  128. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_packaging.py +0 -0
  129. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_packet.py +0 -0
  130. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_platform_markers.py +0 -0
  131. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_resolve.py +0 -0
  132. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_sockerr.py +0 -0
  133. {ipscout-2.1.0 → ipscout-2.3.0}/tests/test_static_neighbours.py +0 -0
@@ -5,6 +5,234 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.3.0] 2026-10-26 12:19:25
9
+
10
+ ### Added
11
+
12
+ - `lookup_mac(ip, active=True, fallback_to_cache=True)` (and the same on `get_mac_address`, plus
13
+ `ipscout mac --active --fallback-to-cache`): ask actively, and read the neighbour cache instead
14
+ only when this host cannot ask, meaning no privilege (`IPScoutPermissionError`), or no interface
15
+ or route that can carry the request, or a send or read that failed (`IPScoutUnsupportedError`). An active ask nobody answered stays an
16
+ active answer and is never replaced by a cache entry; a host name and a zone naming no interface
17
+ still raise. `fallback_to_cache=True` without `active=True` raises `ValueError`.
18
+ - `MacMethod` (`ACTIVE`, `CACHE`) and `MacLookup.method`: every MAC answer says whether it was asked
19
+ for or read from the cache, and `ipscout mac` prints and emits it. `--strict` keeps it too, and now
20
+ also carries the interface of a direct answer.
21
+
22
+ ### Fixed
23
+
24
+ - An active MAC lookup no longer reports a request it could not make as "nobody answered":
25
+ - On Windows, a `SendARP` or `ResolveIpNetEntry2` failure other than no reply (no route to the
26
+ address, among others) and a missing `iphlpapi.dll` now raise `IPScoutUnsupportedError`.
27
+ They used to return `None`. Only `ERROR_BAD_NET_NAME` reads as no reply.
28
+ - On Linux and macOS, a send or receive that fails after the socket opens (the interface went
29
+ away, the link is down) now raises `IPScoutUnsupportedError` too, instead of returning `None`.
30
+ - `lookup_mac(..., active=True)` resolves a zone before anything is sent on every platform, by
31
+ index or by either Windows name (the friendly one `local_interfaces()` reports, or the
32
+ `if_nametoindex` one), and raises `IPScoutResolutionError` when it names no interface. Before,
33
+ the routed branch and Windows never looked at the zone, so a typo passed unseen. The cache read
34
+ now matches on the interface the zone resolves to, so a numeric zone such as `%2` finds the
35
+ entry on that link rather than whichever link holds the address.
36
+ - On Windows, an active lookup of an address with no route raises `IPScoutUnsupportedError`
37
+ instead of reporting nobody answered: `SendARP` gives the same status for both.
38
+
39
+ ## [2.2.0] 2026-10-26 11:06:07
40
+
41
+ ### Added
42
+
43
+ - `read_snmp_system` / `aread_snmp_system` and the `ipscout snmp-system` command: the SNMP system
44
+ group (sysUpTime, sysName, sysDescr, sysObjectID) of many hosts, one GET each, SNMPv1 or v2c as the
45
+ caller chooses. Each host gets an `SnmpSystem` record with an `SnmpState` (ANSWERED, SILENT,
46
+ REFUSED, ERROR, UNREACHABLE) instead of an exception; under v1 a value the agent lacks is asked
47
+ around and reported as `None`. The uptime tells a restart (it fell) from mere reachability.
48
+ - `ipscout.deadline`: the per-host deadline helpers, shared by the banner grab and the SNMP read.
49
+ - `packet.max_payload_size(family)` and `packet.require_payload_size(payload_size, family)`: the
50
+ one per-family payload bound, which `ping` and `build_echo_request` both apply.
51
+ - `models.RoundTrip` (a finite round trip of 0 or more milliseconds), `api.HostNames` /
52
+ `api.require_host_names` (a collection of host names, refusing one bare string),
53
+ `traceroute.TracerouteSettings` and `traceroute.DEFAULT_MAX_HOPS`, `bpf.MAX_BPF_DEVICES`, and a
54
+ `method` field on `portscan.PortScanSettings`.
55
+ - `ipscout.models.require_port`, `Port`, `MIN_PORT` and `MAX_PORT`: the one port-range check
56
+ (1-65535) every entry point and record taking a port number now applies.
57
+ - `ipscout.packet.MAX_PAYLOAD_SIZE_IPV4` (65507) and `MAX_PAYLOAD_SIZE_IPV6` (65527): the most ICMP
58
+ payload one echo can carry.
59
+ - `AddressFamily.ip_version` and `AddressFamily.of_version()`.
60
+ - `ipscout.portscan.DEFAULT_TIMEOUT` (1.0 s per port), and `DEFAULT_CONCURRENCY` exported from
61
+ `ipscout.api` and `ipscout.scan`; the CLI options take their defaults from these.
62
+ - Argument models and checks every entry point shares: `banner.BannerSettings`,
63
+ `portscan.PortScanSettings`, `scan.SweepSettings`; `models.Timeout` / `require_timeout` (above 0,
64
+ at most 2147483 s, finite), `models.MAX_TIMES` (65535, the 16-bit echo sequence),
65
+ `models.MAX_HOP_LIMIT` (255), `models.parse_address` / `require_address`, `models.needs_zone`,
66
+ `pool.Concurrency` / `require_concurrency` (at least 1), and `errors.validation_problems` /
67
+ `refused_arguments`, which word every argument refusal the same way.
68
+ - `models.DEFAULT_TIMEOUT` (2.0 s, re-exported by `service`), `models.DISCARD_PORT`,
69
+ `AddressFamily.unspecified_address`, `packet.MAX_IP_LENGTH`, `packet.UDP_HEADER_SIZE`,
70
+ `packet.IPV4_MIN_HEADER_SIZE`, `arp.normalise_mac` (where `neighbours.normalise_mac` now comes
71
+ from), `arp.format_hardware_address`, `arp.FRAME_RECEIVE_SIZE`, `ports.CAPTURE_RECEIVE_BUFFER`,
72
+ `ports.MIN_CAPTURE_WAIT`, `wol.DEFAULT_BROADCAST`, `scan.refuse_a_partial_scope`,
73
+ `scan.sweep_argument_refusal`.
74
+ - Windows backend helpers: `winapi.IpStatus` (an `IntEnum` of the IP_STATUS codes with their text),
75
+ `win_family` / `family_from_win`, `sockaddr_in6`, `sockaddr_inet_address`, `address_text`,
76
+ `reply_buffer_size`, `DEFAULT_TTL`, `ICMP_REPLY_HEADROOM`, `MAX_ADAPTER_ADDRESS_LENGTH`,
77
+ `NO_ERROR`, `MS_LAYOUT`; `transport_windows.ipv6_destination` and `echo_outcome`;
78
+ `neighbours_windows.declared_mac`. macOS route-socket helpers in `bsdroute`: `RTM_GET`,
79
+ `NO_METRICS`, `pack_sockaddr_in6`, `open_route_socket`.
80
+
81
+ ### Changed
82
+
83
+ - Every argument refusal reads `<operation> refused its arguments: <argument>: <reason>` (one part
84
+ per bad argument) and is a plain `ValueError` with pydantic's report kept as `__cause__`. `ping`
85
+ raised the `pydantic.ValidationError` itself, and with `raise_on_error=False` its `.error` held
86
+ pydantic's text, error-type tags and a docs URL; the CLI printed the same text.
87
+ - Arguments that were accepted and then misreported are refused before anything is sent:
88
+ `concurrency` below 1 (silently run as 1); a `timeout` of zero, below zero, infinite or above
89
+ 2147483 s in `ping`, `traceroute`, `scan_ports`, `syn_scan`, `arp_scan` and `grab_banners` (a zero
90
+ timeout reported open ports as `FILTERED`, a sweep returned the cache as its result, a traceroute
91
+ walked 30 silent hops, and 1e12 raised `OverflowError` mid-probe); `times` above 65535; a negative
92
+ `payload_size`; `max_hops` above 255; a sweep `limit` below 1; an empty `farewell` (silently not
93
+ sent); a NaN or infinite `DhcpSession` window.
94
+ - Address arguments are parsed at entry: `query_route` and `lookup_mac` refuse a host name, a
95
+ malformed literal, a literal of the other family or a zone on an IPv4 address with `ValueError`
96
+ (they returned `None` / `UNKNOWN`, the same as "no route"); `wake_on_lan` refuses a broadcast that
97
+ is empty, `0.0.0.0`, a name or not IPv4 (an empty one sent the packet to this host and reported it
98
+ sent); `scan_ports` resolves the host once and uses that address for every port (a name was
99
+ resolved per port, and the SYN path passed the name to `inet_aton`); an unresolvable host raises
100
+ `IPScoutResolutionError` instead of reporting every port `FILTERED`.
101
+ - Records refuse values no producer makes: `ResponseObject` (counts 0-65535, received at most sent
102
+ and matching the answered round trips, `reached` exactly when something answered, `ip` of its
103
+ `family`, a non-empty `error`), `TraceHop.ttl` 1-255, `InterfaceAddress` / `SubnetInfo` prefix
104
+ length within the family, `mtu` above 0, `Interface.index` at least 1, `SweepScope.limit` at least
105
+ 1, `ObserveDhcpReport` windows, `JsonEnvelope` (`ok` exactly when `error` is absent),
106
+ `ParsedReply` field widths, `EchoPayload.token` exactly 8 bytes, and `DhcpReply` (canonical MAC,
107
+ a non-zero IPv4 offer, a 32-bit transaction id). `build_echo_request` refuses a token of the wrong
108
+ length and a sequence or identifier outside 0-65535.
109
+ - A `ping` that fails before sending reports what it knew: the resolved address and family when
110
+ resolution succeeded (it reported `0.0.0.0` / IPv4 for an IPv6 target), and 0 pings sent.
111
+ - `bootp.offers_from_frames(mac=...)` accepts any written form of the address; another spelling
112
+ than lowercase-colon silently matched nothing.
113
+ - CLI: a bad option value is a usage error (exit 2, naming the option) before any probe, checked
114
+ through the library's own argument types; `ping-many -c 0` exited 1 with every row failed.
115
+ `wake`, `gateway --to` and `mac` with a bad argument exit 2 naming the command (they escaped with
116
+ exit 1 or 22 and `command: null`). `traceroute --max-hops` is limited to 1-255. In human mode a
117
+ usage error shows Click's usage text instead of a traceback.
118
+
119
+ - Every port number is held to 1-65535 the same way, with the message
120
+ `port out of range 1-65535: N`. `scan_ports` / `ascan_ports` / `syn_scan` refuse a port LIST
121
+ holding an out-of-range port with `ValueError` (only a specification string was checked);
122
+ `grab_banners` / `agrab_banners` keep refusing a bad port, now in that wording; and
123
+ `PortResult`, `Banner` and `WakeReport` refuse one at construction, where pydantic's
124
+ `greater than or equal to 1` wording used to appear.
125
+ - `ping` / `aping` / `ping_many` check `tcp_port` on every call, also when no TCP fallback is
126
+ used: out of range it raises, or with `raise_on_error=False` is reported on `.error`. It used
127
+ to be ignored unless the fallback engaged.
128
+ - On Windows, `path_mtu()` sends its don't-fragment probes with TTL 128, Windows' own default, as
129
+ `ping` does. It used 64, so a target more than 64 hops away could not be probed.
130
+ - `Banner` requires `error` exactly when the state is `UNREACHABLE` and refuses an empty one. It
131
+ used to forbid `error` on the other states but allowed an `UNREACHABLE` banner without a reason.
132
+
133
+ - More arguments are checked strictly before anything is sent, in the
134
+ `<operation> refused its arguments: <argument>: <reason>` shape:
135
+ - `ping` / `aping` / `ping_many` / `is_reachable`: `times=True` sent one echo and `timeout="1"`
136
+ waited a second; both are refused now (`is_reachable`, which never raises, answers `False`).
137
+ - `ping_many` / `aping_many` and `grab_banners` / `agrab_banners`: one string passed as the host
138
+ collection (`ping_many("printer.lan")`) probed each character as a host; it is refused, and so
139
+ is a host that is not a string (a raw pydantic error from `Banner` before). `ping_many` checks
140
+ `concurrency` before it takes anything from the targets; the refusal used to come after the
141
+ targets iterable was consumed, as `concurrency must be at least 1, got 0`.
142
+ - `traceroute` / `atraceroute` / `trace_path` / `atrace_path`: `max_hops=True` walked one hop and
143
+ `3.0` or `"5"` raised `TypeError`; a non-bool `resolve_names` (`"no"` switched lookups on) and a
144
+ `timeout` of the wrong type are refused. The messages change from
145
+ `max_hops must be 1-255, got 256` (and `max_hops must be at least 1, got 0` in 2.1.0) to
146
+ `traceroute refused its arguments: max_hops: Input should be less than or equal to 255`. The
147
+ arguments are checked before the name is resolved, so a bad `max_hops` with an unresolvable
148
+ name is a `ValueError`, no longer `IPScoutResolutionError`.
149
+ - `scan_ports` / `ascan_ports` / `syn_scan` with a port LIST: `[True]` and `[80.5]` scanned port
150
+ 1 and 80 and keyed the result `True` / `80.5`; they are refused, and a list port out of range
151
+ now reads `port scan refused its arguments: ports.0: port out of range 1-65535: 0` (a
152
+ specification string keeps the bare `port out of range 1-65535: 0`).
153
+ `scan_ports(method="syn")` with a plain string silently ran a connect scan; it is refused.
154
+ - `observe_dhcp_first_reachable`: a `probe_timeout` of 0, negative, NaN or text, or a `tcp_port`
155
+ outside 1-65535, watched the whole window and returned `None` (port 0 silently dropped the TCP
156
+ attempt); they are refused before the capture opens.
157
+ - `DhcpSession` / `observe_dhcp`: a `settle` above 2147483 s is refused. A `timeout` of 0 reads
158
+ `timeout must be above 0 and at most 2147483 seconds, got 0` (2.1.0:
159
+ `timeout must be positive, got 0: a window of zero could never see anything`).
160
+ - `get_mac_address`, `lookup_mac` and `resolve_active`: text that is not an address literal
161
+ raises `ValueError`. In 2.1.0 `get_mac_address("nonsense")` returned `None`, and with
162
+ `active=True` raised `IPScoutUnsupportedError` (no route). `resolve_active` checks its
163
+ `timeout` (0 returned `None` without waiting, -1 raised a raw `Timeout value out of range`).
164
+ - `SweepScope.from_networks("192.0.2.0/24", ...)` (one network as text) is refused instead of
165
+ being split into characters.
166
+ - `resolve_active` and `lookup_mac(active=True)` on Linux and macOS honour an RFC 4007 zone
167
+ (`fe80::1%eth0`, by name or index): the request goes out on that interface. The zoned text used to
168
+ make the backend's `inet_pton` fail, and `None` came back with nothing sent. A zone naming no
169
+ interface raises `IPScoutResolutionError`. On Windows the zone is still not used.
170
+ - `build_echo_request` refuses a `payload_size` its family cannot carry with the same message
171
+ `ping` uses (`10**12` raised `MemoryError`, 70000 a raw pydantic error, and IPv4 65508-65527 was
172
+ built). `build_arp_request` / `build_neighbour_solicitation` refuse a sender address that is not
173
+ six bytes (`"aa:bb"` was zero-padded into the frame).
174
+ - Records refuse more values no producer makes: `EchoResult.rtt_ms` NaN, infinite or negative, a
175
+ round trip without a source (or the reverse), `ttl_expired` without an answer; `TraceHop` reached
176
+ or named without an address, a round trip without an address, an empty `hostname`;
177
+ `Interface` with an empty name, an address filed under the other family, a hardware address not
178
+ in lowercase colon form or all zero, an MTU above 0xFFFFFFFF; `Neighbour` whose `ip` is not a
179
+ bare literal of its `family` or whose `mac` is not `aa:bb:cc:dd:ee:ff`; `MacLookup` whose
180
+ `via_ip` and `NEXT_HOP` scope disagree, `DIRECT` without a MAC or `UNKNOWN` with one;
181
+ `SubnetInfo` whose network is not the one its address sits on, whose broadcast is not that
182
+ network's, or whose gateway is not of its family; `SweepScope` networks covering more than its
183
+ limit; `FindIpReport` with `scanned` and `scope` disagreeing; `ObserveDhcpReport` and
184
+ `WakeReport` with a non-canonical MAC, unbounded windows, or a broadcast that is not a usable
185
+ IPv4 address.
186
+ - `SubnetInfo.network` is an `IPv4Network` / `IPv6Network` instead of text. JSON output is
187
+ unchanged; Python callers reading it as a string need `str(...)`.
188
+ - CLI: `wake`, `find-ip` and `observe-dhcp` report the MAC as `aa:bb:cc:dd:ee:ff` whatever spelling
189
+ was typed (they echoed the input).
190
+
191
+ ### Fixed
192
+
193
+ - TCP fallback probes over IPv6 (`TcpEchoTransport`, `AsyncTcpEchoTransport`) could reach an IPv4
194
+ listener: an IPv4-mapped target (`::ffff:a.b.c.d`) connected through IPv4 on Linux and macOS,
195
+ and on macOS with Python 3.15 a plain IPv4 literal did too, so the host read as reachable over
196
+ IPv6. The probe socket now sets `IPV6_V6ONLY`, and such a target reads as unanswered.
197
+ - Windows `local_interfaces()`: an adapter with no friendly name was named `b'{GUID}'` (the repr
198
+ of the bytes) and one with no name at all `''`; it is now `{GUID}`, or `ifindex-<n>` /
199
+ `adapter-<position>` when even that is missing.
200
+ - macOS `dhcp_capture_available()`: a busy `/dev/bpf0` answered `False` although a capture would
201
+ claim the next free device; it now tries the same pool a capture does.
202
+ - `reverse_dns` answers `None` for an empty PTR name, which a traceroute with `resolve_names=True`
203
+ would otherwise have failed on.
204
+ - `aping`, `aping_many`, `ais_reachable` and `atraceroute` resolve their target in a worker
205
+ thread, and `atrace_path(resolve_names=True)` looks up each hop's name there too. They called
206
+ `getaddrinfo` / `gethostbyaddr` on the event-loop thread, so one slow name server stalled every
207
+ other task in the loop: in `aping_many`, one slow name held up the whole sweep.
208
+ - `is_reachable` / `ais_reachable` resolve the target once and probe that one address on both the
209
+ ICMP and the TCP attempt; they resolved twice, so round-robin DNS could make the two attempts
210
+ probe different hosts. ICMP versus TCP is now chosen for the family the target resolved to, not
211
+ for IPv4, and a socket error from either attempt answers `False` instead of escaping. A
212
+ `tcp_port` outside 1-65535 raised an uncaught `OverflowError` from the connect; it now makes only
213
+ the TCP attempt answer `False`, so a host that answers ICMP still reads as reachable.
214
+ - `ping` / `aping` / `ping_many` refuse a `payload_size` above 65507 bytes for an IPv4 target or
215
+ 65527 for IPv6 (raised, or on `.error` with `raise_on_error=False`). Such an echo cannot be sent,
216
+ and the failed send was reported as a live host that never answered.
217
+ - `wake_on_lan` refuses a port outside 1-65535 with `ValueError`. It raised an uncaught
218
+ `OverflowError` from the socket layer, which the CLI printed as a traceback.
219
+ - An interface whose sysfs MTU reads 0 is listed with no MTU instead of failing the listing.
220
+
221
+ ### Removed
222
+
223
+ Backend-module names replaced by the shared definitions above. Nothing in the top-level `ipscout`
224
+ namespace is removed or changed.
225
+
226
+ - `winapi.IP_SUCCESS`, `IP_DEST_NET_UNREACHABLE`, `IP_DEST_HOST_UNREACHABLE`, `IP_REQ_TIMED_OUT`,
227
+ `IP_TTL_EXPIRED_TRANSIT`: use `winapi.IpStatus` members, which compare equal to the same numbers.
228
+ - `neighbours_linux`, `neighbours_windows`, `bsdroute` and `tcpsyn` no longer define their own
229
+ `format_mac`, `MAC_LENGTH` and `checksum`; they import `arp.format_mac`, `arp.MAC_LENGTH` and
230
+ `packet.checksum` (the same RFC 1071 sum) and drop them from `__all__`. An old import still
231
+ resolves, except that `neighbours_windows.format_mac` now takes one argument: for a
232
+ length-prefixed buffer use `neighbours_windows.declared_mac(raw, length)`.
233
+ - `neighbours_windows.resolve_active(ip)` takes the family as a second argument; an IPv4 address in
234
+ a non-dotted-quad form such as `192.0.2` resolves to nothing instead of being read as `192.0.0.2`.
235
+
8
236
  ## [2.1.0] 2026-10-24 17:18:42
9
237
 
10
238
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ipscout
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Ping, traceroute and local-network inspection in pure Python - no subprocess, and no admin rights on the default paths
5
5
  Project-URL: Homepage, https://github.com/bitranox/ipscout
6
6
  Project-URL: Repository, https://github.com/bitranox/ipscout.git
@@ -202,6 +202,7 @@ ipscout reachable example.com
202
202
  ipscout traceroute 1.1.1.1 --max-hops 10
203
203
  ipscout scan-ports 192.168.1.10 --ports 22,80,443,8000-8100
204
204
  ipscout banners 192.0.2.0/24 --port 21 --farewell 'QUIT\r\n' # what each host says first
205
+ ipscout snmp-system 192.0.2.0/28 # uptime, name and model over SNMP
205
206
  ipscout mac 8.8.8.8 # the gateway's, labelled NEXT_HOP
206
207
  ipscout find-ip dc:b2:2f:44:34:59 --scan
207
208
  ipscout arp-scan --network 192.168.1.0/24
@@ -236,6 +237,7 @@ ipscout.default_gateway() # and query_route() for any destination
236
237
  ipscout.subnet_info() # addressing, gateway, stored DHCP facts
237
238
  ipscout.scan_ports(host, "22,80,8000-8100")
238
239
  ipscout.grab_banners(hosts, 21, farewell=b"QUIT\r\n") # each host's first line, on one connection
240
+ ipscout.read_snmp_system(hosts) # uptime, sysName, sysDescr, sysObjectID; a falling uptime is a restart
239
241
  ipscout.path_mtu("8.8.8.8")
240
242
  ipscout.wake_on_lan(mac, broadcast="192.168.1.255")
241
243
  ipscout.observe_dhcp(mac, interface="br0") # a machine that has no address yet (needs elevation)
@@ -244,7 +246,9 @@ ipscout.remove_neighbour(ip, interface="eth0") # and out again
244
246
  ```
245
247
 
246
248
  A MAC address does not survive a router hop, so `lookup_mac` puts the scope in the answer and
247
- `get_mac_address` returns `None` for anything routed rather than passing off the gateway's as the host's. A
249
+ `get_mac_address` returns `None` for anything routed rather than passing off the gateway's as the host's. Every
250
+ answer also says in `method` whether it was asked for (`active=True`) or read from the cache, and
251
+ `fallback_to_cache=True` reads the cache only when this host cannot ask actively. A
248
252
  sweep with no network given covers the subnets this host is attached to that fit inside one sweep's
249
253
  4096-address budget, and names the ones left out - a container bridge on a `/16` is skipped rather than
250
254
  cancelling the sweep, and a search that matched nothing while a network went uncovered says so instead of
@@ -156,6 +156,7 @@ ipscout reachable example.com
156
156
  ipscout traceroute 1.1.1.1 --max-hops 10
157
157
  ipscout scan-ports 192.168.1.10 --ports 22,80,443,8000-8100
158
158
  ipscout banners 192.0.2.0/24 --port 21 --farewell 'QUIT\r\n' # what each host says first
159
+ ipscout snmp-system 192.0.2.0/28 # uptime, name and model over SNMP
159
160
  ipscout mac 8.8.8.8 # the gateway's, labelled NEXT_HOP
160
161
  ipscout find-ip dc:b2:2f:44:34:59 --scan
161
162
  ipscout arp-scan --network 192.168.1.0/24
@@ -190,6 +191,7 @@ ipscout.default_gateway() # and query_route() for any destination
190
191
  ipscout.subnet_info() # addressing, gateway, stored DHCP facts
191
192
  ipscout.scan_ports(host, "22,80,8000-8100")
192
193
  ipscout.grab_banners(hosts, 21, farewell=b"QUIT\r\n") # each host's first line, on one connection
194
+ ipscout.read_snmp_system(hosts) # uptime, sysName, sysDescr, sysObjectID; a falling uptime is a restart
193
195
  ipscout.path_mtu("8.8.8.8")
194
196
  ipscout.wake_on_lan(mac, broadcast="192.168.1.255")
195
197
  ipscout.observe_dhcp(mac, interface="br0") # a machine that has no address yet (needs elevation)
@@ -198,7 +200,9 @@ ipscout.remove_neighbour(ip, interface="eth0") # and out again
198
200
  ```
199
201
 
200
202
  A MAC address does not survive a router hop, so `lookup_mac` puts the scope in the answer and
201
- `get_mac_address` returns `None` for anything routed rather than passing off the gateway's as the host's. A
203
+ `get_mac_address` returns `None` for anything routed rather than passing off the gateway's as the host's. Every
204
+ answer also says in `method` whether it was asked for (`active=True`) or read from the cache, and
205
+ `fallback_to_cache=True` reads the cache only when this host cannot ask actively. A
202
206
  sweep with no network given covers the subnets this host is attached to that fit inside one sweep's
203
207
  4096-address budget, and names the ones left out - a container bridge on a `/16` is skipped rather than
204
208
  cancelling the sweep, and a search that matched nothing while a network went uncovered says so instead of
@@ -58,7 +58,7 @@ The import-linter contract `Layered dependencies point one way only` is enforced
58
58
  listed highest first; each may import only from those below it.
59
59
 
60
60
  ```
61
- scan : subnet : portscan : banner : mtu : wol : dhcp sweeps and single-purpose probes
61
+ scan : subnet : portscan : banner : snmp : mtu : wol : dhcp sweeps and single-purpose probes
62
62
  api ping / aping / ping_many / aping_many / is_reachable / ais_reachable
63
63
  traceroute traceroute / atraceroute / trace_path / atrace_path
64
64
  static_neighbours set_neighbour / remove_neighbour
@@ -72,8 +72,8 @@ transport_posix : transport_windows : transport_tcp
72
72
  : routes_linux : routes_macos : routes_windows
73
73
  : neighbours_linux : neighbours_macos : neighbours_windows
74
74
  : dhcp_linux : dhcp_windows : dhcp_macos
75
- packet : resolve : ports : winapi : netlink : bsdroute : arp : tcpsyn
76
- : pool : sockerr : unixio : bootp : bpf
75
+ packet : deadline : resolve : ports : winapi : netlink : bsdroute : arp : tcpsyn
76
+ : pool : sockerr : unixio : bootp : bpf : snmpber
77
77
  models
78
78
  errors
79
79
  ```
@@ -104,8 +104,11 @@ caller asked for and what the machine could do. A transport reports what happene
104
104
  ### Data flow, one ping
105
105
 
106
106
  1. `api.ping` resolves the target once through `resolve.resolve_one`, which fixes the address
107
- family up front rather than guessing it later.
108
- 2. `api._prepare` builds a `service.PingRequest`. Pydantic field bounds reject a bad `times`,
107
+ family up front rather than guessing it later. The async entry points (`aping`,
108
+ `ais_reachable`, `atraceroute`, and the hop names of `atrace_path`) run that lookup in a worker
109
+ thread, because `getaddrinfo` blocks for as long as the name server takes and one slow name on
110
+ the loop thread would stall every other probe in a sweep.
111
+ 2. `api._request` builds a `service.PingRequest`. Pydantic field bounds reject a bad `times`,
109
112
  `timeout` or `interval` here, before any packet moves, identically for every caller path.
110
113
  3. `factory.make_transport` picks the backend.
111
114
  4. `service.run_ping` sends the echoes, paced against the run's start so a slow reply does not push
@@ -198,42 +201,45 @@ own value only as a fallback, which keeps loopback and LAN timings meaningful.
198
201
 
199
202
  ### Public surface
200
203
 
201
- | Module | Role |
202
- |------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
203
- | `__init__.py` | Re-exports the public API so importers depend on the package, not its layout. |
204
- | `api.py` | `ping`, `aping`, `ping_many`, `aping_many`, `is_reachable`, `ais_reachable`. Owns the error contract. |
205
- | `traceroute.py` | `traceroute`, `atraceroute`, `trace_path`, `atrace_path`, and the capability refusal. |
206
- | `interfaces.py` | `local_interfaces`, dispatching to the per-OS backend. |
207
- | `routes.py` | `query_route`, `default_gateway`, dispatching to the per-OS backend. |
208
- | `neighbours.py` | `neighbours`, `lookup_mac`, `get_mac_address`, `resolve_active`, `normalise_mac`. |
209
- | `static_neighbours.py` | `set_neighbour`, `remove_neighbour`: write and delete a permanent IPv4 entry, choosing the interface and reading the result back. |
210
- | `scan.py` | `arp_scan`, `find_ip_by_mac`, `local_networks`, `sweep_scope`. Sweep, then read what the kernel learned. |
211
- | `subnet.py` | `subnet_info`, composing interfaces, routes and the stored lease. |
212
- | `portscan.py` | `scan_ports`, `ascan_ports`, `syn_scan`, `parse_ports`. |
213
- | `banner.py` | `grab_banners`, `agrab_banners`: connect, read the first line, say goodbye and close, all on one connection per host. |
214
- | `mtu.py` | `path_mtu`, by kernel query on Linux and by bisection elsewhere. |
215
- | `wol.py` | `wake_on_lan` and the magic packet it builds. |
216
- | `dhcp.py` | `observe_dhcp` and its session: the capture loop, the quiet window and the platform dispatch. |
217
- | `resolve.py` | `resolve`, `resolve_one` (returns a `ResolvedTarget` named tuple), `reverse_dns`, `family_of`. |
218
- | `factory.py` | `make_transport`, `make_async_transport`, `icmp_available`. The only `sys.platform` branch in the probing path. |
204
+ | Module | Role |
205
+ |------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
206
+ | `__init__.py` | Re-exports the public API so importers depend on the package, not its layout. |
207
+ | `api.py` | `ping`, `aping`, `ping_many`, `aping_many`, `is_reachable`, `ais_reachable`. Owns the error contract. |
208
+ | `traceroute.py` | `traceroute`, `atraceroute`, `trace_path`, `atrace_path`, and the capability refusal. |
209
+ | `interfaces.py` | `local_interfaces`, dispatching to the per-OS backend. |
210
+ | `routes.py` | `query_route`, `default_gateway`, dispatching to the per-OS backend. |
211
+ | `neighbours.py` | `neighbours`, `lookup_mac`, `get_mac_address`, `resolve_active`, `normalise_mac`, and `ask_or_read_cache`, the one rule for falling back from an active ask to the cache. |
212
+ | `static_neighbours.py` | `set_neighbour`, `remove_neighbour`: write and delete a permanent IPv4 entry, choosing the interface and reading the result back. |
213
+ | `scan.py` | `arp_scan`, `find_ip_by_mac`, `local_networks`, `sweep_scope`. Sweep, then read what the kernel learned. |
214
+ | `subnet.py` | `subnet_info`, composing interfaces, routes and the stored lease. |
215
+ | `portscan.py` | `scan_ports`, `ascan_ports`, `syn_scan`, `parse_ports`. |
216
+ | `banner.py` | `grab_banners`, `agrab_banners`: connect, read the first line, say goodbye and close, all on one connection per host. |
217
+ | `snmp.py` | `read_snmp_system`, `aread_snmp_system`: one SNMP GET of the system group per host on a connected UDP socket, re-asking under v1 without a value the agent lacks. |
218
+ | `mtu.py` | `path_mtu`, by kernel query on Linux and by bisection elsewhere. |
219
+ | `wol.py` | `wake_on_lan` and the magic packet it builds. |
220
+ | `dhcp.py` | `observe_dhcp` and its session: the capture loop, the quiet window and the platform dispatch. |
221
+ | `resolve.py` | `resolve`, `resolve_one` (returns a `ResolvedTarget` named tuple), `reverse_dns`, `family_of`. |
222
+ | `factory.py` | `make_transport`, `make_async_transport`, `icmp_available`. The only `sys.platform` branch in the probing path. |
219
223
 
220
224
  ### Core
221
225
 
222
- | Module | Role |
223
- |---------------|-------------------------------------------------------------------------------------------------------------------------|
224
- | `errors.py` | `IPScoutError` and its subclasses, sweep refusals included. No dependencies. |
225
- | `models.py` | Frozen result models and the `str`-subclass enums. |
226
- | `ports.py` | `EchoResult`, `EchoTransport`, `AsyncEchoTransport`. The seams. |
227
- | `packet.py` | ICMP encode and decode. Total functions over bytes: no sockets, no clock, no I/O. |
228
- | `service.py` | `PingRequest`, `run_ping`, `arun_ping`. Probe sequencing and aggregation. |
229
- | `netlink.py` | Message and attribute walkers and encoders, and the acknowledgement reader, for the Linux route and neighbour backends. |
230
- | `bsdroute.py` | Routing-message and `sockaddr` walkers and encoders for the macOS backends, plus `sysctl`. |
231
- | `arp.py` | ARP and NDP codecs. Testable without the privilege the sockets need. |
232
- | `bootp.py` | BOOTP/DHCP reply codec, separate from the capture for the same reason. |
233
- | `bpf.py` | BSD packet filter ioctl encoding and record splitting, shared by two backends. |
234
- | `tcpsyn.py` | TCP SYN codec and its pseudo-header checksum, for the half-open scan. |
235
- | `pool.py` | `gather_bounded`: a fixed worker pool, so a long work list never becomes one Task per item. |
236
- | `sockerr.py` | `is_refusal`: one reading of a refused connect across every platform's spelling, shared by every module that connects. |
226
+ | Module | Role |
227
+ |---------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
228
+ | `errors.py` | `IPScoutError` and its subclasses, sweep refusals included, and `could_not_ask` for a resolution request that could not be sent. No dependencies. |
229
+ | `models.py` | Frozen result models and the `str`-subclass enums. |
230
+ | `ports.py` | `EchoResult`, `EchoTransport`, `AsyncEchoTransport`. The seams. |
231
+ | `packet.py` | ICMP encode and decode. Total functions over bytes: no sockets, no clock, no I/O. |
232
+ | `service.py` | `PingRequest`, `run_ping`, `arun_ping`. Probe sequencing and aggregation. |
233
+ | `netlink.py` | Message and attribute walkers and encoders, and the acknowledgement reader, for the Linux route and neighbour backends. |
234
+ | `bsdroute.py` | Routing-message and `sockaddr` walkers and encoders for the macOS backends, plus `sysctl`. |
235
+ | `arp.py` | ARP and NDP codecs. Testable without the privilege the sockets need. |
236
+ | `bootp.py` | BOOTP/DHCP reply codec, separate from the capture for the same reason. |
237
+ | `bpf.py` | BSD packet filter ioctl encoding and record splitting, shared by two backends. |
238
+ | `tcpsyn.py` | TCP SYN codec and its pseudo-header checksum, for the half-open scan. |
239
+ | `deadline.py` | `within` and `failure_reason`: one deadline across several awaits, shared by the banner grab and the SNMP read. |
240
+ | `snmpber.py` | BER encode of an SNMP GET and a structural parse of the reply. Total functions over bytes, like `packet.py`. |
241
+ | `pool.py` | `gather_bounded`: a fixed worker pool, so a long work list never becomes one Task per item. |
242
+ | `sockerr.py` | `is_refusal`: one reading of a refused connect across every platform's spelling, shared by every module that connects. |
237
243
 
238
244
  ### Platform backends
239
245
 
@@ -258,13 +264,13 @@ own value only as a fallback, which keeps loopback and LAN timings meaningful.
258
264
 
259
265
  ### CLI adapter
260
266
 
261
- | Module | Role |
262
- |---------------------|----------------------------------------------------------------------------|
263
- | `cli.py` | The rich-click group, twenty-two subcommands, `_emit` and `_fail`, `main`. |
264
- | `serialize.py` | `to_jsonable` and `dumps` at the output boundary. |
265
- | `typed_click.py` | Typed facade over rich-click's partially-typed decorators. |
266
- | `__main__.py` | `python -m ipscout`. |
267
- | `__init__conf__.py` | Static package metadata, kept in sync with `pyproject.toml` by automation. |
267
+ | Module | Role |
268
+ |---------------------|------------------------------------------------------------------------------|
269
+ | `cli.py` | The rich-click group, twenty-three subcommands, `_emit` and `_fail`, `main`. |
270
+ | `serialize.py` | `to_jsonable` and `dumps` at the output boundary. |
271
+ | `typed_click.py` | Typed facade over rich-click's partially-typed decorators. |
272
+ | `__main__.py` | `python -m ipscout`. |
273
+ | `__init__conf__.py` | Static package metadata, kept in sync with `pyproject.toml` by automation. |
268
274
 
269
275
  ---
270
276
 
@@ -281,6 +287,8 @@ and mutating it can only make it disagree with reality.
281
287
  | `InterfaceAddress` | `address`, `prefix_len` |
282
288
  | `MacLookup` | `ip`, `mac`, `scope`, `via_ip`, `interface` |
283
289
  | `SubnetInfo` | `interface`, `address`, `prefix_len`, `network`, `family`, `broadcast`, `gateway`, `dns_servers`, `domain`, `dhcp_server`, `lease_obtained`, `lease_expires`, `mtu` |
290
+ | `Banner` | `host`, `port`, `state`, `line`, `error` |
291
+ | `SnmpSystem` | `host`, `state`, `uptime_seconds`, `name`, `description`, `object_id`, `error` |
284
292
  | `CapabilityReport` | `icmp_ipv4`, `icmp_ipv6`, `traceroute` |
285
293
  | `ReachabilityReport` | `target`, `reachable` |
286
294
  | `ResolveReport` | `target`, `addresses` |