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
3 changes: 3 additions & 0 deletions .github/workflows/publish-python-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
61 changes: 61 additions & 0 deletions .github/workflows/test-python-sdk.yml
Original file line number Diff line number Diff line change
@@ -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
38 changes: 21 additions & 17 deletions python-sdk/exospherehost/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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):
"""
Expand Down
2 changes: 2 additions & 0 deletions python-sdk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
29 changes: 29 additions & 0 deletions python-sdk/tests/test_base_node.py
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions python-sdk/tests/test_runtime_validation.py
Original file line number Diff line number Diff line change
@@ -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])
28 changes: 28 additions & 0 deletions python-sdk/tests/test_state_manager.py
Original file line number Diff line number Diff line change
@@ -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=[]))
Loading