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
optforge/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""OptForge: cross-platform developer utilities with a stable, scriptable CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from optforge.core.version import get_version
|
|
6
|
+
|
|
7
|
+
__version__ = get_version()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
"""Entry point for the ``optforge`` dispatcher."""
|
|
12
|
+
from optforge.cli import app
|
|
13
|
+
|
|
14
|
+
app()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["__version__", "main"]
|
optforge/__main__.py
ADDED
optforge/cli.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The ``optforge`` dispatcher that exposes every tool as a subcommand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from optforge.core.version import get_version
|
|
10
|
+
from optforge.diskcheck.cli import app as diskcheck_app
|
|
11
|
+
from optforge.envcheck.cli import app as envcheck_app
|
|
12
|
+
from optforge.portcheck.cli import app as portcheck_app
|
|
13
|
+
from optforge.pycacheclean.cli import app as pycacheclean_app
|
|
14
|
+
from optforge.volume.cli import app as volume_app
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="optforge",
|
|
18
|
+
help="Developer utilities for ports, environment variables, disk usage, Python caches, and volume.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
rich_markup_mode="rich",
|
|
21
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _version_callback(value: bool) -> None:
|
|
26
|
+
if value:
|
|
27
|
+
typer.echo(f"optforge {get_version()}")
|
|
28
|
+
raise typer.Exit()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.callback()
|
|
32
|
+
def main(
|
|
33
|
+
version: Annotated[
|
|
34
|
+
bool | None,
|
|
35
|
+
typer.Option(
|
|
36
|
+
"--version",
|
|
37
|
+
"-V",
|
|
38
|
+
callback=_version_callback,
|
|
39
|
+
is_eager=True,
|
|
40
|
+
help="Show the version and exit.",
|
|
41
|
+
),
|
|
42
|
+
] = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
app.add_typer(portcheck_app, name="portcheck")
|
|
48
|
+
app.add_typer(envcheck_app, name="envcheck")
|
|
49
|
+
app.add_typer(diskcheck_app, name="diskcheck")
|
|
50
|
+
app.add_typer(pycacheclean_app, name="pycache-clean")
|
|
51
|
+
app.add_typer(pycacheclean_app, name="pycacheClean", hidden=True)
|
|
52
|
+
app.add_typer(volume_app, name="vol")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
app()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Shared infrastructure used by every OptForge tool."""
|
|
2
|
+
|
|
3
|
+
from optforge.core.console import ConsoleIO
|
|
4
|
+
from optforge.core.errors import (
|
|
5
|
+
InvalidInput,
|
|
6
|
+
NotFound,
|
|
7
|
+
OperationFailed,
|
|
8
|
+
OptForgeError,
|
|
9
|
+
PermissionDenied,
|
|
10
|
+
)
|
|
11
|
+
from optforge.core.formatting import format_bytes, format_percent
|
|
12
|
+
from optforge.core.version import get_version
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ConsoleIO",
|
|
16
|
+
"InvalidInput",
|
|
17
|
+
"NotFound",
|
|
18
|
+
"OperationFailed",
|
|
19
|
+
"OptForgeError",
|
|
20
|
+
"PermissionDenied",
|
|
21
|
+
"format_bytes",
|
|
22
|
+
"format_percent",
|
|
23
|
+
"get_version",
|
|
24
|
+
]
|
optforge/core/cli.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared CLI plumbing: exit codes and a consistent error boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Self
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from optforge.core.console import ConsoleIO
|
|
12
|
+
from optforge.core.errors import OptForgeError, PermissionDenied
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExitCode(IntEnum):
|
|
16
|
+
"""Exit codes shared by every OptForge command.
|
|
17
|
+
|
|
18
|
+
``0`` and ``1`` answer a yes/no question; ``2`` is reserved by Typer for
|
|
19
|
+
invalid usage; ``3`` means the check could not run.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
OK = 0
|
|
23
|
+
NO = 1
|
|
24
|
+
ERROR = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ErrorBoundary:
|
|
28
|
+
"""Convert expected exceptions into a clean message and exit code 3.
|
|
29
|
+
|
|
30
|
+
Unexpected exceptions are re-raised untouched so they keep their traceback.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, console: ConsoleIO, *, permission_hint: str | None = None) -> None:
|
|
34
|
+
self._console = console
|
|
35
|
+
self._permission_hint = permission_hint
|
|
36
|
+
|
|
37
|
+
def __enter__(self) -> Self:
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def __exit__(
|
|
41
|
+
self,
|
|
42
|
+
exc_type: type[BaseException] | None,
|
|
43
|
+
exc: BaseException | None,
|
|
44
|
+
traceback: TracebackType | None,
|
|
45
|
+
) -> bool:
|
|
46
|
+
if exc is None:
|
|
47
|
+
return False
|
|
48
|
+
if isinstance(exc, PermissionDenied):
|
|
49
|
+
self._console.error(str(exc))
|
|
50
|
+
if self._permission_hint:
|
|
51
|
+
self._console.hint(self._permission_hint)
|
|
52
|
+
raise typer.Exit(ExitCode.ERROR) from exc
|
|
53
|
+
if isinstance(exc, OptForgeError):
|
|
54
|
+
self._console.error(str(exc))
|
|
55
|
+
raise typer.Exit(ExitCode.ERROR) from exc
|
|
56
|
+
return False
|
optforge/core/console.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""A thin, markup-safe wrapper around Rich consoles.
|
|
2
|
+
|
|
3
|
+
Every value that comes from the operating system (process names, environment
|
|
4
|
+
variables, paths) is rendered through :class:`rich.text.Text` so a value such as
|
|
5
|
+
``[red]oops`` can never inject markup into the terminal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterable, Sequence
|
|
12
|
+
from contextlib import AbstractContextManager
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConsoleIO:
|
|
21
|
+
"""Human output on stdout and diagnostics on stderr."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
out: Console | None = None,
|
|
26
|
+
err: Console | None = None,
|
|
27
|
+
*,
|
|
28
|
+
force_terminal: bool | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.out = out or Console(force_terminal=force_terminal)
|
|
31
|
+
self.err = err or Console(stderr=True, force_terminal=force_terminal)
|
|
32
|
+
|
|
33
|
+
def print(self, *values: Any, **kwargs: Any) -> None:
|
|
34
|
+
self.out.print(*values, **kwargs)
|
|
35
|
+
|
|
36
|
+
def text(self, value: object) -> None:
|
|
37
|
+
"""Print a value verbatim, with no markup interpretation."""
|
|
38
|
+
self.out.print(Text(str(value)))
|
|
39
|
+
|
|
40
|
+
def success(self, message: str) -> None:
|
|
41
|
+
self.out.print(f"[green]{message}[/green]")
|
|
42
|
+
|
|
43
|
+
def warning(self, message: str) -> None:
|
|
44
|
+
self.err.print(f"[yellow]{message}[/yellow]")
|
|
45
|
+
|
|
46
|
+
def error(self, message: str) -> None:
|
|
47
|
+
self.err.print(f"[red]error:[/red] {message}")
|
|
48
|
+
|
|
49
|
+
def hint(self, message: str) -> None:
|
|
50
|
+
self.err.print(f"[dim]{message}[/dim]")
|
|
51
|
+
|
|
52
|
+
def json(self, data: Any) -> None:
|
|
53
|
+
"""Emit exact, machine-parseable JSON on stdout."""
|
|
54
|
+
payload = json.dumps(data, indent=2, ensure_ascii=False, default=_json_default)
|
|
55
|
+
self.out.print(payload, markup=False, highlight=False, soft_wrap=True)
|
|
56
|
+
|
|
57
|
+
def table(self, title: str | None, columns: Sequence[tuple[str, dict[str, Any]]]) -> Table:
|
|
58
|
+
table = Table(title=title, title_justify="left", header_style="bold")
|
|
59
|
+
for header, options in columns:
|
|
60
|
+
table.add_column(header, **options)
|
|
61
|
+
return table
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def cell(value: object) -> Text:
|
|
65
|
+
"""Return a Rich cell that treats ``value`` as literal text."""
|
|
66
|
+
return Text(str(value))
|
|
67
|
+
|
|
68
|
+
def status(self, message: str) -> AbstractContextManager[Any]:
|
|
69
|
+
return self.out.status(message)
|
|
70
|
+
|
|
71
|
+
def render(self, *renderables: Iterable[Any] | Any) -> None:
|
|
72
|
+
for renderable in renderables:
|
|
73
|
+
self.out.print(renderable)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _json_default(value: Any) -> Any:
|
|
77
|
+
if isinstance(value, set | frozenset):
|
|
78
|
+
return sorted(value)
|
|
79
|
+
if hasattr(value, "__fspath__"):
|
|
80
|
+
return str(value)
|
|
81
|
+
if hasattr(value, "to_dict"):
|
|
82
|
+
return value.to_dict()
|
|
83
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
optforge/core/errors.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Exception hierarchy for expected, user-facing failures.
|
|
2
|
+
|
|
3
|
+
Anything raised from this module is safe to show to a human: the message is
|
|
4
|
+
written for a terminal, not for a stack trace. Unexpected exceptions are left
|
|
5
|
+
alone so they keep their traceback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OptForgeError(Exception):
|
|
12
|
+
"""Base class for every expected OptForge failure."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PermissionDenied(OptForgeError):
|
|
16
|
+
"""The operating system refused access to the requested resource."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvalidInput(OptForgeError):
|
|
20
|
+
"""The caller supplied a value that cannot be used."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NotFound(OptForgeError):
|
|
24
|
+
"""The requested resource does not exist."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OperationFailed(OptForgeError):
|
|
28
|
+
"""An operation was attempted but could not be completed."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Small, dependency-free formatting helpers shared by the CLIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_BINARY_UNITS = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_bytes(value: int | float) -> str:
|
|
9
|
+
"""Render a byte count using binary units (1024-based).
|
|
10
|
+
|
|
11
|
+
>>> format_bytes(1536)
|
|
12
|
+
'1.50 KiB'
|
|
13
|
+
"""
|
|
14
|
+
size = float(value)
|
|
15
|
+
unit = _BINARY_UNITS[0]
|
|
16
|
+
for candidate in _BINARY_UNITS:
|
|
17
|
+
unit = candidate
|
|
18
|
+
if abs(size) < 1024.0 or candidate == _BINARY_UNITS[-1]:
|
|
19
|
+
break
|
|
20
|
+
size /= 1024.0
|
|
21
|
+
return f"{size:.2f} {unit}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_percent(value: float, *, digits: int = 2) -> str:
|
|
25
|
+
"""Render a percentage with a fixed number of decimal places."""
|
|
26
|
+
return f"{value:.{digits}f}%"
|
optforge/core/net.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Cross-platform socket address resolution.
|
|
2
|
+
|
|
3
|
+
The standard library's ``socket.create_connection`` resolves names with an
|
|
4
|
+
unbounded DNS call, which silently ignores a caller-supplied timeout. OptForge
|
|
5
|
+
resolves addresses in a worker thread so the deadline is real, then connects to
|
|
6
|
+
each returned endpoint with the remaining time.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import socket
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from optforge.core.errors import OperationFailed
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Endpoint:
|
|
22
|
+
"""One resolved socket address."""
|
|
23
|
+
|
|
24
|
+
family: int
|
|
25
|
+
sockaddr: tuple[Any, ...]
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def address(self) -> str:
|
|
29
|
+
return str(self.sockaddr[0])
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def port(self) -> int:
|
|
33
|
+
return int(self.sockaddr[1])
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def family_name(self) -> str:
|
|
37
|
+
return "ipv6" if self.family == socket.AF_INET6 else "ipv4"
|
|
38
|
+
|
|
39
|
+
def label(self) -> str:
|
|
40
|
+
if self.family == socket.AF_INET6:
|
|
41
|
+
return f"[{self.address}]:{self.port}"
|
|
42
|
+
return f"{self.address}:{self.port}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_endpoints(host: str, port: int, *, passive: bool = False) -> list[Endpoint]:
|
|
46
|
+
"""Resolve ``host``/``port`` to every usable TCP endpoint.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
OperationFailed: if the name cannot be resolved.
|
|
50
|
+
"""
|
|
51
|
+
flags = socket.AI_PASSIVE if passive else 0
|
|
52
|
+
try:
|
|
53
|
+
infos = socket.getaddrinfo(
|
|
54
|
+
host or None,
|
|
55
|
+
port,
|
|
56
|
+
type=socket.SOCK_STREAM,
|
|
57
|
+
flags=flags,
|
|
58
|
+
)
|
|
59
|
+
except socket.gaierror as exc:
|
|
60
|
+
reason = exc.strerror or str(exc)
|
|
61
|
+
raise OperationFailed(f"Could not resolve {host!r}: {reason}") from exc
|
|
62
|
+
|
|
63
|
+
endpoints: list[Endpoint] = []
|
|
64
|
+
seen: set[tuple[int, tuple[Any, ...]]] = set()
|
|
65
|
+
for family, _type, _proto, _canonname, sockaddr in infos:
|
|
66
|
+
key = (family, sockaddr)
|
|
67
|
+
if key in seen:
|
|
68
|
+
continue
|
|
69
|
+
seen.add(key)
|
|
70
|
+
endpoints.append(Endpoint(family=family, sockaddr=sockaddr))
|
|
71
|
+
if not endpoints:
|
|
72
|
+
raise OperationFailed(f"Could not resolve {host!r} to any TCP address.")
|
|
73
|
+
return endpoints
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_endpoints_with_timeout(host: str, port: int, timeout: float) -> list[Endpoint]:
|
|
77
|
+
"""Resolve ``host`` while enforcing ``timeout`` on the DNS lookup."""
|
|
78
|
+
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="optforge-dns")
|
|
79
|
+
future = executor.submit(resolve_endpoints, host, port)
|
|
80
|
+
try:
|
|
81
|
+
return future.result(timeout=timeout)
|
|
82
|
+
except FuturesTimeoutError as exc:
|
|
83
|
+
raise OperationFailed(
|
|
84
|
+
f"Timed out after {timeout:g}s while resolving {host!r}."
|
|
85
|
+
) from exc
|
|
86
|
+
finally:
|
|
87
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Cached process-name lookups for socket owners."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
import psutil
|
|
8
|
+
|
|
9
|
+
_UNKNOWN = "-"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProcessNameResolver:
|
|
13
|
+
"""Resolve PIDs to names once, then serve from memory.
|
|
14
|
+
|
|
15
|
+
A busy machine can have hundreds of listening sockets owned by a handful of
|
|
16
|
+
processes. Looking each PID up on every scan is the difference between one
|
|
17
|
+
system call and hundreds.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, factory: Callable[[int], psutil.Process] = psutil.Process) -> None:
|
|
21
|
+
self._factory = factory
|
|
22
|
+
self._cache: dict[int, str] = {}
|
|
23
|
+
|
|
24
|
+
def name(self, pid: int | None) -> str:
|
|
25
|
+
if pid is None:
|
|
26
|
+
return _UNKNOWN
|
|
27
|
+
cached = self._cache.get(pid)
|
|
28
|
+
if cached is not None:
|
|
29
|
+
return cached
|
|
30
|
+
try:
|
|
31
|
+
resolved = self._factory(pid).name()
|
|
32
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
33
|
+
resolved = _UNKNOWN
|
|
34
|
+
self._cache[pid] = resolved
|
|
35
|
+
return resolved
|
|
36
|
+
|
|
37
|
+
def clear(self) -> None:
|
|
38
|
+
self._cache.clear()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Key-based secret detection and redaction for environment inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_SECRET_KEY = re.compile(
|
|
8
|
+
r"(?:"
|
|
9
|
+
r"KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|CREDENTIAL|PRIVATE|"
|
|
10
|
+
r"AUTH|COOKIE|SESSION|SIGNATURE|CERT|DSN|DATABASE_URL|CONNECTION_STRING"
|
|
11
|
+
r")",
|
|
12
|
+
re.IGNORECASE,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
DEFAULT_MASK = "<redacted>"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SecretRedactor:
|
|
19
|
+
"""Decide whether an environment variable name is sensitive.
|
|
20
|
+
|
|
21
|
+
Detection is deliberately name-based and conservative: it errs toward
|
|
22
|
+
masking values that merely *look* like secrets, because a false positive is
|
|
23
|
+
cheap and a leaked token is not.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, mask: str = DEFAULT_MASK, pattern: re.Pattern[str] = _SECRET_KEY) -> None:
|
|
27
|
+
self._mask = mask
|
|
28
|
+
self._pattern = pattern
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def mask(self) -> str:
|
|
32
|
+
return self._mask
|
|
33
|
+
|
|
34
|
+
def is_sensitive(self, key: str) -> bool:
|
|
35
|
+
return bool(self._pattern.search(key))
|
|
36
|
+
|
|
37
|
+
def redact(self, key: str, value: str) -> tuple[str, bool]:
|
|
38
|
+
"""Return ``(display_value, was_redacted)`` for ``key``/``value``."""
|
|
39
|
+
if self.is_sensitive(key):
|
|
40
|
+
return self._mask, True
|
|
41
|
+
return value, False
|
optforge/core/version.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Version resolution that works from an installed wheel and a source checkout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
FALLBACK_VERSION = "0.2.0"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_version(distribution: str = "optforge") -> str:
|
|
11
|
+
"""Return the installed distribution version, or the source-tree fallback."""
|
|
12
|
+
try:
|
|
13
|
+
return version(distribution)
|
|
14
|
+
except PackageNotFoundError:
|
|
15
|
+
return FALLBACK_VERSION
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""``diskcheck``: inspect disk usage and warn on storage pressure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from optforge.core.cli import ErrorBoundary, ExitCode
|
|
11
|
+
from optforge.core.console import ConsoleIO
|
|
12
|
+
from optforge.core.formatting import format_bytes, format_percent
|
|
13
|
+
from optforge.core.version import get_version
|
|
14
|
+
from optforge.diskcheck.models import DiskInfo
|
|
15
|
+
from optforge.diskcheck.service import DiskService
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="diskcheck",
|
|
19
|
+
help="Inspect disk usage and warn when storage pressure crosses a threshold.",
|
|
20
|
+
no_args_is_help=False,
|
|
21
|
+
rich_markup_mode="rich",
|
|
22
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
23
|
+
)
|
|
24
|
+
console = ConsoleIO()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _service() -> DiskService:
|
|
28
|
+
return DiskService()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _version_callback(value: bool) -> None:
|
|
32
|
+
if value:
|
|
33
|
+
typer.echo(f"diskcheck (optforge {get_version()})")
|
|
34
|
+
raise typer.Exit()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _table(infos: list[DiskInfo], warn_at: float | None) -> None:
|
|
38
|
+
table = console.table(
|
|
39
|
+
"Disk usage",
|
|
40
|
+
[
|
|
41
|
+
("Path", {"no_wrap": True}),
|
|
42
|
+
("Total", {"justify": "right"}),
|
|
43
|
+
("Used", {"justify": "right"}),
|
|
44
|
+
("Free", {"justify": "right"}),
|
|
45
|
+
("Used %", {"justify": "right"}),
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
for info in infos:
|
|
49
|
+
pressure = warn_at is not None and info.percent_used >= warn_at
|
|
50
|
+
percent = Text(format_percent(info.percent_used), style="red" if pressure else "")
|
|
51
|
+
table.add_row(
|
|
52
|
+
console.cell(info.path),
|
|
53
|
+
format_bytes(info.total),
|
|
54
|
+
format_bytes(info.used),
|
|
55
|
+
format_bytes(info.free),
|
|
56
|
+
percent,
|
|
57
|
+
)
|
|
58
|
+
console.print(table)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.callback(invoke_without_command=True)
|
|
62
|
+
def main(
|
|
63
|
+
path: Annotated[str, typer.Argument(help="Directory or drive to inspect.")] = ".",
|
|
64
|
+
all_volumes: Annotated[
|
|
65
|
+
bool,
|
|
66
|
+
typer.Option("--all", "-a", help="Show every mounted volume instead of one path."),
|
|
67
|
+
] = False,
|
|
68
|
+
warn_at: Annotated[
|
|
69
|
+
float | None,
|
|
70
|
+
typer.Option(
|
|
71
|
+
"--warn-at",
|
|
72
|
+
min=0.0,
|
|
73
|
+
max=100.0,
|
|
74
|
+
help="Exit 1 when used percent reaches this value.",
|
|
75
|
+
),
|
|
76
|
+
] = None,
|
|
77
|
+
as_json: Annotated[
|
|
78
|
+
bool,
|
|
79
|
+
typer.Option("--json", help="Print machine-readable JSON."),
|
|
80
|
+
] = False,
|
|
81
|
+
version: Annotated[
|
|
82
|
+
bool | None,
|
|
83
|
+
typer.Option(
|
|
84
|
+
"--version",
|
|
85
|
+
"-V",
|
|
86
|
+
callback=_version_callback,
|
|
87
|
+
is_eager=True,
|
|
88
|
+
help="Show the version and exit.",
|
|
89
|
+
),
|
|
90
|
+
] = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Show disk usage for a path or every mounted volume."""
|
|
93
|
+
with ErrorBoundary(console):
|
|
94
|
+
infos = _service().volumes() if all_volumes else [_service().usage(path)]
|
|
95
|
+
|
|
96
|
+
if as_json:
|
|
97
|
+
payload = [info.to_dict() for info in infos] if all_volumes else infos[0].to_dict()
|
|
98
|
+
console.json(payload)
|
|
99
|
+
else:
|
|
100
|
+
_table(infos, warn_at)
|
|
101
|
+
|
|
102
|
+
over_threshold = warn_at is not None and any(info.percent_used >= warn_at for info in infos)
|
|
103
|
+
if over_threshold:
|
|
104
|
+
if not as_json:
|
|
105
|
+
console.warning(
|
|
106
|
+
f"Storage pressure: at least one volume is at or above {warn_at:g}% used."
|
|
107
|
+
)
|
|
108
|
+
raise typer.Exit(ExitCode.NO)
|
|
109
|
+
raise typer.Exit(ExitCode.OK)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
app()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Value objects for disk inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class DiskInfo:
|
|
11
|
+
"""Usage for one path or mounted volume."""
|
|
12
|
+
|
|
13
|
+
path: str
|
|
14
|
+
total: int
|
|
15
|
+
used: int
|
|
16
|
+
free: int
|
|
17
|
+
percent_used: float
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def percent_free(self) -> float:
|
|
21
|
+
return 100.0 - self.percent_used
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"path": self.path,
|
|
26
|
+
"total": self.total,
|
|
27
|
+
"used": self.used,
|
|
28
|
+
"free": self.free,
|
|
29
|
+
"percent_used": round(self.percent_used, 2),
|
|
30
|
+
"percent_free": round(self.percent_free, 2),
|
|
31
|
+
}
|