Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/commands/docs.py: 100%
82 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"""Docs command for the Bijux CLI.
6Generates a machine-readable specification of the entire CLI, outputting it as
7JSON or YAML. This command is designed for automation, enabling integration
8with external documentation tools or APIs. It supports outputting to stdout or
9a file and ensures all text is ASCII-safe.
11Output Contract:
12 * Success (file): `{"status": "written", "file": "<path>"}`
13 * Success (stdout): The raw specification string is printed directly.
14 * Spec fields: `{"version": str, "commands": list, ...}`
15 * Verbose: Adds `{"python": str, "platform": str}` to the spec.
16 * Error: `{"error": str, "code": int}`
18Exit Codes:
19 * `0`: Success.
20 * `1`: Fatal or internal error.
21 * `2`: CLI argument, flag, or format error.
22 * `3`: ASCII or encoding error.
23"""
25from __future__ import annotations
27from collections.abc import Mapping
28import os
29from pathlib import Path
30import platform
32import typer
33import typer.core
35from bijux_cli.__version__ import __version__
36from bijux_cli.commands.utilities import (
37 contains_non_ascii_env,
38 emit_and_exit,
39 emit_error_and_exit,
40 validate_common_flags,
41)
42from bijux_cli.core.constants import (
43 HELP_DEBUG,
44 HELP_FORMAT,
45 HELP_NO_PRETTY,
46 HELP_QUIET,
47 HELP_VERBOSE,
48)
49from bijux_cli.core.enums import OutputFormat
51typer.core.rich = None # type: ignore[attr-defined,assignment]
53docs_app = typer.Typer( # pytype: skip-file
54 name="docs",
55 help="(-h, --help) Generate API specifications (OpenAPI-like) for Bijux CLI.",
56 rich_markup_mode=None,
57 context_settings={"help_option_names": ["-h", "--help"]},
58 no_args_is_help=False,
59)
61CLI_VERSION = __version__
64def _default_output_path(base: Path, fmt: str) -> Path:
65 """Computes the default output file path for a CLI spec.
67 Args:
68 base (Path): The output directory path.
69 fmt (str): The output format extension, either "json" or "yaml".
71 Returns:
72 Path: The fully resolved path to the output specification file.
73 """
74 return base / f"spec.{fmt}"
77def _resolve_output_target(out: Path | None, fmt: str) -> tuple[str, Path | None]:
78 """Resolves the output target and file path for the CLI spec.
80 Determines if the output should go to stdout or a file, resolving the
81 final path if a directory is provided.
83 Args:
84 out (Path | None): The user-provided output path, which can be a file,
85 a directory, or '-' for stdout.
86 fmt (str): The output format extension ("json" or "yaml").
88 Returns:
89 tuple[str, Path | None]: A tuple containing the target and path. The
90 target is a string ("-" for stdout or a file path), and the path
91 is the resolved `Path` object or `None` for stdout.
92 """
93 if out is None:
94 path = _default_output_path(Path.cwd(), fmt)
95 return str(path), path
96 if str(out) == "-":
97 return "-", None
98 if out.is_dir():
99 path = _default_output_path(out, fmt)
100 return str(path), path
101 return str(out), out
104def _build_spec_payload(include_runtime: bool) -> Mapping[str, object]:
105 """Builds the CLI specification payload.
107 Args:
108 include_runtime (bool): If True, includes Python and platform metadata
109 in the specification.
111 Returns:
112 Mapping[str, object]: A dictionary containing the CLI version, a list
113 of registered commands, and optional runtime details.
115 Raises:
116 ValueError: If the version string or platform metadata contains
117 non-ASCII characters.
118 """
119 from bijux_cli.commands import list_registered_command_names
120 from bijux_cli.commands.utilities import ascii_safe
122 version_str = ascii_safe(CLI_VERSION, "version")
123 payload: dict[str, object] = {
124 "version": version_str,
125 "commands": list_registered_command_names(),
126 }
127 if include_runtime:
128 payload["python"] = ascii_safe(platform.python_version(), "python_version")
129 payload["platform"] = ascii_safe(platform.platform(), "platform")
130 return payload
133OUT_OPTION = typer.Option(
134 None,
135 "--out",
136 "-o",
137 help="Output file path or '-' for stdout. If a directory is given, a default file name is used.",
138)
141@docs_app.callback(invoke_without_command=True)
142def docs(
143 ctx: typer.Context,
144 out: Path | None = OUT_OPTION,
145 quiet: bool = typer.Option(False, "-q", "--quiet", help=HELP_QUIET),
146 verbose: bool = typer.Option(False, "-v", "--verbose", help=HELP_VERBOSE),
147 fmt: str = typer.Option("json", "-f", "--format", help=HELP_FORMAT),
148 pretty: bool = typer.Option(True, "--pretty/--no-pretty", help=HELP_NO_PRETTY),
149 debug: bool = typer.Option(False, "-d", "--debug", help=HELP_DEBUG),
150) -> None:
151 """Defines the entrypoint and logic for the `bijux docs` command.
153 This function orchestrates the entire specification generation process. It
154 validates CLI flags, checks for ASCII-safe environment variables, resolves
155 the output destination, builds the specification payload, and writes the
156 result to a file or stdout. All errors are handled and emitted in a
157 structured format before exiting with a specific code.
159 Args:
160 ctx (typer.Context): The Typer context, used for managing command state.
161 out (Path | None): The output destination: a file path, a directory, or
162 '-' to signify stdout.
163 quiet (bool): If True, suppresses all output except for errors.
164 verbose (bool): If True, includes Python and platform metadata in the spec.
165 fmt (str): The output format, either "json" or "yaml". Defaults to "json".
166 pretty (bool): If True, pretty-prints the output for human readability.
167 debug (bool): If True, enables debug diagnostics, implying `verbose`
168 and `pretty`.
170 Returns:
171 None:
173 Raises:
174 SystemExit: Exits the application with a contract-compliant status code
175 and payload upon any error, including argument validation, ASCII
176 violations, serialization failures, or I/O issues.
177 """
178 from bijux_cli.commands.utilities import normalize_format
179 from bijux_cli.infra.serializer import OrjsonSerializer, PyYAMLSerializer
180 from bijux_cli.infra.telemetry import NullTelemetry
182 command = "docs"
183 effective_include_runtime = (verbose or debug) and not quiet
184 effective_pretty = True if (debug and not quiet) else pretty
186 fmt_lower = normalize_format(fmt)
188 if ctx.args:
189 stray = ctx.args[0]
190 msg = (
191 f"No such option: {stray}"
192 if stray.startswith("-")
193 else f"Too many arguments: {' '.join(ctx.args)}"
194 )
195 emit_error_and_exit(
196 msg,
197 code=2,
198 failure="args",
199 command=command,
200 fmt=fmt_lower,
201 quiet=quiet,
202 include_runtime=effective_include_runtime,
203 debug=debug,
204 )
206 validate_common_flags(
207 fmt,
208 command,
209 quiet,
210 include_runtime=effective_include_runtime,
211 )
213 if contains_non_ascii_env():
214 emit_error_and_exit(
215 "Non-ASCII characters in environment variables",
216 code=3,
217 failure="ascii_env",
218 command=command,
219 fmt=fmt_lower,
220 quiet=quiet,
221 include_runtime=effective_include_runtime,
222 debug=debug,
223 )
225 out_env = os.environ.get("BIJUXCLI_DOCS_OUT")
226 if out is None and out_env:
227 out = Path(out_env)
229 target, path = _resolve_output_target(out, fmt_lower)
231 try:
232 spec = _build_spec_payload(effective_include_runtime)
233 except ValueError as exc:
234 emit_error_and_exit(
235 str(exc),
236 code=3,
237 failure="ascii",
238 command=command,
239 fmt=fmt_lower,
240 quiet=quiet,
241 include_runtime=effective_include_runtime,
242 debug=debug,
243 )
245 output_format = OutputFormat.YAML if fmt_lower == "yaml" else OutputFormat.JSON
246 serializer = (
247 PyYAMLSerializer(NullTelemetry())
248 if output_format is OutputFormat.YAML
249 else OrjsonSerializer(NullTelemetry())
250 )
251 try:
252 content = serializer.dumps(spec, fmt=output_format, pretty=effective_pretty)
253 except Exception as exc:
254 emit_error_and_exit(
255 f"Serialization failed: {exc}",
256 code=1,
257 failure="serialize",
258 command=command,
259 fmt=fmt_lower,
260 quiet=quiet,
261 include_runtime=effective_include_runtime,
262 debug=debug,
263 )
265 if os.environ.get("BIJUXCLI_TEST_IO_FAIL") == "1":
266 emit_error_and_exit(
267 "Simulated I/O failure for test",
268 code=1,
269 failure="io_fail",
270 command=command,
271 fmt=fmt_lower,
272 quiet=quiet,
273 include_runtime=effective_include_runtime,
274 debug=debug,
275 )
277 if target == "-":
278 if not quiet:
279 typer.echo(content)
280 raise typer.Exit(0)
282 if path is None:
283 emit_error_and_exit(
284 "Internal error: expected non-null output path",
285 code=1,
286 failure="internal",
287 command=command,
288 fmt=fmt_lower,
289 quiet=quiet,
290 include_runtime=effective_include_runtime,
291 debug=debug,
292 )
294 parent = path.parent
295 if not parent.exists():
296 emit_error_and_exit(
297 f"Output directory does not exist: {parent}",
298 code=2,
299 failure="output_dir",
300 command=command,
301 fmt=fmt_lower,
302 quiet=quiet,
303 include_runtime=effective_include_runtime,
304 debug=debug,
305 )
307 try:
308 path.write_text(content, encoding="utf-8")
309 except Exception as exc:
310 emit_error_and_exit(
311 f"Failed to write spec: {exc}",
312 code=2,
313 failure="write",
314 command=command,
315 fmt=fmt_lower,
316 quiet=quiet,
317 include_runtime=effective_include_runtime,
318 debug=debug,
319 )
321 emit_and_exit(
322 {"status": "written", "file": str(path)},
323 output_format,
324 effective_pretty,
325 verbose,
326 debug,
327 quiet,
328 command,
329 )