ipscout 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ipscout/__init__.py +137 -0
- ipscout/__init__conf__.py +79 -0
- ipscout/__main__.py +18 -0
- ipscout/api.py +425 -0
- ipscout/arp.py +250 -0
- ipscout/bsdroute.py +260 -0
- ipscout/cli.py +765 -0
- ipscout/errors.py +95 -0
- ipscout/factory.py +129 -0
- ipscout/interfaces.py +47 -0
- ipscout/interfaces_posix.py +358 -0
- ipscout/interfaces_windows.py +223 -0
- ipscout/leases_linux.py +219 -0
- ipscout/models.py +564 -0
- ipscout/mtu.py +242 -0
- ipscout/neighbours.py +303 -0
- ipscout/neighbours_linux.py +352 -0
- ipscout/neighbours_macos.py +336 -0
- ipscout/neighbours_windows.py +243 -0
- ipscout/netlink.py +153 -0
- ipscout/packet.py +416 -0
- ipscout/pool.py +82 -0
- ipscout/ports.py +150 -0
- ipscout/portscan.py +387 -0
- ipscout/py.typed +0 -0
- ipscout/resolve.py +201 -0
- ipscout/routes.py +98 -0
- ipscout/routes_linux.py +217 -0
- ipscout/routes_macos.py +206 -0
- ipscout/routes_windows.py +175 -0
- ipscout/scan.py +168 -0
- ipscout/serialize.py +89 -0
- ipscout/service.py +165 -0
- ipscout/subnet.py +111 -0
- ipscout/tcpsyn.py +250 -0
- ipscout/traceroute.py +237 -0
- ipscout/transport_posix.py +621 -0
- ipscout/transport_tcp.py +211 -0
- ipscout/transport_windows.py +350 -0
- ipscout/typed_click.py +57 -0
- ipscout/unixio.py +50 -0
- ipscout/winapi.py +577 -0
- ipscout/wol.py +106 -0
- ipscout-1.0.0.dist-info/METADATA +338 -0
- ipscout-1.0.0.dist-info/RECORD +48 -0
- ipscout-1.0.0.dist-info/WHEEL +4 -0
- ipscout-1.0.0.dist-info/entry_points.txt +2 -0
- ipscout-1.0.0.dist-info/licenses/LICENSE +22 -0
ipscout/__init__.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""ipscout - network reachability and local-network inspection, without admin rights.
|
|
2
|
+
|
|
3
|
+
Probes hosts and inspects the local network entirely in-process: no subprocess
|
|
4
|
+
is ever spawned, and no administrator or root privileges are required for the
|
|
5
|
+
default paths.
|
|
6
|
+
|
|
7
|
+
How that is possible:
|
|
8
|
+
On Linux and macOS ICMP goes over ``SOCK_DGRAM``/``IPPROTO_ICMP``, the
|
|
9
|
+
unprivileged "ping socket", where the kernel handles the privileged part.
|
|
10
|
+
On Windows it goes through ``iphlpapi.dll`` via ctypes, which likewise
|
|
11
|
+
needs no elevation - unlike raw sockets, which do.
|
|
12
|
+
|
|
13
|
+
The error contract:
|
|
14
|
+
Setup problems raise; network conditions do not. A down host, a timeout, or
|
|
15
|
+
total packet loss all return a ``ResponseObject`` with ``reached=False``.
|
|
16
|
+
Missing permission, an unresolvable name, or an invalid argument raise.
|
|
17
|
+
``is_reachable`` is the deliberate exception - it never raises.
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
>>> import ipscout
|
|
21
|
+
>>> result = ipscout.ping("127.0.0.1", 2, interval=0)
|
|
22
|
+
>>> result.reached
|
|
23
|
+
True
|
|
24
|
+
>>> ipscout.is_reachable("127.0.0.1")
|
|
25
|
+
True
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from .__init__conf__ import print_info
|
|
32
|
+
from .api import (
|
|
33
|
+
ais_reachable,
|
|
34
|
+
aping,
|
|
35
|
+
aping_many,
|
|
36
|
+
is_reachable,
|
|
37
|
+
ping,
|
|
38
|
+
ping_many,
|
|
39
|
+
)
|
|
40
|
+
from .errors import (
|
|
41
|
+
IPScoutError,
|
|
42
|
+
IPScoutPermissionError,
|
|
43
|
+
IPScoutResolutionError,
|
|
44
|
+
IPScoutUnsupportedError,
|
|
45
|
+
)
|
|
46
|
+
from .factory import icmp_available
|
|
47
|
+
from .interfaces import local_interfaces
|
|
48
|
+
from .models import (
|
|
49
|
+
AddressFamily,
|
|
50
|
+
CapabilityReport,
|
|
51
|
+
CommandName,
|
|
52
|
+
Interface,
|
|
53
|
+
InterfaceAddress,
|
|
54
|
+
LeaseInfo,
|
|
55
|
+
MacLookup,
|
|
56
|
+
MacScope,
|
|
57
|
+
Neighbour,
|
|
58
|
+
NeighbourState,
|
|
59
|
+
PackageInfo,
|
|
60
|
+
PortState,
|
|
61
|
+
ProbeMethod,
|
|
62
|
+
ReachabilityReport,
|
|
63
|
+
ResolveReport,
|
|
64
|
+
ResponseObject,
|
|
65
|
+
ReverseDnsReport,
|
|
66
|
+
RouteInfo,
|
|
67
|
+
ScanMethod,
|
|
68
|
+
SubnetInfo,
|
|
69
|
+
TraceHop,
|
|
70
|
+
)
|
|
71
|
+
from .mtu import path_mtu
|
|
72
|
+
from .neighbours import get_mac_address, lookup_mac, neighbours, normalise_mac
|
|
73
|
+
from .portscan import ascan_ports, parse_ports, scan_ports
|
|
74
|
+
from .resolve import resolve, reverse_dns
|
|
75
|
+
from .routes import default_gateway, query_route
|
|
76
|
+
from .scan import arp_scan, find_ip_by_mac, local_networks
|
|
77
|
+
from .subnet import subnet_info
|
|
78
|
+
from .traceroute import atrace_path, atraceroute, trace_path, traceroute
|
|
79
|
+
from .wol import wake_on_lan
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"AddressFamily",
|
|
83
|
+
"CapabilityReport",
|
|
84
|
+
"CommandName",
|
|
85
|
+
"IPScoutError",
|
|
86
|
+
"IPScoutPermissionError",
|
|
87
|
+
"IPScoutResolutionError",
|
|
88
|
+
"IPScoutUnsupportedError",
|
|
89
|
+
"Interface",
|
|
90
|
+
"InterfaceAddress",
|
|
91
|
+
"LeaseInfo",
|
|
92
|
+
"MacLookup",
|
|
93
|
+
"MacScope",
|
|
94
|
+
"Neighbour",
|
|
95
|
+
"NeighbourState",
|
|
96
|
+
"PackageInfo",
|
|
97
|
+
"PortState",
|
|
98
|
+
"ProbeMethod",
|
|
99
|
+
"ReachabilityReport",
|
|
100
|
+
"ResolveReport",
|
|
101
|
+
"ResponseObject",
|
|
102
|
+
"ReverseDnsReport",
|
|
103
|
+
"RouteInfo",
|
|
104
|
+
"ScanMethod",
|
|
105
|
+
"SubnetInfo",
|
|
106
|
+
"TraceHop",
|
|
107
|
+
"ais_reachable",
|
|
108
|
+
"aping",
|
|
109
|
+
"aping_many",
|
|
110
|
+
"arp_scan",
|
|
111
|
+
"ascan_ports",
|
|
112
|
+
"atrace_path",
|
|
113
|
+
"atraceroute",
|
|
114
|
+
"default_gateway",
|
|
115
|
+
"find_ip_by_mac",
|
|
116
|
+
"get_mac_address",
|
|
117
|
+
"icmp_available",
|
|
118
|
+
"is_reachable",
|
|
119
|
+
"local_interfaces",
|
|
120
|
+
"local_networks",
|
|
121
|
+
"lookup_mac",
|
|
122
|
+
"neighbours",
|
|
123
|
+
"normalise_mac",
|
|
124
|
+
"parse_ports",
|
|
125
|
+
"path_mtu",
|
|
126
|
+
"ping",
|
|
127
|
+
"ping_many",
|
|
128
|
+
"print_info",
|
|
129
|
+
"query_route",
|
|
130
|
+
"resolve",
|
|
131
|
+
"reverse_dns",
|
|
132
|
+
"scan_ports",
|
|
133
|
+
"subnet_info",
|
|
134
|
+
"trace_path",
|
|
135
|
+
"traceroute",
|
|
136
|
+
"wake_on_lan",
|
|
137
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Static package metadata surfaced to CLI commands and documentation.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Expose the current project metadata as simple constants. These values are kept
|
|
6
|
+
in sync with ``pyproject.toml`` by development automation (tests, push
|
|
7
|
+
pipelines), so runtime code does not query packaging metadata.
|
|
8
|
+
|
|
9
|
+
Contents
|
|
10
|
+
--------
|
|
11
|
+
* Module-level constants describing the published package.
|
|
12
|
+
* :func:`print_info` rendering the constants for the CLI ``info`` command.
|
|
13
|
+
|
|
14
|
+
System Role
|
|
15
|
+
-----------
|
|
16
|
+
Lives in the adapters/platform layer; CLI transports import these constants to
|
|
17
|
+
present authoritative project information without invoking packaging APIs.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
#: Distribution name declared in ``pyproject.toml``.
|
|
25
|
+
name = "ipscout"
|
|
26
|
+
#: Human-readable summary shown in CLI help output.
|
|
27
|
+
title = "Ping, traceroute and local-network inspection in pure Python - no subprocess, no admin rights"
|
|
28
|
+
#: Current release version pulled from ``pyproject.toml`` by automation.
|
|
29
|
+
version = "1.0.0"
|
|
30
|
+
#: Repository homepage presented to users.
|
|
31
|
+
homepage = "https://github.com/bitranox/ipscout"
|
|
32
|
+
#: Author attribution surfaced in CLI output.
|
|
33
|
+
author = "bitranox"
|
|
34
|
+
#: Contact email surfaced in CLI output.
|
|
35
|
+
author_email = "bitranox@gmail.com"
|
|
36
|
+
#: Console-script name published by the package.
|
|
37
|
+
shell_command = "ipscout"
|
|
38
|
+
|
|
39
|
+
#: Vendor identifier for lib_layered_config paths (macOS/Windows)
|
|
40
|
+
LAYEREDCONF_VENDOR: str = "bitranox"
|
|
41
|
+
#: Application display name for lib_layered_config paths (macOS/Windows)
|
|
42
|
+
LAYEREDCONF_APP: str = "ipscout"
|
|
43
|
+
#: Configuration slug for lib_layered_config Linux paths and environment variables
|
|
44
|
+
LAYEREDCONF_SLUG: str = "ipscout"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_info() -> None:
|
|
48
|
+
"""Print the summarised metadata block used by the CLI ``info`` command.
|
|
49
|
+
|
|
50
|
+
Why
|
|
51
|
+
Provides a single, auditable rendering function so documentation and
|
|
52
|
+
CLI output always match the system design reference.
|
|
53
|
+
|
|
54
|
+
Side Effects
|
|
55
|
+
Writes to ``stdout``.
|
|
56
|
+
|
|
57
|
+
Examples
|
|
58
|
+
--------
|
|
59
|
+
>>> print_info() # doctest: +ELLIPSIS
|
|
60
|
+
Info for ipscout:
|
|
61
|
+
...
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
fields = [
|
|
65
|
+
("name", name),
|
|
66
|
+
("title", title),
|
|
67
|
+
("version", version),
|
|
68
|
+
("homepage", homepage),
|
|
69
|
+
("author", author),
|
|
70
|
+
("author_email", author_email),
|
|
71
|
+
("shell_command", shell_command),
|
|
72
|
+
]
|
|
73
|
+
pad = max(len(label) for label, _ in fields)
|
|
74
|
+
lines = [f"Info for {name}:", ""]
|
|
75
|
+
lines.extend(f" {label.ljust(pad)} = {value}" for label, value in fields)
|
|
76
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["print_info"]
|
ipscout/__main__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Module entry point for python -m execution.
|
|
2
|
+
|
|
3
|
+
Provide the ``python -m ipscout`` path mandated by the
|
|
4
|
+
project's packaging guidelines. This delegates to the CLI main function
|
|
5
|
+
ensuring consistent behavior between module execution and console scripts.
|
|
6
|
+
|
|
7
|
+
Note:
|
|
8
|
+
Lives in the adapters layer. It bridges CPython's module execution entry
|
|
9
|
+
point to the shared CLI helper defined in cli.py.
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .cli import main
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
raise SystemExit(main())
|
ipscout/api.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""The public callables. Everything a user of this library normally touches.
|
|
2
|
+
|
|
3
|
+
This module owns the error contract, because it is the only layer that knows
|
|
4
|
+
both what the caller asked for and what the machine could actually do.
|
|
5
|
+
|
|
6
|
+
The contract, in one sentence: **setup problems raise, network conditions do
|
|
7
|
+
not.** A host that is down, a packet that times out, a link losing everything -
|
|
8
|
+
all come back as a ``ResponseObject`` with ``reached=False``. Missing ICMP
|
|
9
|
+
permission, an unresolvable name, or a nonsensical argument raise, because no
|
|
10
|
+
amount of retrying fixes them and reporting them as "unreachable" sends people
|
|
11
|
+
hunting for network faults that do not exist.
|
|
12
|
+
|
|
13
|
+
Contents:
|
|
14
|
+
ping / aping: Probe one target.
|
|
15
|
+
ping_many / aping_many: Probe many, with bounded concurrency.
|
|
16
|
+
is_reachable / ais_reachable: The deliberately total convenience shortcut.
|
|
17
|
+
|
|
18
|
+
Note:
|
|
19
|
+
``is_reachable`` is the one exception to the contract above, on purpose:
|
|
20
|
+
it never raises and it always falls back to TCP. That is what makes it a
|
|
21
|
+
shortcut. It is documented on its own docstring so nobody mistakes it for
|
|
22
|
+
``ping(...).reached``.
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
from .errors import IPScoutError
|
|
32
|
+
from .factory import icmp_available, make_async_transport, make_transport
|
|
33
|
+
from .models import AddressFamily, ProbeMethod, ResponseObject
|
|
34
|
+
from .pool import gather_bounded
|
|
35
|
+
from .resolve import resolve_one
|
|
36
|
+
from .service import PingRequest, arun_ping, run_ping
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from collections.abc import Iterable
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"ais_reachable",
|
|
43
|
+
"aping",
|
|
44
|
+
"aping_many",
|
|
45
|
+
"is_reachable",
|
|
46
|
+
"ping",
|
|
47
|
+
"ping_many",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
#: Default concurrency for a sweep. High enough to be useful, low enough not to
|
|
51
|
+
#: exhaust file descriptors on a default ulimit.
|
|
52
|
+
DEFAULT_CONCURRENCY = 64
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _prepare( # noqa: PLR0913 - public API: every knob is keyword-only and independently useful
|
|
56
|
+
target: str,
|
|
57
|
+
times: int,
|
|
58
|
+
timeout: float,
|
|
59
|
+
interval: float,
|
|
60
|
+
family: AddressFamily | None,
|
|
61
|
+
*,
|
|
62
|
+
use_tcp: bool,
|
|
63
|
+
) -> PingRequest:
|
|
64
|
+
"""Resolve the target and validate the parameters into a request."""
|
|
65
|
+
|
|
66
|
+
address, resolved_family = resolve_one(target, family=family)
|
|
67
|
+
return PingRequest(
|
|
68
|
+
target=target,
|
|
69
|
+
address=address,
|
|
70
|
+
family=resolved_family,
|
|
71
|
+
times=times,
|
|
72
|
+
timeout=timeout,
|
|
73
|
+
interval=interval,
|
|
74
|
+
method=ProbeMethod.TCP if use_tcp else ProbeMethod.ICMP,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _suppressed(target: str, times: int, exc: Exception) -> ResponseObject:
|
|
79
|
+
"""Return the unreached result used when ``raise_on_error=False``."""
|
|
80
|
+
|
|
81
|
+
return ResponseObject(target=target, number_of_pings=times, error=str(exc))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ping( # noqa: PLR0913 - public API: every knob is keyword-only and independently useful
|
|
85
|
+
target: str,
|
|
86
|
+
times: int = 4,
|
|
87
|
+
*,
|
|
88
|
+
timeout: float = 2.0,
|
|
89
|
+
interval: float = 0.2,
|
|
90
|
+
family: AddressFamily | None = None,
|
|
91
|
+
payload_size: int = 56,
|
|
92
|
+
allow_tcp_fallback: bool = False,
|
|
93
|
+
tcp_port: int = 443,
|
|
94
|
+
raise_on_error: bool = True,
|
|
95
|
+
) -> ResponseObject:
|
|
96
|
+
"""Ping a target and report what came back.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
target: Hostname or IP address literal.
|
|
100
|
+
times: How many echoes to send.
|
|
101
|
+
timeout: Seconds to wait for each reply.
|
|
102
|
+
interval: Seconds between the start of consecutive echoes.
|
|
103
|
+
family: Force IPv4 or IPv6. ``None`` takes the resolver's preference.
|
|
104
|
+
payload_size: Bytes of ICMP payload per echo.
|
|
105
|
+
allow_tcp_fallback: Probe over TCP when ICMP is unavailable. Off by
|
|
106
|
+
default: a TCP result is not an ICMP result, and substituting one
|
|
107
|
+
silently would misreport a filtered port as a dead host.
|
|
108
|
+
tcp_port: Port used only when the TCP fallback is engaged.
|
|
109
|
+
raise_on_error: Keep the strict contract. Set False to report every
|
|
110
|
+
failure as ``reached=False`` with the reason on ``.error``
|
|
111
|
+
instead.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The result. Unreachable targets, timeouts and total loss are all
|
|
115
|
+
reported here rather than raised.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
IPScoutResolutionError: The target does not resolve.
|
|
119
|
+
IPScoutPermissionError: ICMP is unavailable and no fallback was allowed.
|
|
120
|
+
IPScoutUnsupportedError: No ICMP backend exists for this platform.
|
|
121
|
+
ValueError: ``times``, ``timeout`` or ``interval`` is out of range.
|
|
122
|
+
|
|
123
|
+
Examples:
|
|
124
|
+
>>> result = ping("127.0.0.1", 2, interval=0)
|
|
125
|
+
>>> result.reached, result.packets_received
|
|
126
|
+
(True, 2)
|
|
127
|
+
>>> result.ip
|
|
128
|
+
'127.0.0.1'
|
|
129
|
+
|
|
130
|
+
A name that does not resolve raises rather than reading as "down":
|
|
131
|
+
|
|
132
|
+
>>> ping("nothing.invalid")
|
|
133
|
+
Traceback (most recent call last):
|
|
134
|
+
...
|
|
135
|
+
ipscout.errors.IPScoutResolutionError: cannot resolve 'nothing.invalid': ...
|
|
136
|
+
|
|
137
|
+
Unless the caller asks for failures to be reported rather than raised:
|
|
138
|
+
|
|
139
|
+
>>> muted = ping("nothing.invalid", raise_on_error=False)
|
|
140
|
+
>>> muted.reached, muted.error is None
|
|
141
|
+
(False, False)
|
|
142
|
+
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
use_tcp = allow_tcp_fallback and not icmp_available(family or AddressFamily.IPV4)
|
|
147
|
+
request = _prepare(target, times, timeout, interval, family, use_tcp=use_tcp)
|
|
148
|
+
with make_transport(
|
|
149
|
+
request.address,
|
|
150
|
+
request.family,
|
|
151
|
+
payload_size=payload_size,
|
|
152
|
+
use_tcp=use_tcp,
|
|
153
|
+
tcp_port=tcp_port,
|
|
154
|
+
) as transport:
|
|
155
|
+
return run_ping(request, transport)
|
|
156
|
+
except (IPScoutError, ValueError) as exc:
|
|
157
|
+
if raise_on_error:
|
|
158
|
+
raise
|
|
159
|
+
return _suppressed(target, times, exc)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def aping( # noqa: PLR0913 - public API: every knob is keyword-only and independently useful
|
|
163
|
+
target: str,
|
|
164
|
+
times: int = 4,
|
|
165
|
+
*,
|
|
166
|
+
timeout: float = 2.0,
|
|
167
|
+
interval: float = 0.2,
|
|
168
|
+
family: AddressFamily | None = None,
|
|
169
|
+
payload_size: int = 56,
|
|
170
|
+
allow_tcp_fallback: bool = False,
|
|
171
|
+
tcp_port: int = 443,
|
|
172
|
+
raise_on_error: bool = True,
|
|
173
|
+
) -> ResponseObject:
|
|
174
|
+
"""Ping a target without blocking the event loop.
|
|
175
|
+
|
|
176
|
+
Takes the same arguments as :func:`ping` and honours the same contract.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
The result.
|
|
180
|
+
|
|
181
|
+
Note:
|
|
182
|
+
Genuinely asynchronous on Linux and macOS, where the ICMP socket is
|
|
183
|
+
registered with the event loop. On Windows the underlying
|
|
184
|
+
``IcmpSendEcho`` is a blocking C call with no asyncio integration, so
|
|
185
|
+
it runs in a bounded thread pool instead. The behaviour is identical;
|
|
186
|
+
the scaling characteristics are not.
|
|
187
|
+
|
|
188
|
+
Examples:
|
|
189
|
+
>>> import asyncio
|
|
190
|
+
>>> result = asyncio.run(aping("127.0.0.1", 2, interval=0))
|
|
191
|
+
>>> result.reached
|
|
192
|
+
True
|
|
193
|
+
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
use_tcp = allow_tcp_fallback and not icmp_available(family or AddressFamily.IPV4)
|
|
198
|
+
request = _prepare(target, times, timeout, interval, family, use_tcp=use_tcp)
|
|
199
|
+
transport = make_async_transport(
|
|
200
|
+
request.address,
|
|
201
|
+
request.family,
|
|
202
|
+
payload_size=payload_size,
|
|
203
|
+
use_tcp=use_tcp,
|
|
204
|
+
tcp_port=tcp_port,
|
|
205
|
+
)
|
|
206
|
+
async with transport:
|
|
207
|
+
return await arun_ping(request, transport)
|
|
208
|
+
except (IPScoutError, ValueError) as exc:
|
|
209
|
+
if raise_on_error:
|
|
210
|
+
raise
|
|
211
|
+
return _suppressed(target, times, exc)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def aping_many( # noqa: PLR0913 - public API: every knob is keyword-only and independently useful
|
|
215
|
+
targets: Iterable[str],
|
|
216
|
+
*,
|
|
217
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
218
|
+
times: int = 4,
|
|
219
|
+
timeout: float = 2.0,
|
|
220
|
+
interval: float = 0.2,
|
|
221
|
+
family: AddressFamily | None = None,
|
|
222
|
+
payload_size: int = 56,
|
|
223
|
+
allow_tcp_fallback: bool = False,
|
|
224
|
+
tcp_port: int = 443,
|
|
225
|
+
raise_on_error: bool = False,
|
|
226
|
+
) -> dict[str, ResponseObject]:
|
|
227
|
+
"""Ping many targets concurrently.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
targets: The targets. Duplicates are collapsed, since the result is
|
|
231
|
+
keyed by target and a repeat could only overwrite itself.
|
|
232
|
+
concurrency: Maximum probes in flight at once.
|
|
233
|
+
times: Echoes per target.
|
|
234
|
+
timeout: Seconds to wait for each reply.
|
|
235
|
+
interval: Seconds between one target's consecutive echoes.
|
|
236
|
+
family: Force IPv4 or IPv6.
|
|
237
|
+
payload_size: Bytes of ICMP payload per echo.
|
|
238
|
+
allow_tcp_fallback: Allow the TCP probe when ICMP is unavailable.
|
|
239
|
+
tcp_port: Port used only when the TCP fallback is engaged.
|
|
240
|
+
raise_on_error: Defaults to **False** here, unlike :func:`ping`. In a
|
|
241
|
+
sweep one bad name among two hundred should not destroy the other
|
|
242
|
+
199 results; the failure is reported on that target's own
|
|
243
|
+
``.error`` instead. Set True to make any single failure raise.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
A mapping of target to result, in the order the targets were given.
|
|
247
|
+
|
|
248
|
+
Examples:
|
|
249
|
+
>>> import asyncio
|
|
250
|
+
>>> results = asyncio.run(aping_many(["127.0.0.1", "::1"], times=1))
|
|
251
|
+
>>> sorted(results)
|
|
252
|
+
['127.0.0.1', '::1']
|
|
253
|
+
>>> all(r.reached for r in results.values())
|
|
254
|
+
True
|
|
255
|
+
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
unique = list(dict.fromkeys(targets))
|
|
259
|
+
|
|
260
|
+
async def one(target: str) -> tuple[str, ResponseObject]:
|
|
261
|
+
# Returns the target with its result: the pool completes out of order,
|
|
262
|
+
# so pairing by position afterwards would attribute results to the
|
|
263
|
+
# wrong hosts.
|
|
264
|
+
result = await aping(
|
|
265
|
+
target,
|
|
266
|
+
times,
|
|
267
|
+
timeout=timeout,
|
|
268
|
+
interval=interval,
|
|
269
|
+
family=family,
|
|
270
|
+
payload_size=payload_size,
|
|
271
|
+
allow_tcp_fallback=allow_tcp_fallback,
|
|
272
|
+
tcp_port=tcp_port,
|
|
273
|
+
raise_on_error=raise_on_error,
|
|
274
|
+
)
|
|
275
|
+
return target, result
|
|
276
|
+
|
|
277
|
+
# A worker pool rather than a semaphore inside a gather over everything:
|
|
278
|
+
# the semaphore bounds probes in flight while still creating one Task per
|
|
279
|
+
# target up front, so a sweep of a large subnet holds them all at once.
|
|
280
|
+
completed = await gather_bounded(unique, one, concurrency)
|
|
281
|
+
results = dict(completed)
|
|
282
|
+
if len(results) != len(unique): # pragma: no cover - a lost result is a bug, not a condition
|
|
283
|
+
msg = f"sweep returned {len(results)} results for {len(unique)} targets"
|
|
284
|
+
raise RuntimeError(msg)
|
|
285
|
+
return results
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def ping_many( # noqa: PLR0913 - public API: every knob is keyword-only and independently useful
|
|
289
|
+
targets: Iterable[str],
|
|
290
|
+
*,
|
|
291
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
292
|
+
times: int = 4,
|
|
293
|
+
timeout: float = 2.0,
|
|
294
|
+
interval: float = 0.2,
|
|
295
|
+
family: AddressFamily | None = None,
|
|
296
|
+
payload_size: int = 56,
|
|
297
|
+
allow_tcp_fallback: bool = False,
|
|
298
|
+
tcp_port: int = 443,
|
|
299
|
+
raise_on_error: bool = False,
|
|
300
|
+
) -> dict[str, ResponseObject]:
|
|
301
|
+
"""Ping many targets concurrently from synchronous code.
|
|
302
|
+
|
|
303
|
+
Takes the same arguments as :func:`aping_many`.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
A mapping of target to result, in the order the targets were given.
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
RuntimeError: Called from inside a running event loop, where it would
|
|
310
|
+
deadlock. Await :func:`aping_many` there instead. Failing loudly
|
|
311
|
+
beats hanging.
|
|
312
|
+
|
|
313
|
+
Examples:
|
|
314
|
+
>>> results = ping_many(["127.0.0.1", "::1"], times=1)
|
|
315
|
+
>>> sorted(results)
|
|
316
|
+
['127.0.0.1', '::1']
|
|
317
|
+
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
asyncio.get_running_loop()
|
|
322
|
+
except RuntimeError:
|
|
323
|
+
pass
|
|
324
|
+
else:
|
|
325
|
+
msg = "ping_many() cannot run inside an event loop; await aping_many() instead"
|
|
326
|
+
raise RuntimeError(msg)
|
|
327
|
+
|
|
328
|
+
return asyncio.run(
|
|
329
|
+
aping_many(
|
|
330
|
+
targets,
|
|
331
|
+
concurrency=concurrency,
|
|
332
|
+
times=times,
|
|
333
|
+
timeout=timeout,
|
|
334
|
+
interval=interval,
|
|
335
|
+
family=family,
|
|
336
|
+
payload_size=payload_size,
|
|
337
|
+
allow_tcp_fallback=allow_tcp_fallback,
|
|
338
|
+
tcp_port=tcp_port,
|
|
339
|
+
raise_on_error=raise_on_error,
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def is_reachable(
|
|
345
|
+
target: str,
|
|
346
|
+
*,
|
|
347
|
+
timeout: float = 2.0,
|
|
348
|
+
tcp_port: int = 443,
|
|
349
|
+
family: AddressFamily | None = None,
|
|
350
|
+
) -> bool:
|
|
351
|
+
"""Return whether a target answers, by any means available.
|
|
352
|
+
|
|
353
|
+
The deliberate exception to this module's error contract. It **never
|
|
354
|
+
raises** and it **always** tries TCP when ICMP does not answer or is
|
|
355
|
+
unavailable. That is the entire point of the shortcut.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
target: Hostname or IP address literal.
|
|
359
|
+
timeout: Seconds to allow for each attempt.
|
|
360
|
+
tcp_port: Port for the TCP attempt.
|
|
361
|
+
family: Force IPv4 or IPv6.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
True if anything answered.
|
|
365
|
+
|
|
366
|
+
Note:
|
|
367
|
+
This is not ``ping(target).reached``. That reports whether *ICMP*
|
|
368
|
+
succeeded and raises on a bad name; this reports whether the host is
|
|
369
|
+
alive by any route and answers False for a bad name. Use ``ping`` when
|
|
370
|
+
the distinction matters, and this when it does not.
|
|
371
|
+
|
|
372
|
+
Examples:
|
|
373
|
+
>>> is_reachable("127.0.0.1")
|
|
374
|
+
True
|
|
375
|
+
>>> is_reachable("nothing.invalid")
|
|
376
|
+
False
|
|
377
|
+
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
if ping(target, 1, timeout=timeout, interval=0, family=family, raise_on_error=False).reached:
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
# Fall through to TCP unconditionally. Going via ping(allow_tcp_fallback=True)
|
|
384
|
+
# would not do it: that only substitutes TCP when ICMP is *unavailable*, and
|
|
385
|
+
# the common case here is ICMP being available but the target not answering it.
|
|
386
|
+
try:
|
|
387
|
+
address, resolved = resolve_one(target, family=family)
|
|
388
|
+
with make_transport(address, resolved, use_tcp=True, tcp_port=tcp_port) as transport:
|
|
389
|
+
return transport.probe(sequence=1, timeout=timeout).answered
|
|
390
|
+
except (IPScoutError, ValueError, OSError):
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def ais_reachable(
|
|
395
|
+
target: str,
|
|
396
|
+
*,
|
|
397
|
+
timeout: float = 2.0,
|
|
398
|
+
tcp_port: int = 443,
|
|
399
|
+
family: AddressFamily | None = None,
|
|
400
|
+
) -> bool:
|
|
401
|
+
"""Return whether a target answers, without blocking the event loop.
|
|
402
|
+
|
|
403
|
+
Same total, never-raising contract as :func:`is_reachable`.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
True if anything answered.
|
|
407
|
+
|
|
408
|
+
Examples:
|
|
409
|
+
>>> import asyncio
|
|
410
|
+
>>> asyncio.run(ais_reachable("127.0.0.1"))
|
|
411
|
+
True
|
|
412
|
+
|
|
413
|
+
"""
|
|
414
|
+
|
|
415
|
+
icmp = await aping(target, 1, timeout=timeout, interval=0, family=family, raise_on_error=False)
|
|
416
|
+
if icmp.reached:
|
|
417
|
+
return True
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
address, resolved = resolve_one(target, family=family)
|
|
421
|
+
transport = make_async_transport(address, resolved, use_tcp=True, tcp_port=tcp_port)
|
|
422
|
+
async with transport:
|
|
423
|
+
return (await transport.probe(sequence=1, timeout=timeout)).answered
|
|
424
|
+
except (IPScoutError, ValueError, OSError):
|
|
425
|
+
return False
|