diff --git a/.github/workflows/publish-python-sdk.yml b/.github/workflows/publish-python-sdk.yml index ff8a5c3d..ea7d8945 100644 --- a/.github/workflows/publish-python-sdk.yml +++ b/.github/workflows/publish-python-sdk.yml @@ -29,6 +29,9 @@ jobs: - run: uv sync --locked --dev + - name: Run tests + run: uvx --from pytest pytest -q + - run: uv build - run: uv publish diff --git a/.github/workflows/test-python-sdk.yml b/.github/workflows/test-python-sdk.yml new file mode 100644 index 00000000..d7fb8cae --- /dev/null +++ b/.github/workflows/test-python-sdk.yml @@ -0,0 +1,61 @@ +name: Python SDK Tests + +on: + push: + branches: [main] + paths: + - 'python-sdk/**' + pull_request: + branches: [main] + paths: + - 'python-sdk/**' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v2 + with: + cache: true + + - name: Install dev dependencies with uv + working-directory: python-sdk + run: | + uv sync --group dev + + - name: Install python-sdk package (editable) + working-directory: python-sdk + run: | + uv pip install -e . + + - name: Run tests with pytest and coverage + working-directory: python-sdk + run: | + uv run pytest --cov=exospherehost --cov-report=xml --cov-report=term-missing -v --junitxml=pytest-report.xml + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: exospherehost/exospherehost + files: python-sdk/coverage.xml + flags: python-sdk-unittests + name: python-sdk-coverage-report + fail_ci_if_error: true + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: python-sdk-test-results + path: python-sdk/pytest-report.xml + retention-days: 30 \ No newline at end of file diff --git a/python-sdk/exospherehost/runtime.py b/python-sdk/exospherehost/runtime.py index f2bb9010..48f6eb6c 100644 --- a/python-sdk/exospherehost/runtime.py +++ b/python-sdk/exospherehost/runtime.py @@ -3,7 +3,7 @@ from asyncio import Queue, sleep from typing import List, Dict -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from .node.BaseNode import BaseNode from aiohttp import ClientSession from logging import getLogger @@ -271,27 +271,31 @@ def _validate_nodes(self): errors.append(f"{node.__name__} does not have an Inputs class") if not hasattr(node, "Outputs"): errors.append(f"{node.__name__} does not have an Outputs class") - if not issubclass(node.Inputs, BaseModel): - errors.append(f"{node.__name__} does not have an Inputs class that inherits from pydantic.BaseModel") - if not issubclass(node.Outputs, BaseModel): + inputs_is_basemodel = hasattr(node, "Inputs") and issubclass(node.Inputs, BaseModel) + if not inputs_is_basemodel: + errors.append(f"{node.__name__} does not have an Inputs class that inherits from pydantic.BaseModel") + outputs_is_basemodel = hasattr(node, "Outputs") and issubclass(node.Outputs, BaseModel) + if not outputs_is_basemodel: errors.append(f"{node.__name__} does not have an Outputs class that inherits from pydantic.BaseModel") if not hasattr(node, "Secrets"): errors.append(f"{node.__name__} does not have an Secrets class") - if not issubclass(node.Secrets, BaseModel): + secrets_is_basemodel = hasattr(node, "Secrets") and issubclass(node.Secrets, BaseModel) + if not secrets_is_basemodel: errors.append(f"{node.__name__} does not have an Secrets class that inherits from pydantic.BaseModel") # check all data objects are strings - for field_name, field_info in node.Inputs.model_fields.items(): - if field_info.annotation is not str: - errors.append(f"{node.__name__}.Inputs field '{field_name}' must be of type str, got {field_info.annotation}") - - for field_name, field_info in node.Outputs.model_fields.items(): - if field_info.annotation is not str: - errors.append(f"{node.__name__}.Outputs field '{field_name}' must be of type str, got {field_info.annotation}") - - for field_name, field_info in node.Secrets.model_fields.items(): - if field_info.annotation is not str: - errors.append(f"{node.__name__}.Secrets field '{field_name}' must be of type str, got {field_info.annotation}") + if inputs_is_basemodel: + for field_name, field_info in node.Inputs.model_fields.items(): + if field_info.annotation is not str: + errors.append(f"{node.__name__}.Inputs field '{field_name}' must be of type str, got {field_info.annotation}") + if outputs_is_basemodel: + for field_name, field_info in node.Outputs.model_fields.items(): + if field_info.annotation is not str: + errors.append(f"{node.__name__}.Outputs field '{field_name}' must be of type str, got {field_info.annotation}") + if secrets_is_basemodel: + for field_name, field_info in node.Secrets.model_fields.items(): + if field_info.annotation is not str: + errors.append(f"{node.__name__}.Secrets field '{field_name}' must be of type str, got {field_info.annotation}") # Find nodes with the same __class__.__name__ class_names = [node.__name__ for node in self._nodes] @@ -300,7 +304,7 @@ def _validate_nodes(self): errors.append(f"Duplicate node class names found: {duplicate_class_names}") if len(errors) > 0: - raise ValidationError("Following errors while validating nodes: " + "\n".join(errors)) + raise ValueError("Following errors while validating nodes: " + "\n".join(errors)) async def _worker(self): """ diff --git a/python-sdk/pyproject.toml b/python-sdk/pyproject.toml index d6beb429..c68a0e21 100644 --- a/python-sdk/pyproject.toml +++ b/python-sdk/pyproject.toml @@ -31,4 +31,6 @@ version = {attr = "exospherehost._version.version"} [dependency-groups] dev = [ "ruff>=0.12.5", + "pytest>=8.3.0", + "pytest-cov>=5.0.0", ] diff --git a/python-sdk/tests/test_base_node.py b/python-sdk/tests/test_base_node.py new file mode 100644 index 00000000..9625999e --- /dev/null +++ b/python-sdk/tests/test_base_node.py @@ -0,0 +1,29 @@ +from exospherehost.node.BaseNode import BaseNode +from pydantic import BaseModel +import asyncio + + +class EchoNode(BaseNode): + class Inputs(BaseModel): + text: str + + class Outputs(BaseModel): + message: str + + class Secrets(BaseModel): + token: str + + async def execute(self) -> Outputs: + return self.Outputs(message=f"{self.inputs.text}:{self.secrets.token}") + + +def test_base_node_execute_sets_inputs_and_returns_outputs(): + node = EchoNode() + inputs = EchoNode.Inputs(text="hello") + secrets = EchoNode.Secrets(token="tkn") + outputs = asyncio.run(node._execute(inputs, secrets)) + + assert isinstance(outputs, EchoNode.Outputs) + assert outputs.message == "hello:tkn" + assert node.inputs == inputs + assert node.secrets == secrets \ No newline at end of file diff --git a/python-sdk/tests/test_runtime_validation.py b/python-sdk/tests/test_runtime_validation.py new file mode 100644 index 00000000..d135b258 --- /dev/null +++ b/python-sdk/tests/test_runtime_validation.py @@ -0,0 +1,95 @@ +import pytest +from pydantic import BaseModel +from exospherehost.runtime import Runtime +from exospherehost.node.BaseNode import BaseNode + + +class GoodNode(BaseNode): + class Inputs(BaseModel): + name: str + + class Outputs(BaseModel): + message: str + + class Secrets(BaseModel): + api_key: str + + async def execute(self): + return self.Outputs(message=f"hi {self.inputs.name}") + + +class BadNodeWrongInputsBase(BaseNode): + Inputs = object # not a pydantic BaseModel + class Outputs(BaseModel): + message: str + class Secrets(BaseModel): + token: str + async def execute(self): + return self.Outputs(message="x") + + +class BadNodeWrongTypes(BaseNode): + class Inputs(BaseModel): + count: int + class Outputs(BaseModel): + ok: bool + class Secrets(BaseModel): + secret: bytes + async def execute(self): + return self.Outputs(ok=True) + + + + +def test_runtime_missing_config_raises(monkeypatch): + # Ensure env vars not set + monkeypatch.delenv("EXOSPHERE_STATE_MANAGER_URI", raising=False) + monkeypatch.delenv("EXOSPHERE_API_KEY", raising=False) + with pytest.raises(ValueError): + Runtime(namespace="ns", name="rt", nodes=[GoodNode]) + + +def test_runtime_with_env_ok(monkeypatch): + monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm") + monkeypatch.setenv("EXOSPHERE_API_KEY", "k") + rt = Runtime(namespace="ns", name="rt", nodes=[GoodNode]) + assert rt is not None + + +def test_runtime_invalid_params_raises(monkeypatch): + monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm") + monkeypatch.setenv("EXOSPHERE_API_KEY", "k") + with pytest.raises(ValueError): + Runtime(namespace="ns", name="rt", nodes=[GoodNode], batch_size=0) + with pytest.raises(ValueError): + Runtime(namespace="ns", name="rt", nodes=[GoodNode], workers=0) + + +def test_node_validation_errors(monkeypatch): + monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm") + monkeypatch.setenv("EXOSPHERE_API_KEY", "k") + with pytest.raises(ValueError) as e: + Runtime(namespace="ns", name="rt", nodes=[BadNodeWrongInputsBase]) + assert "Inputs class that inherits" in str(e.value) + + with pytest.raises(ValueError) as e2: + Runtime(namespace="ns", name="rt", nodes=[BadNodeWrongTypes]) + msg = str(e2.value) + assert "Inputs field" in msg and "Outputs field" in msg and "Secrets field" in msg + + +def test_duplicate_node_names_raise(monkeypatch): + monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm") + monkeypatch.setenv("EXOSPHERE_API_KEY", "k") + class AnotherGood(BaseNode): + class Inputs(BaseModel): + name: str + class Outputs(BaseModel): + message: str + class Secrets(BaseModel): + api_key: str + async def execute(self): + return self.Outputs(message="ok") + AnotherGood.__name__ = "GoodNode" # force duplicate name + with pytest.raises(ValueError): + Runtime(namespace="ns", name="rt", nodes=[GoodNode, AnotherGood]) \ No newline at end of file diff --git a/python-sdk/tests/test_state_manager.py b/python-sdk/tests/test_state_manager.py new file mode 100644 index 00000000..ba8f5ccf --- /dev/null +++ b/python-sdk/tests/test_state_manager.py @@ -0,0 +1,28 @@ +import pytest +import asyncio +from exospherehost.statemanager import StateManager, TriggerState + + +def test_trigger_requires_either_state_or_states(monkeypatch): + monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm") + monkeypatch.setenv("EXOSPHERE_API_KEY", "k") + sm = StateManager(namespace="ns") + with pytest.raises(ValueError): + asyncio.run(sm.trigger("g")) + + +def test_trigger_rejects_both_state_and_states(monkeypatch): + monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm") + monkeypatch.setenv("EXOSPHERE_API_KEY", "k") + sm = StateManager(namespace="ns") + state = TriggerState(identifier="id", inputs={}) + with pytest.raises(ValueError): + asyncio.run(sm.trigger("g", state=state, states=[state])) + + +def test_trigger_rejects_empty_states_list(monkeypatch): + monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm") + monkeypatch.setenv("EXOSPHERE_API_KEY", "k") + sm = StateManager(namespace="ns") + with pytest.raises(ValueError): + asyncio.run(sm.trigger("g", states=[])) \ No newline at end of file