Skip to content
Open
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: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ repos:
exclude: "^tests/fixtures/|.json|^codegen"
- id: mixed-line-ending
- id: name-tests-test
exclude: "^tests/main.py"
exclude: "^(tests/main\\.py|tests/resources/)"
- id: requirements-txt-fixer
- id: trailing-whitespace
exclude: "docs/source/_static|ATTRIBUTIONS.md||API_REFEREENCE"
Expand Down
4 changes: 2 additions & 2 deletions requirements/SHR-UTILS-1.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
---
itemId: SHR-UTILS-1
itemTitle: Central MCP Server for SDK and Plugin Tool Access
itemTitle: Central MCP Server for SDK Tool Access
itemType: Requirement
Requirement type: ENVIRONMENT
---

## Description

Users shall be able to expose SDK and plugin functionality to AI agents via a central MCP server for use in AI-assisted development workflows.
Developers shall be able to expose SDK functionality to AI agents via a central MCP server for use in AI-assisted development workflows.
10 changes: 10 additions & 0 deletions requirements/SHR-UTILS-2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
itemId: SHR-UTILS-2
itemTitle: Plugin System for SDK Extension
itemType: Requirement
Requirement type: ENVIRONMENT
---

## Description

Developers shall be able to extend the Python SDK at runtime with custom plugin modules that contribute business logic, CLI commands, and UI pages.
10 changes: 10 additions & 0 deletions requirements/SWR-UTILS-2-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
itemId: SWR-UTILS-2-1
itemTitle: Plugin Module Discovery and Loading
itemHasParent: SHR-UTILS-2
itemType: Requirement
Requirement type: FUNCTIONAL
Layer: System (backend logic)
---

System shall discover and load externally installed plugin modules at runtime, making their functionality available without requiring changes to the core SDK codebase.
10 changes: 10 additions & 0 deletions requirements/SWR-UTILS-2-2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
itemId: SWR-UTILS-2-2
itemTitle: Plugin CLI Command Integration
itemHasParent: SHR-UTILS-2
itemType: Requirement
Requirement type: FUNCTIONAL
Layer: System (backend logic)
---

System shall automatically register CLI commands contributed by plugin modules into the SDK command-line interface.
10 changes: 10 additions & 0 deletions requirements/SWR-UTILS-2-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
itemId: SWR-UTILS-2-3
itemTitle: Plugin GUI Page Integration
itemHasParent: SHR-UTILS-2
itemType: Requirement
Requirement type: FUNCTIONAL
Layer: System (backend logic)
---

