From a4eff171c597416080393f166d25126e5b4eacbd Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:31:32 -0700 Subject: [PATCH] Add opt-in Rich and OpenTelemetry integrations --- README.md | 5 + docs/integrations.md | 54 ++++++++ docs/output-contracts.md | 5 + lib/python/base_cli/__init__.py | 7 +- lib/python/base_cli/app.py | 26 ++++ lib/python/base_cli/context.py | 1 + lib/python/base_cli/integrations.py | 197 ++++++++++++++++++++++++++++ lib/python/base_cli/output.py | 16 ++- pyproject.toml | 6 + tests/test_integrations.py | 115 ++++++++++++++++ 10 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 docs/integrations.md create mode 100644 lib/python/base_cli/integrations.py create mode 100644 tests/test_integrations.py diff --git a/README.md b/README.md index 1c1546b..26546c2 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ for Unicode display width and safely truncate oversized cells. See [`docs/output-contracts.md`](docs/output-contracts.md) for the output rules and deterministic width controls. +Optional Rich tables and OpenTelemetry lifecycle spans are available through +separate extras; they are never imported or required by the default install. +See [`docs/integrations.md`](docs/integrations.md) for opt-in configuration and +graceful-degradation behavior. + ## Design Goals CLI tools should be easy to write, but not magical. A command should be diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000..0bfb68c --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,54 @@ +# Optional integrations + +The core `base-cli` install has no Rich or OpenTelemetry dependency. Install +only the integration you use: + +```bash +python -m pip install 'base-cli[rich]' +python -m pip install 'base-cli[telemetry]' +``` + +## Rich human tables + +Pass `rich=True` when constructing an app and pass the active context's flag to +the shared record renderer: + +```python +import base_cli + +app = base_cli.App(name="catalog", rich=True) + +@app.command() +def list_items(ctx: base_cli.Context) -> None: + base_cli.render_records( + ({"name": "base", "path": "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/work/base"},), + requested_format="text", + columns=(("NAME", "name"), ("PATH", "path")), + rich=ctx.rich, + ) +``` + +Rich is used only for interactive human text. Redirected text remains TSV, and +CSV, TSV, JSON, and YAML contracts do not change. If Rich is missing or its +renderer fails, the deterministic built-in table is used automatically. + +## OpenTelemetry lifecycle spans + +Telemetry is opt-in and can use the application's configured global provider or +an explicitly supplied tracer: + +```python +import base_cli + +app = base_cli.App( + name="catalog", + telemetry=base_cli.TelemetryOptions(), +) +``` + +Each invocation emits a `base_cli.run` span with a start event and a finish +event. Safe attributes include the run ID, CLI name, environment, dry-run flag, +outcome, exit code, and duration. Raw argv, configuration values, filesystem +paths, and secrets are never attached. A missing API package, invalid provider, +or failing exporter is logged at debug level and treated as a no-op; it cannot +change the command's exit status or cleanup behavior. diff --git a/docs/output-contracts.md b/docs/output-contracts.md index 72bd995..6949406 100644 --- a/docs/output-contracts.md +++ b/docs/output-contracts.md @@ -18,3 +18,8 @@ Long cells are bounded by `max_cell_width` (80 by default), and the complete table is fitted to the detected terminal width (120 columns as a safe fallback) using an ellipsis. Pass `terminal_width` and `max_cell_width` explicitly when a caller needs deterministic rendering in tests or a custom frontend. + +For an optional polished human table, pass `rich=True` to `render_records()`. +Rich is consulted only for interactive `text`; all redirected and structured +formats retain the rules above and fall back to the built-in renderer if Rich +is unavailable or fails. diff --git a/lib/python/base_cli/__init__.py b/lib/python/base_cli/__init__.py index 0ea75cf..cbe6910 100644 --- a/lib/python/base_cli/__init__.py +++ b/lib/python/base_cli/__init__.py @@ -30,7 +30,7 @@ def _resolve_version() -> str: __version__ = _resolve_version() -from . import command_filters, command_protocol, extensions, history, json_contracts, testing +from . import command_filters, command_protocol, extensions, history, integrations, json_contracts, testing from .attachment import ( AttachmentAdapter, AttachmentContextFactory, @@ -87,6 +87,7 @@ def _resolve_version() -> str: ) from .exit_codes import ExitCode from .inspection import inspection_envelope, render_inspection_json +from .integrations import TelemetryOptions, TelemetrySession, try_render_rich_table from .json_contracts import ( JSON_CONTRACT_VERSION, JSON_ERROR_SCHEMA, @@ -190,6 +191,7 @@ def _resolve_version() -> str: "dumps_records", "error_envelope", "history", + "integrations", "inspection_envelope", "render_inspection_json", "testing", @@ -233,6 +235,9 @@ def _resolve_version() -> str: "success_envelope", "RuntimeBinding", "ServicesT", + "TelemetryOptions", + "TelemetrySession", + "try_render_rich_table", "HistoryWriter", "HistoryDisplayResolver", "UserConfigLoader", diff --git a/lib/python/base_cli/app.py b/lib/python/base_cli/app.py index 7639b96..5a056f0 100644 --- a/lib/python/base_cli/app.py +++ b/lib/python/base_cli/app.py @@ -39,6 +39,7 @@ from .errors import ConfigurationError from .exit_codes import ExitCode from .history import utc_now +from .integrations import TelemetryOptions, TelemetrySession, finish_telemetry, start_telemetry from .logging import configure_logger, log_invocation from .json_contracts import dumps_envelope, error_envelope, success_envelope from .lifecycle_options import ( @@ -471,11 +472,17 @@ def __init__( max_run_bundles: int | None = None, max_run_age_seconds: float | None = None, max_run_total_bytes: int | None = None, + rich: bool = False, + telemetry: TelemetryOptions | None = None, ) -> None: if max_log_files is not None and max_log_files < 1: raise ValueError("max_log_files must be greater than 0 when set.") if retention is not None and not isinstance(retention, RetentionPolicy): raise TypeError("retention must be a RetentionPolicy instance or None.") + if not isinstance(rich, bool): + raise TypeError("rich must be a bool.") + if telemetry is not None and not isinstance(telemetry, TelemetryOptions): + raise TypeError("telemetry must be a TelemetryOptions instance or None.") if retention is not None and any( value is not None for value in (max_run_bundles, max_run_age_seconds, max_run_total_bytes) @@ -506,6 +513,8 @@ def __init__( self.help = help self.log_to_file = log_to_file self.max_log_files = max_log_files + self.rich = rich + self.telemetry = telemetry # Standalone applications must not inherit a consumer's product # conventions. Consumers with product-specific policies should pass an # explicit profile. @@ -991,6 +1000,7 @@ def wrapper(**kwargs: Any): started_monotonic_ns = time.monotonic_ns() context: Context[Any, Any, Any] | None = None recorder: RunRecorder | None = None + telemetry_session: TelemetrySession | None = None outcome = outcome_from_exit_code(ExitCode.SUCCESS) invocation_argv: list[str] = [] redaction_plan = self._redaction_plan @@ -1013,6 +1023,7 @@ def wrapper(**kwargs: Any): _capture_invocation_context(context, self) invocation_argv = redact_argv(_current_invocation_argv(), redaction_plan) _start_run_recorder(recorder) + telemetry_session = start_telemetry(self.telemetry, context) log_invocation(context.log, invocation_argv, None) if context.project_root is not None: context.log.debug("project_root=%s", context.project_root) @@ -1058,6 +1069,12 @@ def wrapper(**kwargs: Any): except BaseException as exc: # pylint: disable=broad-exception-caught _warn_lifecycle_failure(context, "Run recorder construction failed", exc) if recorder is not None: + finish_telemetry( + telemetry_session, + context, + outcome, + ended_monotonic_ns=ended_monotonic_ns, + ) _finish_run_recorder( recorder, outcome, @@ -1191,6 +1208,7 @@ def _create_context( history_scope=runtime.history_scope, history_parent_run_id=runtime.history_parent_run_id, json_output=bool(standard.get("json")), + rich=self.rich, ) context._run_metadata_path = run_metadata_path @@ -1328,6 +1346,7 @@ def __init__( self.started_monotonic_ns = time.monotonic_ns() self.context: Context[Any, Any, Any] | None = None self.invocation: _AttachedInvocation | None = None + self.telemetry_session: TelemetrySession | None = None self.context_token: Any = None self.invocation_token: Any = None self.original_click_exit: Callable[..., Any] | None = None @@ -1359,6 +1378,7 @@ def __enter__(self) -> _AttachedLifecycleResource: ) self.invocation_token = _ATTACHED_INVOCATION.set(self.invocation) _start_run_recorder(recorder) + self.telemetry_session = start_telemetry(self.attachment.app.telemetry, context) original_click_exit = self.click_context.exit @@ -1489,6 +1509,12 @@ def _finalize(self) -> None: ended_at=ended_at, ended_monotonic_ns=ended_monotonic_ns, ) + finish_telemetry( + self.telemetry_session, + context, + self.outcome, + ended_monotonic_ns=ended_monotonic_ns, + ) try: context.cleanup() except BaseException as exc: # pylint: disable=broad-exception-caught diff --git a/lib/python/base_cli/context.py b/lib/python/base_cli/context.py index 8bcc9f7..7ab679f 100644 --- a/lib/python/base_cli/context.py +++ b/lib/python/base_cli/context.py @@ -73,6 +73,7 @@ class Context(Generic[ConfigT, ApplicationStateT, ServicesT]): framework_config: FrameworkConfig | None = field(default=None, repr=False, compare=False) config_provenance: Mapping[str, str] = field(default_factory=dict, repr=False, compare=False) json_output: bool = False + rich: bool = False _run_metadata_path: Path | None = field(default=None, init=False, repr=False, compare=False) _owns_temp_dir: bool = field(default=False, init=False, repr=False, compare=False) _owned_temp_identity: tuple[int, int] | None = field(default=None, init=False, repr=False, compare=False) diff --git a/lib/python/base_cli/integrations.py b/lib/python/base_cli/integrations.py new file mode 100644 index 0000000..c984c0b --- /dev/null +++ b/lib/python/base_cli/integrations.py @@ -0,0 +1,197 @@ +"""Optional Rich and OpenTelemetry integrations. + +The core package deliberately imports no integration dependency. Optional +libraries are resolved only after a consumer opts in, and every integration +boundary is best-effort so a missing or broken plugin cannot change command +completion. +""" + +from __future__ import annotations + +import time +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, TextIO + + +@dataclass(frozen=True) +class TelemetryOptions: + """Opt-in OpenTelemetry configuration for one :class:`base_cli.App`. + + ``tracer`` and ``tracer_provider`` are useful for applications that own + SDK setup. When neither is supplied, the global OpenTelemetry provider is + used. The API package is imported lazily only when telemetry is enabled. + """ + + enabled: bool = True + tracer: Any | None = None + tracer_provider: Any | None = None + tracer_name: str = "base_cli" + + +@dataclass +class TelemetrySession: + """Best-effort state for a single lifecycle span.""" + + span: Any + started_monotonic_ns: int + + +def try_render_rich_table( + stream: TextIO, + headers: Sequence[str], + rows: Sequence[Sequence[str]], + footer: str | None, + *, + terminal_width: int | None = None, +) -> bool: + """Render a human table with Rich when it is installed and healthy. + + The function returns ``False`` for every unavailable or failing Rich + import/render path so the caller can use the deterministic plain-table + renderer. Machine formats and redirected output never call this helper. + """ + + try: + from rich.box import SIMPLE_HEAD + from rich.console import Console + from rich.table import Table + from rich.text import Text + except BaseException: # pragma: no cover - depends on optional package + return False + + try: + table = Table( + box=SIMPLE_HEAD, + expand=False, + show_header=True, + header_style="bold", + pad_edge=False, + ) + for header in headers: + table.add_column(header, overflow="ellipsis") + for row in rows: + table.add_row(*(Text.from_plain(value) for value in row)) + + console_kwargs: dict[str, Any] = { + "file": stream, + "force_terminal": True, + "color_system": None, + "markup": False, + "highlight": False, + } + if terminal_width is not None: + console_kwargs["width"] = max(1, terminal_width) + console = Console(**console_kwargs) + console.print(table) + if footer: + console.print() + console.print(footer) + return True + except BaseException: # pragma: no cover - depends on optional package + return False + + +def start_telemetry(options: TelemetryOptions | None, context: Any) -> TelemetrySession | None: + """Start a safe lifecycle span, returning ``None`` on any integration failure.""" + + if options is None or not options.enabled: + return None + + try: + tracer = options.tracer + if tracer is None: + from opentelemetry import trace + + tracer = trace.get_tracer( + options.tracer_name, + tracer_provider=options.tracer_provider, + ) + attributes = _start_attributes(context) + try: + span = tracer.start_span("base_cli.run", attributes=attributes) + except TypeError: + # Small test/demonstration tracers may only accept a span name. + span = tracer.start_span("base_cli.run") + if span is None: + return None + _safe_span_call(span, "add_event", "base_cli.run.started", attributes=attributes) + return TelemetrySession(span=span, started_monotonic_ns=time.monotonic_ns()) + except BaseException as exc: # pragma: no cover - optional package/runtime dependent + _debug_integration_failure(context, "OpenTelemetry start failed", exc) + return None + + +def finish_telemetry( + session: TelemetrySession | None, + context: Any, + outcome: Any, + *, + ended_monotonic_ns: int | None = None, +) -> None: + """Finish a lifecycle span without allowing exporters to affect teardown.""" + + if session is None: + return + + try: + ended = ended_monotonic_ns if ended_monotonic_ns is not None else time.monotonic_ns() + duration_ms = round(max(0, ended - session.started_monotonic_ns) / 1_000_000) + attributes = { + **_start_attributes(context), + "base_cli.outcome": str(getattr(outcome, "kind", "unknown")), + "base_cli.status": str(getattr(outcome, "status", "error")), + "base_cli.exit_code": int(getattr(outcome, "exit_code", 1)), + "base_cli.duration_ms": duration_ms, + } + for key, value in attributes.items(): + _safe_span_call(session.span, "set_attribute", key, value) + _safe_span_call( + session.span, + "add_event", + "base_cli.run.finished", + attributes=attributes, + ) + _safe_span_call(session.span, "end") + except BaseException as exc: # pragma: no cover - optional exporter dependent + _debug_integration_failure(context, "OpenTelemetry finish failed", exc) + + +def _start_attributes(context: Any) -> dict[str, Any]: + """Return a deliberately small, non-sensitive attribute set.""" + + return { + "base_cli.run_id": str(getattr(context, "run_id", "")), + "base_cli.cli_name": str(getattr(context, "cli_name", "")), + "base_cli.environment": str(getattr(context, "environment", "")), + "base_cli.dry_run": bool(getattr(context, "dry_run", False)), + } + + +def _safe_span_call(span: Any, method: str, *args: Any, **kwargs: Any) -> None: + try: + callback = getattr(span, method, None) + if callback is not None: + callback(*args, **kwargs) + except BaseException: + # Exporters and SDK shutdown hooks are outside the command's failure + # boundary. A broken exporter must never fail the user command. + pass + + +def _debug_integration_failure(context: Any, message: str, exc: BaseException) -> None: + try: + logger = getattr(context, "log", None) + if logger is not None: + logger.debug("%s: %s", message, exc) + except BaseException: + pass + + +__all__ = [ + "TelemetryOptions", + "TelemetrySession", + "finish_telemetry", + "start_telemetry", + "try_render_rich_table", +] diff --git a/lib/python/base_cli/output.py b/lib/python/base_cli/output.py index 9d2a2ab..f66414f 100644 --- a/lib/python/base_cli/output.py +++ b/lib/python/base_cli/output.py @@ -13,6 +13,7 @@ import unicodedata from ._dependencies import require_yaml +from .integrations import try_render_rich_table PUBLIC_OUTPUT_FORMATS = ("text", "csv", "tsv", "yaml", "json") @@ -74,6 +75,7 @@ def render_records( minimum_widths: Sequence[int] | None = None, terminal_width: int | None = None, max_cell_width: int | None = _DEFAULT_MAX_CELL_WIDTH, + rich: bool = False, ) -> str: """Render records according to the shared public output contract. @@ -84,7 +86,8 @@ def render_records( header or footer. ``minimum_widths`` applies only to terminal table columns. Terminal cells use Unicode display-cell widths and are bounded by ``terminal_width`` and ``max_cell_width`` with deterministic ellipsis - truncation. + truncation. ``rich=True`` opts terminal text into the optional Rich + renderer and otherwise falls back to the built-in table. """ target = stream if stream is not None else sys.stdout @@ -116,6 +119,7 @@ def render_records( minimum_widths, terminal_width=terminal_width, max_cell_width=max_cell_width, + rich=rich, ) return resolved @@ -204,6 +208,7 @@ def _write_table( *, terminal_width: int | None, max_cell_width: int | None, + rich: bool, ) -> None: selected_minimums = minimum_widths or () if len(selected_minimums) > len(columns): @@ -241,6 +246,15 @@ def _write_table( available_width = terminal_width if terminal_width is not None else _terminal_width(stream) widths = _fit_table_width(widths, available_width) + if rich and try_render_rich_table( + stream, + headers, + table_rows, + footer, + terminal_width=terminal_width, + ): + return + stream.write( " ".join(_pad_cell(_truncate(header, width), width) for header, width in zip(headers, widths)).rstrip() ) diff --git a/pyproject.toml b/pyproject.toml index 30a31de..9c217d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,12 @@ typer = [ # the public Click command classes through Typer 0.25.x for now. "typer>=0.12,<0.26", ] +rich = [ + "rich>=13.7,<15", +] +telemetry = [ + "opentelemetry-api>=1.24,<2", +] [project.urls] Homepage = "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/basefoundry/base-cli" diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..32aab17 --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import io +import tempfile +import unittest +from pathlib import Path + +import base_cli +from base_cli.testing import invoke + + +class _Span: + def __init__(self) -> None: + self.name = "" + self.attributes: dict[str, object] = {} + self.events: list[tuple[str, dict[str, object]]] = [] + self.ended = False + + def set_attribute(self, key: str, value: object) -> None: + self.attributes[key] = value + + def add_event(self, name: str, *, attributes: dict[str, object]) -> None: + self.events.append((name, attributes)) + + def end(self) -> None: + self.ended = True + + +class _Tracer: + def __init__(self) -> None: + self.span = _Span() + + def start_span(self, name: str, *, attributes: dict[str, object]) -> _Span: + self.span.name = name + self.span.attributes.update(attributes) + return self.span + + +class _BrokenTracer: + def start_span(self, *_args: object, **_kwargs: object) -> object: + raise RuntimeError("exporter unavailable") + + +class IntegrationTests(unittest.TestCase): + def test_rich_is_a_graceful_fallback_and_machine_output_is_unchanged(self) -> None: + stream = io.StringIO() + stream.isatty = lambda: True # type: ignore[method-assign] + + base_cli.render_records( + ({"name": "base", "path": "/tmp/base"},), + requested_format="text", + columns=(("NAME", "name"), ("PATH", "path")), + stream=stream, + rich=True, + ) + + self.assertIn("NAME", stream.getvalue()) + self.assertIn("base", stream.getvalue()) + + redirected = io.StringIO() + base_cli.render_records( + ({"name": "base", "path": "/tmp/base"},), + requested_format="tsv", + columns=(("NAME", "name"), ("PATH", "path")), + stream=redirected, + rich=True, + ) + self.assertEqual(redirected.getvalue(), "base\t/tmp/base\n") + + def test_telemetry_emits_safe_lifecycle_events(self) -> None: + tracer = _Tracer() + app = base_cli.App( + name="telemetry-demo", + log_to_file=False, + rich=True, + telemetry=base_cli.TelemetryOptions(tracer=tracer), + ) + seen: dict[str, object] = {} + + @app.command() + def main(ctx: base_cli.Context) -> None: + seen["rich"] = ctx.rich + seen["run_id"] = ctx.run_id + + with tempfile.TemporaryDirectory() as tmpdir: + result = invoke(app, [], home=Path(tmpdir)) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue(seen["rich"]) + self.assertEqual(tracer.span.name, "base_cli.run") + self.assertEqual(tracer.span.attributes["base_cli.run_id"], seen["run_id"]) + self.assertNotIn("argv", tracer.span.attributes) + self.assertNotIn("config", tracer.span.attributes) + self.assertTrue(tracer.span.ended) + self.assertEqual( + [name for name, _attributes in tracer.span.events], + ["base_cli.run.started", "base_cli.run.finished"], + ) + self.assertIn("base_cli.duration_ms", tracer.span.attributes) + + def test_missing_or_broken_telemetry_never_changes_completion(self) -> None: + app = base_cli.App( + name="broken-telemetry", + log_to_file=False, + telemetry=base_cli.TelemetryOptions(tracer=_BrokenTracer()), + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + del ctx + + with tempfile.TemporaryDirectory() as tmpdir: + result = invoke(app, [], home=Path(tmpdir)) + + self.assertEqual(result.exit_code, 0, result.output)