diff --git a/compatibility/consumers/atlas_click/src/atlas_click/cli.py b/compatibility/consumers/atlas_click/src/atlas_click/cli.py index 3935d21..da4c15c 100644 --- a/compatibility/consumers/atlas_click/src/atlas_click/cli.py +++ b/compatibility/consumers/atlas_click/src/atlas_click/cli.py @@ -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.") diff --git a/compatibility/consumers/atlas_click/tests/test_consumer.py b/compatibility/consumers/atlas_click/tests/test_consumer.py index fd0f6c6..6f85e08 100644 --- a/compatibility/consumers/atlas_click/tests/test_consumer.py +++ b/compatibility/consumers/atlas_click/tests/test_consumer.py @@ -4,7 +4,6 @@ from pathlib import Path import base_cli - from atlas_click.cli import command diff --git a/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py b/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py index 9ea6672..8bada8b 100644 --- a/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py +++ b/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py @@ -5,7 +5,6 @@ import base_cli import typer - cli = typer.Typer(help="Deploy Beacon services with typed parameters.") diff --git a/compatibility/consumers/beacon_typer/tests/test_consumer.py b/compatibility/consumers/beacon_typer/tests/test_consumer.py index eb7a505..7928c19 100644 --- a/compatibility/consumers/beacon_typer/tests/test_consumer.py +++ b/compatibility/consumers/beacon_typer/tests/test_consumer.py @@ -4,7 +4,6 @@ from pathlib import Path import base_cli - from beacon_typer.cli import command diff --git a/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py b/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py index 512210c..229a361 100644 --- a/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py +++ b/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py @@ -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", @@ -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" diff --git a/compatibility/consumers/cinder_automation/tests/test_consumer.py b/compatibility/consumers/cinder_automation/tests/test_consumer.py index 84c44a5..db37c26 100644 --- a/compatibility/consumers/cinder_automation/tests/test_consumer.py +++ b/compatibility/consumers/cinder_automation/tests/test_consumer.py @@ -4,7 +4,6 @@ from pathlib import Path import base_cli - from cinder_automation.cli import app diff --git a/docs/testing.md b/docs/testing.md index e65a086..eb3f143 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. diff --git a/examples/automation_observability_app/src/automation_observability_app/cli.py b/examples/automation_observability_app/src/automation_observability_app/cli.py index 3d4753d..f147d27 100644 --- a/examples/automation_observability_app/src/automation_observability_app/cli.py +++ b/examples/automation_observability_app/src/automation_observability_app/cli.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import base_cli import click @@ -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, diff --git a/examples/minimal_cli/src/minimal_cli/cli.py b/examples/minimal_cli/src/minimal_cli/cli.py index 452d1d5..070e248 100644 --- a/examples/minimal_cli/src/minimal_cli/cli.py +++ b/examples/minimal_cli/src/minimal_cli/cli.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import base_cli app = base_cli.App( @@ -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: """Print a deterministic greeting.""" ctx.log.info("greeting requested for %s", name) diff --git a/scripts/validate_consumer_typing.py b/scripts/validate_consumer_typing.py new file mode 100644 index 0000000..580313b --- /dev/null +++ b/scripts/validate_consumer_typing.py @@ -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"], + cwd=root, + check=True, + capture_output=True, + 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()) diff --git a/tests/full_validate.sh b/tests/full_validate.sh index f7fcbee..6138c9f 100755 --- a/tests/full_validate.sh +++ b/tests/full_validate.sh @@ -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() { diff --git a/tests/test_validate_consumer_typing.py b/tests/test_validate_consumer_typing.py new file mode 100644 index 0000000..51e3632 --- /dev/null +++ b/tests/test_validate_consumer_typing.py @@ -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"]