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
6 changes: 3 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@ jobs:
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
run: python -m pip install ".[dev,typer,rich,telemetry,yaml]"
- name: Run optional integration and YAML renderer tests
run: python -m pytest -q tests/test_optional_scenarios.py tests/test_yaml_output.py
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ boundary explained beside each scenario, see the
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.
- The [YAML output guide](docs/yaml-output.md) explains its optional renderer
extra and the preflight behavior when it is absent.
- The [release process](docs/release-process.md) covers reproducible wheel and
source-distribution validation separately from Base-CLI versioning.

Expand Down
14 changes: 14 additions & 0 deletions docs/yaml-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Optional YAML Output

YAML is a supported Base-CLI renderer, but it is intentionally not part of the
demo's minimal dependencies. Install the optional extra before using
`--format yaml`:

```console
$ python -m pip install "base-cli-demo[yaml]"
$ northstar --quiet status --format yaml
```

Without the extra, Northstar reports this install command before running the
consumer command. In particular, a reconciliation does not persist local state
and then fail while trying to render YAML.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ typer = [
rich = [
"rich>=13.7,<15",
]
yaml = [
"base-cli[yaml]>=0.4.3,<0.5",
]
telemetry = [
"opentelemetry-api>=1.24,<2",
]
Expand Down
24 changes: 24 additions & 0 deletions src/base_cli_demo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
from collections.abc import Mapping
from importlib import import_module
from importlib.resources import files
from typing import Any

Expand All @@ -19,6 +20,28 @@
)


def _check_format_dependency(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Altitude: the consumer re-implements "is the optional YAML renderer available" via find_spec instead of reusing base_cli's own check, letting the two diverge (see the find_spec-vs-import gap flagged on line 28). base_cli only exposes this as the private base_cli._dependencies.require_yaml (a real try/import, not find_spec) — there's no public preflight helper, so every consumer with a --format option has to hand-roll an equivalent, and a future optional format would need the same ad hoc logic copy-pasted again instead of one shared, correct base_cli entry point.

Flagged by automated review (base-cli-demo#34).

_context: click.Context, _parameter: click.Parameter, value: str
) -> str:
"""Reject unavailable optional renderers before command side effects."""

if value.lower() == "yaml":
try:
yaml = import_module("yaml")
safe_dump = getattr(yaml, "safe_dump", None)
if not callable(safe_dump):
raise ImportError("PyYAML does not expose safe_dump")
# Exercise the same serializer entry point used by Base-CLI so a
# broken or shadowing module is rejected before reconciliation.
safe_dump([], sort_keys=False, allow_unicode=True)
except Exception as exc:
raise click.ClickException(
"YAML output requires the optional renderer; install it with "
"`python -m pip install 'base-cli-demo[yaml]'`."
) from exc
return value


def _load_services() -> tuple[dict[str, str], ...]:
"""Load and validate the application-owned deterministic fixture."""

Expand Down Expand Up @@ -124,6 +147,7 @@ def _format_option(function: Any) -> Any:
default="text",
show_default=True,
help="Render text, CSV, TSV, YAML, JSON, or NDJSON.",
callback=_check_format_dependency,
)(function)


Expand Down
42 changes: 42 additions & 0 deletions tests/test_yaml_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

import importlib.util
from pathlib import Path
from typing import Any

import base_cli
import pytest

from base_cli_demo.cli import command


def invoke(args: list[str], home: Path) -> Any:
return base_cli.testing.invoke(command, ["--quiet", *args], home=home)


def test_yaml_without_optional_renderer_fails_before_reconciliation_state(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
def missing_renderer(_name: str) -> object:
raise ModuleNotFoundError("yaml")

monkeypatch.setattr("base_cli_demo.cli.import_module", missing_renderer)
result = invoke(["release", "reconcile", "--format", "yaml"], tmp_path)

assert result.exit_code == 1
assert "base-cli-demo[yaml]" in result.output + getattr(result, "stderr", "")
assert list(tmp_path.rglob("last-reconciliation.json")) == []


def test_yaml_output_renders_with_the_optional_extra(tmp_path: Path) -> None:
if importlib.util.find_spec("yaml") is None:
pytest.skip("PyYAML is installed by the optional yaml extra")

result = base_cli.testing.invoke(
command,
["--quiet", "status", "--format", "yaml"],
home=tmp_path,
)

assert result.exit_code == 0, result.output
assert "service: orders-api" in result.stdout
Loading
Loading