diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 517cc86..a96020f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,3 +27,17 @@ jobs: run: python -m pip install ".[dev]" - name: Run consumer tests run: python -m pytest + + optional-integrations: + runs-on: macos-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Install optional integration extras + run: python -m pip install ".[dev,typer,rich,telemetry]" + - name: Run optional integration tests + run: python -m pytest -q tests/test_optional_scenarios.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7810425..c6fb584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,3 +15,4 @@ and versions are tracked in the repo-root `VERSION` file. - Added lifecycle safety examples for dry-run, structured output and errors, redacted diagnostics, temporary paths, and cleanup. - Added released-package compatibility CI and installed-wheel README command checks. +- Added optional Typer, Rich, and telemetry learning scenarios with minimal-path fallbacks. diff --git a/README.md b/README.md index 9d48559..8babf50 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ boundary explained beside each scenario, see the structured errors, redacted diagnostics, temporary paths, and cleanup. - The [released-package compatibility guide](docs/compatibility.md) explains the supported Base-CLI range and the installed-wheel CI gate. -- The [released-package compatibility guide](docs/compatibility.md) explains - the supported Base-CLI range and the installed-wheel CI gate. +- The [optional integration scenarios](docs/optional-integrations.md) show + Typer, Rich, and OpenTelemetry without making them core dependencies. ## Framework boundary diff --git a/docs/optional-integrations.md b/docs/optional-integrations.md new file mode 100644 index 0000000..853ced1 --- /dev/null +++ b/docs/optional-integrations.md @@ -0,0 +1,59 @@ +# Optional integration scenarios + +The default `base-cli-demo` install remains a small Click application with no +Typer, Rich, or OpenTelemetry SDK dependency. Each optional scenario is a +separate console entry point and degrades to a successful minimal path when +its integration is not installed. + +## Typer + +Install the Typer extra and run the adapter scenario: + +```console +$ python -m pip install ".[typer]" +$ northstar-typer --quiet --name Ada --count 2 +adapter=typer +hello Ada +hello Ada +``` + +The scenario uses `base_cli.attach_typer()` when Typer is present. Without the +extra, the same command uses a native Click fallback and reports +`adapter=click-fallback`; its exit status and output contract remain usable. + +## Rich + +Install Rich to opt into polished human tables: + +```console +$ python -m pip install ".[rich]" +$ northstar-rich --quiet status +``` + +The app is constructed with `rich=True`. Base-CLI lazily uses Rich only for +interactive human text and falls back to its deterministic renderer if Rich is +missing. Machine formats are unchanged: + +```console +$ northstar-rich --quiet status --format json +[{"service":"orders-api","status":"ready"},{"service":"web","status":"degraded"}] +``` + +## OpenTelemetry + +Install the optional API package to enable the lifecycle integration: + +```console +$ python -m pip install ".[telemetry]" +$ northstar-telemetry --quiet +telemetry=enabled +``` + +Without the extra, the command reports `telemetry=unavailable (install +[telemetry])` and still exits successfully. With the extra, Base-CLI owns the +`base_cli.run` lifecycle span and its bounded safe attributes; the scenario +does not attach argv, configuration, paths, or secrets. + +The focused tests run in both modes: the normal CI job exercises the minimal +fallbacks, while the optional-integration CI job installs all three extras and +exercises the enabled adapters. diff --git a/pyproject.toml b/pyproject.toml index bb8a725..bcc242b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,21 @@ dependencies = [ dev = [ "pytest>=8,<9", ] +typer = [ + "typer>=0.12,<0.28", +] +rich = [ + "rich>=13.7,<15", +] +telemetry = [ + "opentelemetry-api>=1.24,<2", +] [project.scripts] northstar = "base_cli_demo.cli:main" +northstar-typer = "base_cli_demo.typer_scenario:main" +northstar-rich = "base_cli_demo.rich_scenario:main" +northstar-telemetry = "base_cli_demo.telemetry_scenario:main" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/base_cli_demo/rich_scenario.py b/src/base_cli_demo/rich_scenario.py new file mode 100644 index 0000000..d9270f5 --- /dev/null +++ b/src/base_cli_demo/rich_scenario.py @@ -0,0 +1,56 @@ +"""Optional Rich output scenario.""" + +from __future__ import annotations + +from typing import Any + +import base_cli +import click + +OUTPUT_FORMAT = click.Choice( + base_cli.output_format_choices().split("|"), + case_sensitive=False, +) + + +@click.group(name="northstar-rich", help="Run the optional Rich output scenario.") +def cli() -> None: + """Keep Rich an optional presentation layer.""" + + +@cli.command() +@click.option( + "--format", + "output_format", + type=OUTPUT_FORMAT, + default="text", + show_default=True, +) +def status(output_format: str) -> None: + """Render a human table or a normal machine format.""" + + context = base_cli.get_current_context() + records: tuple[dict[str, Any], ...] = ( + {"service": "orders-api", "status": "ready"}, + {"service": "web", "status": "degraded"}, + ) + base_cli.render_records( + records, + requested_format=output_format, + columns=(("SERVICE", "service"), ("STATUS", "status")), + rich=context.rich, + ) + + +app = base_cli.App(name="northstar-rich", rich=True, log_to_file=False) +command = app.attach(cli) + + +def main() -> int: + """Use Rich when present and built-in output otherwise.""" + + return base_cli.run_app(command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/base_cli_demo/telemetry_scenario.py b/src/base_cli_demo/telemetry_scenario.py new file mode 100644 index 0000000..de8cb68 --- /dev/null +++ b/src/base_cli_demo/telemetry_scenario.py @@ -0,0 +1,38 @@ +"""Optional OpenTelemetry lifecycle scenario.""" + +from __future__ import annotations + +import importlib.util + +import base_cli +import click + +TELEMETRY_AVAILABLE = importlib.util.find_spec("opentelemetry") is not None + + +@click.command(name="northstar-telemetry", help="Run the optional telemetry scenario.") +def status() -> None: + """Report whether the optional lifecycle span integration is configured.""" + + context = base_cli.get_current_context() + context.log.info("telemetry scenario invoked") + state = "enabled" if TELEMETRY_AVAILABLE else "unavailable (install [telemetry])" + click.echo(f"telemetry={state}") + + +app = base_cli.App( + name="northstar-telemetry", + log_to_file=False, + telemetry=base_cli.TelemetryOptions() if TELEMETRY_AVAILABLE else None, +) +command = app.attach(status) + + +def main() -> int: + """Run telemetry without making its SDK a core dependency.""" + + return base_cli.run_app(command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/base_cli_demo/typer_scenario.py b/src/base_cli_demo/typer_scenario.py new file mode 100644 index 0000000..c22b883 --- /dev/null +++ b/src/base_cli_demo/typer_scenario.py @@ -0,0 +1,61 @@ +"""Optional Typer adapter scenario with a Click fallback.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import base_cli +import click + + +def _hello(output: Callable[[str], Any], name: str, count: int, adapter: str) -> None: + context = base_cli.get_current_context() + context.log.info("running the %s adapter", adapter) + output(f"adapter={adapter}") + for _ in range(count): + output(f"hello {name}") + + +def _click_command() -> Any: + @click.command(name="northstar-typer") + @click.option("--name", default="Ada", show_default=True) + @click.option("--count", default=1, type=click.IntRange(min=1), show_default=True) + def cli(name: str, count: int) -> None: + """Run the Typer learning scenario through its fallback path.""" + _hello(click.echo, name, count, "click-fallback") + + return base_cli.attach(cli, name="northstar-typer", log_to_file=False) + + +def main() -> int: + """Run native Typer when installed, otherwise use the Click fallback.""" + + try: + import typer + except ImportError: + command = _click_command() + else: + typer_app = typer.Typer( + name="northstar-typer", + help="Run the Typer adapter learning scenario.", + no_args_is_help=True, + ) + + @typer_app.command() + def hello( + name: str = typer.Option("Ada", "--name", help="Name to greet."), + count: int = typer.Option(1, "--count", min=1, help="Greeting count."), + ) -> None: + _hello(typer.echo, name, count, "typer") + + command = base_cli.attach_typer( + typer_app, + name="northstar-typer", + log_to_file=False, + ) + return base_cli.run_app(command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_optional_scenarios.py b/tests/test_optional_scenarios.py new file mode 100644 index 0000000..64247de --- /dev/null +++ b/tests/test_optional_scenarios.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +def run_scenario( + module: str, args: list[str], home: Path +) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.update( + { + "HOME": str(home / "home"), + "BASE_CLI_CACHE_DIR": str(home / "cache"), + "USERPROFILE": str(home / "home"), + "LOCALAPPDATA": str(home / "home" / "AppData" / "Local"), + } + ) + return subprocess.run( + [sys.executable, "-m", module, *args], + capture_output=True, + cwd=home, + env=environment, + text=True, + check=False, + ) + + +@pytest.mark.parametrize( + ("module", "args", "expected"), + [ + ("base_cli_demo.typer_scenario", ["--quiet", "--name", "Ada"], "hello Ada"), + ("base_cli_demo.rich_scenario", ["--quiet", "status"], "orders-api"), + ( + "base_cli_demo.telemetry_scenario", + ["--quiet"], + "telemetry=", + ), + ], +) +def test_optional_scenarios_succeed_on_the_minimal_install( + module: str, args: list[str], expected: str, tmp_path: Path +) -> None: + result = run_scenario(module, args, tmp_path) + + assert result.returncode == 0, f"{module}: {result.stdout}\n{result.stderr}" + assert expected in result.stdout + + +def test_rich_machine_output_remains_plain_json(tmp_path: Path) -> None: + result = run_scenario( + "base_cli_demo.rich_scenario", + ["--quiet", "status", "--format", "json"], + tmp_path, + ) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)[0] == {"service": "orders-api", "status": "ready"} + + +def test_typer_adapter_path_is_used_when_the_optional_dependency_is_installed( + tmp_path: Path, +) -> None: + if importlib.util.find_spec("typer") is None: + pytest.skip("Typer is not installed in the minimal test environment") + + result = run_scenario( + "base_cli_demo.typer_scenario", + ["--quiet", "--name", "Ada"], + tmp_path, + ) + + assert result.returncode == 0, result.stderr + assert "adapter=typer" in result.stdout + + +def test_telemetry_reports_the_optional_state_without_affecting_exit_status( + tmp_path: Path, +) -> None: + result = run_scenario( + "base_cli_demo.telemetry_scenario", + ["--quiet"], + tmp_path, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.startswith("telemetry=")