System shall automatically register GUI pages contributed by plugin modules into the SDK graphical user interface.
5 changes: 4 additions & 1 deletion specifications/SPEC-UTILS-SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ itemId: SPEC-UTILS-SERVICE
itemTitle: Utils Module Specification
itemType: Software Item Spec
itemIsRelatedTo: SPEC-GUI-SERVICE, SPEC-BUCKET-SERVICE, SPEC-DATASET-SERVICE, SPEC-NOTEBOOK-SERVICE, SPEC-PLATFORM-SERVICE, SPEC-QUPATH-SERVICE, SPEC-SYSTEM-SERVICE, SPEC-WSI-SERVICE
itemFulfills: SWR-APPLICATION-1-1, SWR-APPLICATION-1-2, SWR-APPLICATION-2-1, SWR-APPLICATION-2-2, SWR-APPLICATION-2-3, SWR-APPLICATION-2-4, SWR-APPLICATION-2-5, SWR-APPLICATION-2-6, SWR-APPLICATION-2-7, SWR-APPLICATION-2-8, SWR-APPLICATION-2-9, SWR-APPLICATION-2-10, SWR-APPLICATION-2-11, SWR-APPLICATION-2-12, SWR-APPLICATION-2-13, SWR-APPLICATION-2-14, SWR-APPLICATION-2-15, SWR-APPLICATION-2-16, SWR-APPLICATION-3-1, SWR-APPLICATION-3-2, SWR-APPLICATION-3-3, SWR-BUCKET-1-1, SWR-BUCKET-1-2, SWR-BUCKET-1-3, SWR-BUCKET-1-4, SWR-BUCKET-1-5, SWR-BUCKET-1-6, SWR-BUCKET-1-7, SWR-BUCKET-1-8, SWR-BUCKET-1-9, SWR-DATASET-1-1, SWR-DATASET-1-2, SWR-DATASET-1-3, SWR-NOTEBOOK-1-1, SWR-UTILS-1-1, SWR-VISUALIZATION-1-1, SWR-VISUALIZATION-1-2, SWR-VISUALIZATION-1-3, SWR-VISUALIZATION-1-4, SWR_SYSTEM_CLI_HEALTH_1, SWR_SYSTEM_GUI_HEALTH_1, SWR_SYSTEM_GUI_SETTINGS_1
itemFulfills: SWR-APPLICATION-1-1, SWR-APPLICATION-1-2, SWR-APPLICATION-2-1, SWR-APPLICATION-2-2, SWR-APPLICATION-2-3, SWR-APPLICATION-2-4, SWR-APPLICATION-2-5, SWR-APPLICATION-2-6, SWR-APPLICATION-2-7, SWR-APPLICATION-2-8, SWR-APPLICATION-2-9, SWR-APPLICATION-2-10, SWR-APPLICATION-2-11, SWR-APPLICATION-2-12, SWR-APPLICATION-2-13, SWR-APPLICATION-2-14, SWR-APPLICATION-2-15, SWR-APPLICATION-2-16, SWR-APPLICATION-3-1, SWR-APPLICATION-3-2, SWR-APPLICATION-3-3, SWR-BUCKET-1-1, SWR-BUCKET-1-2, SWR-BUCKET-1-3, SWR-BUCKET-1-4, SWR-BUCKET-1-5, SWR-BUCKET-1-6, SWR-BUCKET-1-7, SWR-BUCKET-1-8, SWR-BUCKET-1-9, SWR-DATASET-1-1, SWR-DATASET-1-2, SWR-DATASET-1-3, SWR-NOTEBOOK-1-1, SWR-UTILS-1-1, SWR-UTILS-2-1, SWR-UTILS-2-2, SWR-UTILS-2-3, SWR-VISUALIZATION-1-1, SWR-VISUALIZATION-1-2, SWR-VISUALIZATION-1-3, SWR-VISUALIZATION-1-4, SWR_SYSTEM_CLI_HEALTH_1, SWR_SYSTEM_GUI_HEALTH_1, SWR_SYSTEM_GUI_SETTINGS_1
Layer: Infrastructure Service
Version: 1.0.0
Date: 2025-10-13
Expand All @@ -29,6 +29,9 @@ The Utils Module shall:
- **[FR-08]** Provide file system utilities for user data directory management and path sanitization
- **[FR-09]** Support process information gathering and runtime environment detection
- **[FR-10]** Provide a central MCP server with auto-discovery of plugin tools, namespace isolation, and CLI commands for running the server and listing available tools
- **[FR-11]** Discover and load externally installed plugin modules at runtime without requiring changes to the core SDK codebase
- **[FR-12]** Automatically register CLI commands contributed by plugin modules into the SDK command-line interface
- **[FR-13]** Automatically register GUI pages contributed by plugin modules into the SDK graphical user interface

### 1.3 Non-Functional Requirements

Expand Down
11 changes: 8 additions & 3 deletions specifications/SPEC_PLATFORM_SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The Platform Module shall:
- **[FR-01]** Provide secure OAuth 2.0 authentication with support for both Authorization Code with PKCE and Device Authorization flows
- **[FR-02]** Manage JWT token lifecycle including acquisition, caching, validation, and refresh operations
- **[FR-03]** Configure and provide authenticated API clients for interaction with Aignostics Platform services
- **[FR-04]** Support multiple deployment environments (production, staging, development) with automatic endpoint configuration
- **[FR-04]** Support multiple deployment environments (production, staging, development, test) with automatic endpoint configuration
- **[FR-05]** Provide CLI commands for user authentication operations (login, logout, whoami)
- **[FR-06]** Handle authentication errors with retry mechanisms and fallback flows
- **[FR-07]** Support proxy configurations and SSL certificate handling for enterprise environments
Expand Down Expand Up @@ -313,8 +313,13 @@ class ApplicationRun:
def cancel(self) -> None:
"""Cancels the application run."""

def results(self) -> Iterator[ItemResultData]:
"""Retrieves the results of all items in the run."""
def results(
self,
nocache: bool = False,
item_ids: list[str] | None = None,
external_ids: list[str] | None = None,
) -> Iterator[ItemResultData]:
"""Retrieves the results of items in the run, optionally filtered by item or external IDs."""

def item_status(self) -> dict[str, ItemStatus]:
"""Retrieves the status of all items in the run."""
Expand Down
12 changes: 12 additions & 0 deletions tests/aignostics/utils/TC-UTILS-PLUGIN-02.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Feature: Plugin CLI Command Integration

The SDK automatically registers CLI commands contributed by plugin modules
into the SDK command-line interface when the plugin is installed.

