Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/commands/plugins/uninstall.py: 100%
49 statements
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
1# SPDX-License-Identifier: MIT
2# Copyright © 2025 Bijan Mousavi
4"""Implements the `plugins uninstall` subcommand for the Bijux CLI.
6This module contains the logic for permanently removing an installed plugin
7from the filesystem. The operation locates the plugin directory by its exact
8name, performs security checks (e.g., refusing to act on symbolic links),
9and uses a file lock to ensure atomicity before deleting the directory.
11Output Contract:
12 * Success: `{"status": "uninstalled", "plugin": str}`
13 * Error: `{"error": str, "code": int}`
15Exit Codes:
16 * `0`: Success.
17 * `1`: A fatal error occurred (e.g., plugin not found, permission denied,
18 filesystem error).
19 * `2`: An invalid flag was provided (e.g., bad format).
20 * `3`: An ASCII or encoding error was detected in the environment.
21"""
23from __future__ import annotations
25from collections.abc import Iterator
26import contextlib
27import fcntl
28from pathlib import Path
29import shutil
30import unicodedata
32import typer
34from bijux_cli.commands.plugins.utils import refuse_on_symlink
35from bijux_cli.commands.utilities import (
36 emit_error_and_exit,
37 new_run_command,
38 validate_common_flags,
39)
40from bijux_cli.core.constants import (
41 HELP_DEBUG,
42 HELP_FORMAT,
43 HELP_NO_PRETTY,
44 HELP_QUIET,
45 HELP_VERBOSE,
46)
47from bijux_cli.services.plugins import get_plugins_dir
50def uninstall_plugin(
51 name: str = typer.Argument(..., help="Plugin name"),
52 quiet: bool = typer.Option(False, "-q", "--quiet", help=HELP_QUIET),
53 verbose: bool = typer.Option(False, "-v", "--verbose", help=HELP_VERBOSE),
54 fmt: str = typer.Option("json", "-f", "--format", help=HELP_FORMAT),
55 pretty: bool = typer.Option(True, "--pretty/--no-pretty", help=HELP_NO_PRETTY),
56 debug: bool = typer.Option(False, "-d", "--debug", help=HELP_DEBUG),
57) -> None:
58 """Removes an installed plugin by deleting its directory.
60 This function locates the plugin directory by name, performs several safety
61 checks, acquires a file lock to ensure atomicity, and then permanently
62 removes the plugin from the filesystem.
64 Args:
65 name (str): The name of the plugin to uninstall. The match is
66 case-sensitive and Unicode-aware.
67 quiet (bool): If True, suppresses all output except for errors.
68 verbose (bool): If True, includes Python/platform details in error outputs.
69 fmt (str): The output format for confirmation or error messages.
70 pretty (bool): If True, pretty-prints the output.
71 debug (bool): If True, enables debug diagnostics.
73 Returns:
74 None:
76 Raises:
77 SystemExit: Always exits with a contract-compliant status code and
78 payload, indicating success or detailing an error.
79 """
80 command = "plugins uninstall"
82 fmt_lower = validate_common_flags(fmt, command, quiet)
83 plugins_dir = get_plugins_dir()
84 refuse_on_symlink(plugins_dir, command, fmt_lower, quiet, verbose, debug)
86 lock_file = plugins_dir / ".bijux_install.lock"
88 plugin_dirs: list[Path] = []
89 try:
90 plugin_dirs = [
91 p
92 for p in plugins_dir.iterdir()
93 if p.is_dir()
94 and unicodedata.normalize("NFC", p.name)
95 == unicodedata.normalize("NFC", name)
96 ]
97 except Exception as exc:
98 emit_error_and_exit(
99 f"Could not list plugins dir '{plugins_dir}': {exc}",
100 code=1,
101 failure="list_failed",
102 command=command,
103 fmt=fmt_lower,
104 quiet=quiet,
105 include_runtime=verbose,
106 debug=debug,
107 )
109 if not plugin_dirs:
110 emit_error_and_exit(
111 f"Plugin '{name}' is not installed.",
112 code=1,
113 failure="not_installed",
114 command=command,
115 fmt=fmt_lower,
116 quiet=quiet,
117 include_runtime=verbose,
118 debug=debug,
119 )
121 plug_path = plugin_dirs[0]
123 @contextlib.contextmanager
124 def _lock(fp: Path) -> Iterator[None]:
125 """Provides an exclusive, non-blocking file lock.
127 This context manager attempts to acquire a lock on the specified file.
128 It is used to ensure atomic filesystem operations within the plugins
129 directory.
131 Args:
132 fp (Path): The path to the file to lock.
134 Yields:
135 None: Yields control to the `with` block once the lock is acquired.
136 """
137 fp.parent.mkdir(parents=True, exist_ok=True)
138 with fp.open("w") as fh:
139 fcntl.flock(fh, fcntl.LOCK_EX)
140 try:
141 yield
142 finally:
143 fcntl.flock(fh, fcntl.LOCK_UN)
145 with _lock(lock_file):
146 if not plug_path.exists():
147 pass
148 elif plug_path.is_symlink():
149 emit_error_and_exit(
150 f"Plugin path '{plug_path}' is a symlink. Refusing to uninstall.",
151 code=1,
152 failure="symlink_path",
153 command=command,
154 fmt=fmt_lower,
155 quiet=quiet,
156 include_runtime=verbose,
157 debug=debug,
158 )
159 elif not plug_path.is_dir():
160 emit_error_and_exit(
161 f"Plugin path '{plug_path}' is not a directory.",
162 code=1,
163 failure="not_dir",
164 command=command,
165 fmt=fmt_lower,
166 quiet=quiet,
167 include_runtime=verbose,
168 debug=debug,
169 )
170 else:
171 try:
172 shutil.rmtree(plug_path)
173 except PermissionError:
174 emit_error_and_exit(
175 f"Permission denied removing '{plug_path}'",
176 code=1,
177 failure="permission_denied",
178 command=command,
179 fmt=fmt_lower,
180 quiet=quiet,
181 include_runtime=verbose,
182 debug=debug,
183 )
184 except Exception as exc:
185 emit_error_and_exit(
186 f"Failed to remove '{plug_path}': {exc}",
187 code=1,
188 failure="remove_failed",
189 command=command,
190 fmt=fmt_lower,
191 quiet=quiet,
192 include_runtime=verbose,
193 debug=debug,
194 )
196 payload = {"status": "uninstalled", "plugin": name}
198 new_run_command(
199 command_name=command,
200 payload_builder=lambda include: payload,
201 quiet=quiet,
202 verbose=verbose,
203 fmt=fmt_lower,
204 pretty=pretty,
205 debug=debug,
206 )