Skip to content

Commit 5b7076d

Browse files
Add python sdk unit tests and ci (#214)
* Add tests and improve runtime validation for Python SDK Co-authored-by: nivedit <nivedit@aikin.club> * python-sdk: add unit tests; fix Runtime node validation guards; add PR CI with coverage upload to Codecov; revert publish trigger to _version.py and run tests before publish; remove unused helper and pytest.ini; add pytest/pytest-cov to dev deps * chore: remove stray get-pip.py * fix(ci): install python-sdk package in test workflow; ruff fixes (remove unused imports); --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 7038dc2 commit 5b7076d

7 files changed

Lines changed: 239 additions & 17 deletions

File tree

.github/workflows/publish-python-sdk.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ jobs:
2929

3030
- run: uv sync --locked --dev
3131

32+
- name: Run tests
33+
run: uvx --from pytest pytest -q
34+
3235
- run: uv build
3336

3437
- run: uv publish
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Python SDK Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'python-sdk/**'
8+
pull_request:
9+
branches: [main]
10+
paths:
11+
- 'python-sdk/**'
12+
13+
jobs:
14+
test:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Checkout code
18+
uses: actions/checkout@v4
19+
20+
- name: Set up Python
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: '3.12'
24+
25+
- name: Install uv
26+
uses: astral-sh/setup-uv@v2
27+
with:
28+
cache: true
29+
30+
- name: Install dev dependencies with uv
31+
working-directory: python-sdk
32+
run: |
33+
uv sync --group dev
34+
35+
- name: Install python-sdk package (editable)
36+
working-directory: python-sdk
37+
run: |
38+
uv pip install -e .
39+
40+
- name: Run tests with pytest and coverage
41+
working-directory: python-sdk
42+
run: |
43+
uv run pytest --cov=exospherehost --cov-report=xml --cov-report=term-missing -v --junitxml=pytest-report.xml
44+
45+
- name: Upload coverage reports to Codecov
46+
uses: codecov/codecov-action@v5
47+
with:
48+
token: ${{ secrets.CODECOV_TOKEN }}
49+
slug: exospherehost/exospherehost
50+
files: python-sdk/coverage.xml
51+
flags: python-sdk-unittests
52+
name: python-sdk-coverage-report
53+
fail_ci_if_error: true
54+
55+
- name: Upload test results
56+
uses: actions/upload-artifact@v4
57+
if: always()
58+
with:
59+
name: python-sdk-test-results
60+
path: python-sdk/pytest-report.xml
61+
retention-days: 30

python-sdk/exospherehost/runtime.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from asyncio import Queue, sleep
44
from typing import List, Dict
55

6-
from pydantic import BaseModel, ValidationError
6+
from pydantic import BaseModel
77
from .node.BaseNode import BaseNode
88
from aiohttp import ClientSession
99
from logging import getLogger
@@ -271,27 +271,31 @@ def _validate_nodes(self):
271271
errors.append(f"{node.__name__} does not have an Inputs class")
272272
if not hasattr(node, "Outputs"):
273273
errors.append(f"{node.__name__} does not have an Outputs class")
274-
if not issubclass(node.Inputs, BaseModel):
275-
errors.append(f"{node.__name__} does not have an Inputs class that inherits from pydantic.BaseModel")
276-
if not issubclass(node.Outputs, BaseModel):
274+
inputs_is_basemodel = hasattr(node, "Inputs") and issubclass(node.Inputs, BaseModel)
275+
if not inputs_is_basemodel:
276+
errors.append(f"{node.__name__} does not have an Inputs class that inherits from pydantic.BaseModel")
277+
outputs_is_basemodel = hasattr(node, "Outputs") and issubclass(node.Outputs, BaseModel)
278+
if not outputs_is_basemodel:
277279
errors.append(f"{node.__name__} does not have an Outputs class that inherits from pydantic.BaseModel")
278280
if not hasattr(node, "Secrets"):
279281
errors.append(f"{node.__name__} does not have an Secrets class")
280-
if not issubclass(node.Secrets, BaseModel):
282+
secrets_is_basemodel = hasattr(node, "Secrets") and issubclass(node.Secrets, BaseModel)
283+
if not secrets_is_basemodel:
281284
errors.append(f"{node.__name__} does not have an Secrets class that inherits from pydantic.BaseModel")
282285

283286
# check all data objects are strings
284-
for field_name, field_info in node.Inputs.model_fields.items():
285-
if field_info.annotation is not str:
286-
errors.append(f"{node.__name__}.Inputs field '{field_name}' must be of type str, got {field_info.annotation}")
287-
288-
for field_name, field_info in node.Outputs.model_fields.items():
289-
if field_info.annotation is not str:
290-
errors.append(f"{node.__name__}.Outputs field '{field_name}' must be of type str, got {field_info.annotation}")
291-
292-
for field_name, field_info in node.Secrets.model_fields.items():
293-
if field_info.annotation is not str:
294-
errors.append(f"{node.__name__}.Secrets field '{field_name}' must be of type str, got {field_info.annotation}")
287+
if inputs_is_basemodel:
288+
for field_name, field_info in node.Inputs.model_fields.items():
289+
if field_info.annotation is not str:
290+
errors.append(f"{node.__name__}.Inputs field '{field_name}' must be of type str, got {field_info.annotation}")
291+
if outputs_is_basemodel:
292+
for field_name, field_info in node.Outputs.model_fields.items():
293+
if field_info.annotation is not str:
294+
errors.append(f"{node.__name__}.Outputs field '{field_name}' must be of type str, got {field_info.annotation}")
295+
if secrets_is_basemodel:
296+
for field_name, field_info in node.Secrets.model_fields.items():
297+
if field_info.annotation is not str:
298+
errors.append(f"{node.__name__}.Secrets field '{field_name}' must be of type str, got {field_info.annotation}")
295299

296300
# Find nodes with the same __class__.__name__
297301
class_names = [node.__name__ for node in self._nodes]
@@ -300,7 +304,7 @@ def _validate_nodes(self):
300304
errors.append(f"Duplicate node class names found: {duplicate_class_names}")
301305

302306
if len(errors) > 0:
303-
raise ValidationError("Following errors while validating nodes: " + "\n".join(errors))
307+
raise ValueError("Following errors while validating nodes: " + "\n".join(errors))
304308

305309
async def _worker(self):
306310
"""

python-sdk/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,6 @@ version = {attr = "exospherehost._version.version"}
3131
[dependency-groups]
3232
dev = [
3333
"ruff>=0.12.5",
34+
"pytest>=8.3.0",
35+
"pytest-cov>=5.0.0",
3436
]

python-sdk/tests/test_base_node.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from exospherehost.node.BaseNode import BaseNode
2+
from pydantic import BaseModel
3+
import asyncio
4+
5+
6+
class EchoNode(BaseNode):
7+
class Inputs(BaseModel):
8+
text: str
9+
10+
class Outputs(BaseModel):
11+
message: str
12+
13+
class Secrets(BaseModel):
14+
token: str
15+
16+
async def execute(self) -> Outputs:
17+
return self.Outputs(message=f"{self.inputs.text}:{self.secrets.token}")
18+
19+
20+
def test_base_node_execute_sets_inputs_and_returns_outputs():
21+
node = EchoNode()
22+
inputs = EchoNode.Inputs(text="hello")
23+
secrets = EchoNode.Secrets(token="tkn")
24+
outputs = asyncio.run(node._execute(inputs, secrets))
25+
26+
assert isinstance(outputs, EchoNode.Outputs)
27+
assert outputs.message == "hello:tkn"
28+
assert node.inputs == inputs
29+
assert node.secrets == secrets
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import pytest
2+
from pydantic import BaseModel
3+
from exospherehost.runtime import Runtime
4+
from exospherehost.node.BaseNode import BaseNode
5+
6+
7+
class GoodNode(BaseNode):
8+
class Inputs(BaseModel):
9+
name: str
10+
11+
class Outputs(BaseModel):
12+
message: str
13+
14+
class Secrets(BaseModel):
15+
api_key: str
16+
17+
async def execute(self):
18+
return self.Outputs(message=f"hi {self.inputs.name}")
19+
20+
21+
class BadNodeWrongInputsBase(BaseNode):
22+
Inputs = object # not a pydantic BaseModel
23+
class Outputs(BaseModel):
24+
message: str
25+
class Secrets(BaseModel):
26+
token: str
27+
async def execute(self):
28+
return self.Outputs(message="x")
29+
30+
31+
class BadNodeWrongTypes(BaseNode):
32+
class Inputs(BaseModel):
33+
count: int
34+
class Outputs(BaseModel):
35+
ok: bool
36+
class Secrets(BaseModel):
37+
secret: bytes
38+
async def execute(self):
39+
return self.Outputs(ok=True)
40+
41+
42+
43+
44+
def test_runtime_missing_config_raises(monkeypatch):
45+
# Ensure env vars not set
46+
monkeypatch.delenv("EXOSPHERE_STATE_MANAGER_URI", raising=False)
47+
monkeypatch.delenv("EXOSPHERE_API_KEY", raising=False)
48+
with pytest.raises(ValueError):
49+
Runtime(namespace="ns", name="rt", nodes=[GoodNode])
50+
51+
52+
def test_runtime_with_env_ok(monkeypatch):
53+
monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm")
54+
monkeypatch.setenv("EXOSPHERE_API_KEY", "k")
55+
rt = Runtime(namespace="ns", name="rt", nodes=[GoodNode])
56+
assert rt is not None
57+
58+
59+
def test_runtime_invalid_params_raises(monkeypatch):
60+
monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm")
61+
monkeypatch.setenv("EXOSPHERE_API_KEY", "k")
62+
with pytest.raises(ValueError):
63+
Runtime(namespace="ns", name="rt", nodes=[GoodNode], batch_size=0)
64+
with pytest.raises(ValueError):
65+
Runtime(namespace="ns", name="rt", nodes=[GoodNode], workers=0)
66+
67+
68+
def test_node_validation_errors(monkeypatch):
69+
monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm")
70+
monkeypatch.setenv("EXOSPHERE_API_KEY", "k")
71+
with pytest.raises(ValueError) as e:
72+
Runtime(namespace="ns", name="rt", nodes=[BadNodeWrongInputsBase])
73+
assert "Inputs class that inherits" in str(e.value)
74+
75+
with pytest.raises(ValueError) as e2:
76+
Runtime(namespace="ns", name="rt", nodes=[BadNodeWrongTypes])
77+
msg = str(e2.value)
78+
assert "Inputs field" in msg and "Outputs field" in msg and "Secrets field" in msg
79+
80+
81+
def test_duplicate_node_names_raise(monkeypatch):
82+
monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm")
83+
monkeypatch.setenv("EXOSPHERE_API_KEY", "k")
84+
class AnotherGood(BaseNode):
85+
class Inputs(BaseModel):
86+
name: str
87+
class Outputs(BaseModel):
88+
message: str
89+
class Secrets(BaseModel):
90+
api_key: str
91+
async def execute(self):
92+
return self.Outputs(message="ok")
93+
AnotherGood.__name__ = "GoodNode" # force duplicate name
94+
with pytest.raises(ValueError):
95+
Runtime(namespace="ns", name="rt", nodes=[GoodNode, AnotherGood])
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import pytest
2+
import asyncio
3+
from exospherehost.statemanager import StateManager, TriggerState
4+
5+
6+
def test_trigger_requires_either_state_or_states(monkeypatch):
7+
monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm")
8+
monkeypatch.setenv("EXOSPHERE_API_KEY", "k")
9+
sm = StateManager(namespace="ns")
10+
with pytest.raises(ValueError):
11+
asyncio.run(sm.trigger("g"))
12+
13+
14+
def test_trigger_rejects_both_state_and_states(monkeypatch):
15+
monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm")
16+
monkeypatch.setenv("EXOSPHERE_API_KEY", "k")
17+
sm = StateManager(namespace="ns")
18+
state = TriggerState(identifier="id", inputs={})
19+
with pytest.raises(ValueError):
20+
asyncio.run(sm.trigger("g", state=state, states=[state]))
21+
22+
23+
def test_trigger_rejects_empty_states_list(monkeypatch):
24+
monkeypatch.setenv("EXOSPHERE_STATE_MANAGER_URI", "http://sm")
25+
monkeypatch.setenv("EXOSPHERE_API_KEY", "k")
26+
sm = StateManager(namespace="ns")
27+
with pytest.raises(ValueError):
28+
asyncio.run(sm.trigger("g", states=[]))

0 commit comments

Comments
 (0)