@tests:SWR-UTILS-2-2
@id:TC-UTILS-PLUGIN-02
Scenario: Plugin CLI commands are registered in the SDK CLI after installation
Given a plugin package registers an entry point under "aignostics.plugins"
And the plugin exposes a Typer CLI instance
When the SDK CLI is prepared via prepare_cli()
Then the plugin's CLI is registered in the SDK command-line interface
12 changes: 12 additions & 0 deletions tests/aignostics/utils/TC-UTILS-PLUGIN-03.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Feature: Plugin GUI Page Integration

The SDK automatically registers GUI navigation entries contributed by plugin
modules into the SDK graphical user interface when the plugin is installed.

@tests:SWR-UTILS-2-3
@id:TC-UTILS-PLUGIN-03
Scenario: Plugin GUI navigation entries are available in the SDK after installation
Given a plugin package registers an entry point under "aignostics.plugins"
And the plugin exposes a BaseNavBuilder subclass
When the SDK GUI collects navigation groups via gui_get_nav_groups()
Then the plugin's navigation entries are included in the SDK navigation
45 changes: 45 additions & 0 deletions tests/aignostics/utils/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Shared fixtures for utils tests."""

from __future__ import annotations

import importlib
import site
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from collections.abc import Iterator

DUMMY_PLUGIN_DIR = Path(__file__).resolve().parents[2] / "resources" / "mcp_dummy_plugin"


@pytest.fixture(scope="session")
def install_dummy_plugin() -> Iterator[None]:
"""Install the dummy plugin package in editable mode for the test session.

Refreshes site-packages so the running interpreter sees the new package
and its entry points without a process restart.
"""
subprocess.run(
[sys.executable, "-m", "pip", "install", "--no-deps", "-e", str(DUMMY_PLUGIN_DIR)],
check=True,
capture_output=True,
text=True,
)
Comment on lines +27 to +32
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The session fixture installs the dummy plugin via pip install -e. With a PEP 517 backend (hatchling), pip will use build isolation by default, which can trigger additional build-env installs and potentially network access during test runs. Consider adding --no-build-isolation (and ensuring hatchling is available in the test environment) to make the integration tests more reliable and faster in constrained CI environments.

Copilot uses AI. Check for mistakes.

importlib.invalidate_caches()
for sp in site.getsitepackages():
site.addsitedir(sp)

yield

subprocess.run(
[sys.executable, "-m", "pip", "uninstall", "-y", "mcp-dummy-plugin"],
check=True,
capture_output=True,
text=True,
)
26 changes: 13 additions & 13 deletions tests/aignostics/utils/di_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,15 +240,15 @@ def clear_di_caches() -> Generator[None, None, None]:
@pytest.mark.unit
def test_discover_plugin_packages_returns_tuple(clear_di_caches, record_property) -> None:
"""Test that discover_plugin_packages returns a tuple."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
result = discover_plugin_packages()
assert isinstance(result, tuple)


