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.
Files changed (45) hide show
  1. optforge/__init__.py +17 -0
  2. optforge/__main__.py +6 -0
  3. optforge/cli.py +56 -0
  4. optforge/core/__init__.py +24 -0
  5. optforge/core/cli.py +56 -0
  6. optforge/core/console.py +83 -0
  7. optforge/core/errors.py +28 -0
  8. optforge/core/formatting.py +26 -0
  9. optforge/core/net.py +87 -0
  10. optforge/core/processes.py +38 -0
  11. optforge/core/redaction.py +41 -0
  12. optforge/core/version.py +15 -0
  13. optforge/diskcheck/__init__.py +6 -0
  14. optforge/diskcheck/cli.py +113 -0
  15. optforge/diskcheck/models.py +31 -0
  16. optforge/diskcheck/service.py +56 -0
  17. optforge/envcheck/__init__.py +6 -0
  18. optforge/envcheck/cli.py +102 -0
  19. optforge/envcheck/models.py +33 -0
  20. optforge/envcheck/service.py +66 -0
  21. optforge/killNotif/cli.py +110 -0
  22. optforge/killNotif/core.py +24 -0
  23. optforge/killNotif/job.ps1 +52 -0
  24. optforge/portcheck/__init__.py +14 -0
  25. optforge/portcheck/binder.py +72 -0
  26. optforge/portcheck/cli.py +248 -0
  27. optforge/portcheck/connector.py +60 -0
  28. optforge/portcheck/models.py +117 -0
  29. optforge/portcheck/scanner.py +76 -0
  30. optforge/portcheck/service.py +109 -0
  31. optforge/portcheck/waiter.py +54 -0
  32. optforge/pycacheClean/__init__.py +6 -0
  33. optforge/pycacheClean/cleaner.py +43 -0
  34. optforge/pycacheClean/cli.py +127 -0
  35. optforge/pycacheClean/models.py +55 -0
  36. optforge/pycacheClean/scanner.py +122 -0
  37. optforge/pycacheClean/service.py +79 -0
  38. optforge/volume/__init__.py +7 -0
  39. optforge/volume/cli.py +224 -0
  40. optforge/volume/core.py +280 -0
  41. optforge-0.2.0.dist-info/METADATA +322 -0
  42. optforge-0.2.0.dist-info/RECORD +45 -0
  43. optforge-0.2.0.dist-info/WHEEL +4 -0
  44. optforge-0.2.0.dist-info/entry_points.txt +9 -0
  45. 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"]