From aa10ca159e97cd3fb270e05d489842cfc4758bb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 20:38:10 +0000 Subject: [PATCH 1/8] Initial plan From f879d8d1c853ae30b4a1639d42ef4fb7e7c547e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 20:41:11 +0000 Subject: [PATCH 2/8] fix: ignore hidden directories during server discovery Agent-Logs-Url: https://github.com/plugboard-dev/plugboard/sessions/0bdaf24d-13ed-4323-8342-0f9fe1e414ab Co-authored-by: toby-coleman <13170610+toby-coleman@users.noreply.github.com> --- plugboard/cli/server/__init__.py | 3 ++- tests/unit/test_cli.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/plugboard/cli/server/__init__.py b/plugboard/cli/server/__init__.py index e5ea2177..4ace54ca 100644 --- a/plugboard/cli/server/__init__.py +++ b/plugboard/cli/server/__init__.py @@ -43,7 +43,8 @@ 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): + 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 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 69b837d7..331cc9ea 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -227,6 +227,36 @@ def test_cli_server_discover(test_project_dir: Path) -> None: assert process_route.called +def test_cli_server_discover_ignores_hidden_directories(tmp_path: Path) -> None: + """Tests the server discover command ignores hidden directories like .venv.""" + project_dir = tmp_path / "test_project" + project_dir.mkdir() + (project_dir / "test_file.py").write_text("") + hidden_dir = project_dir / ".venv" + hidden_dir.mkdir() + (hidden_dir / "bad_module.py").write_text('raise RuntimeError("should not import")') + + with respx.mock: + respx.post("http://test:8000/types/component").respond(json={"status": "ok"}) + respx.post("http://test:8000/types/connector").respond(json={"status": "ok"}) + respx.post("http://test:8000/types/event").respond(json={"status": "ok"}) + respx.post("http://test:8000/types/process").respond(json={"status": "ok"}) + + result = runner.invoke( + app, + [ + "server", + "discover", + str(project_dir), + "--api-url", + "http://test:8000", + ], + ) + + assert result.exit_code == 0 + assert "Discovery complete" in result.stdout + + def test_cli_server_discover_with_env_var(test_project_dir: Path) -> None: """Tests the server discover command with environment variable.""" with respx.mock: From f98c19014b8743b0fd6e98d770400cbd92ca2d65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 20:42:29 +0000 Subject: [PATCH 3/8] test: strengthen hidden-directory discovery coverage Agent-Logs-Url: https://github.com/plugboard-dev/plugboard/sessions/0bdaf24d-13ed-4323-8342-0f9fe1e414ab Co-authored-by: toby-coleman <13170610+toby-coleman@users.noreply.github.com> --- plugboard/cli/server/__init__.py | 1 + tests/unit/test_cli.py | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/plugboard/cli/server/__init__.py b/plugboard/cli/server/__init__.py index 4ace54ca..47d8b728 100644 --- a/plugboard/cli/server/__init__.py +++ b/plugboard/cli/server/__init__.py @@ -44,6 +44,7 @@ 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): + # 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("__"): diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 331cc9ea..1dc68a81 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -4,6 +4,7 @@ marked async so that they do not interfere with pytest-asyncio's event loop. """ +import json from pathlib import Path import tempfile import typing as _t @@ -231,13 +232,21 @@ def test_cli_server_discover_ignores_hidden_directories(tmp_path: Path) -> None: """Tests the server discover command ignores hidden directories like .venv.""" project_dir = tmp_path / "test_project" project_dir.mkdir() - (project_dir / "test_file.py").write_text("") + (project_dir / "test_file.py").write_text( + "from plugboard.component import Component, IOController as IO\n\n" + "class VisibleComponent(Component):\n" + " io = IO(outputs=['out'])\n\n" + " async def step(self) -> None:\n" + " self.out = 1\n" + ) hidden_dir = project_dir / ".venv" hidden_dir.mkdir() (hidden_dir / "bad_module.py").write_text('raise RuntimeError("should not import")') with respx.mock: - respx.post("http://test:8000/types/component").respond(json={"status": "ok"}) + component_route = respx.post("http://test:8000/types/component").respond( + json={"status": "ok"} + ) respx.post("http://test:8000/types/connector").respond(json={"status": "ok"}) respx.post("http://test:8000/types/event").respond(json={"status": "ok"}) respx.post("http://test:8000/types/process").respond(json={"status": "ok"}) @@ -255,6 +264,10 @@ def test_cli_server_discover_ignores_hidden_directories(tmp_path: Path) -> None: assert result.exit_code == 0 assert "Discovery complete" in result.stdout + assert any( + json.loads(call.request.content)["name"] == "VisibleComponent" + for call in component_route.calls + ) def test_cli_server_discover_with_env_var(test_project_dir: Path) -> None: From ca90bfd7209c45f1deec6ed4c9e191af837944c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 20:43:27 +0000 Subject: [PATCH 4/8] test: clarify hidden-directory discovery regression Agent-Logs-Url: https://github.com/plugboard-dev/plugboard/sessions/0bdaf24d-13ed-4323-8342-0f9fe1e414ab Co-authored-by: toby-coleman <13170610+toby-coleman@users.noreply.github.com> --- tests/unit/test_cli.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 1dc68a81..2e3bb37a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -7,6 +7,7 @@ import json from pathlib import Path import tempfile +import textwrap import typing as _t from unittest.mock import AsyncMock, MagicMock, patch @@ -233,11 +234,16 @@ def test_cli_server_discover_ignores_hidden_directories(tmp_path: Path) -> None: project_dir = tmp_path / "test_project" project_dir.mkdir() (project_dir / "test_file.py").write_text( - "from plugboard.component import Component, IOController as IO\n\n" - "class VisibleComponent(Component):\n" - " io = IO(outputs=['out'])\n\n" - " async def step(self) -> None:\n" - " self.out = 1\n" + 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() ) hidden_dir = project_dir / ".venv" hidden_dir.mkdir() @@ -263,6 +269,7 @@ def test_cli_server_discover_ignores_hidden_directories(tmp_path: Path) -> None: ) assert result.exit_code == 0 + assert result.exception is None assert "Discovery complete" in result.stdout assert any( json.loads(call.request.content)["name"] == "VisibleComponent" From e749206467e93c2168233b7d8dae415d12609205 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:02:27 +0000 Subject: [PATCH 5/8] refactor: simplify discovery traversal and tests Agent-Logs-Url: https://github.com/plugboard-dev/plugboard/sessions/95e9b248-3b2e-44b6-8916-b2c37aa87c09 Co-authored-by: toby-coleman <13170610+toby-coleman@users.noreply.github.com> --- plugboard/cli/server/__init__.py | 40 ++++++----- tests/unit/test_cli.py | 117 ++++++++++++++++--------------- 2 files changed, 85 insertions(+), 72 deletions(-) diff --git a/plugboard/cli/server/__init__.py b/plugboard/cli/server/__init__.py index 47d8b728..7f1da4c7 100644 --- a/plugboard/cli/server/__init__.py +++ b/plugboard/cli/server/__init__.py @@ -2,7 +2,6 @@ import importlib import inspect -import os from pathlib import Path import typing as _t @@ -43,22 +42,29 @@ 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): - # 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] - - if base_package: - module_name = f"{base_package}.{module_name}" - - try: - importlib.import_module(module_name) - except (ModuleNotFoundError, ImportError, SyntaxError) as e: - logger.warning(f"Failed to import module {module_name}: {e}") + + def _walk(current_path: Path) -> None: + for child in current_path.iterdir(): + if child.name.startswith("."): + continue + + if child.is_dir(): + _walk(child) + continue + + if child.suffix != ".py" or child.name.startswith("__"): + continue + + module_name = ".".join(child.relative_to(path).with_suffix("").parts) + if base_package: + module_name = f"{base_package}.{module_name}" + + try: + importlib.import_module(module_name) + except (ModuleNotFoundError, ImportError, SyntaxError) as e: + logger.warning(f"Failed to import module {module_name}: {e}") + + _walk(path) def _get_all_subclasses(cls: type) -> set: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 2e3bb37a..4ee25d2b 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -22,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"]) @@ -37,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 @@ -193,8 +224,26 @@ 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), + (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( @@ -211,7 +260,7 @@ def test_cli_server_discover(test_project_dir: Path) -> None: [ "server", "discover", - str(test_project_dir), + str(project_dir), "--api-url", "http://test:8000", ], @@ -227,54 +276,12 @@ def test_cli_server_discover(test_project_dir: Path) -> None: assert connector_route.called assert event_route.called assert process_route.called - - -def test_cli_server_discover_ignores_hidden_directories(tmp_path: Path) -> None: - """Tests the server discover command ignores hidden directories like .venv.""" - project_dir = tmp_path / "test_project" - project_dir.mkdir() - (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() - ) - hidden_dir = project_dir / ".venv" - hidden_dir.mkdir() - (hidden_dir / "bad_module.py").write_text('raise RuntimeError("should not import")') - - with respx.mock: - component_route = respx.post("http://test:8000/types/component").respond( - json={"status": "ok"} - ) - respx.post("http://test:8000/types/connector").respond(json={"status": "ok"}) - respx.post("http://test:8000/types/event").respond(json={"status": "ok"}) - respx.post("http://test:8000/types/process").respond(json={"status": "ok"}) - - result = runner.invoke( - app, - [ - "server", - "discover", - str(project_dir), - "--api-url", - "http://test:8000", - ], - ) - - assert result.exit_code == 0 - assert result.exception is None - assert "Discovery complete" in result.stdout - assert any( - json.loads(call.request.content)["name"] == "VisibleComponent" - for call in component_route.calls - ) + if expected_component_name is not None: + assert result.exception is 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: From fe09f85a395e4161ff34ab36169d57ea6d1e08ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:03:36 +0000 Subject: [PATCH 6/8] test: broaden discovery parametrization Agent-Logs-Url: https://github.com/plugboard-dev/plugboard/sessions/95e9b248-3b2e-44b6-8916-b2c37aa87c09 Co-authored-by: toby-coleman <13170610+toby-coleman@users.noreply.github.com> --- tests/unit/test_cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 4ee25d2b..f1ed980f 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -228,6 +228,8 @@ def test_cli_ai_agents_template_is_packaged_file() -> None: ("as_package", "include_hidden_dir", "expected_component_name"), [ (True, False, None), + (True, True, None), + (False, False, "VisibleComponent"), (False, True, "VisibleComponent"), ], ) @@ -268,6 +270,7 @@ def test_cli_server_discover( # 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 @@ -277,7 +280,6 @@ def test_cli_server_discover( assert event_route.called assert process_route.called if expected_component_name is not None: - assert result.exception is None assert any( json.loads(call.request.content)["name"] == expected_component_name for call in component_route.calls From 372dba6f6dc2bda2552b57bb68ce821aa7e93c52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:13:34 +0000 Subject: [PATCH 7/8] refactor: restore os.walk directory pruning Agent-Logs-Url: https://github.com/plugboard-dev/plugboard/sessions/2d8dca45-18df-4778-8cbe-25c9bccbf641 Co-authored-by: toby-coleman <13170610+toby-coleman@users.noreply.github.com> --- plugboard/cli/server/__init__.py | 40 ++++++++++++++------------------ 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/plugboard/cli/server/__init__.py b/plugboard/cli/server/__init__.py index 7f1da4c7..47d8b728 100644 --- a/plugboard/cli/server/__init__.py +++ b/plugboard/cli/server/__init__.py @@ -2,6 +2,7 @@ import importlib import inspect +import os from pathlib import Path import typing as _t @@ -42,29 +43,22 @@ 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() - - def _walk(current_path: Path) -> None: - for child in current_path.iterdir(): - if child.name.startswith("."): - continue - - if child.is_dir(): - _walk(child) - continue - - if child.suffix != ".py" or child.name.startswith("__"): - continue - - module_name = ".".join(child.relative_to(path).with_suffix("").parts) - if base_package: - module_name = f"{base_package}.{module_name}" - - try: - importlib.import_module(module_name) - except (ModuleNotFoundError, ImportError, SyntaxError) as e: - logger.warning(f"Failed to import module {module_name}: {e}") - - _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] + + if base_package: + module_name = f"{base_package}.{module_name}" + + try: + importlib.import_module(module_name) + except (ModuleNotFoundError, ImportError, SyntaxError) as e: + logger.warning(f"Failed to import module {module_name}: {e}") def _get_all_subclasses(cls: type) -> set: From 58f203211228ceff60638e415ff51df6382f020a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:14:33 +0000 Subject: [PATCH 8/8] refactor: simplify os.walk module path handling Agent-Logs-Url: https://github.com/plugboard-dev/plugboard/sessions/2d8dca45-18df-4778-8cbe-25c9bccbf641 Co-authored-by: toby-coleman <13170610+toby-coleman@users.noreply.github.com> --- plugboard/cli/server/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugboard/cli/server/__init__.py b/plugboard/cli/server/__init__.py index 47d8b728..02d8a696 100644 --- a/plugboard/cli/server/__init__.py +++ b/plugboard/cli/server/__init__.py @@ -49,8 +49,8 @@ def _import_recursive(path: Path, base_package: _t.Optional[str] = None) -> None 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}"