Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions docs/integrations.md
Original file line number Diff line number Diff line change
@@ -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": "/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.
5 changes: 5 additions & 0 deletions docs/output-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 6 additions & 1 deletion lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -190,6 +191,7 @@ def _resolve_version() -> str:
"dumps_records",
"error_envelope",
"history",
"integrations",
"inspection_envelope",
"render_inspection_json",
"testing",
Expand Down Expand Up @@ -233,6 +235,9 @@ def _resolve_version() -> str:
"success_envelope",
"RuntimeBinding",
"ServicesT",
"TelemetryOptions",
"TelemetrySession",
"try_render_rich_table",
"HistoryWriter",
"HistoryDisplayResolver",
"UserConfigLoader",
Expand Down
26 changes: 26 additions & 0 deletions lib/python/base_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/python/base_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading