Skip to content

Commit 27249fc

Browse files
authored
Introduce new models for graph execution and added Start_delay (#348)
* Update version to 0.0.2b5 and introduce new models for graph execution - Incremented the version in _version.py to 0.0.2b5. - Added new models in models.py for GraphNode, Unites, RetryPolicy, and StoreConfig, enhancing the structure and validation of graph execution parameters. - Refactored StateManager methods to utilize the new models, improving type safety and clarity in the trigger and upsert_graph functions. - Updated trigger_graph to include a start_delay parameter for delayed execution, enhancing flexibility in graph triggering. * Refactor __init__.py to streamline exports and include new models - Removed TriggerState from exports and added new models: UnitesStrategyEnum, UnitesModel, GraphNodeModel, RetryStrategyEnum, RetryPolicyModel, and StoreConfigModel. - Updated __all__ to reflect the changes in exported components, enhancing the module's structure and usability. * Refactor tests to remove TriggerState and utilize GraphNodeModel - Removed instances of TriggerState from tests, replacing them with a dictionary structure for state representation. - Updated test assertions to use GraphNodeModel for graph node definitions, enhancing clarity and consistency in the test suite. - Adjusted related test cases to ensure compatibility with the new state representation, improving overall test robustness. * Add class method decorators to StoreConfigModel validators - Updated the `validate_required_keys` and `validate_default_values` methods in `StoreConfigModel` to be class methods, enhancing their functionality and consistency with the model's design. - Introduced a new test file for comprehensive testing of `GraphNodeModel`, `StoreConfigModel`, and `StateManager`, including validation checks and default behavior, improving test coverage and reliability. * Remove unused import of asyncio in test_models_and_statemanager_new.py to streamline the test file.
1 parent e72eab7 commit 27249fc

11 files changed

Lines changed: 441 additions & 108 deletions

python-sdk/exospherehost/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ async def execute(self, inputs: Inputs) -> Outputs:
3737
from ._version import version as __version__
3838
from .runtime import Runtime
3939
from .node.BaseNode import BaseNode
40-
from .statemanager import StateManager, TriggerState
40+
from .statemanager import StateManager
4141
from .signals import PruneSignal, ReQueueAfterSignal
42+
from .models import UnitesStrategyEnum, UnitesModel, GraphNodeModel, RetryStrategyEnum, RetryPolicyModel, StoreConfigModel
4243

4344
VERSION = __version__
4445

45-
__all__ = ["Runtime", "BaseNode", "StateManager", "TriggerState", "VERSION", "PruneSignal", "ReQueueAfterSignal"]
46+
__all__ = ["Runtime", "BaseNode", "StateManager", "VERSION", "PruneSignal", "ReQueueAfterSignal", "UnitesStrategyEnum", "UnitesModel", "GraphNodeModel", "RetryStrategyEnum", "RetryPolicyModel", "StoreConfigModel"]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version = "0.0.2b4"
1+
version = "0.0.2b5"

python-sdk/exospherehost/models.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from pydantic import BaseModel, Field, field_validator
2+
from typing import Any, Optional, List
3+
from enum import Enum
4+
5+
6+
class UnitesStrategyEnum(str, Enum):
7+
ALL_SUCCESS = "ALL_SUCCESS"
8+
ALL_DONE = "ALL_DONE"
9+
10+
11+
class UnitesModel(BaseModel):
12+
identifier: str = Field(..., description="Identifier of the node")
13+
strategy: UnitesStrategyEnum = Field(default=UnitesStrategyEnum.ALL_SUCCESS, description="Strategy of the unites")
14+
15+
16+
class GraphNodeModel(BaseModel):
17+
node_name: str = Field(..., description="Name of the node")
18+
namespace: str = Field(..., description="Namespace of the node")
19+
identifier: str = Field(..., description="Identifier of the node")
20+
inputs: dict[str, Any] = Field(..., description="Inputs of the node")
21+
next_nodes: Optional[List[str]] = Field(None, description="Next nodes to execute")
22+
unites: Optional[UnitesModel] = Field(None, description="Unites of the node")
23+
24+
@field_validator('node_name')
25+
@classmethod
26+
def validate_node_name(cls, v: str) -> str:
27+
trimmed_v = v.strip()
28+
if trimmed_v == "" or trimmed_v is None:
29+
raise ValueError("Node name cannot be empty")
30+
return trimmed_v
31+
32+
@field_validator('identifier')
33+
@classmethod
34+
def validate_identifier(cls, v: str) -> str:
35+
trimmed_v = v.strip()
36+
if trimmed_v == "" or trimmed_v is None:
37+
raise ValueError("Node identifier cannot be empty")
38+
elif trimmed_v == "store":
39+
raise ValueError("Node identifier cannot be reserved word 'store'")
40+
return trimmed_v
41+
42+
@field_validator('next_nodes')
43+
@classmethod
44+
def validate_next_nodes(cls, v: Optional[List[str]]) -> Optional[List[str]]:
45+
identifiers = set()
46+
errors = []
47+
trimmed_v = []
48+
49+
if v is not None:
50+
for next_node_identifier in v:
51+
trimmed_next_node_identifier = next_node_identifier.strip()
52+
53+
if trimmed_next_node_identifier == "" or trimmed_next_node_identifier is None:
54+
errors.append("Next node identifier cannot be empty")
55+
continue
56+
57+
if trimmed_next_node_identifier in identifiers:
58+
errors.append(f"Next node identifier {trimmed_next_node_identifier} is not unique")
59+
continue
60+
61+
identifiers.add(trimmed_next_node_identifier)
62+
trimmed_v.append(trimmed_next_node_identifier)
63+
if errors:
64+
raise ValueError("\n".join(errors))
65+
return trimmed_v
66+
67+
@field_validator('unites')
68+
@classmethod
69+
def validate_unites(cls, v: Optional[UnitesModel]) -> Optional[UnitesModel]:
70+
trimmed_v = v
71+
if v is not None:
72+
trimmed_v = UnitesModel(identifier=v.identifier.strip(), strategy=v.strategy)
73+
if trimmed_v.identifier == "" or trimmed_v.identifier is None:
74+
raise ValueError("Unites identifier cannot be empty")
75+
return trimmed_v
76+
77+
78+
class RetryStrategyEnum(str, Enum):
79+
EXPONENTIAL = "EXPONENTIAL"
80+
EXPONENTIAL_FULL_JITTER = "EXPONENTIAL_FULL_JITTER"
81+
EXPONENTIAL_EQUAL_JITTER = "EXPONENTIAL_EQUAL_JITTER"
82+
83+
LINEAR = "LINEAR"
84+
LINEAR_FULL_JITTER = "LINEAR_FULL_JITTER"
85+
LINEAR_EQUAL_JITTER = "LINEAR_EQUAL_JITTER"
86+
87+
FIXED = "FIXED"
88+
FIXED_FULL_JITTER = "FIXED_FULL_JITTER"
89+
FIXED_EQUAL_JITTER = "FIXED_EQUAL_JITTER"
90+
91+
92+
class RetryPolicyModel(BaseModel):
93+
max_retries: int = Field(default=3, description="The maximum number of retries", ge=0)
94+
strategy: RetryStrategyEnum = Field(default=RetryStrategyEnum.EXPONENTIAL, description="The method of retry")
95+
backoff_factor: int = Field(default=2000, description="The backoff factor in milliseconds (default: 2000 = 2 seconds)", gt=0)
96+
exponent: int = Field(default=2, description="The exponent for the exponential retry strategy", gt=0)
97+
max_delay: int | None = Field(default=None, description="The maximum delay in milliseconds (no default limit when None)", gt=0)
98+
99+
100+
class StoreConfigModel(BaseModel):
101+
required_keys: list[str] = Field(default_factory=list, description="Required keys of the store")
102+
default_values: dict[str, str] = Field(default_factory=dict, description="Default values of the store")
103+
104+
@field_validator("required_keys")
105+
@classmethod
106+
def validate_required_keys(cls, v: list[str]) -> list[str]:
107+
errors = []
108+
keys = set()
109+
trimmed_keys = []
110+
111+
for key in v:
112+
trimmed_key = key.strip() if key is not None else ""
113+
114+
if trimmed_key == "":
115+
errors.append("Key cannot be empty or contain only whitespace")
116+
continue
117+
118+
if '.' in trimmed_key:
119+
errors.append(f"Key '{trimmed_key}' cannot contain '.' character")
120+
continue
121+
122+
if trimmed_key in keys:
123+
errors.append(f"Key '{trimmed_key}' is duplicated")
124+
continue
125+
126+
keys.add(trimmed_key)
127+
trimmed_keys.append(trimmed_key)
128+
129+
if len(errors) > 0:
130+
raise ValueError("\n".join(errors))
131+
return trimmed_keys
132+
133+
@field_validator("default_values")
134+
@classmethod
135+
def validate_default_values(cls, v: dict[str, str]) -> dict[str, str]:
136+
errors = []
137+
keys = set()
138+
normalized_dict = {}
139+
140+
for key, value in v.items():
141+
trimmed_key = key.strip() if key is not None else ""
142+
143+
if trimmed_key == "":
144+
errors.append("Key cannot be empty or contain only whitespace")
145+
continue
146+
147+
if '.' in trimmed_key:
148+
errors.append(f"Key '{trimmed_key}' cannot contain '.' character")
149+
continue
150+
151+
if trimmed_key in keys:
152+
errors.append(f"Key '{trimmed_key}' is duplicated")
153+
continue
154+
155+
keys.add(trimmed_key)
156+
normalized_dict[trimmed_key] = str(value)
157+
158+
if len(errors) > 0:
159+
raise ValueError("\n".join(errors))
160+
return normalized_dict

python-sdk/exospherehost/statemanager.py

Lines changed: 12 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,7 @@
33
import asyncio
44
import time
55

6-
from typing import Any
7-
from pydantic import BaseModel
8-
9-
10-
class TriggerState(BaseModel):
11-
"""
12-
Represents a trigger state for graph execution.
13-
14-
A trigger state contains an identifier and a set of input parameters that
15-
will be passed to the graph when it is triggered for execution.
16-
17-
Attributes:
18-
identifier (str): A unique identifier for this trigger state. This is used
19-
to distinguish between different trigger states and may be used by the
20-
graph to determine how to process the trigger.
21-
inputs (dict[str, str]): A dictionary of input parameters that will be
22-
passed to the graph. The keys are parameter names and values are
23-
parameter values, both as strings.
24-
25-
Example:
26-
```python
27-
# Create a trigger state with identifier and inputs
28-
trigger_state = TriggerState(
29-
identifier="user-login",
30-
inputs={
31-
"user_id": "12345",
32-
"session_token": "abc123def456",
33-
"timestamp": "2024-01-15T10:30:00Z"
34-
}
35-
)
36-
```
37-
"""
38-
identifier: str
39-
inputs: dict[str, str]
6+
from .models import GraphNodeModel, RetryPolicyModel, StoreConfigModel
407

418

429
class StateManager:
@@ -67,7 +34,7 @@ def _get_upsert_graph_endpoint(self, graph_name: str):
6734
def _get_get_graph_endpoint(self, graph_name: str):
6835
return f"{self._state_manager_uri}/{self._state_manager_version}/namespace/{self._namespace}/graph/{graph_name}"
6936

70-
async def trigger(self, graph_name: str, inputs: dict[str, str] | None = None, store: dict[str, str] | None = None):
37+
async def trigger(self, graph_name: str, inputs: dict[str, str] | None = None, store: dict[str, str] | None = None, start_delay: int = 0):
7138
"""
7239
Trigger execution of a graph.
7340
@@ -82,7 +49,8 @@ async def trigger(self, graph_name: str, inputs: dict[str, str] | None = None, s
8249
graph. Strings only.
8350
store (dict[str, str] | None): Optional key-value store that will be merged
8451
into the graph-level store before execution (beta).
85-
52+
start_delay (int): Optional delay in milliseconds before the graph starts execution.
53+
8654
Returns:
8755
dict: JSON payload returned by the state-manager API.
8856
@@ -108,6 +76,7 @@ async def trigger(self, graph_name: str, inputs: dict[str, str] | None = None, s
10876
store = {}
10977

11078
body = {
79+
"start_delay": start_delay,
11180
"inputs": inputs,
11281
"store": store
11382
}
@@ -156,7 +125,7 @@ async def get_graph(self, graph_name: str):
156125
raise Exception(f"Failed to get graph: {response.status} {await response.text()}")
157126
return await response.json()
158127

159-
async def upsert_graph(self, graph_name: str, graph_nodes: list[dict[str, Any]], secrets: dict[str, str], retry_policy: dict[str, Any] | None = None, store_config: dict[str, Any] | None = None, validation_timeout: int = 60, polling_interval: int = 1):
128+
async def upsert_graph(self, graph_name: str, graph_nodes: list[GraphNodeModel], secrets: dict[str, str], retry_policy: RetryPolicyModel | None = None, store_config: StoreConfigModel | None = None, validation_timeout: int = 60, polling_interval: int = 1):
160129
"""
161130
Create or update a graph definition.
162131
@@ -169,10 +138,10 @@ async def upsert_graph(self, graph_name: str, graph_nodes: list[dict[str, Any]],
169138
170139
Args:
171140
graph_name (str): Graph identifier.
172-
graph_nodes (list[dict[str, Any]]): Graph node list.
141+
graph_nodes (list[GraphNodeModel]): List of graph node models defining the workflow.
173142
secrets (dict[str, str]): Secrets available to all nodes.
174-
retry_policy (dict[str, Any] | None): Optional per-node retry policy.
175-
store_config (dict[str, Any] | None): Beta configuration for the
143+
retry_policy (RetryPolicyModel | None): Optional per-node retry policy configuration.
144+
store_config (StoreConfigModel | None): Beta configuration for the
176145
graph-level store (schema is subject to change).
177146
validation_timeout (int): Seconds to wait for validation (default 60).
178147
polling_interval (int): Polling interval in seconds (default 1).
@@ -189,13 +158,13 @@ async def upsert_graph(self, graph_name: str, graph_nodes: list[dict[str, Any]],
189158
}
190159
body = {
191160
"secrets": secrets,
192-
"nodes": graph_nodes
161+
"nodes": [node.model_dump() for node in graph_nodes]
193162
}
194163

195164
if retry_policy is not None:
196-
body["retry_policy"] = retry_policy
165+
body["retry_policy"] = retry_policy.model_dump()
197166
if store_config is not None:
198-
body["store_config"] = store_config
167+
body["store_config"] = store_config.model_dump()
199168

200169
async with aiohttp.ClientSession() as session:
201170
async with session.put(endpoint, json=body, headers=headers) as response: # type: ignore

python-sdk/tests/test_coverage_additions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ async def test_statemanager_trigger_defaults(monkeypatch):
4343
# Verify it sent empty inputs/store when omitted
4444
mock_session.post.assert_called_once()
4545
_, kwargs = mock_session.post.call_args
46-
assert kwargs["json"] == {"inputs": {}, "store": {}}
46+
assert kwargs["json"] == {"inputs": {}, "store": {}, "start_delay": 0}
4747

4848

4949
class _DummyNode(BaseNode):

python-sdk/tests/test_integration.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import asyncio
33
from unittest.mock import AsyncMock, patch, MagicMock
44
from pydantic import BaseModel
5-
from exospherehost import Runtime, BaseNode, StateManager, TriggerState
5+
from exospherehost import Runtime, BaseNode, StateManager
66

77

88
def create_mock_aiohttp_session():
@@ -205,21 +205,26 @@ async def test_state_manager_graph_lifecycle(self, mock_env_vars):
205205
sm = StateManager(namespace="test_namespace")
206206

207207
# Test graph creation
208+
from exospherehost.models import GraphNodeModel
208209
graph_nodes = [
209-
{"name": "IntegrationTestNode", "type": "test"}
210+
GraphNodeModel(
211+
node_name="IntegrationTestNode",
212+
namespace="test_namespace",
213+
identifier="IntegrationTestNode",
214+
inputs={"type": "test"},
215+
next_nodes=None,
216+
unites=None
217+
)
210218
]
211219
secrets = {"api_key": "test_key", "database_url": "db://test"}
212220

213221
result = await sm.upsert_graph("test_graph", graph_nodes, secrets, validation_timeout=10, polling_interval=0.1) # type: ignore
214222
assert result["validation_status"] == "VALID"
215223

216224
# Test graph triggering
217-
trigger_state = TriggerState(
218-
identifier="test_trigger",
219-
inputs={"user_id": "123", "action": "login"}
220-
)
225+
trigger_state = {"identifier": "test_trigger", "inputs": {"user_id": "123", "action": "login"}}
221226

222-
trigger_result = await sm.trigger("test_graph", inputs=trigger_state.inputs)
227+
trigger_result = await sm.trigger("test_graph", inputs=trigger_state["inputs"])
223228
assert trigger_result == {"status": "triggered"}
224229

225230

@@ -448,10 +453,10 @@ async def test_state_manager_error_propagation(self, mock_env_vars):
448453
mock_session_class.return_value = mock_session
449454

450455
sm = StateManager(namespace="error_test")
451-
trigger_state = TriggerState(identifier="test", inputs={"key": "value"})
456+
trigger_state = {"identifier": "test", "inputs": {"key": "value"}}
452457

453458
with pytest.raises(Exception, match="Failed to trigger state: 404 Graph not found"):
454-
await sm.trigger("nonexistent_graph", inputs=trigger_state.inputs)
459+
await sm.trigger("nonexistent_graph", inputs=trigger_state["inputs"])
455460

456461

457462
class TestConcurrencyIntegration:

0 commit comments

Comments
 (0)