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..c387412 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -55,3 +55,11 @@ 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. + +### 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..01747ee --- /dev/null +++ b/scripts/validate_consumer_typing.py @@ -0,0 +1,46 @@ +"""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") + 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() {