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
14 changes: 14 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
59 changes: 59 additions & 0 deletions docs/optional-integrations.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
56 changes: 56 additions & 0 deletions src/base_cli_demo/rich_scenario.py
Original file line number Diff line number Diff line change
@@ -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())
38 changes: 38 additions & 0 deletions src/base_cli_demo/telemetry_scenario.py
Original file line number Diff line number Diff line change
@@ -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())
61 changes: 61 additions & 0 deletions src/base_cli_demo/typer_scenario.py
Original file line number Diff line number Diff line change
@@ -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())
93 changes: 93 additions & 0 deletions tests/test_optional_scenarios.py
Original file line number Diff line number Diff line change
@@ -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=")
Loading