Skip to content

Commit 8d77f6d

Browse files
authored
Update version to 0.0.7b10 and enhance secret retrieval logic in Runtime class (#277)
* Update version to 0.0.7b10 and enhance secret retrieval logic in Runtime class - Bumped version from 0.0.7b9 to 0.0.7b10 in _version.py. - Modified _get_secrets method to return secrets directly if present, with error logging for missing secrets. - Introduced _need_secrets method to check if a node requires secrets before attempting to retrieve them. - Updated _worker method to conditionally fetch secrets based on node requirements. * fixed tests * Enhance test for duplicate node names in Runtime validation - Added two classes with the same name using different approaches to test for duplicate node names. - Suppressed RuntimeWarning for unawaited coroutines during the test execution. - Updated the test to ensure it raises a ValueError when duplicate node names are used.
1 parent 1d998e1 commit 8d77f6d

6 files changed

Lines changed: 337 additions & 14 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version = "0.0.7b9"
1+
version = "0.0.7b10"

python-sdk/exospherehost/runtime.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,11 @@ async def _get_secrets(self, state_id: str) -> Dict[str, str]:
293293
logger.error(f"Failed to get secrets for state {state_id}: {res}")
294294
return {}
295295

296-
return res
296+
if "secrets" in res:
297+
return res["secrets"]
298+
else:
299+
logger.error(f"'secrets' not found in response for state {state_id}")
300+
return {}
297301

298302
def _validate_nodes(self):
299303
"""
@@ -352,6 +356,12 @@ def _validate_nodes(self):
352356
if len(errors) > 0:
353357
raise ValueError("Following errors while validating nodes: " + "\n".join(errors))
354358

359+
def _need_secrets(self, node: type[BaseNode]) -> bool:
360+
"""
361+
Check if the node needs secrets.
362+
"""
363+
return len(node.Secrets.model_fields.keys()) > 0
364+
355365
async def _worker(self, idx: int):
356366
"""
357367
Worker task that processes states from the queue.
@@ -369,10 +379,12 @@ async def _worker(self, idx: int):
369379
node = self._node_mapping[state["node_name"]]
370380
logger.info(f"Executing state {state['state_id']} for node {node.__name__}")
371381

372-
secrets = await self._get_secrets(state["state_id"])
373-
logger.info(f"Got secrets for state {state['state_id']} for node {node.__name__}")
382+
secrets = {}
383+
if self._need_secrets(node):
384+
secrets = await self._get_secrets(state["state_id"])
385+
logger.info(f"Got secrets for state {state['state_id']} for node {node.__name__}")
374386

375-
outputs = await node()._execute(node.Inputs(**state["inputs"]), node.Secrets(**secrets["secrets"])) # type: ignore
387+
outputs = await node()._execute(node.Inputs(**state["inputs"]), node.Secrets(**secrets))
376388
logger.info(f"Got outputs for state {state['state_id']} for node {node.__name__}")
377389

378390
if outputs is None:
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import pytest
2+
from pydantic import BaseModel
3+
from exospherehost.node.BaseNode import BaseNode
4+
5+
6+
class TestBaseNodeAbstract:
7+
"""Test the abstract BaseNode class and its NotImplementedError."""
8+
9+
def test_base_node_abstract_execute(self):
10+
"""Test that BaseNode.execute raises NotImplementedError."""
11+
# Create a concrete subclass that implements execute but raises NotImplementedError
12+
class ConcreteNode(BaseNode):
13+
class Inputs(BaseModel):
14+
name: str
15+
16+
class Outputs(BaseModel):
17+
message: str
18+
19+
class Secrets(BaseModel):
20+
pass
21+
22+
async def execute(self):
23+
raise NotImplementedError("execute method must be implemented by all concrete node classes")
24+
25+
node = ConcreteNode()
26+
27+
with pytest.raises(NotImplementedError, match="execute method must be implemented by all concrete node classes"):
28+
# This should raise NotImplementedError
29+
import asyncio
30+
asyncio.run(node.execute())
31+
32+
def test_base_node_abstract_execute_with_inputs(self):
33+
"""Test that BaseNode._execute raises NotImplementedError when execute is not implemented."""
34+
# Create a concrete subclass that implements execute but raises NotImplementedError
35+
class ConcreteNode(BaseNode):
36+
class Inputs(BaseModel):
37+
name: str
38+
39+
class Outputs(BaseModel):
40+
message: str
41+
42+
class Secrets(BaseModel):
43+
pass
44+
45+
async def execute(self):
46+
raise NotImplementedError("execute method must be implemented by all concrete node classes")
47+
48+
node = ConcreteNode()
49+
50+
with pytest.raises(NotImplementedError, match="execute method must be implemented by all concrete node classes"):
51+
# This should raise NotImplementedError
52+
import asyncio
53+
asyncio.run(node._execute(node.Inputs(name="test"), node.Secrets())) # type: ignore
54+
55+
def test_base_node_initialization(self):
56+
"""Test that BaseNode initializes correctly."""
57+
# Create a concrete subclass
58+
class ConcreteNode(BaseNode):
59+
class Inputs(BaseModel):
60+
name: str
61+
62+
class Outputs(BaseModel):
63+
message: str
64+
65+
class Secrets(BaseModel):
66+
pass
67+
68+
async def execute(self):
69+
return self.Outputs(message="test")
70+
71+
node = ConcreteNode()
72+
assert node.inputs is None
73+
74+
def test_base_node_inputs_class(self):
75+
"""Test that BaseNode has Inputs class."""
76+
assert hasattr(BaseNode, 'Inputs')
77+
assert issubclass(BaseNode.Inputs, BaseModel)
78+
79+
def test_base_node_outputs_class(self):
80+
"""Test that BaseNode has Outputs class."""
81+
assert hasattr(BaseNode, 'Outputs')
82+
assert issubclass(BaseNode.Outputs, BaseModel)
83+
84+
def test_base_node_secrets_class(self):
85+
"""Test that BaseNode has Secrets class."""
86+
assert hasattr(BaseNode, 'Secrets')
87+
assert issubclass(BaseNode.Secrets, BaseModel)

python-sdk/tests/test_runtime_comprehensive.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ async def test_worker_successful_execution(self, runtime_config):
282282
with patch('exospherehost.runtime.Runtime._get_secrets') as mock_get_secrets, \
283283
patch('exospherehost.runtime.Runtime._notify_executed') as mock_notify_executed:
284284

285-
mock_get_secrets.return_value = {"secrets": {"api_key": "test_key"}}
285+
mock_get_secrets.return_value = {"api_key": "test_key"}
286286
mock_notify_executed.return_value = None
287287

288288
runtime = Runtime(**runtime_config)
@@ -327,7 +327,7 @@ async def test_worker_with_list_output(self, runtime_config):
327327
with patch('exospherehost.runtime.Runtime._get_secrets') as mock_get_secrets, \
328328
patch('exospherehost.runtime.Runtime._notify_executed') as mock_notify_executed:
329329

330-
mock_get_secrets.return_value = {"secrets": {"api_key": "test_key"}}
330+
mock_get_secrets.return_value = {"api_key": "test_key"}
331331
mock_notify_executed.return_value = None
332332

333333
runtime = Runtime(**runtime_config)
@@ -362,7 +362,7 @@ async def test_worker_with_none_output(self, runtime_config):
362362
with patch('exospherehost.runtime.Runtime._get_secrets') as mock_get_secrets, \
363363
patch('exospherehost.runtime.Runtime._notify_executed') as mock_notify_executed:
364364

365-
mock_get_secrets.return_value = {"secrets": {"api_key": "test_key"}}
365+
mock_get_secrets.return_value = {"api_key": "test_key"}
366366
mock_notify_executed.return_value = None
367367

368368
runtime = Runtime(**runtime_config)
@@ -394,7 +394,7 @@ async def test_worker_execution_error(self, runtime_config):
394394
with patch('exospherehost.runtime.Runtime._get_secrets') as mock_get_secrets, \
395395
patch('exospherehost.runtime.Runtime._notify_errored') as mock_notify_errored:
396396

397-
mock_get_secrets.return_value = {"secrets": {"api_key": "test_key"}}
397+
mock_get_secrets.return_value = {"api_key": "test_key"}
398398
mock_notify_errored.return_value = None
399399

400400
runtime = Runtime(**runtime_config)
@@ -511,7 +511,7 @@ async def test_get_secrets_success(self, runtime_config):
511511
runtime = Runtime(**runtime_config)
512512
result = await runtime._get_secrets("test_state_1")
513513

514-
assert result == {"secrets": {"api_key": "secret_key"}}
514+
assert result == {"api_key": "secret_key"}
515515

516516
@pytest.mark.asyncio
517517
async def test_get_secrets_failure(self, runtime_config):
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import pytest
2+
import asyncio
3+
import warnings
4+
from unittest.mock import AsyncMock, patch, MagicMock
5+
from pydantic import BaseModel
6+
from exospherehost.runtime import Runtime, _setup_default_logging
7+
from exospherehost.node.BaseNode import BaseNode
8+
9+
10+
class MockTestNode(BaseNode):
11+
class Inputs(BaseModel):
12+
name: str
13+
14+
class Outputs(BaseModel):
15+
message: str
16+
17+
class Secrets(BaseModel):
18+
api_key: str
19+
20+
async def execute(self):
21+
return self.Outputs(message=f"Hello {self.inputs.name}") # type: ignore
22+
23+
24+
class MockTestNodeWithNonStringFields(BaseNode):
25+
class Inputs(BaseModel):
26+
name: str
27+
count: int # This should cause validation error
28+
29+
class Outputs(BaseModel):
30+
message: str
31+
32+
class Secrets(BaseModel):
33+
api_key: str
34+
35+
async def execute(self):
36+
return self.Outputs(message=f"Hello {self.inputs.name}") # type: ignore
37+
38+
39+
class MockTestNodeWithoutSecrets(BaseNode):
40+
class Inputs(BaseModel):
41+
name: str
42+
43+
class Outputs(BaseModel):
44+
message: str
45+
46+
class Secrets(BaseModel):
47+
pass # Empty secrets
48+
49+
async def execute(self):
50+
return self.Outputs(message=f"Hello {self.inputs.name}") # type: ignore
51+
52+
53+
class MockTestNodeWithError(BaseNode):
54+
class Inputs(BaseModel):
55+
should_fail: str
56+
57+
class Outputs(BaseModel):
58+
result: str
59+
60+
class Secrets(BaseModel):
61+
api_key: str
62+
63+
async def execute(self):
64+
if self.inputs.should_fail == "true": # type: ignore
65+
raise ValueError("Test error")
66+
return self.Outputs(result="success")
67+
68+
69+
class TestRuntimeEdgeCases:
70+
"""Test edge cases and error handling scenarios in the Runtime class."""
71+
72+
def test_setup_default_logging_disabled(self, monkeypatch):
73+
"""Test that _setup_default_logging returns early when disabled."""
74+
monkeypatch.setenv('EXOSPHERE_DISABLE_DEFAULT_LOGGING', 'true')
75+
76+
# This should not raise any exceptions and should return early
77+
_setup_default_logging()
78+
79+
def test_setup_default_logging_invalid_level(self, monkeypatch):
80+
"""Test _setup_default_logging with invalid log level."""
81+
monkeypatch.setenv('EXOSPHERE_LOG_LEVEL', 'INVALID_LEVEL')
82+
83+
# Should fall back to INFO level
84+
_setup_default_logging()
85+
86+
def test_runtime_validation_non_string_fields(self):
87+
"""Test that Runtime validates node fields are strings."""
88+
with pytest.raises(ValueError, match="must be of type str"):
89+
Runtime(
90+
namespace="test",
91+
name="test",
92+
nodes=[MockTestNodeWithNonStringFields],
93+
state_manager_uri="http://localhost:8080",
94+
key="test_key"
95+
)
96+
97+
def test_runtime_validation_duplicate_node_names(self):
98+
"""Test that Runtime validates no duplicate node names."""
99+
# Create two classes with the same name
100+
class TestNode1(MockTestNode):
101+
pass
102+
103+
class TestNode2(MockTestNode):
104+
pass
105+
106+
# Rename the second class to have the same name as the first
107+
TestNode2.__name__ = "TestNode1"
108+
109+
# Suppress the RuntimeWarning about unawaited coroutines
110+
with warnings.catch_warnings():
111+
warnings.filterwarnings("ignore", message=".*coroutine.*was never awaited.*", category=RuntimeWarning)
112+
with pytest.raises(ValueError, match="Duplicate node class names found"):
113+
Runtime(
114+
namespace="test",
115+
name="test",
116+
nodes=[TestNode1, TestNode2],
117+
state_manager_uri="http://localhost:8080",
118+
key="test_key"
119+
)
120+
121+
def test_need_secrets_empty_secrets(self):
122+
"""Test _need_secrets with empty secrets class."""
123+
runtime = Runtime(
124+
namespace="test",
125+
name="test",
126+
nodes=[MockTestNodeWithoutSecrets],
127+
state_manager_uri="http://localhost:8080",
128+
key="test_key"
129+
)
130+
131+
# Should return False for empty secrets
132+
assert not runtime._need_secrets(MockTestNodeWithoutSecrets)
133+
134+
def test_need_secrets_with_secrets(self):
135+
"""Test _need_secrets with secrets class that has fields."""
136+
runtime = Runtime(
137+
namespace="test",
138+
name="test",
139+
nodes=[MockTestNode],
140+
state_manager_uri="http://localhost:8080",
141+
key="test_key"
142+
)
143+
144+
# Should return True for secrets with fields
145+
assert runtime._need_secrets(MockTestNode)
146+
147+
@pytest.mark.asyncio
148+
async def test_enqueue_error_handling(self):
149+
"""Test error handling in _enqueue method."""
150+
runtime = Runtime(
151+
namespace="test",
152+
name="test",
153+
nodes=[MockTestNode],
154+
state_manager_uri="http://localhost:8080",
155+
key="test_key"
156+
)
157+
158+
# Mock _enqueue_call to raise an exception
159+
with patch.object(runtime, '_enqueue_call', side_effect=Exception("Test error")):
160+
# This should not raise an exception but log an error
161+
# We'll test this by checking that the method doesn't crash
162+
task = asyncio.create_task(runtime._enqueue())
163+
await asyncio.sleep(0.1) # Let it run briefly
164+
task.cancel()
165+
try:
166+
await task
167+
except asyncio.CancelledError:
168+
pass
169+
170+
def test_start_without_running_loop(self):
171+
"""Test start method when no event loop is running."""
172+
runtime = Runtime(
173+
namespace="test",
174+
name="test",
175+
nodes=[MockTestNode],
176+
state_manager_uri="http://localhost:8080",
177+
key="test_key"
178+
)
179+
180+
# Mock _start to avoid actual execution
181+
with patch.object(runtime, '_start', new_callable=AsyncMock):
182+
# This should not raise an exception
183+
result = runtime.start()
184+
assert result is None
185+
186+
def test_start_with_running_loop(self):
187+
"""Test start method when an event loop is already running."""
188+
runtime = Runtime(
189+
namespace="test",
190+
name="test",
191+
nodes=[MockTestNode],
192+
state_manager_uri="http://localhost:8080",
193+
key="test_key"
194+
)
195+
196+
# Mock _start to avoid actual execution
197+
with patch.object(runtime, '_start', new_callable=AsyncMock):
198+
# Create a mock loop
199+
mock_loop = MagicMock()
200+
mock_task = MagicMock()
201+
mock_loop.create_task.return_value = mock_task
202+
203+
with patch('asyncio.get_running_loop', return_value=mock_loop):
204+
result = runtime.start()
205+
assert result == mock_task

0 commit comments

Comments
 (0)