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,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()