optforge 0.2.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.
- optforge/__init__.py +17 -0
- optforge/__main__.py +6 -0
- optforge/cli.py +56 -0
- optforge/core/__init__.py +24 -0
- optforge/core/cli.py +56 -0
- optforge/core/console.py +83 -0
- optforge/core/errors.py +28 -0
- optforge/core/formatting.py +26 -0
- optforge/core/net.py +87 -0
- optforge/core/processes.py +38 -0
- optforge/core/redaction.py +41 -0
- optforge/core/version.py +15 -0
- optforge/diskcheck/__init__.py +6 -0
- optforge/diskcheck/cli.py +113 -0
- optforge/diskcheck/models.py +31 -0
- optforge/diskcheck/service.py +56 -0
- optforge/envcheck/__init__.py +6 -0
- optforge/envcheck/cli.py +102 -0
- optforge/envcheck/models.py +33 -0
- optforge/envcheck/service.py +66 -0
- optforge/killNotif/cli.py +110 -0
- optforge/killNotif/core.py +24 -0
- optforge/killNotif/job.ps1 +52 -0
- optforge/portcheck/__init__.py +14 -0
- optforge/portcheck/binder.py +72 -0
- optforge/portcheck/cli.py +248 -0
- optforge/portcheck/connector.py +60 -0
- optforge/portcheck/models.py +117 -0
- optforge/portcheck/scanner.py +76 -0
- optforge/portcheck/service.py +109 -0
- optforge/portcheck/waiter.py +54 -0
- optforge/pycacheClean/__init__.py +6 -0
- optforge/pycacheClean/cleaner.py +43 -0
- optforge/pycacheClean/cli.py +127 -0
- optforge/pycacheClean/models.py +55 -0
- optforge/pycacheClean/scanner.py +122 -0
- optforge/pycacheClean/service.py +79 -0
- optforge/volume/__init__.py +7 -0
- optforge/volume/cli.py +224 -0
- optforge/volume/core.py +280 -0
- optforge-0.2.0.dist-info/METADATA +322 -0
- optforge-0.2.0.dist-info/RECORD +45 -0
- optforge-0.2.0.dist-info/WHEEL +4 -0
- optforge-0.2.0.dist-info/entry_points.txt +9 -0
- optforge-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""``portcheck``: inspect listening ports, test bindability, check connectivity.
|
|
2
|
+
|
|
3
|
+
Exit codes
|
|
4
|
+
0 the answer is yes (free / found / connected / reached)
|
|
5
|
+
1 the answer is no (in use / missing / unreachable / timed out)
|
|
6
|
+
2 invalid usage (raised by Typer)
|
|
7
|
+
3 the check could not run (permission denied, OS error)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager, nullcontext
|
|
14
|
+
from typing import Annotated, Any
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from optforge.core.cli import ErrorBoundary, ExitCode
|
|
20
|
+
from optforge.core.console import ConsoleIO
|
|
21
|
+
from optforge.core.version import get_version
|
|
22
|
+
from optforge.portcheck.models import Listener, PortState, SortKey
|
|
23
|
+
from optforge.portcheck.service import DEFAULT_HOST, PortService
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="portcheck",
|
|
27
|
+
help="Inspect listening ports, find their owners, and test connectivity.",
|
|
28
|
+
epilog="Exit codes: 0 yes/ok | 1 no | 2 bad usage | 3 could not run",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
rich_markup_mode="rich",
|
|
31
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
32
|
+
)
|
|
33
|
+
console = ConsoleIO()
|
|
34
|
+
_PERMISSION_HINT = "Run as Administrator (Windows) or with sudo (Linux/macOS)."
|
|
35
|
+
|
|
36
|
+
PortArg = Annotated[
|
|
37
|
+
int,
|
|
38
|
+
typer.Argument(min=1, max=65535, metavar="PORT", show_default=False, help="Port number (1-65535)."),
|
|
39
|
+
]
|
|
40
|
+
HostOpt = Annotated[str, typer.Option("--host", "-H", help="Address used for bind and connect probes.")]
|
|
41
|
+
QuietOpt = Annotated[bool, typer.Option("--quiet", "-q", help="Print nothing; use the exit code only.")]
|
|
42
|
+
JsonOpt = Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _service() -> PortService:
|
|
46
|
+
"""Construct the service lazily so ``--help`` and ``--version`` stay instant."""
|
|
47
|
+
return PortService()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _version_callback(value: bool) -> None:
|
|
51
|
+
if value:
|
|
52
|
+
typer.echo(f"portcheck (optforge {get_version()})")
|
|
53
|
+
raise typer.Exit()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@contextmanager
|
|
57
|
+
def _guard() -> Iterator[None]:
|
|
58
|
+
with ErrorBoundary(console, permission_hint=_PERMISSION_HINT):
|
|
59
|
+
yield
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _scope_text(scope: str) -> Text:
|
|
63
|
+
style = {"all interfaces": "yellow", "local only": "green"}.get(scope, "")
|
|
64
|
+
return Text(scope, style=style)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _table(rows: list[Listener], title: str | None) -> Any:
|
|
68
|
+
table = console.table(
|
|
69
|
+
title,
|
|
70
|
+
[
|
|
71
|
+
("Port", {"justify": "right", "style": "cyan", "no_wrap": True}),
|
|
72
|
+
("Address", {"no_wrap": True}),
|
|
73
|
+
("Scope", {"no_wrap": True}),
|
|
74
|
+
("PID", {"justify": "right", "no_wrap": True}),
|
|
75
|
+
("Process", {}),
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
for row in rows:
|
|
79
|
+
table.add_row(
|
|
80
|
+
str(row.port),
|
|
81
|
+
console.cell(row.address),
|
|
82
|
+
_scope_text(row.scope),
|
|
83
|
+
"-" if row.pid is None else str(row.pid),
|
|
84
|
+
console.cell(row.process),
|
|
85
|
+
)
|
|
86
|
+
return table
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _hint_hidden(rows: list[Listener]) -> None:
|
|
90
|
+
if any(row.pid is None for row in rows):
|
|
91
|
+
console.hint("Some process details are hidden. Run as Administrator / sudo to see them.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.callback()
|
|
95
|
+
def main(
|
|
96
|
+
version: Annotated[
|
|
97
|
+
bool | None,
|
|
98
|
+
typer.Option(
|
|
99
|
+
"--version",
|
|
100
|
+
"-V",
|
|
101
|
+
callback=_version_callback,
|
|
102
|
+
is_eager=True,
|
|
103
|
+
help="Show the version and exit.",
|
|
104
|
+
),
|
|
105
|
+
] = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command("list")
|
|
111
|
+
def list_ports(
|
|
112
|
+
sort: Annotated[
|
|
113
|
+
SortKey,
|
|
114
|
+
typer.Option("--sort", "-s", case_sensitive=False, help="Column to sort by."),
|
|
115
|
+
] = SortKey.port,
|
|
116
|
+
limit: Annotated[
|
|
117
|
+
int | None, typer.Option("--limit", "-n", min=1, help="Show at most N rows.")
|
|
118
|
+
] = None,
|
|
119
|
+
process: Annotated[
|
|
120
|
+
str | None,
|
|
121
|
+
typer.Option("--process", "-p", metavar="TEXT", help="Only rows whose process name contains TEXT."),
|
|
122
|
+
] = None,
|
|
123
|
+
exposed: Annotated[
|
|
124
|
+
bool, typer.Option("--exposed", help="Only ports reachable from other machines.")
|
|
125
|
+
] = False,
|
|
126
|
+
as_json: JsonOpt = False,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""List listening TCP ports."""
|
|
129
|
+
with _guard():
|
|
130
|
+
rows = _service().query(sort=sort, limit=limit, process=process, exposed=exposed)
|
|
131
|
+
|
|
132
|
+
if as_json:
|
|
133
|
+
console.json([row.to_dict() for row in rows])
|
|
134
|
+
return
|
|
135
|
+
if not rows:
|
|
136
|
+
console.text("No listening TCP ports match.")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
console.print(_table(rows, "Listening TCP ports"))
|
|
140
|
+
_hint_hidden(rows)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command()
|
|
144
|
+
def check(
|
|
145
|
+
port: PortArg,
|
|
146
|
+
host: HostOpt = DEFAULT_HOST,
|
|
147
|
+
quiet: QuietOpt = False,
|
|
148
|
+
as_json: JsonOpt = False,
|
|
149
|
+
expect: Annotated[
|
|
150
|
+
PortState,
|
|
151
|
+
typer.Option("--expect", case_sensitive=False, help="Exit 0 only if the port matches this state."),
|
|
152
|
+
] = PortState.free,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Check whether a port is free or in use."""
|
|
155
|
+
with _guard():
|
|
156
|
+
result = _service().check(port, host)
|
|
157
|
+
|
|
158
|
+
if as_json:
|
|
159
|
+
console.json(result.to_dict())
|
|
160
|
+
elif not quiet:
|
|
161
|
+
if result.in_use:
|
|
162
|
+
console.success(f"Port {port} is in use.")
|
|
163
|
+
if result.owners:
|
|
164
|
+
console.print(_table(result.owners, None))
|
|
165
|
+
_hint_hidden(result.owners)
|
|
166
|
+
else:
|
|
167
|
+
console.success(f"Port {port} is free on {host}.")
|
|
168
|
+
|
|
169
|
+
matched = result.in_use if expect is PortState.used else not result.in_use
|
|
170
|
+
raise typer.Exit(ExitCode.OK if matched else ExitCode.NO)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command()
|
|
174
|
+
def find(port: PortArg, as_json: JsonOpt = False) -> None:
|
|
175
|
+
"""Show the process listening on a port."""
|
|
176
|
+
with _guard():
|
|
177
|
+
owners = _service().find(port)
|
|
178
|
+
|
|
179
|
+
if as_json:
|
|
180
|
+
console.json([owner.to_dict() for owner in owners])
|
|
181
|
+
elif owners:
|
|
182
|
+
console.print(_table(owners, f"Port {port}"))
|
|
183
|
+
_hint_hidden(owners)
|
|
184
|
+
else:
|
|
185
|
+
console.text(f"Nothing is listening on port {port}.")
|
|
186
|
+
raise typer.Exit(ExitCode.OK if owners else ExitCode.NO)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
def connect(
|
|
191
|
+
host: Annotated[str, typer.Argument(metavar="HOST", help="Hostname or IP address.")],
|
|
192
|
+
port: PortArg,
|
|
193
|
+
timeout: Annotated[
|
|
194
|
+
float, typer.Option("--timeout", "-t", min=0.1, help="Seconds to wait.")
|
|
195
|
+
] = 3.0,
|
|
196
|
+
quiet: QuietOpt = False,
|
|
197
|
+
as_json: JsonOpt = False,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Test a TCP connection to a host and port."""
|
|
200
|
+
with _guard():
|
|
201
|
+
result = _service().connect(host, port, timeout)
|
|
202
|
+
|
|
203
|
+
if as_json:
|
|
204
|
+
console.json({"host": host, "port": port, **result.to_dict()})
|
|
205
|
+
elif not quiet:
|
|
206
|
+
target = f"{host}:{port}"
|
|
207
|
+
if result.ok:
|
|
208
|
+
took = f" in {result.seconds * 1000:.0f} ms" if result.seconds is not None else ""
|
|
209
|
+
console.success(f"Connected to {target}{took}.")
|
|
210
|
+
else:
|
|
211
|
+
console.error(f"Could not connect to {target}: {result.error or 'unknown error'}")
|
|
212
|
+
raise typer.Exit(ExitCode.OK if result.ok else ExitCode.NO)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command()
|
|
216
|
+
def wait(
|
|
217
|
+
port: PortArg,
|
|
218
|
+
until: Annotated[
|
|
219
|
+
PortState,
|
|
220
|
+
typer.Option("--until", "-u", case_sensitive=False, help="Wait until the port is in use or free."),
|
|
221
|
+
] = PortState.used,
|
|
222
|
+
timeout: Annotated[
|
|
223
|
+
float, typer.Option("--timeout", "-t", min=0.1, help="Give up after N seconds.")
|
|
224
|
+
] = 30.0,
|
|
225
|
+
interval: Annotated[
|
|
226
|
+
float, typer.Option("--interval", "-i", min=0.05, help="Seconds between checks.")
|
|
227
|
+
] = 0.5,
|
|
228
|
+
host: HostOpt = DEFAULT_HOST,
|
|
229
|
+
quiet: QuietOpt = False,
|
|
230
|
+
as_json: JsonOpt = False,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Wait until a port is in use or free."""
|
|
233
|
+
spinner = nullcontext() if quiet or as_json else console.status(f"Waiting for port {port} to be {until.value}...")
|
|
234
|
+
with _guard(), spinner:
|
|
235
|
+
result = _service().wait(port, until, timeout=timeout, interval=interval, host=host)
|
|
236
|
+
|
|
237
|
+
if as_json:
|
|
238
|
+
console.json(result.to_dict())
|
|
239
|
+
elif not quiet:
|
|
240
|
+
if result.reached:
|
|
241
|
+
console.success(f"Port {port} is {until.value}.")
|
|
242
|
+
else:
|
|
243
|
+
console.error(f"Timed out after {timeout:g}s waiting for port {port} to be {until.value}.")
|
|
244
|
+
raise typer.Exit(ExitCode.OK if result.reached else ExitCode.NO)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
app()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Bounded-timeout TCP connection attempts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
from optforge.core.errors import OptForgeError
|
|
10
|
+
from optforge.core.net import resolve_endpoints_with_timeout
|
|
11
|
+
from optforge.portcheck.models import ConnectResult
|
|
12
|
+
|
|
13
|
+
Resolver = Callable[[str, int, float], list]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Connector:
|
|
17
|
+
"""Connect to ``host:port`` while honouring one wall-clock deadline."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
resolver: Resolver = resolve_endpoints_with_timeout,
|
|
22
|
+
clock: Callable[[], float] = time.perf_counter,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._resolver = resolver
|
|
25
|
+
self._clock = clock
|
|
26
|
+
|
|
27
|
+
def connect(self, host: str, port: int, timeout: float) -> ConnectResult:
|
|
28
|
+
start = self._clock()
|
|
29
|
+
try:
|
|
30
|
+
endpoints = self._resolver(host, port, timeout)
|
|
31
|
+
except OptForgeError as exc:
|
|
32
|
+
return ConnectResult(ok=False, error=str(exc))
|
|
33
|
+
|
|
34
|
+
last_error = "no endpoint accepted the connection"
|
|
35
|
+
for endpoint in endpoints:
|
|
36
|
+
remaining = timeout - (self._clock() - start)
|
|
37
|
+
if remaining <= 0:
|
|
38
|
+
last_error = f"timed out after {timeout:g}s"
|
|
39
|
+
break
|
|
40
|
+
try:
|
|
41
|
+
with socket.socket(endpoint.family, socket.SOCK_STREAM) as sock:
|
|
42
|
+
sock.settimeout(remaining)
|
|
43
|
+
sock.connect(endpoint.sockaddr)
|
|
44
|
+
return ConnectResult(
|
|
45
|
+
ok=True,
|
|
46
|
+
seconds=self._clock() - start,
|
|
47
|
+
endpoint=endpoint.label(),
|
|
48
|
+
)
|
|
49
|
+
except OSError as exc:
|
|
50
|
+
last_error = _reason(exc)
|
|
51
|
+
|
|
52
|
+
return ConnectResult(ok=False, error=last_error)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _reason(exc: OSError) -> str:
|
|
56
|
+
if isinstance(exc, ConnectionRefusedError):
|
|
57
|
+
return "connection refused"
|
|
58
|
+
if isinstance(exc, TimeoutError):
|
|
59
|
+
return "timed out"
|
|
60
|
+
return exc.strerror or str(exc)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Value objects returned by the port inspection service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PortState(str, Enum):
|
|
11
|
+
"""The two states a waiter can wait for."""
|
|
12
|
+
|
|
13
|
+
free = "free"
|
|
14
|
+
used = "used"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SortKey(str, Enum):
|
|
18
|
+
"""Columns accepted by ``portcheck list --sort``."""
|
|
19
|
+
|
|
20
|
+
port = "port"
|
|
21
|
+
pid = "pid"
|
|
22
|
+
process = "process"
|
|
23
|
+
address = "address"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def scope_for(address: str) -> str:
|
|
27
|
+
"""Classify a bind address by how far it is reachable."""
|
|
28
|
+
if address in ("", "0.0.0.0", "::", "*"):
|
|
29
|
+
return "all interfaces"
|
|
30
|
+
if address == "::1" or address.startswith("127."):
|
|
31
|
+
return "local only"
|
|
32
|
+
if address.startswith("169.254.") or address.lower().startswith("fe80"):
|
|
33
|
+
return "link local"
|
|
34
|
+
return "specific"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Listener:
|
|
39
|
+
"""One listening TCP socket and, when the OS allows it, its owner."""
|
|
40
|
+
|
|
41
|
+
port: int
|
|
42
|
+
address: str
|
|
43
|
+
family: str
|
|
44
|
+
pid: int | None
|
|
45
|
+
process: str
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def scope(self) -> str:
|
|
49
|
+
return scope_for(self.address)
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"port": self.port,
|
|
54
|
+
"address": self.address,
|
|
55
|
+
"family": self.family,
|
|
56
|
+
"scope": self.scope,
|
|
57
|
+
"pid": self.pid,
|
|
58
|
+
"process": self.process,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class ConnectResult:
|
|
64
|
+
"""Outcome of a single TCP connection attempt."""
|
|
65
|
+
|
|
66
|
+
ok: bool
|
|
67
|
+
seconds: float | None = None
|
|
68
|
+
error: str | None = None
|
|
69
|
+
endpoint: str | None = None
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"ok": self.ok,
|
|
74
|
+
"seconds": self.seconds,
|
|
75
|
+
"error": self.error,
|
|
76
|
+
"endpoint": self.endpoint,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class CheckResult:
|
|
82
|
+
"""Answer to "is this port free?"."""
|
|
83
|
+
|
|
84
|
+
port: int
|
|
85
|
+
host: str
|
|
86
|
+
in_use: bool
|
|
87
|
+
bindable: bool
|
|
88
|
+
owners: list[Listener] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict[str, Any]:
|
|
91
|
+
return {
|
|
92
|
+
"port": self.port,
|
|
93
|
+
"host": self.host,
|
|
94
|
+
"in_use": self.in_use,
|
|
95
|
+
"bindable": self.bindable,
|
|
96
|
+
"owners": [owner.to_dict() for owner in self.owners],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class WaitResult:
|
|
102
|
+
"""Outcome of polling until a port reaches the requested state."""
|
|
103
|
+
|
|
104
|
+
port: int
|
|
105
|
+
until: str
|
|
106
|
+
reached: bool
|
|
107
|
+
elapsed: float
|
|
108
|
+
checks: int
|
|
109
|
+
|
|
110
|
+
def to_dict(self) -> dict[str, Any]:
|
|
111
|
+
return {
|
|
112
|
+
"port": self.port,
|
|
113
|
+
"until": self.until,
|
|
114
|
+
"reached": self.reached,
|
|
115
|
+
"elapsed": self.elapsed,
|
|
116
|
+
"checks": self.checks,
|
|
117
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Listening-socket discovery built on psutil."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import psutil
|
|
10
|
+
|
|
11
|
+
from optforge.core.errors import PermissionDenied
|
|
12
|
+
from optforge.core.processes import ProcessNameResolver
|
|
13
|
+
from optforge.portcheck.models import Listener
|
|
14
|
+
|
|
15
|
+
ConnectionProvider = Callable[..., Iterable[Any]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ListenerScanner:
|
|
19
|
+
"""Turn the operating system's socket table into :class:`Listener` rows.
|
|
20
|
+
|
|
21
|
+
The provider is injectable so the scanner can be tested without touching the
|
|
22
|
+
real socket table.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
provider: ConnectionProvider = psutil.net_connections,
|
|
28
|
+
resolver: ProcessNameResolver | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._provider = provider
|
|
31
|
+
self._resolver = resolver or ProcessNameResolver()
|
|
32
|
+
|
|
33
|
+
def scan(self) -> list[Listener]:
|
|
34
|
+
"""Return every listening TCP socket, de-duplicated and sorted by port."""
|
|
35
|
+
try:
|
|
36
|
+
connections = list(self._provider(kind="tcp"))
|
|
37
|
+
except psutil.AccessDenied as exc:
|
|
38
|
+
raise PermissionDenied(
|
|
39
|
+
"The operating system denied access to the socket table."
|
|
40
|
+
) from exc
|
|
41
|
+
except OSError as exc:
|
|
42
|
+
raise PermissionDenied(f"Could not read the socket table: {exc}") from exc
|
|
43
|
+
|
|
44
|
+
rows: list[Listener] = []
|
|
45
|
+
seen: set[tuple[int, str, int | None]] = set()
|
|
46
|
+
for connection in connections:
|
|
47
|
+
if getattr(connection, "status", None) != psutil.CONN_LISTEN:
|
|
48
|
+
continue
|
|
49
|
+
local = getattr(connection, "laddr", None)
|
|
50
|
+
if local is None:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
family = getattr(connection, "family", socket.AF_INET)
|
|
54
|
+
port = int(local.port)
|
|
55
|
+
address = str(getattr(local, "ip", "") or ("::" if family == socket.AF_INET6 else "0.0.0.0"))
|
|
56
|
+
pid = getattr(connection, "pid", None)
|
|
57
|
+
key = (port, address, pid)
|
|
58
|
+
if key in seen:
|
|
59
|
+
continue
|
|
60
|
+
seen.add(key)
|
|
61
|
+
|
|
62
|
+
rows.append(
|
|
63
|
+
Listener(
|
|
64
|
+
port=port,
|
|
65
|
+
address=address,
|
|
66
|
+
family="ipv6" if family == socket.AF_INET6 else "ipv4",
|
|
67
|
+
pid=pid,
|
|
68
|
+
process=self._resolver.name(pid),
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
rows.sort(key=lambda row: (row.port, row.address, row.pid or 0))
|
|
73
|
+
return rows
|
|
74
|
+
|
|
75
|
+
def owners(self, port: int) -> list[Listener]:
|
|
76
|
+
return [row for row in self.scan() if row.port == port]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""High-level port operations composed from the focused components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from optforge.portcheck.binder import PortBinder
|
|
6
|
+
from optforge.portcheck.connector import Connector
|
|
7
|
+
from optforge.portcheck.models import (
|
|
8
|
+
CheckResult,
|
|
9
|
+
ConnectResult,
|
|
10
|
+
Listener,
|
|
11
|
+
PortState,
|
|
12
|
+
SortKey,
|
|
13
|
+
WaitResult,
|
|
14
|
+
)
|
|
15
|
+
from optforge.portcheck.scanner import ListenerScanner
|
|
16
|
+
from optforge.portcheck.waiter import PortWaiter
|
|
17
|
+
|
|
18
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PortService:
|
|
22
|
+
"""Facade used by the CLI and by library consumers."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
scanner: ListenerScanner | None = None,
|
|
27
|
+
binder: PortBinder | None = None,
|
|
28
|
+
connector: Connector | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._scanner = scanner or ListenerScanner()
|
|
31
|
+
self._binder = binder or PortBinder()
|
|
32
|
+
self._connector = connector or Connector()
|
|
33
|
+
|
|
34
|
+
def listeners(self) -> list[Listener]:
|
|
35
|
+
"""Every listening TCP socket on the machine."""
|
|
36
|
+
return self._scanner.scan()
|
|
37
|
+
|
|
38
|
+
def query(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
sort: SortKey = SortKey.port,
|
|
42
|
+
limit: int | None = None,
|
|
43
|
+
process: str | None = None,
|
|
44
|
+
exposed: bool = False,
|
|
45
|
+
) -> list[Listener]:
|
|
46
|
+
"""Return listeners filtered and ordered for display."""
|
|
47
|
+
rows = self._scanner.scan()
|
|
48
|
+
if process:
|
|
49
|
+
needle = process.lower()
|
|
50
|
+
rows = [row for row in rows if needle in row.process.lower()]
|
|
51
|
+
if exposed:
|
|
52
|
+
rows = [row for row in rows if row.scope == "all interfaces"]
|
|
53
|
+
rows.sort(key=_sort_key(sort))
|
|
54
|
+
if limit is not None:
|
|
55
|
+
rows = rows[:limit]
|
|
56
|
+
return rows
|
|
57
|
+
|
|
58
|
+
def find(self, port: int) -> list[Listener]:
|
|
59
|
+
"""Every listener bound to ``port``."""
|
|
60
|
+
return [row for row in self._scanner.scan() if row.port == port]
|
|
61
|
+
|
|
62
|
+
def check(self, port: int, host: str = DEFAULT_HOST) -> CheckResult:
|
|
63
|
+
"""Answer whether ``port`` is free, using owners plus a bind probe."""
|
|
64
|
+
owners = self.find(port)
|
|
65
|
+
bindable = self._binder.is_free(port, host)
|
|
66
|
+
return CheckResult(
|
|
67
|
+
port=port,
|
|
68
|
+
host=host,
|
|
69
|
+
in_use=bool(owners) or not bindable,
|
|
70
|
+
bindable=bindable,
|
|
71
|
+
owners=owners,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def connect(self, host: str, port: int, timeout: float) -> ConnectResult:
|
|
75
|
+
"""Attempt a TCP connection with a real deadline."""
|
|
76
|
+
return self._connector.connect(host, port, timeout)
|
|
77
|
+
|
|
78
|
+
def wait(
|
|
79
|
+
self,
|
|
80
|
+
port: int,
|
|
81
|
+
until: PortState,
|
|
82
|
+
*,
|
|
83
|
+
timeout: float,
|
|
84
|
+
interval: float,
|
|
85
|
+
host: str = DEFAULT_HOST,
|
|
86
|
+
) -> WaitResult:
|
|
87
|
+
"""Poll until the port is ``used`` or ``free``.
|
|
88
|
+
|
|
89
|
+
The probes are deliberately cheap: a TCP connect for ``used`` and a
|
|
90
|
+
pair of wildcard bind probes for ``free``. Neither walks the socket
|
|
91
|
+
table, so a long wait stays cheap.
|
|
92
|
+
"""
|
|
93
|
+
state = PortState(until)
|
|
94
|
+
if state is PortState.used:
|
|
95
|
+
probe_timeout = min(max(interval * 2.0, 0.2), 1.0)
|
|
96
|
+
probe = lambda: self._connector.connect(host, port, probe_timeout).ok # noqa: E731
|
|
97
|
+
else:
|
|
98
|
+
probe = lambda: self._binder.is_free_on_all_interfaces(port) # noqa: E731
|
|
99
|
+
return PortWaiter(port, state.value, probe).wait(timeout, interval)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _sort_key(sort: SortKey) -> object:
|
|
103
|
+
keys = {
|
|
104
|
+
SortKey.port: lambda row: (row.port, row.address),
|
|
105
|
+
SortKey.pid: lambda row: (row.pid is None, row.pid or 0, row.port),
|
|
106
|
+
SortKey.process: lambda row: (row.process.lower(), row.port),
|
|
107
|
+
SortKey.address: lambda row: (row.address, row.port),
|
|
108
|
+
}
|
|
109
|
+
return keys[sort]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Deadline-driven polling for port state changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from optforge.portcheck.models import WaitResult
|
|
9
|
+
|
|
10
|
+
Probe = Callable[[], bool]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PortWaiter:
|
|
14
|
+
"""Poll a probe until it returns ``True`` or the deadline expires."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
port: int,
|
|
19
|
+
until: str,
|
|
20
|
+
probe: Probe,
|
|
21
|
+
*,
|
|
22
|
+
clock: Callable[[], float] = time.monotonic,
|
|
23
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._port = port
|
|
26
|
+
self._until = until
|
|
27
|
+
self._probe = probe
|
|
28
|
+
self._clock = clock
|
|
29
|
+
self._sleep = sleep
|
|
30
|
+
|
|
31
|
+
def wait(self, timeout: float, interval: float) -> WaitResult:
|
|
32
|
+
start = self._clock()
|
|
33
|
+
deadline = start + timeout
|
|
34
|
+
checks = 0
|
|
35
|
+
while True:
|
|
36
|
+
checks += 1
|
|
37
|
+
if self._probe():
|
|
38
|
+
return WaitResult(
|
|
39
|
+
port=self._port,
|
|
40
|
+
until=self._until,
|
|
41
|
+
reached=True,
|
|
42
|
+
elapsed=self._clock() - start,
|
|
43
|
+
checks=checks,
|
|
44
|
+
)
|
|
45
|
+
now = self._clock()
|
|
46
|
+
if now >= deadline:
|
|
47
|
+
return WaitResult(
|
|
48
|
+
port=self._port,
|
|
49
|
+
until=self._until,
|
|
50
|
+
reached=False,
|
|
51
|
+
elapsed=now - start,
|
|
52
|
+
checks=checks,
|
|
53
|
+
)
|
|
54
|
+
self._sleep(min(interval, max(0.0, deadline - now)))
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Remove Python ``__pycache__`` directories safely."""
|
|
2
|
+
|
|
3
|
+
from optforge.pycacheclean.models import CacheTarget, CleanFailure, CleanReport
|
|
4
|
+
from optforge.pycacheclean.service import CacheService
|
|
5
|
+
|
|
6
|
+
__all__ = ["CacheService", "CacheTarget", "CleanFailure", "CleanReport"]
|