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
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
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m optforge``."""
2
+
3
+ from optforge import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
@@ -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")
@@ -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
@@ -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,6 @@
1
+ """Inspect disk usage for a path or every mounted volume."""
2
+
3
+ from optforge.diskcheck.models import DiskInfo
4
+ from optforge.diskcheck.service import DiskService
5
+
6
+ __all__ = ["DiskInfo", "DiskService"]
@@ -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
+ }