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

1# SPDX-License-Identifier: MIT 

2# Copyright © 2025 Bijan Mousavi 

3 

4"""Implements the `plugins uninstall` subcommand for the Bijux CLI. 

5 

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. 

10 

11Output Contract: 

12 * Success: `{"status": "uninstalled", "plugin": str}` 

13 * Error: `{"error": str, "code": int}` 

14 

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""" 

22 

23from __future__ import annotations 

24 

25from collections.abc import Iterator 

26import contextlib 

27import fcntl 

28from pathlib import Path 

29import shutil 

30import unicodedata 

31 

32import typer 

33 

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 

48 

49 

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. 

59 

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. 

63 

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. 

72 

73 Returns: 

74 None: 

75 

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" 

81 

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) 

85 

86 lock_file = plugins_dir / ".bijux_install.lock" 

87 

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 ) 

108 

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 ) 

120 

121 plug_path = plugin_dirs[0] 

122 

123 @contextlib.contextmanager 

124 def _lock(fp: Path) -> Iterator[None]: 

125 """Provides an exclusive, non-blocking file lock. 

126 

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. 

130 

131 Args: 

132 fp (Path): The path to the file to lock. 

133 

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) 

144 

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 ) 

195 

196 payload = {"status": "uninstalled", "plugin": name} 

197 

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 )