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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,11 +481,14 @@ def test_command(tmp_path: Path) -> None:
assert "hello Ada" in result.stdout
```

The helper wraps Click's `CliRunner`, sets `HOME` when requested, supplies
`cwd` to Base's context discovery without mutating process-global cwd, 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. Pass
The helper wraps Click's `CliRunner`, sets `HOME` when requested, and supplies
`cwd` to Base's context discovery by temporarily changing process-global cwd
for the duration of the invocation. Calls that use `cwd` are serialized and
the caller's cwd is restored afterward, but this remains process-global: do not
use it concurrently with code that changes cwd outside `invoke()` or from
threads spawned by the invoked command. Use `cwd` for commands whose behavior
depends on project discovery, including tests that intentionally run outside a
Base project. Pass
`manifest={...}` with `cwd` to write a temporary `base_manifest.yaml` before
the command runs.

Expand Down
23 changes: 15 additions & 8 deletions lib/python/base_cli/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
from collections.abc import Mapping
from pathlib import Path
from threading import RLock
from typing import TYPE_CHECKING, Any

from .paths import use_working_dir
Expand All @@ -13,6 +14,9 @@
from click.testing import Result


_INVOKE_CWD_LOCK = RLock()


# pylint: disable=too-many-arguments
def invoke(
app: Any,
Expand Down Expand Up @@ -42,15 +46,18 @@ def invoke(
if "mix_stderr" in inspect.signature(CliRunner).parameters:
runner_kwargs["mix_stderr"] = False
runner = CliRunner(**runner_kwargs)
with use_working_dir(cwd_path):
if cwd_path is None:
if cwd_path is None:
with use_working_dir(None):
return runner.invoke(app.click_command, args or [], env=invoke_env)
original_cwd = Path.cwd()
os.chdir(cwd_path)
try:
return runner.invoke(app.click_command, args or [], env=invoke_env)
finally:
os.chdir(original_cwd)

with _INVOKE_CWD_LOCK:
with use_working_dir(cwd_path):
original_cwd = Path.cwd()
os.chdir(cwd_path)
try:
return runner.invoke(app.click_command, args or [], env=invoke_env)
finally:
os.chdir(original_cwd)


def _write_manifest_fixture(cwd: Path, manifest: Mapping[str, Any]) -> None:
Expand Down
56 changes: 56 additions & 0 deletions tests/test_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
import subprocess
import sys
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest import mock
import base_cli
from base_cli.testing import invoke

Expand Down Expand Up @@ -133,3 +136,56 @@ def main(ctx: base_cli.Context) -> None:
self.assertEqual(result.exit_code, 0, result.output)
self.assertIsNone(seen["project_root"])
self.assertIsNone(seen["manifest_path"])

def test_invoke_with_cwd_serializes_process_cwd_mutation(self) -> None:
app = base_cli.App(name="testing-cwd-serialization", log_to_file=False)

@app.command()
def main() -> None:
return None

first_started = threading.Event()
second_started = threading.Event()
release_first = threading.Event()
state_lock = threading.Lock()
observed_cwds: list[Path] = []
active_calls = 0
max_active_calls = 0

def fake_invoke(_runner: object, *_args: object, **_kwargs: object) -> object:
nonlocal active_calls, max_active_calls
with state_lock:
active_calls += 1
max_active_calls = max(max_active_calls, active_calls)
observed_cwds.append(Path.cwd())
if len(observed_cwds) == 1:
first_started.set()
else:
second_started.set()
if not release_first.wait(timeout=5):
raise AssertionError("timed out waiting to release invoke")
with state_lock:
active_calls -= 1
return mock.sentinel.result

with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
first_cwd = root / "first"
second_cwd = root / "second"
first_cwd.mkdir()
second_cwd.mkdir()
original_cwd = Path.cwd()

with mock.patch("click.testing.CliRunner.invoke", new=fake_invoke):
with ThreadPoolExecutor(max_workers=2) as executor:
first = executor.submit(invoke, app, home=root / "home", cwd=first_cwd)
self.assertTrue(first_started.wait(timeout=5))
second = executor.submit(invoke, app, home=root / "home", cwd=second_cwd)
self.assertFalse(second_started.wait(timeout=0.1))
release_first.set()
self.assertIs(first.result(timeout=5), mock.sentinel.result)
self.assertIs(second.result(timeout=5), mock.sentinel.result)

self.assertEqual(max_active_calls, 1)
self.assertEqual(set(observed_cwds), {first_cwd.resolve(), second_cwd.resolve()})
self.assertEqual(Path.cwd(), original_cwd)