@pytest.mark.unit
def test_discover_plugin_packages_uses_correct_entry_point_group(clear_di_caches, record_property) -> None:
"""Test that discover_plugin_packages uses the correct entry point group."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
assert PLUGIN_ENTRY_POINT_GROUP == "aignostics.plugins"


Expand All @@ -258,7 +258,7 @@ def test_discover_plugin_packages_extracts_values_from_entry_points(
mock_entry_points: Mock, clear_di_caches, record_property
) -> None:
"""Test that discover_plugin_packages extracts values from entry points."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
# Setup mock entry points
mock_ep1 = MagicMock()
mock_ep1.value = "plugin_one"
Expand All @@ -280,7 +280,7 @@ def test_discover_plugin_packages_returns_empty_tuple_when_no_plugins(
mock_entry_points: Mock, clear_di_caches, record_property
) -> None:
"""Test that discover_plugin_packages returns empty tuple when no plugins registered."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
mock_entry_points.return_value = []

result = discover_plugin_packages()
Expand All @@ -292,7 +292,7 @@ def test_discover_plugin_packages_returns_empty_tuple_when_no_plugins(
@patch("aignostics.utils._di.entry_points")
def test_discover_plugin_packages_is_cached(mock_entry_points: Mock, clear_di_caches, record_property) -> None:
"""Test that discover_plugin_packages caches results."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
mock_ep = MagicMock()
mock_ep.value = "cached_plugin"
mock_entry_points.return_value = [mock_ep]
Expand All @@ -309,7 +309,7 @@ def test_discover_plugin_packages_is_cached(mock_entry_points: Mock, clear_di_ca
@pytest.mark.unit
def test_locate_implementations_searches_plugins(clear_di_caches, record_property) -> None:
"""Test that locate_implementations searches plugin packages."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
import aignostics.utils._di as di_module

plugin_instance = AnotherDummyBase()
Expand Down Expand Up @@ -339,7 +339,7 @@ def import_side_effect(name: str) -> ModuleType:
@pytest.mark.unit
def test_locate_implementations_caches_results(clear_di_caches, record_property) -> None:
"""Test that locate_implementations caches results."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
import aignostics.utils._di as di_module

mock_package = MagicMock()
Expand All @@ -359,7 +359,7 @@ def test_locate_implementations_caches_results(clear_di_caches, record_property)
@pytest.mark.unit
def test_locate_subclasses_searches_plugins(clear_di_caches, record_property) -> None:
"""Test that locate_subclasses searches plugin packages."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
import aignostics.utils._di as di_module

class PluginSubClass(AnotherDummyBase):
Expand Down Expand Up @@ -391,7 +391,7 @@ def import_side_effect(name: str) -> ModuleType:
@pytest.mark.unit
def test_locate_subclasses_excludes_base_class(clear_di_caches, record_property) -> None:
"""Test that locate_subclasses excludes the base class itself."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
import aignostics.utils._di as di_module

mock_package = MagicMock()
Expand All @@ -411,7 +411,7 @@ def test_locate_subclasses_excludes_base_class(clear_di_caches, record_property)
@pytest.mark.unit
def test_locate_subclasses_caches_results(clear_di_caches, record_property) -> None:
"""Test that locate_subclasses caches results."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
import aignostics.utils._di as di_module

mock_package = MagicMock()
Expand All @@ -431,7 +431,7 @@ def test_locate_subclasses_caches_results(clear_di_caches, record_property) -> N
@pytest.mark.unit
def test_locate_subclasses_handles_plugin_import_error(clear_di_caches, record_property) -> None:
"""Test that locate_subclasses handles ImportError for plugin packages gracefully."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
import aignostics.utils._di as di_module

mock_package = MagicMock()
Expand All @@ -453,7 +453,7 @@ def import_side_effect(name: str) -> ModuleType:
@pytest.mark.unit
def test_locate_subclasses_handles_module_import_error(clear_di_caches, record_property) -> None:
"""Test that locate_subclasses handles ImportError for individual modules gracefully."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
import aignostics.utils._di as di_module

mock_package = MagicMock()
Expand Down Expand Up @@ -482,7 +482,7 @@ def test_locate_implementations_and_subclasses_search_both_plugins_and_main_pack
record_property,
) -> None:
"""Test that both functions search plugins first, then main package."""
record_property("tested-item-id", "SPEC-UTILS-DI")
record_property("tested-item-id", "SPEC-UTILS-SERVICE")
import aignostics.utils._di as di_module

import_order: list[str] = []
Expand Down
48 changes: 1 addition & 47 deletions tests/aignostics/utils/mcp_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
from __future__ import annotations

import asyncio
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch

Expand Down Expand Up @@ -190,54 +187,13 @@ def test_mcp_list_tools_empty(record_property) -> None:
# Integration Plugin Auto-Discovery Tests
# =============================================================================

DUMMY_PLUGIN_DIR = Path(__file__).resolve().parents[2] / "resources" / "mcp_dummy_plugin"


def _clear_mcp_discovery_caches() -> None:
"""Invalidate DI and plugin caches so MCP discovery starts fresh."""
_implementation_cache.pop(FastMCP, None)
discover_plugin_packages.cache_clear()


@pytest.fixture(scope="session")
def install_dummy_mcp_plugin() -> Iterator[None]:
"""Install the dummy MCP plugin in editable mode and make it importable.

Refreshes site-packages so the running interpreter sees the new package
and its entry points without a process restart.
"""
import importlib
import site

subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"--no-deps",
"-e",
str(DUMMY_PLUGIN_DIR),
],
check=True,
capture_output=True,
text=True,
)

importlib.invalidate_caches()
for sp in site.getsitepackages():
site.addsitedir(sp)

yield

subprocess.run(
[sys.executable, "-m", "pip", "uninstall", "-y", "mcp-dummy-plugin"],
check=True,
capture_output=True,
text=True,
)


@pytest.fixture
def clear_mcp_caches() -> Iterator[None]:
"""Clear MCP discovery caches before and after the test."""
Expand All @@ -249,9 +205,7 @@ def clear_mcp_caches() -> Iterator[None]:
@pytest.mark.integration
@pytest.mark.sequential
@pytest.mark.timeout(timeout=60)
def test_mcp_server_discovers_and_serves_plugin_tools(
install_dummy_mcp_plugin, clear_mcp_caches, record_property
) -> None:
def test_mcp_server_discovers_and_serves_plugin_tools(install_dummy_plugin, clear_mcp_caches, record_property) -> None:
"""Integration: entry point registration -> discovery -> mount -> client round-trip."""
record_property("tested-item-id", "TC-UTILS-MCP-01")

Expand Down
Loading