Skip to content
8 changes: 5 additions & 3 deletions plugboard/cli/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ async def _post_to_api(url: str, data: dict) -> None:
def _import_recursive(path: Path, base_package: _t.Optional[str] = None) -> None:
"""Import all modules recursively from the given path."""
logger = DI.logger.resolve_sync()
for root, _dirs, files in os.walk(path):
for root, dirs, files in os.walk(path):
# Update dirs in place so os.walk skips hidden directories like .venv.
dirs[:] = [directory for directory in dirs if not directory.startswith(".")]
for file in files:
if file.endswith(".py") and not file.startswith("__"):
# Construct module name
rel_path = os.path.relpath(os.path.join(root, file), path)
module_name = rel_path.replace(os.sep, ".")[:-3]
rel_path = Path(root, file).relative_to(path)
module_name = ".".join(rel_path.with_suffix("").parts)

if base_package:
module_name = f"{base_package}.{module_name}"
Expand Down
73 changes: 66 additions & 7 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
marked async so that they do not interfere with pytest-asyncio's event loop.
"""

import json
from pathlib import Path
import tempfile
import textwrap
import typing as _t
from unittest.mock import AsyncMock, MagicMock, patch

Expand All @@ -20,6 +22,41 @@
runner = CliRunner()


def _create_test_project(
base_path: Path,
*,
as_package: bool = True,
include_hidden_dir: bool = False,
) -> Path:
"""Create a minimal Python project for CLI discovery tests."""
project_dir = base_path / "test_project"
project_dir.mkdir()

if as_package:
(project_dir / "__init__.py").write_text("")
(project_dir / "test_file.py").write_text("")
else:
(project_dir / "test_file.py").write_text(
textwrap.dedent("""
from plugboard.component import Component, IOController as IO


class VisibleComponent(Component):
io = IO(outputs=["out"])

async def step(self) -> None:
self.out = 1
""").strip()
)

if include_hidden_dir:
hidden_dir = project_dir / ".venv"
hidden_dir.mkdir()
(hidden_dir / "bad_module.py").write_text('raise RuntimeError("should not import")')

return project_dir


def test_cli_version() -> None:
"""Tests the version command."""
result = runner.invoke(app, ["version"])
Expand All @@ -35,11 +72,7 @@ def test_cli_version() -> None:
def test_project_dir() -> _t.Iterator[Path]:
"""Create a minimal Python package for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
project_dir = Path(tmpdir) / "test_project"
project_dir.mkdir()
(project_dir / "__init__.py").write_text("")
(project_dir / "test_file.py").write_text("")
yield project_dir
yield _create_test_project(Path(tmpdir))


@pytest.mark.asyncio
Expand Down Expand Up @@ -191,8 +224,28 @@ def test_cli_ai_agents_template_is_packaged_file() -> None:
assert not _AGENTS_MD.is_symlink()


def test_cli_server_discover(test_project_dir: Path) -> None:
@pytest.mark.parametrize(
("as_package", "include_hidden_dir", "expected_component_name"),
[
(True, False, None),
(True, True, None),
(False, False, "VisibleComponent"),
(False, True, "VisibleComponent"),
],
)
def test_cli_server_discover(
tmp_path: Path,
as_package: bool,
include_hidden_dir: bool,
expected_component_name: str | None,
) -> None:
"""Tests the server discover command."""
project_dir = _create_test_project(
tmp_path,
as_package=as_package,
include_hidden_dir=include_hidden_dir,
)

with respx.mock:
# Mock all the API endpoints
component_route = respx.post("http://test:8000/types/component").respond(
Expand All @@ -209,14 +262,15 @@ def test_cli_server_discover(test_project_dir: Path) -> None:
[
"server",
"discover",
str(test_project_dir),
str(project_dir),
"--api-url",
"http://test:8000",
],
)

# CLI must run without error
assert result.exit_code == 0
assert result.exception is None
assert "Discovery complete" in result.stdout

# At minimum, should have discovered plugboard's built-in types
Expand All @@ -225,6 +279,11 @@ def test_cli_server_discover(test_project_dir: Path) -> None:
assert connector_route.called
assert event_route.called
assert process_route.called
if expected_component_name is not None:
assert any(
json.loads(call.request.content)["name"] == expected_component_name
for call in component_route.calls
)


def test_cli_server_discover_with_env_var(test_project_dir: Path) -> None:
Expand Down
Loading