Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/commands/help.py: 99%
154 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 `help` command for the Bijux CLI.
6This module provides a contextual help system that can generate and display
7help text for any command or subcommand. It supports multiple output formats,
8including human-readable text for interactive use and structured JSON or YAML
9for automation and integration purposes. It also includes special logic to
10suppress known noisy warnings from the plugin system during help generation.
12Output Contract:
13 * Human: Standard CLI help text is printed to stdout.
14 * JSON/YAML: `{"help": str}`
15 * Verbose: Adds `{"python": str, "platform": str, "runtime_ms": int}`.
16 * Error: `{"error": str, "code": int}`
18Exit Codes:
19 * `0`: Success.
20 * `1`: Fatal or internal error.
21 * `2`: CLI argument, flag, or "command not found" error.
22 * `3`: ASCII or encoding error.
23"""
25from __future__ import annotations
27from collections.abc import Mapping
28import platform as _platform
29import sys
30import sys as _sys
31import time
32from typing import Any
34import click
35import click as _click
36import typer
37import typer as _typer
39from bijux_cli.commands.utilities import (
40 ascii_safe,
41 contains_non_ascii_env,
42 emit_and_exit,
43 emit_error_and_exit,
44 validate_common_flags,
45)
46from bijux_cli.contracts import EmitterProtocol
47from bijux_cli.core.constants import (
48 HELP_DEBUG,
49 HELP_FORMAT_HELP,
50 HELP_NO_PRETTY,
51 HELP_QUIET,
52 HELP_VERBOSE,
53)
54from bijux_cli.core.di import DIContainer
55from bijux_cli.core.enums import OutputFormat
57if len(_sys.argv) > 1 and _sys.argv[1] == "help" and "--quiet" in _sys.argv:
58 import io
59 import sys
60 from typing import IO, Any, AnyStr
62 import click as _click
63 import typer as _typer
65 _orig_stderr = sys.stderr
66 _orig_click_echo = _click.echo
67 _orig_click_secho = _click.secho
69 class _FilteredStderr(io.TextIOBase):
70 """A proxy for sys.stderr that filters known noisy plugin warnings."""
72 def write(self, data: str) -> int:
73 """Writes to stderr, suppressing specific noisy plugin warnings.
75 Args:
76 data (str): The string to write to the stream.
78 Returns:
79 int: The number of characters written, or 0 if suppressed.
80 """
81 if data.strip() == "":
82 return 0
83 if (
84 "Plugin 'test-src' does not expose a Typer app via 'cli()' or 'app'"
85 in data
86 or "does not expose a Typer app" in data
87 ):
88 return 0
89 return _orig_stderr.write(data)
91 def flush(self) -> None:
92 """Flushes the underlying stderr stream."""
93 _orig_stderr.flush()
95 def __getattr__(self, name: str) -> Any:
96 """Delegates attribute access to the original `sys.stderr`.
98 Args:
99 name (str): The name of the attribute to access.
101 Returns:
102 Any: The attribute from the original `sys.stderr`.
103 """
104 return getattr(_orig_stderr, name)
106 sys.stderr = _FilteredStderr()
108 def _filtered_echo(
109 message: Any = None,
110 file: IO[AnyStr] | None = None,
111 nl: bool = True,
112 err: bool = False,
113 color: bool | None = None,
114 **styles: Any,
115 ) -> None:
116 """A proxy for click.echo that filters known plugin warnings.
118 Args:
119 message (Any): The message to print.
120 file (IO[AnyStr] | None): The output stream.
121 nl (bool): If True, print a newline character at the end.
122 err (bool): If True, print to stderr instead of stdout.
123 color (bool | None): If True, enable color output.
124 **styles: Additional style keyword arguments for `click.secho`.
125 """
126 text = "" if message is None else str(message)
127 if not text.strip():
128 return
129 if (
130 text.startswith("[WARN] Plugin 'test-src'")
131 and "does not expose a Typer app" in text
132 ):
133 return
134 if styles: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 _orig_click_secho(message, file=file, nl=nl, err=err, color=color, **styles)
136 else:
137 _orig_click_echo(message, file=file, nl=nl, err=err, color=color)
139 _click.echo = _filtered_echo
140 _click.secho = _filtered_echo
141 _typer.echo = _filtered_echo
142 _typer.secho = _filtered_echo
144_HUMAN = "human"
145_VALID_FORMATS = ("human", "json", "yaml")
148def _find_target_command(
149 ctx: typer.Context, path: list[str]
150) -> tuple[click.Command, click.Context] | None:
151 """Locates the Click command and context for a given command path.
153 Args:
154 ctx (typer.Context): The Typer context object for the CLI.
155 path (list[str]): A list of command and subcommand tokens.
157 Returns:
158 tuple[click.Command, click.Context] | None: A tuple containing the
159 matched command and its context, or None if not found.
160 """
161 root_cmd: click.Command | None = ctx.parent.command if ctx.parent else None
162 if not root_cmd:
163 return None
165 current_cmd: click.Command | None = root_cmd
166 current_ctx = click.Context(root_cmd, info_name="bijux")
168 for token in path:
169 if not isinstance(current_cmd, click.Group):
170 return None
171 next_cmd = current_cmd.get_command(current_ctx, token)
172 if not next_cmd:
173 return None
174 current_ctx = click.Context(next_cmd, info_name=token, parent=current_ctx)
175 current_cmd = next_cmd
177 assert current_cmd is not None # noqa: S101 # nosec: B101
178 return current_cmd, current_ctx
181def _get_formatted_help(cmd: click.Command, ctx: click.Context) -> str:
182 """Gets and formats the help text for a command.
184 This helper ensures that the short help option '-h' is included in the
185 final help text if it was defined in the command's context settings.
187 Args:
188 cmd (click.Command): The Click command object.
189 ctx (click.Context): The Click context for the command.
191 Returns:
192 str: The formatted help text.
193 """
194 help_text = cmd.get_help(ctx)
195 if (
196 hasattr(cmd, "context_settings")
197 and cmd.context_settings
198 and "-h" in cmd.context_settings.get("help_option_names", [])
199 and "-h, --help" not in help_text
200 ):
201 help_text = help_text.replace("--help", "-h, --help")
202 return help_text
205def _build_help_payload(
206 help_text: str, include_runtime: bool, started_at: float
207) -> Mapping[str, Any]:
208 """Builds a structured help payload for JSON/YAML output.
210 Args:
211 help_text (str): The CLI help text to be included in the payload.
212 include_runtime (bool): If True, adds Python, platform, and runtime
213 metadata to the payload.
214 started_at (float): The start time from `time.perf_counter()` to use
215 for calculating the runtime duration.
217 Returns:
218 Mapping[str, Any]: A payload containing help text and optional runtime
219 fields.
220 """
221 payload: dict[str, Any] = {"help": help_text}
222 if include_runtime:
223 payload["python"] = ascii_safe(sys.version.split()[0], "python_version")
224 payload["platform"] = ascii_safe(_platform.platform(), "platform")
225 payload["runtime_ms"] = int((time.perf_counter() - started_at) * 1_000)
226 return payload
229typer.core.rich = None # type: ignore[attr-defined,assignment]
231help_app = typer.Typer( # pytype: skip-file
232 name="help",
233 add_completion=False,
234 help="Show help for any CLI command or subcommand.",
235 context_settings={
236 "help_option_names": ["-h", "--help"],
237 "ignore_unknown_options": True,
238 "allow_extra_args": True,
239 "allow_interspersed_args": True,
240 },
241)
243ARGS = typer.Argument(None, help="Command path, e.g. 'config get'.")
246@help_app.callback(invoke_without_command=True)
247def help_callback(
248 ctx: typer.Context,
249 command_path: list[str] | None = ARGS,
250 quiet: bool = typer.Option(False, "-q", "--quiet", help=HELP_QUIET),
251 verbose: bool = typer.Option(False, "-v", "--verbose", help=HELP_VERBOSE),
252 fmt: str = typer.Option(_HUMAN, "-f", "--format", help=HELP_FORMAT_HELP),
253 pretty: bool = typer.Option(True, "--pretty/--no-pretty", help=HELP_NO_PRETTY),
254 debug: bool = typer.Option(False, "-d", "--debug", help=HELP_DEBUG),
255) -> None:
256 """Defines the entrypoint and logic for the `bijux help` command.
258 This function orchestrates the entire help generation process. It parses the
259 target command path, finds the corresponding command object, performs ASCII
260 and format validation, and emits the help text in the specified format.
262 Args:
263 ctx (typer.Context): The Typer context for the CLI.
264 command_path (list[str] | None): A list of tokens representing the path
265 to the target command (e.g., `["config", "get"]`).
266 quiet (bool): If True, suppresses all output. The exit code is the
267 primary indicator of outcome.
268 verbose (bool): If True, includes Python and platform details in
269 structured output formats.
270 fmt (str): The output format: "human", "json", or "yaml".
271 pretty (bool): If True, pretty-prints structured output.
272 debug (bool): If True, enables debug diagnostics, implying `verbose`
273 and `pretty`.
275 Returns:
276 None:
278 Raises:
279 SystemExit: Always exits with a contract-compliant exit code and payload
280 upon completion or error.
281 """
282 started_at = time.perf_counter()
284 if "-h" in sys.argv or "--help" in sys.argv:
285 all_args = sys.argv[2:]
286 known_flags_with_args = {"-f", "--format"}
287 path_tokens = []
288 i = 0
289 while i < len(all_args):
290 arg = all_args[i]
291 if arg in known_flags_with_args:
292 i += 2
293 elif arg.startswith("-"):
294 i += 1
295 else:
296 path_tokens.append(arg)
297 i += 1
299 target = _find_target_command(ctx, path_tokens) or _find_target_command(ctx, [])
300 if target:
301 target_cmd, target_ctx = target
302 help_text = _get_formatted_help(target_cmd, target_ctx)
303 typer.echo(help_text)
304 raise typer.Exit(0)
306 tokens = command_path or []
307 command = "help"
308 effective_include_runtime = (verbose or debug) and not quiet
309 effective_pretty = True if (debug and not quiet) else pretty
310 fmt_lower = fmt.strip().lower()
311 error_fmt = fmt_lower if fmt_lower in ("json", "yaml") else "json"
313 if quiet:
314 if fmt_lower not in _VALID_FORMATS:
315 raise SystemExit(2)
317 for token in tokens:
318 if "\x00" in token:
319 raise SystemExit(3)
320 try:
321 token.encode("ascii")
322 except UnicodeEncodeError as err:
323 raise SystemExit(3) from err
325 if contains_non_ascii_env():
326 raise SystemExit(3)
328 if not _find_target_command(ctx, tokens):
329 raise SystemExit(2)
331 raise SystemExit(0)
333 if fmt_lower != "human":
334 validate_common_flags(
335 fmt,
336 command,
337 quiet,
338 include_runtime=effective_include_runtime,
339 )
341 if fmt_lower not in _VALID_FORMATS:
342 emit_error_and_exit(
343 f"Unsupported format: '{fmt}'",
344 code=2,
345 failure="format",
346 command=command,
347 fmt=error_fmt,
348 quiet=quiet,
349 include_runtime=effective_include_runtime,
350 debug=debug,
351 )
353 for token in tokens:
354 if "\x00" in token:
355 emit_error_and_exit(
356 "Embedded null byte in command path",
357 code=3,
358 failure="null_byte",
359 command=command,
360 fmt=error_fmt,
361 quiet=quiet,
362 include_runtime=effective_include_runtime,
363 debug=debug,
364 )
365 try:
366 token.encode("ascii")
367 except UnicodeEncodeError:
368 emit_error_and_exit(
369 f"Non-ASCII characters in command path: {token!r}",
370 code=3,
371 failure="ascii",
372 command=command,
373 fmt=error_fmt,
374 quiet=quiet,
375 include_runtime=effective_include_runtime,
376 debug=debug,
377 )
379 if contains_non_ascii_env():
380 emit_error_and_exit(
381 "Non-ASCII in environment",
382 code=3,
383 failure="ascii",
384 command=command,
385 fmt=error_fmt,
386 quiet=quiet,
387 include_runtime=effective_include_runtime,
388 debug=debug,
389 )
391 target = _find_target_command(ctx, tokens)
392 if not target:
393 emit_error_and_exit(
394 f"No such command: {' '.join(tokens)}",
395 code=2,
396 failure="not_found",
397 command=command,
398 fmt=error_fmt,
399 quiet=quiet,
400 include_runtime=effective_include_runtime,
401 debug=debug,
402 )
404 DIContainer.current().resolve(EmitterProtocol)
405 target_cmd, target_ctx = target
406 help_text = _get_formatted_help(target_cmd, target_ctx)
408 if fmt_lower == _HUMAN:
409 typer.echo(help_text)
410 raise typer.Exit(0)
412 try:
413 payload = _build_help_payload(help_text, effective_include_runtime, started_at)
414 except ValueError as exc:
415 emit_error_and_exit(
416 str(exc),
417 code=3,
418 failure="ascii",
419 command=command,
420 fmt=fmt_lower,
421 quiet=quiet,
422 include_runtime=effective_include_runtime,
423 debug=debug,
424 )
426 output_format = OutputFormat.YAML if fmt_lower == "yaml" else OutputFormat.JSON
427 emit_and_exit(
428 payload=payload,
429 fmt=output_format,
430 effective_pretty=effective_pretty,
431 verbose=verbose,
432 debug=debug,
433 quiet=quiet,
434 command=command,
435 exit_code=0,
436 )