diff --git a/CHANGELOG.md b/CHANGELOG.md index 4431b07..8feccb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and Base versions are tracked in the repo-root `VERSION` file. - Added `ctx.workspace_root` to `base_cli.Context` so workspace-aware commands can use the configured workspace root without reaching through user config. +- Added `base_cli.testing.invoke(..., manifest={...})` for project-aware tests + that need a fixture `base_manifest.yaml`. - Documented the `base-bash-libs` Homebrew/core readiness path, including the formula-name audit command and future `basefoundry` dependency plan. - Documented the `basectl setup` parallelism evaluation and the decision to diff --git a/lib/python/base_cli/README.md b/lib/python/base_cli/README.md index a06d6e3..ba9994a 100644 --- a/lib/python/base_cli/README.md +++ b/lib/python/base_cli/README.md @@ -386,9 +386,14 @@ from base_cli.testing import invoke def test_command(tmp_path: Path) -> None: project = tmp_path / "project" project.mkdir() - (project / "base_manifest.yaml").write_text("project:\n name: demo\n") - result = invoke(app, ["--name", "Ada"], home=tmp_path, cwd=project) + result = invoke( + app, + ["--name", "Ada"], + home=tmp_path, + cwd=project, + manifest={"project": {"name": "demo"}, "artifacts": []}, + ) assert result.exit_code == 0 assert "hello Ada" in result.stdout @@ -397,7 +402,9 @@ def test_command(tmp_path: Path) -> None: The helper wraps Click's `CliRunner`, sets `HOME` when requested, changes to `cwd` only for the invocation, and keeps stderr separate on Click versions that support it. Use `cwd` for commands whose behavior depends on project discovery, -including tests that intentionally run outside a Base project. +including tests that intentionally run outside a Base project. Pass +`manifest={...}` with `cwd` to write a temporary `base_manifest.yaml` before +the command runs. When `home` is supplied, `invoke()` also defaults `BASE_CACHE_DIR` to `/.cache/base` so helper-based tests do not inherit a developer's real diff --git a/lib/python/base_cli/testing.py b/lib/python/base_cli/testing.py index 234ebcd..d4c8e1a 100644 --- a/lib/python/base_cli/testing.py +++ b/lib/python/base_cli/testing.py @@ -2,17 +2,27 @@ import inspect import os +from collections.abc import Mapping from pathlib import Path from typing import Any +# pylint: disable=too-many-arguments def invoke( app: Any, args: list[str] | None = None, home: Path | None = None, cwd: Path | str | None = None, env: dict[str, str] | None = None, + *, + manifest: Mapping[str, Any] | None = None, ): + cwd_path = Path(cwd) if cwd is not None else None + if manifest is not None: + if cwd_path is None: + raise ValueError("manifest requires cwd so base_manifest.yaml has a target directory.") + _write_manifest_fixture(cwd_path, manifest) + try: from click.testing import CliRunner except ImportError as exc: @@ -27,10 +37,22 @@ def invoke( runner_kwargs["mix_stderr"] = False runner = CliRunner(**runner_kwargs) original_cwd = Path.cwd() - if cwd is not None: - os.chdir(cwd) + if cwd_path is not None: + os.chdir(cwd_path) try: return runner.invoke(app.click_command, args or [], env=invoke_env) finally: - if cwd is not None: + if cwd_path is not None: os.chdir(original_cwd) + + +def _write_manifest_fixture(cwd: Path, manifest: Mapping[str, Any]) -> None: + try: + import yaml + except ImportError as exc: + raise RuntimeError("PyYAML is required to write base_cli.testing manifest fixtures.") from exc + + (cwd / "base_manifest.yaml").write_text( + yaml.safe_dump(dict(manifest), sort_keys=False), + encoding="utf-8", + ) diff --git a/lib/python/base_cli/tests/test_testing.py b/lib/python/base_cli/tests/test_testing.py new file mode 100644 index 0000000..3e4859a --- /dev/null +++ b/lib/python/base_cli/tests/test_testing.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +import base_cli +from base_cli.testing import invoke + + +@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") +class InvokeTests(unittest.TestCase): + def test_invoke_writes_manifest_fixture_into_cwd(self) -> None: + app = base_cli.App(name="testing-manifest", log_to_file=False) + seen: dict[str, Path | None] = {} + + @app.command() + def main(ctx: base_cli.Context) -> None: + seen["project_root"] = ctx.project_root + seen["manifest_path"] = ctx.manifest_path + + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + project = root / "project" + project.mkdir() + + result = invoke( + app, + [], + home=home, + cwd=project, + manifest={"project": {"name": "demo"}, "artifacts": []}, + ) + + manifest_path = project / "base_manifest.yaml" + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen["project_root"], project.resolve()) + self.assertEqual(seen["manifest_path"], manifest_path.resolve()) + + def test_invoke_rejects_manifest_without_cwd(self) -> None: + app = base_cli.App(name="testing-manifest-without-cwd", log_to_file=False) + + with self.assertRaisesRegex(ValueError, "manifest requires cwd"): + invoke(app, [], manifest={"project": {"name": "demo"}}) + + def test_invoke_with_cwd_without_manifest_preserves_no_manifest_behavior(self) -> None: + app = base_cli.App(name="testing-no-manifest", log_to_file=False) + seen: dict[str, Path | None] = {} + + @app.command() + def main(ctx: base_cli.Context) -> None: + seen["project_root"] = ctx.project_root + seen["manifest_path"] = ctx.manifest_path + + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + isolated = root / "isolated" + isolated.mkdir() + + result = invoke(app, [], home=home, cwd=isolated) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertIsNone(seen["project_root"]) + self.assertIsNone(seen["manifest_path"])