Skip to content
2 changes: 1 addition & 1 deletion compatibility/consumers/atlas_click/src/atlas_click/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from __future__ import annotations

import click
import base_cli
import click


@click.group(name="atlas-consumer", help="Inventory resources managed by Atlas.")
Expand Down
1 change: 0 additions & 1 deletion compatibility/consumers/atlas_click/tests/test_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from pathlib import Path

import base_cli

from atlas_click.cli import command


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import base_cli
import typer


cli = typer.Typer(help="Deploy Beacon services with typed parameters.")


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from pathlib import Path

import base_cli

from beacon_typer.cli import command


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

from __future__ import annotations

import click
import base_cli
from typing import Any

import base_cli
import click

app = base_cli.App(
name="cinder-consumer",
Expand All @@ -27,7 +28,7 @@
default="json",
show_default=True,
)
def reconcile(ctx: base_cli.Context, target: str, output_format: str) -> None:
def reconcile(ctx: base_cli.Context[Any, Any, Any], target: str, output_format: str) -> None:
"""Publish the result of one idempotent reconciliation step."""

action = "would-reconcile" if ctx.dry_run else "reconciled"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from pathlib import Path

import base_cli

from cinder_automation.cli import app


Expand Down
19 changes: 19 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,22 @@ The full gate writes a machine-readable result to
`$BASE_CLI_VALIDATION_RESULT` (or `/tmp/base-cli-validation-result.json`). If
Node.js is unavailable, the result is marked `partial`, the gate exits with
status `2`, and it cannot be reported as an authoritative pass.

Examples and compatibility consumers use the fully parameterized context type
when strict mypy checks a callback directly:

```python
from typing import Any
import base_cli

def main(ctx: base_cli.Context[Any, Any, Any]) -> None:
...
```

### Consumer source quality

The style gate runs Ruff over the entire repository with its standard generated-file
exclusions. The typing gate checks every Git-visible Python source outside `lib/`
(checked separately), `scripts/` (validation tools), and `tests/` (test harnesses)
with strict mypy. This includes example and compatibility consumer packages and
new top-level source directories; untracked sources are included during development.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from typing import Any

import base_cli
import click

Expand Down Expand Up @@ -35,7 +37,7 @@
)
@base_cli.option("--api-token", hidden=True, help="Optional secret for a real adapter.")
def run(
ctx: base_cli.Context,
ctx: base_cli.Context[Any, Any, Any],
target: str,
output_format: str,
api_token: str | None,
Expand Down
4 changes: 3 additions & 1 deletion examples/minimal_cli/src/minimal_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from typing import Any

import base_cli

app = base_cli.App(
Expand All @@ -13,7 +15,7 @@

@app.command()
@base_cli.option("--name", required=True, help="Name to greet.")
def greet(ctx: base_cli.Context, name: str) -> None:
def greet(ctx: base_cli.Context[Any, Any, Any], name: str) -> None:
Comment thread
codeforester marked this conversation as resolved.
"""Print a deterministic greeting."""

ctx.log.info("greeting requested for %s", name)
Expand Down
48 changes: 48 additions & 0 deletions scripts/validate_consumer_typing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Type-check every example and compatibility consumer at strict settings."""

from __future__ import annotations

import os
import subprocess
import sys
from pathlib import Path


def main() -> int:
root = Path(__file__).resolve().parents[1]
# Git discovery also includes new, untracked source directories. Infrastructure
# scripts/tests have separate runtime gates; all product/example code is typed.
result = subprocess.run(
["git", "ls-files", "--cached", "--others", "--exclude-standard", "--", "*.py"],
Comment thread
codeforester marked this conversation as resolved.
cwd=root,
check=True,
capture_output=True,
Comment thread
codeforester marked this conversation as resolved.
text=True,
)
files = sorted(set(result.stdout.splitlines()))
excluded = {"lib", "scripts", "tests"}
sources = [name for name in files if Path(name).parts[0] not in excluded]
if not sources:
raise RuntimeError("No consumer Python sources found")
print("Consumer typing sources:")
print("\n".join(f"- {source}" for source in sources))
return subprocess.run(
[sys.executable, "-m", "mypy", "--strict", "--explicit-package-bases", *sources],
cwd=root,
env={
**os.environ,
"MYPYPATH": os.pathsep.join(
str(p)
for p in [
root / "lib/python",
*sorted(root.glob("examples/*/src")),
*sorted(root.glob("compatibility/consumers/*/src")),
]
),
},
check=False,
).returncode


if __name__ == "__main__":
raise SystemExit(main())
7 changes: 5 additions & 2 deletions tests/full_validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,15 @@ run_typing() {
require_commands python mypy
python -m mypy --strict examples/typed_consumer.py
python -m mypy --strict lib/python/base_cli
# Discover all Python sources so new top-level directories cannot escape checks.
python scripts/validate_consumer_typing.py
}

run_style() {
require_commands ruff
ruff format --check lib/python/base_cli scripts examples tests
ruff check lib/python/base_cli scripts examples tests
# Markdown examples are validated by the dedicated documentation gate.
ruff format --check --exclude "*.md" .
ruff check .
}

run_contracts() {
Expand Down
34 changes: 34 additions & 0 deletions tests/test_validate_consumer_typing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

import importlib.util
import subprocess
from pathlib import Path
from unittest.mock import patch

SCRIPT = Path(__file__).parents[1] / "scripts" / "validate_consumer_typing.py"
SPEC = importlib.util.spec_from_file_location("validate_consumer_typing", SCRIPT)
if SPEC is None or SPEC.loader is None: # pragma: no cover
raise ImportError(f"Unable to load {SCRIPT}")
module = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(module)


def test_typing_gate_discovers_and_reports_only_consumer_sources() -> None:
discovery = subprocess.CompletedProcess(
["git"],
0,
"lib/python/base_cli/core.py\nscripts/tool.py\ntests/test.py\n"
"examples/demo/src/demo/cli.py\ncompatibility/consumers/atlas/src/atlas/cli.py\n",
"",
)
mypy = subprocess.CompletedProcess(["mypy"], 0)
with patch.object(module.subprocess, "run", side_effect=[discovery, mypy]) as run:
assert module.main() == 0

command = run.call_args_list[1].args[0]
assert command[-2:] == ["compatibility/consumers/atlas/src/atlas/cli.py", "examples/demo/src/demo/cli.py"]
assert "lib/python/base_cli/core.py" not in command
assert "scripts/tool.py" not in command
assert "tests/test.py" not in command
environment = run.call_args_list[1].kwargs["env"]
assert str(SCRIPT.parents[1] / "lib/python") in environment["MYPYPATH"]
Loading