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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions lib/python/base_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
`<home>/.cache/base` so helper-based tests do not inherit a developer's real
Expand Down
28 changes: 25 additions & 3 deletions lib/python/base_cli/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
)
68 changes: 68 additions & 0 deletions lib/python/base_cli/tests/test_testing.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading