Coverage for / home / runner / work / bijux-cli / bijux-cli / src / bijux_cli / cli / plugins / commands / install.py: 95%
62 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
1# SPDX-License-Identifier: Apache-2.0
2# Copyright © 2025 Bijan Mousavi
4"""Implements the `plugins install` subcommand for the Bijux CLI.
6This module installs a plugin from PyPI by package name only. It validates that
7the installed package exposes a `bijux_cli.plugins` entry point and that its
8metadata declares compatibility with the running bijux-cli version.
10Output Contract:
11 * Install Success: `{"status": "installed", "plugin": str, "dest": str}`
12 * Dry Run Success: `{"status": "dry-run", "plugin": str, ...}`
13 * Error: `{"error": "...", "code": int}`
15Exit Codes:
16 * `0`: Success.
17 * `1`: A fatal error occurred (e.g., source not found, invalid name,
18 version incompatibility, 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
25import os
26from pathlib import Path
27import shutil
28import subprocess # noqa: S603 # nosec B404 - intentional CLI invocation
29import sys
31import typer
33from bijux_cli.cli.core.command import (
34 new_run_command,
35 raise_exit_intent,
36 validate_common_flags,
37)
38from bijux_cli.cli.core.constants import (
39 OPT_FORMAT,
40 OPT_LOG_LEVEL,
41 OPT_PRETTY,
42 OPT_QUIET,
43)
44from bijux_cli.cli.core.help_text import (
45 HELP_FORMAT,
46 HELP_LOG_LEVEL,
47 HELP_NO_PRETTY,
48 HELP_QUIET,
49)
50from bijux_cli.core.enums import ErrorType
51from bijux_cli.core.precedence import current_execution_policy
52from bijux_cli.plugins import get_plugins_dir
53from bijux_cli.plugins import install_plugin as install_local_plugin
54from bijux_cli.plugins.catalog import PLUGIN_NAME_RE
55from bijux_cli.plugins.metadata import (
56 discover_plugins,
57 get_plugin_metadata,
58 invalidate_plugin_cache,
59 plugins_for_package,
60)
63def install_plugin(
64 name: str = typer.Argument(..., help="PyPI package name"),
65 dry_run: bool = typer.Option(False, "--dry-run"),
66 force: bool = typer.Option(False, "--force", "-F"),
67 quiet: bool = typer.Option(False, *OPT_QUIET, help=HELP_QUIET),
68 fmt: str = typer.Option("json", *OPT_FORMAT, help=HELP_FORMAT),
69 pretty: bool = typer.Option(False, OPT_PRETTY, help=HELP_NO_PRETTY),
70 log_level: str = typer.Option("info", *OPT_LOG_LEVEL, help=HELP_LOG_LEVEL),
71) -> None:
72 """Installs a plugin from PyPI by package name.
74 Args:
75 name (str): The package name to install from PyPI.
76 dry_run (bool): If True, simulates the installation without making changes.
77 force (bool): If True, overwrites an existing plugin of the same name.
78 quiet (bool): If True, suppresses all output except for errors.
79 fmt (str): The output format for confirmation or error messages.
80 pretty (bool): If True, pretty-prints the output. log_level (str): Logging level for diagnostics.
82 Returns:
83 None:
85 Raises:
86 SystemExit: Always exits with a contract-compliant status code and
87 payload, indicating success or detailing an error.
88 """
89 command = "plugins install"
91 policy = current_execution_policy()
92 quiet = policy.quiet
93 include_runtime = policy.include_runtime
94 log_level_value = policy.log_level
95 pretty = policy.pretty
96 fmt_lower = validate_common_flags(
97 fmt,
98 command,
99 quiet,
100 include_runtime=include_runtime,
101 log_level=log_level_value,
102 )
103 local_path = Path(name)
104 if not local_path.exists() and (
105 not PLUGIN_NAME_RE.fullmatch(name) or not name.isascii()
106 ):
107 raise_exit_intent(
108 "Invalid package name: only ASCII letters, digits, dash and underscore are allowed.",
109 code=1,
110 failure="invalid_name",
111 error_type=ErrorType.USER_INPUT,
112 command=command,
113 fmt=fmt_lower,
114 quiet=quiet,
115 include_runtime=include_runtime,
116 log_level=log_level_value,
117 )
119 if dry_run:
120 payload: dict[str, object] = {"status": "dry-run", "package": name}
121 elif local_path.exists():
122 invalidate_plugin_cache()
123 try:
124 install_local_plugin(str(local_path), force=force)
125 invalidate_plugin_cache()
126 discover_plugins()
127 meta = get_plugin_metadata(local_path.name)
128 except Exception as exc:
129 plug_dir = get_plugins_dir() / local_path.name
130 if plug_dir.exists(): 130 ↛ 132line 130 didn't jump to line 132 because the condition on line 130 was always true
131 shutil.rmtree(plug_dir, ignore_errors=True)
132 raise_exit_intent(
133 str(exc),
134 code=1,
135 failure="metadata_error",
136 error_type=ErrorType.PLUGIN,
137 command=command,
138 fmt=fmt_lower,
139 quiet=quiet,
140 include_runtime=include_runtime,
141 log_level=log_level_value,
142 )
143 payload = {
144 "status": "installed",
145 "package": str(local_path),
146 "plugins": [meta.name],
147 }
148 else:
149 invalidate_plugin_cache()
150 cmd = [sys.executable, "-m", "pip", "install", name]
151 if force: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true
152 cmd.append("--upgrade")
153 env = os.environ.copy()
154 env.setdefault("PIP_DISABLE_PIP_VERSION_CHECK", "1")
155 proc = subprocess.run( # noqa: S603 # nosec B603 - controlled command list
156 cmd,
157 env=env,
158 capture_output=True,
159 text=True,
160 )
161 if proc.returncode != 0:
162 detail = proc.stderr.strip() or proc.stdout.strip()
163 raise_exit_intent(
164 f"pip install failed: {detail}",
165 code=1,
166 failure="pip_install_failed",
167 error_type=ErrorType.PLUGIN,
168 command=command,
169 fmt=fmt_lower,
170 quiet=quiet,
171 include_runtime=include_runtime,
172 log_level=log_level_value,
173 )
175 invalidate_plugin_cache()
176 try:
177 discover_plugins()
178 plugins = plugins_for_package(name)
179 except Exception as exc:
180 raise_exit_intent(
181 str(exc),
182 code=1,
183 failure="metadata_error",
184 error_type=ErrorType.PLUGIN,
185 command=command,
186 fmt=fmt_lower,
187 quiet=quiet,
188 include_runtime=include_runtime,
189 log_level=log_level_value,
190 )
192 if not plugins:
193 raise_exit_intent(
194 "Package installed but no bijux_cli.plugins entry point found.",
195 code=1,
196 failure="entrypoint_missing",
197 error_type=ErrorType.PLUGIN,
198 command=command,
199 fmt=fmt_lower,
200 quiet=quiet,
201 include_runtime=include_runtime,
202 log_level=log_level_value,
203 )
205 payload = {
206 "status": "installed",
207 "package": name,
208 "plugins": [p.name for p in plugins],
209 }
211 new_run_command(
212 command_name=command,
213 payload_builder=lambda include: payload,
214 quiet=quiet,
215 fmt=fmt_lower,
216 pretty=pretty,
217 log_level=log_level,
218 )