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,43 @@
|
|
|
1
|
+
"""Deletion of discovered cache directories, with per-target failure reporting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from optforge.pycacheclean.models import CacheTarget, CleanFailure, CleanReport
|
|
10
|
+
|
|
11
|
+
Remover = Callable[..., Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CacheCleaner:
|
|
15
|
+
"""Remove targets and report exactly what succeeded and what did not."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, remover: Remover = shutil.rmtree) -> None:
|
|
18
|
+
self._remover = remover
|
|
19
|
+
|
|
20
|
+
def clean(self, targets: Iterable[CacheTarget], *, dry_run: bool = False) -> CleanReport:
|
|
21
|
+
removed: list[CacheTarget] = []
|
|
22
|
+
failed: list[CleanFailure] = []
|
|
23
|
+
|
|
24
|
+
for target in targets:
|
|
25
|
+
if dry_run:
|
|
26
|
+
removed.append(target)
|
|
27
|
+
continue
|
|
28
|
+
errors: list[str] = []
|
|
29
|
+
|
|
30
|
+
def on_error(_func: Any, path: str, exc: BaseException) -> None:
|
|
31
|
+
errors.append(f"{path}: {exc}")
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
self._remover(target.path, onexc=on_error)
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
failed.append(CleanFailure(path=target.path, error=str(exc)))
|
|
37
|
+
continue
|
|
38
|
+
if errors:
|
|
39
|
+
failed.append(CleanFailure(path=target.path, error="; ".join(errors)))
|
|
40
|
+
else:
|
|
41
|
+
removed.append(target)
|
|
42
|
+
|
|
43
|
+
return CleanReport(dry_run=dry_run, removed=removed, failed=failed)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""``pycache-clean``: remove ``__pycache__`` directories safely and visibly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from optforge.core.cli import ErrorBoundary, ExitCode
|
|
12
|
+
from optforge.core.console import ConsoleIO
|
|
13
|
+
from optforge.core.formatting import format_bytes
|
|
14
|
+
from optforge.core.version import get_version
|
|
15
|
+
from optforge.pycacheclean.service import CacheService
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="pycache-clean",
|
|
19
|
+
help="Remove Python __pycache__ directories. Skips virtualenvs by default.",
|
|
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() -> CacheService:
|
|
28
|
+
return CacheService()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _version_callback(value: bool) -> None:
|
|
32
|
+
if value:
|
|
33
|
+
typer.echo(f"pycache-clean (optforge {get_version()})")
|
|
34
|
+
raise typer.Exit()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.callback(invoke_without_command=True)
|
|
38
|
+
def main(
|
|
39
|
+
path: Annotated[str, typer.Argument(help="Directory to scan for cache directories.")] = ".",
|
|
40
|
+
recursive: Annotated[
|
|
41
|
+
bool, typer.Option("--recursive/--no-recursive", help="Search recursively beneath the path.")
|
|
42
|
+
] = True,
|
|
43
|
+
max_depth: Annotated[
|
|
44
|
+
int | None,
|
|
45
|
+
typer.Option("--max-depth", min=1, help="Maximum directory depth to traverse."),
|
|
46
|
+
] = None,
|
|
47
|
+
exclude: Annotated[
|
|
48
|
+
list[str] | None,
|
|
49
|
+
typer.Option("--exclude", "-e", help="Additional directory name to skip. Repeatable."),
|
|
50
|
+
] = None,
|
|
51
|
+
skip_venvs: Annotated[
|
|
52
|
+
bool,
|
|
53
|
+
typer.Option("--skip-venv/--include-venv", help="Skip (or include) venv and .venv trees."),
|
|
54
|
+
] = True,
|
|
55
|
+
measure: Annotated[
|
|
56
|
+
bool, typer.Option("--size/--no-size", help="Measure files and bytes per target.")
|
|
57
|
+
] = True,
|
|
58
|
+
dry_run: Annotated[
|
|
59
|
+
bool, typer.Option("--dry-run", help="List what would be removed without deleting it.")
|
|
60
|
+
] = False,
|
|
61
|
+
yes: Annotated[
|
|
62
|
+
bool, typer.Option("--yes", "-y", help="Delete without asking for confirmation.")
|
|
63
|
+
] = False,
|
|
64
|
+
allow_root: Annotated[
|
|
65
|
+
bool, typer.Option("--allow-root", help="Permit scanning the filesystem root.")
|
|
66
|
+
] = False,
|
|
67
|
+
as_json: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
|
|
68
|
+
version: Annotated[
|
|
69
|
+
bool | None,
|
|
70
|
+
typer.Option(
|
|
71
|
+
"--version",
|
|
72
|
+
"-V",
|
|
73
|
+
callback=_version_callback,
|
|
74
|
+
is_eager=True,
|
|
75
|
+
help="Show the version and exit.",
|
|
76
|
+
),
|
|
77
|
+
] = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Remove __pycache__ directories under a path."""
|
|
80
|
+
service = _service()
|
|
81
|
+
with ErrorBoundary(console):
|
|
82
|
+
targets = service.scan(
|
|
83
|
+
path,
|
|
84
|
+
recursive=recursive,
|
|
85
|
+
max_depth=max_depth,
|
|
86
|
+
exclude=set(exclude or ()),
|
|
87
|
+
skip_venvs=skip_venvs,
|
|
88
|
+
measure=measure,
|
|
89
|
+
allow_root=allow_root,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if not targets:
|
|
93
|
+
if as_json:
|
|
94
|
+
console.json({"dry_run": dry_run, "removed": [], "failed": [], "freed_bytes": 0})
|
|
95
|
+
else:
|
|
96
|
+
console.text(f"No __pycache__ directories found under {path!r}.")
|
|
97
|
+
raise typer.Exit(ExitCode.OK)
|
|
98
|
+
|
|
99
|
+
if not dry_run and not yes:
|
|
100
|
+
if not sys.stdin.isatty():
|
|
101
|
+
console.error("Refusing to delete without --yes in a non-interactive session.")
|
|
102
|
+
raise typer.Exit(2)
|
|
103
|
+
noun = "directory" if len(targets) == 1 else "directories"
|
|
104
|
+
if not typer.confirm(f"Delete {len(targets)} __pycache__ {noun}?", default=False):
|
|
105
|
+
console.text("Aborted; nothing was deleted.")
|
|
106
|
+
raise typer.Exit(ExitCode.OK)
|
|
107
|
+
|
|
108
|
+
report = service.remove(targets, dry_run=dry_run)
|
|
109
|
+
|
|
110
|
+
if as_json:
|
|
111
|
+
console.json(report.to_dict())
|
|
112
|
+
else:
|
|
113
|
+
verb = "Would remove" if dry_run else "Removed"
|
|
114
|
+
console.text(f"{verb} {len(report.removed)} cache directories.")
|
|
115
|
+
for target in report.removed:
|
|
116
|
+
detail = f" ({target.files} files, {format_bytes(target.bytes)})" if measure else ""
|
|
117
|
+
console.print(Text(f" - {target.path}{detail}"))
|
|
118
|
+
for failure in report.failed:
|
|
119
|
+
console.error(f"{failure.path}: {failure.error}")
|
|
120
|
+
if not dry_run:
|
|
121
|
+
console.text(f"Freed {format_bytes(report.freed_bytes)}.")
|
|
122
|
+
|
|
123
|
+
raise typer.Exit(ExitCode.OK if report.ok else ExitCode.NO)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
app()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Value objects for Python cache cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class CacheTarget:
|
|
12
|
+
"""A ``__pycache__`` directory discovered by the scanner."""
|
|
13
|
+
|
|
14
|
+
path: Path
|
|
15
|
+
files: int = 0
|
|
16
|
+
bytes: int = 0
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, Any]:
|
|
19
|
+
return {"path": str(self.path), "files": self.files, "bytes": self.bytes}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class CleanFailure:
|
|
24
|
+
"""A target that could not be removed."""
|
|
25
|
+
|
|
26
|
+
path: Path
|
|
27
|
+
error: str
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict[str, Any]:
|
|
30
|
+
return {"path": str(self.path), "error": self.error}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class CleanReport:
|
|
35
|
+
"""What a cleanup run did (or would do)."""
|
|
36
|
+
|
|
37
|
+
dry_run: bool
|
|
38
|
+
removed: list[CacheTarget] = field(default_factory=list)
|
|
39
|
+
failed: list[CleanFailure] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def freed_bytes(self) -> int:
|
|
43
|
+
return sum(target.bytes for target in self.removed)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def ok(self) -> bool:
|
|
47
|
+
return not self.failed
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, Any]:
|
|
50
|
+
return {
|
|
51
|
+
"dry_run": self.dry_run,
|
|
52
|
+
"removed": [target.to_dict() for target in self.removed],
|
|
53
|
+
"failed": [failure.to_dict() for failure in self.failed],
|
|
54
|
+
"freed_bytes": self.freed_bytes,
|
|
55
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Discovery of ``__pycache__`` directories with venv and symlink safety."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from optforge.pycacheclean.models import CacheTarget
|
|
9
|
+
|
|
10
|
+
_CACHE_DIR = "__pycache__"
|
|
11
|
+
_VENV_DIRS = frozenset({"venv", ".venv", "env", ".env"})
|
|
12
|
+
_DEFAULT_SKIP = frozenset(
|
|
13
|
+
{
|
|
14
|
+
".git",
|
|
15
|
+
".hg",
|
|
16
|
+
".svn",
|
|
17
|
+
".tox",
|
|
18
|
+
".nox",
|
|
19
|
+
".mypy_cache",
|
|
20
|
+
".ruff_cache",
|
|
21
|
+
".pytest_cache",
|
|
22
|
+
"node_modules",
|
|
23
|
+
"site-packages",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CacheScanner:
|
|
29
|
+
"""Walk a tree and return every cache directory worth removing.
|
|
30
|
+
|
|
31
|
+
The scanner never follows symlinks, never descends into a cache directory,
|
|
32
|
+
and skips virtualenvs by default so a cleanup does not destroy a project's
|
|
33
|
+
installed dependencies.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, skip: set[str] | None = None, *, skip_venvs: bool = True) -> None:
|
|
37
|
+
self._extra_skip = {name.lower() for name in (skip or set())}
|
|
38
|
+
self._skip_venvs = skip_venvs
|
|
39
|
+
|
|
40
|
+
def scan(
|
|
41
|
+
self,
|
|
42
|
+
root: str | os.PathLike[str],
|
|
43
|
+
*,
|
|
44
|
+
recursive: bool = True,
|
|
45
|
+
max_depth: int | None = None,
|
|
46
|
+
exclude: set[str] | None = None,
|
|
47
|
+
measure: bool = True,
|
|
48
|
+
skip_venvs: bool | None = None,
|
|
49
|
+
) -> list[CacheTarget]:
|
|
50
|
+
"""Return matching cache directories under ``root``."""
|
|
51
|
+
root_path = Path(root).expanduser()
|
|
52
|
+
skip = self._build_skip(exclude, skip_venvs)
|
|
53
|
+
if not recursive:
|
|
54
|
+
candidate = root_path / _CACHE_DIR
|
|
55
|
+
return [self._target(candidate, measure)] if candidate.is_dir() else []
|
|
56
|
+
|
|
57
|
+
targets: list[CacheTarget] = []
|
|
58
|
+
stack: list[tuple[str, int]] = [(os.fspath(root_path), 0)]
|
|
59
|
+
while stack:
|
|
60
|
+
current, depth = stack.pop()
|
|
61
|
+
try:
|
|
62
|
+
entries = list(os.scandir(current))
|
|
63
|
+
except OSError:
|
|
64
|
+
continue
|
|
65
|
+
for entry in entries:
|
|
66
|
+
try:
|
|
67
|
+
if not entry.is_dir(follow_symlinks=False):
|
|
68
|
+
continue
|
|
69
|
+
except OSError:
|
|
70
|
+
continue
|
|
71
|
+
name = entry.name
|
|
72
|
+
if name.lower() in skip:
|
|
73
|
+
continue
|
|
74
|
+
child_depth = depth + 1
|
|
75
|
+
if name == _CACHE_DIR:
|
|
76
|
+
if max_depth is None or child_depth <= max_depth:
|
|
77
|
+
targets.append(self._target(Path(entry.path), measure))
|
|
78
|
+
continue
|
|
79
|
+
if max_depth is not None and child_depth >= max_depth:
|
|
80
|
+
continue
|
|
81
|
+
stack.append((entry.path, child_depth))
|
|
82
|
+
|
|
83
|
+
return sorted(targets, key=lambda target: str(target.path))
|
|
84
|
+
|
|
85
|
+
def _build_skip(self, exclude: set[str] | None, skip_venvs: bool | None) -> set[str]:
|
|
86
|
+
skip = set(self._extra_skip) | _DEFAULT_SKIP
|
|
87
|
+
include_venvs = self._skip_venvs if skip_venvs is None else skip_venvs
|
|
88
|
+
if include_venvs:
|
|
89
|
+
skip |= _VENV_DIRS
|
|
90
|
+
else:
|
|
91
|
+
skip -= _VENV_DIRS
|
|
92
|
+
skip |= {name.lower() for name in (exclude or set())}
|
|
93
|
+
return skip
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _target(path: Path, measure: bool) -> CacheTarget:
|
|
97
|
+
if not measure:
|
|
98
|
+
return CacheTarget(path=path)
|
|
99
|
+
files, size = _measure(path)
|
|
100
|
+
return CacheTarget(path=path, files=files, bytes=size)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _measure(path: Path) -> tuple[int, int]:
|
|
104
|
+
files = 0
|
|
105
|
+
size = 0
|
|
106
|
+
stack = [os.fspath(path)]
|
|
107
|
+
while stack:
|
|
108
|
+
current = stack.pop()
|
|
109
|
+
try:
|
|
110
|
+
entries = list(os.scandir(current))
|
|
111
|
+
except OSError:
|
|
112
|
+
continue
|
|
113
|
+
for entry in entries:
|
|
114
|
+
try:
|
|
115
|
+
if entry.is_dir(follow_symlinks=False):
|
|
116
|
+
stack.append(entry.path)
|
|
117
|
+
elif entry.is_file(follow_symlinks=False):
|
|
118
|
+
files += 1
|
|
119
|
+
size += entry.stat(follow_symlinks=False).st_size
|
|
120
|
+
except OSError:
|
|
121
|
+
continue
|
|
122
|
+
return files, size
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Facade for scanning and cleaning Python cache directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from optforge.core.errors import InvalidInput, NotFound
|
|
9
|
+
from optforge.pycacheclean.cleaner import CacheCleaner
|
|
10
|
+
from optforge.pycacheclean.models import CacheTarget, CleanReport
|
|
11
|
+
from optforge.pycacheclean.scanner import CacheScanner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CacheService:
|
|
15
|
+
"""Resolve a root, scan it, then delete or preview the matches."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, scanner: CacheScanner | None = None, cleaner: CacheCleaner | None = None) -> None:
|
|
18
|
+
self._scanner = scanner or CacheScanner()
|
|
19
|
+
self._cleaner = cleaner or CacheCleaner()
|
|
20
|
+
|
|
21
|
+
def scan(
|
|
22
|
+
self,
|
|
23
|
+
root: str | os.PathLike[str] = ".",
|
|
24
|
+
*,
|
|
25
|
+
recursive: bool = True,
|
|
26
|
+
max_depth: int | None = None,
|
|
27
|
+
exclude: set[str] | None = None,
|
|
28
|
+
skip_venvs: bool = True,
|
|
29
|
+
measure: bool = True,
|
|
30
|
+
allow_root: bool = False,
|
|
31
|
+
) -> list[CacheTarget]:
|
|
32
|
+
path = self._validate_root(root, allow_root=allow_root)
|
|
33
|
+
return self._scanner.scan(
|
|
34
|
+
path,
|
|
35
|
+
recursive=recursive,
|
|
36
|
+
max_depth=max_depth,
|
|
37
|
+
exclude=exclude,
|
|
38
|
+
measure=measure,
|
|
39
|
+
skip_venvs=skip_venvs,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def remove(self, targets: list[CacheTarget], *, dry_run: bool = False) -> CleanReport:
|
|
43
|
+
return self._cleaner.clean(targets, dry_run=dry_run)
|
|
44
|
+
|
|
45
|
+
def clean(
|
|
46
|
+
self,
|
|
47
|
+
root: str | os.PathLike[str] = ".",
|
|
48
|
+
*,
|
|
49
|
+
recursive: bool = True,
|
|
50
|
+
max_depth: int | None = None,
|
|
51
|
+
exclude: set[str] | None = None,
|
|
52
|
+
skip_venvs: bool = True,
|
|
53
|
+
measure: bool = True,
|
|
54
|
+
dry_run: bool = False,
|
|
55
|
+
allow_root: bool = False,
|
|
56
|
+
) -> CleanReport:
|
|
57
|
+
targets = self.scan(
|
|
58
|
+
root,
|
|
59
|
+
recursive=recursive,
|
|
60
|
+
max_depth=max_depth,
|
|
61
|
+
exclude=exclude,
|
|
62
|
+
skip_venvs=skip_venvs,
|
|
63
|
+
measure=measure,
|
|
64
|
+
allow_root=allow_root,
|
|
65
|
+
)
|
|
66
|
+
return self.remove(targets, dry_run=dry_run)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _validate_root(root: str | os.PathLike[str], *, allow_root: bool) -> Path:
|
|
70
|
+
path = Path(root).expanduser()
|
|
71
|
+
if not path.exists():
|
|
72
|
+
raise NotFound(f"Path does not exist: {path}")
|
|
73
|
+
if not path.is_dir():
|
|
74
|
+
raise InvalidInput(f"Not a directory: {path}")
|
|
75
|
+
if not allow_root and path.resolve() == Path(path.anchor):
|
|
76
|
+
raise InvalidInput(
|
|
77
|
+
f"Refusing to scan the filesystem root {path}. Pass --allow-root if you mean it."
|
|
78
|
+
)
|
|
79
|
+
return path
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Output-volume control for the default audio device."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from optforge.volume.core import MAX_VOLUME, MIN_VOLUME, VolumeBackend, VolumeController, create_backend
|
|
6
|
+
|
|
7
|
+
__all__ = ["MAX_VOLUME", "MIN_VOLUME", "VolumeBackend", "VolumeController", "create_backend"]
|
optforge/volume/cli.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""``vol``: get or set the default output volume.
|
|
2
|
+
|
|
3
|
+
Exit codes
|
|
4
|
+
0 the volume was read or changed successfully
|
|
5
|
+
2 invalid usage (raised by Typer)
|
|
6
|
+
3 the volume could not be read or changed
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from optforge.core.cli import ErrorBoundary
|
|
17
|
+
from optforge.core.console import ConsoleIO
|
|
18
|
+
from optforge.core.version import get_version
|
|
19
|
+
from optforge.volume.core import MAX_VOLUME, MIN_VOLUME, VolumeController
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="vol",
|
|
23
|
+
help="Get or set the default output volume.",
|
|
24
|
+
epilog="Exit codes: 0 ok | 2 bad usage | 3 could not run",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
rich_markup_mode="rich",
|
|
27
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
28
|
+
)
|
|
29
|
+
console = ConsoleIO()
|
|
30
|
+
|
|
31
|
+
LevelArg = Annotated[
|
|
32
|
+
int,
|
|
33
|
+
typer.Argument(
|
|
34
|
+
min=MIN_VOLUME,
|
|
35
|
+
max=MAX_VOLUME,
|
|
36
|
+
metavar="PERCENT",
|
|
37
|
+
show_default=False,
|
|
38
|
+
help="Volume percentage (0-100).",
|
|
39
|
+
),
|
|
40
|
+
]
|
|
41
|
+
StepArg = Annotated[
|
|
42
|
+
int,
|
|
43
|
+
typer.Argument(
|
|
44
|
+
min=1,
|
|
45
|
+
max=MAX_VOLUME,
|
|
46
|
+
metavar="STEP",
|
|
47
|
+
show_default=True,
|
|
48
|
+
help="Amount to change (1-100).",
|
|
49
|
+
),
|
|
50
|
+
]
|
|
51
|
+
QuietOpt = Annotated[bool, typer.Option("--quiet", "-q", help="Print nothing; use the exit code only.")]
|
|
52
|
+
JsonOpt = Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _version_callback(value: bool) -> None:
|
|
56
|
+
if value:
|
|
57
|
+
typer.echo(f"vol (optforge {get_version()})")
|
|
58
|
+
raise typer.Exit()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.callback()
|
|
62
|
+
def main(
|
|
63
|
+
version: Annotated[
|
|
64
|
+
bool | None,
|
|
65
|
+
typer.Option(
|
|
66
|
+
"--version",
|
|
67
|
+
"-V",
|
|
68
|
+
callback=_version_callback,
|
|
69
|
+
is_eager=True,
|
|
70
|
+
help="Show the version and exit.",
|
|
71
|
+
),
|
|
72
|
+
] = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _controller() -> VolumeController:
|
|
78
|
+
return VolumeController()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _report(level: int, *, json_output: bool, quiet: bool, reading: bool, muted: bool = False) -> None:
|
|
82
|
+
if quiet:
|
|
83
|
+
return
|
|
84
|
+
if json_output:
|
|
85
|
+
payload: dict[str, Any] = {"volume": level}
|
|
86
|
+
if reading:
|
|
87
|
+
payload["muted"] = muted
|
|
88
|
+
console.json(payload)
|
|
89
|
+
return
|
|
90
|
+
if reading:
|
|
91
|
+
suffix = " (muted)" if muted else ""
|
|
92
|
+
console.print(Text(f"Volume: {level}%{suffix}", style="cyan"))
|
|
93
|
+
else:
|
|
94
|
+
console.print(Text(f"Volume set to {level}%.", style="green"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _report_mute(muted: bool, *, json_output: bool, quiet: bool) -> None:
|
|
98
|
+
if quiet:
|
|
99
|
+
return
|
|
100
|
+
if json_output:
|
|
101
|
+
console.json({"muted": muted})
|
|
102
|
+
return
|
|
103
|
+
console.print(Text("Muted." if muted else "Unmuted.", style="green"))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _show(*, json_output: bool, quiet: bool) -> None:
|
|
107
|
+
with ErrorBoundary(console):
|
|
108
|
+
controller = _controller()
|
|
109
|
+
current = controller.get()
|
|
110
|
+
muted = controller.is_muted()
|
|
111
|
+
_report(current, json_output=json_output, quiet=quiet, reading=True, muted=muted)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command("on")
|
|
115
|
+
def set_on(
|
|
116
|
+
level: LevelArg,
|
|
117
|
+
json_output: JsonOpt = False,
|
|
118
|
+
quiet: QuietOpt = False,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Set the output volume to a percentage (0-100)."""
|
|
121
|
+
with ErrorBoundary(console):
|
|
122
|
+
applied = _controller().set(level)
|
|
123
|
+
_report(applied, json_output=json_output, quiet=quiet, reading=False)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command("full")
|
|
127
|
+
def set_full(
|
|
128
|
+
json_output: JsonOpt = False,
|
|
129
|
+
quiet: QuietOpt = False,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Set the output volume to 100%."""
|
|
132
|
+
with ErrorBoundary(console):
|
|
133
|
+
applied = _controller().full()
|
|
134
|
+
_report(applied, json_output=json_output, quiet=quiet, reading=False)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command("none")
|
|
138
|
+
def set_none(
|
|
139
|
+
json_output: JsonOpt = False,
|
|
140
|
+
quiet: QuietOpt = False,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Set the output volume to 0%."""
|
|
143
|
+
with ErrorBoundary(console):
|
|
144
|
+
applied = _controller().none()
|
|
145
|
+
_report(applied, json_output=json_output, quiet=quiet, reading=False)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command("up")
|
|
149
|
+
def volume_up(
|
|
150
|
+
step: StepArg = 5,
|
|
151
|
+
json_output: JsonOpt = False,
|
|
152
|
+
quiet: QuietOpt = False,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Increase the output volume by STEP (default 5)."""
|
|
155
|
+
with ErrorBoundary(console):
|
|
156
|
+
applied = _controller().up(step)
|
|
157
|
+
_report(applied, json_output=json_output, quiet=quiet, reading=False)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command("down")
|
|
161
|
+
def volume_down(
|
|
162
|
+
step: StepArg = 5,
|
|
163
|
+
json_output: JsonOpt = False,
|
|
164
|
+
quiet: QuietOpt = False,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Decrease the output volume by STEP (default 5)."""
|
|
167
|
+
with ErrorBoundary(console):
|
|
168
|
+
applied = _controller().down(step)
|
|
169
|
+
_report(applied, json_output=json_output, quiet=quiet, reading=False)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command("mute")
|
|
173
|
+
def mute(
|
|
174
|
+
json_output: JsonOpt = False,
|
|
175
|
+
quiet: QuietOpt = False,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Mute the default output."""
|
|
178
|
+
with ErrorBoundary(console):
|
|
179
|
+
muted = _controller().mute()
|
|
180
|
+
_report_mute(muted, json_output=json_output, quiet=quiet)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("unmute")
|
|
184
|
+
def unmute(
|
|
185
|
+
json_output: JsonOpt = False,
|
|
186
|
+
quiet: QuietOpt = False,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Unmute the default output."""
|
|
189
|
+
with ErrorBoundary(console):
|
|
190
|
+
muted = _controller().unmute()
|
|
191
|
+
_report_mute(muted, json_output=json_output, quiet=quiet)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.command("toggle")
|
|
195
|
+
def toggle(
|
|
196
|
+
json_output: JsonOpt = False,
|
|
197
|
+
quiet: QuietOpt = False,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Toggle mute on the default output."""
|
|
200
|
+
with ErrorBoundary(console):
|
|
201
|
+
muted = _controller().toggle_mute()
|
|
202
|
+
_report_mute(muted, json_output=json_output, quiet=quiet)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@app.command("get")
|
|
206
|
+
def get_volume(
|
|
207
|
+
json_output: JsonOpt = False,
|
|
208
|
+
quiet: QuietOpt = False,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Show the current output volume."""
|
|
211
|
+
_show(json_output=json_output, quiet=quiet)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.command("status")
|
|
215
|
+
def status(
|
|
216
|
+
json_output: JsonOpt = False,
|
|
217
|
+
quiet: QuietOpt = False,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Show the current output volume (alias for get)."""
|
|
220
|
+
_show(json_output=json_output, quiet=quiet)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
app()
|