novastack-workflows 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- novastack_workflows-1.0.0/.gitignore +85 -0
- novastack_workflows-1.0.0/PKG-INFO +90 -0
- novastack_workflows-1.0.0/README.md +71 -0
- novastack_workflows-1.0.0/novastack/workflows/__init__.py +19 -0
- novastack_workflows-1.0.0/novastack/workflows/context/__init__.py +3 -0
- novastack_workflows-1.0.0/novastack/workflows/context/context.py +121 -0
- novastack_workflows-1.0.0/novastack/workflows/context/state_store.py +123 -0
- novastack_workflows-1.0.0/novastack/workflows/decorators.py +114 -0
- novastack_workflows-1.0.0/novastack/workflows/enums.py +10 -0
- novastack_workflows-1.0.0/novastack/workflows/events.py +32 -0
- novastack_workflows-1.0.0/novastack/workflows/exceptions.py +14 -0
- novastack_workflows-1.0.0/novastack/workflows/runtime/__init__.py +0 -0
- novastack_workflows-1.0.0/novastack/workflows/runtime/engine.py +282 -0
- novastack_workflows-1.0.0/novastack/workflows/runtime/execution_pool.py +51 -0
- novastack_workflows-1.0.0/novastack/workflows/runtime/optimized_queue.py +79 -0
- novastack_workflows-1.0.0/novastack/workflows/types.py +89 -0
- novastack_workflows-1.0.0/novastack/workflows/workflow.py +151 -0
- novastack_workflows-1.0.0/pyproject.toml +38 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
#IDE
|
|
10
|
+
.DS_Store
|
|
11
|
+
.idea
|
|
12
|
+
.vscode
|
|
13
|
+
|
|
14
|
+
# Distribution / packaging
|
|
15
|
+
.Python
|
|
16
|
+
build/
|
|
17
|
+
develop-eggs/
|
|
18
|
+
dist/
|
|
19
|
+
downloads/
|
|
20
|
+
eggs/
|
|
21
|
+
.eggs/
|
|
22
|
+
lib/
|
|
23
|
+
lib64/
|
|
24
|
+
parts/
|
|
25
|
+
sdist/
|
|
26
|
+
var/
|
|
27
|
+
wheels/
|
|
28
|
+
share/python-wheels/
|
|
29
|
+
*.egg-info/
|
|
30
|
+
.installed.cfg
|
|
31
|
+
*.egg
|
|
32
|
+
MANIFEST
|
|
33
|
+
|
|
34
|
+
# PyInstaller
|
|
35
|
+
# Usually these files are written by a python script from a template
|
|
36
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
37
|
+
*.manifest
|
|
38
|
+
*.spec
|
|
39
|
+
|
|
40
|
+
# Installer logs
|
|
41
|
+
pip-log.txt
|
|
42
|
+
pip-delete-this-directory.txt
|
|
43
|
+
|
|
44
|
+
# Unit test / coverage reports
|
|
45
|
+
htmlcov/
|
|
46
|
+
.tox/
|
|
47
|
+
.nox/
|
|
48
|
+
.coverage
|
|
49
|
+
.coverage.*
|
|
50
|
+
.cache
|
|
51
|
+
nosetests.xml
|
|
52
|
+
coverage.xml
|
|
53
|
+
*.cover
|
|
54
|
+
*.py,cover
|
|
55
|
+
.hypothesis/
|
|
56
|
+
.pytest_cache/
|
|
57
|
+
cover/
|
|
58
|
+
|
|
59
|
+
# Mkdocs documentation
|
|
60
|
+
docs/_build/
|
|
61
|
+
docs/api_reference/site/
|
|
62
|
+
|
|
63
|
+
# Ruff
|
|
64
|
+
.ruff_cache/
|
|
65
|
+
|
|
66
|
+
# PyBuilder
|
|
67
|
+
.pybuilder/
|
|
68
|
+
target/
|
|
69
|
+
|
|
70
|
+
# Jupyter Notebook
|
|
71
|
+
.ipynb_checkpoints
|
|
72
|
+
|
|
73
|
+
# pyenv
|
|
74
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
75
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
76
|
+
.python-version
|
|
77
|
+
|
|
78
|
+
# Environments
|
|
79
|
+
.env
|
|
80
|
+
.venv
|
|
81
|
+
env/
|
|
82
|
+
venv/
|
|
83
|
+
ENV/
|
|
84
|
+
env.bak/
|
|
85
|
+
venv.bak/
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: novastack-workflows
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A workflow engine to run AI applications with event-driven, stepwise control.
|
|
5
|
+
Project-URL: Repository, https://github.com/novastack-project/novastack
|
|
6
|
+
Author-email: Leonardo Furnielis <leonardofurnielis@outlook.com>
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Keywords: AI,LLM,QA,RAG,data,observability,retrieval,semantic-search
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Requires-Python: <3.14,>=3.11
|
|
13
|
+
Requires-Dist: pydantic<3.0.0,>=2.12.5
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest-asyncio<2.0.0,>=1.3.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest<10.0.0,>=9.0.3; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff<1.0.0,>=0.15.8; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# NovaStack Workflows
|
|
21
|
+
|
|
22
|
+
A powerful and flexible event-driven workflow engine for Python, designed to build complex asynchronous workflows with ease.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install novastack-workflows
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
Here's a simple example to get you started with NovaStack Workflows:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from novastack.workflows import Workflow, Context, step, Event, StartEvent, StopEvent
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Define a custom event
|
|
39
|
+
class MyEvent(Event):
|
|
40
|
+
message: str
|
|
41
|
+
|
|
42
|
+
# Create your workflow
|
|
43
|
+
class SimpleWorkflow(Workflow):
|
|
44
|
+
"""A simple workflow that processes a message."""
|
|
45
|
+
|
|
46
|
+
@step(on=StartEvent)
|
|
47
|
+
async def start(self, ctx: Context, ev: StartEvent) -> MyEvent:
|
|
48
|
+
# Get input from the start event
|
|
49
|
+
input_msg = ev.get("input_msg", "")
|
|
50
|
+
# Return a custom event to trigger the next step
|
|
51
|
+
return MyEvent(message=f"Processed: {input_msg}")
|
|
52
|
+
|
|
53
|
+
@step(on=MyEvent)
|
|
54
|
+
async def process(self, ctx: Context, ev: MyEvent) -> StopEvent:
|
|
55
|
+
# Process the message and return the final result
|
|
56
|
+
return StopEvent(result=ev.message)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Run the workflow
|
|
60
|
+
async def main():
|
|
61
|
+
workflow = SimpleWorkflow()
|
|
62
|
+
result = await workflow.run(input_msg="Hello, World!")
|
|
63
|
+
|
|
64
|
+
print(result)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Core Concepts
|
|
68
|
+
|
|
69
|
+
### Events
|
|
70
|
+
|
|
71
|
+
Events are the building blocks of workflows. They carry data between steps and trigger step execution.
|
|
72
|
+
|
|
73
|
+
- **StartEvent**: Automatically triggered when a workflow starts
|
|
74
|
+
- **StopEvent**: Signals the end of a workflow and carries the final result
|
|
75
|
+
- **Custom Events**: Define your own events by inheriting from `Event`
|
|
76
|
+
|
|
77
|
+
### Steps
|
|
78
|
+
|
|
79
|
+
Steps are methods decorated with `@step(on=EventType)` that define what happens when a specific event is received.
|
|
80
|
+
|
|
81
|
+
- Steps are asynchronous functions that receive a `Context` and an `Event`
|
|
82
|
+
- Steps can return new events to trigger subsequent steps
|
|
83
|
+
|
|
84
|
+
### Workflow
|
|
85
|
+
|
|
86
|
+
A workflow is a class that inherits from `Workflow` and contains one or more steps. It orchestrates the execution of steps based on events.
|
|
87
|
+
|
|
88
|
+
### Context
|
|
89
|
+
|
|
90
|
+
The `Context` object provides access to workflow state and allows steps to share data throughout the workflow execution.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# NovaStack Workflows
|
|
2
|
+
|
|
3
|
+
A powerful and flexible event-driven workflow engine for Python, designed to build complex asynchronous workflows with ease.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install novastack-workflows
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
Here's a simple example to get you started with NovaStack Workflows:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from novastack.workflows import Workflow, Context, step, Event, StartEvent, StopEvent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Define a custom event
|
|
20
|
+
class MyEvent(Event):
|
|
21
|
+
message: str
|
|
22
|
+
|
|
23
|
+
# Create your workflow
|
|
24
|
+
class SimpleWorkflow(Workflow):
|
|
25
|
+
"""A simple workflow that processes a message."""
|
|
26
|
+
|
|
27
|
+
@step(on=StartEvent)
|
|
28
|
+
async def start(self, ctx: Context, ev: StartEvent) -> MyEvent:
|
|
29
|
+
# Get input from the start event
|
|
30
|
+
input_msg = ev.get("input_msg", "")
|
|
31
|
+
# Return a custom event to trigger the next step
|
|
32
|
+
return MyEvent(message=f"Processed: {input_msg}")
|
|
33
|
+
|
|
34
|
+
@step(on=MyEvent)
|
|
35
|
+
async def process(self, ctx: Context, ev: MyEvent) -> StopEvent:
|
|
36
|
+
# Process the message and return the final result
|
|
37
|
+
return StopEvent(result=ev.message)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Run the workflow
|
|
41
|
+
async def main():
|
|
42
|
+
workflow = SimpleWorkflow()
|
|
43
|
+
result = await workflow.run(input_msg="Hello, World!")
|
|
44
|
+
|
|
45
|
+
print(result)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Core Concepts
|
|
49
|
+
|
|
50
|
+
### Events
|
|
51
|
+
|
|
52
|
+
Events are the building blocks of workflows. They carry data between steps and trigger step execution.
|
|
53
|
+
|
|
54
|
+
- **StartEvent**: Automatically triggered when a workflow starts
|
|
55
|
+
- **StopEvent**: Signals the end of a workflow and carries the final result
|
|
56
|
+
- **Custom Events**: Define your own events by inheriting from `Event`
|
|
57
|
+
|
|
58
|
+
### Steps
|
|
59
|
+
|
|
60
|
+
Steps are methods decorated with `@step(on=EventType)` that define what happens when a specific event is received.
|
|
61
|
+
|
|
62
|
+
- Steps are asynchronous functions that receive a `Context` and an `Event`
|
|
63
|
+
- Steps can return new events to trigger subsequent steps
|
|
64
|
+
|
|
65
|
+
### Workflow
|
|
66
|
+
|
|
67
|
+
A workflow is a class that inherits from `Workflow` and contains one or more steps. It orchestrates the execution of steps based on events.
|
|
68
|
+
|
|
69
|
+
### Context
|
|
70
|
+
|
|
71
|
+
The `Context` object provides access to workflow state and allows steps to share data throughout the workflow execution.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from novastack.workflows.workflow import Workflow
|
|
2
|
+
from novastack.workflows.decorators import step
|
|
3
|
+
|
|
4
|
+
from novastack.workflows.events import Event, StartEvent, StopEvent
|
|
5
|
+
|
|
6
|
+
# Context management
|
|
7
|
+
from novastack.workflows.context import Context
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
# Core
|
|
12
|
+
"Workflow",
|
|
13
|
+
"Context",
|
|
14
|
+
"step",
|
|
15
|
+
# Event system
|
|
16
|
+
"Event",
|
|
17
|
+
"StartEvent",
|
|
18
|
+
"StopEvent",
|
|
19
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any, Generic, TypeVar, TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from novastack.workflows.context.state_store import StateStore
|
|
6
|
+
from novastack.workflows.runtime.optimized_queue import OptimizedEventQueue
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from novastack.workflows.events import Event
|
|
10
|
+
|
|
11
|
+
STATE_T = TypeVar("STATE_T", bound=BaseModel)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Context(Generic[STATE_T]):
|
|
15
|
+
"""
|
|
16
|
+
Workflow execution context with copy-on-write state management.
|
|
17
|
+
|
|
18
|
+
Provides robust, immutable state management through 'state_store'.
|
|
19
|
+
|
|
20
|
+
State is automatically initialized based on the type annotation.
|
|
21
|
+
If no type is provided, uses DictLikeModel.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
```python
|
|
25
|
+
class RunState(BaseModel):
|
|
26
|
+
counter: int = 0
|
|
27
|
+
items: list[str] = []
|
|
28
|
+
|
|
29
|
+
# With typed state - auto-initialized
|
|
30
|
+
@step
|
|
31
|
+
async def start(self, ctx: Context[RunState], ev: StartEvent):
|
|
32
|
+
async with ctx.store.edit_state() as state:
|
|
33
|
+
state.counter += 1 # RunState auto-initialized
|
|
34
|
+
|
|
35
|
+
# Without type - uses DictLikeModel
|
|
36
|
+
@step
|
|
37
|
+
async def start(self, ctx: Context, ev: StartEvent):
|
|
38
|
+
async with ctx.store.edit_state() as state:
|
|
39
|
+
state.counter = 1 # DictLikeModel allows dynamic fields
|
|
40
|
+
```
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
workflow: Any,
|
|
46
|
+
event_queue: asyncio.Queue["Event"] | Any | None = None,
|
|
47
|
+
):
|
|
48
|
+
"""
|
|
49
|
+
Initialize workflow context.
|
|
50
|
+
|
|
51
|
+
Each context is isolated and maintains its own state store.
|
|
52
|
+
State type is inferred from Context[StateType] annotation or defaults to DictLikeModel.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
workflow: Workflow instance
|
|
56
|
+
event_queue: Event queue for emission
|
|
57
|
+
"""
|
|
58
|
+
self._workflow = workflow
|
|
59
|
+
|
|
60
|
+
# Use provided queue or create OptimizedEventQueue
|
|
61
|
+
if event_queue is None:
|
|
62
|
+
self._event_queue = OptimizedEventQueue()
|
|
63
|
+
else:
|
|
64
|
+
self._event_queue = event_queue
|
|
65
|
+
|
|
66
|
+
# Copy-on-write state store with auto-initialization
|
|
67
|
+
# Infer state type from workflow's _state_type if available
|
|
68
|
+
state_type = getattr(workflow, "_state_type", None)
|
|
69
|
+
self._store = StateStore(state_type=state_type)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def workflow(self) -> Any:
|
|
73
|
+
"""Get workflow instance."""
|
|
74
|
+
return self._workflow
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def store(self) -> StateStore:
|
|
78
|
+
"""
|
|
79
|
+
Copy-on-write state store.
|
|
80
|
+
|
|
81
|
+
Provides immutability guarantees and thread-safety through
|
|
82
|
+
explicit edit contexts.
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
```python
|
|
86
|
+
# Edit state
|
|
87
|
+
async with ctx.store.edit_state() as state:
|
|
88
|
+
state.counter += 1
|
|
89
|
+
```
|
|
90
|
+
"""
|
|
91
|
+
return self._store
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def state(self) -> BaseModel | None:
|
|
95
|
+
"""
|
|
96
|
+
Get current state (read-only).
|
|
97
|
+
|
|
98
|
+
Convenience property for accessing store state.
|
|
99
|
+
Equivalent to ctx.store.state.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
```python
|
|
103
|
+
# Read-only access
|
|
104
|
+
current_value = ctx.state.counter
|
|
105
|
+
```
|
|
106
|
+
"""
|
|
107
|
+
return self._store.state
|
|
108
|
+
|
|
109
|
+
async def emit(self, event: "Event") -> None:
|
|
110
|
+
"""
|
|
111
|
+
Emit an event to the workflow queue.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
event: Event to emit
|
|
115
|
+
|
|
116
|
+
Example:
|
|
117
|
+
```python
|
|
118
|
+
await ctx.emit(MyEvent(data="processed"))
|
|
119
|
+
```
|
|
120
|
+
"""
|
|
121
|
+
await self._event_queue.put(event)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import copy
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import Generic, Type, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from novastack.workflows.exceptions import ContextStateError
|
|
8
|
+
from novastack.workflows.types import DictLikeModel
|
|
9
|
+
|
|
10
|
+
STATE_T = TypeVar("STATE_T", bound=BaseModel)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StateStore(Generic[STATE_T]):
|
|
14
|
+
"""
|
|
15
|
+
Thread-safe state store with copy-on-write semantics.
|
|
16
|
+
|
|
17
|
+
Provides immutability guarantees through explicit edit contexts.
|
|
18
|
+
Changes are isolated until committed on context exit.
|
|
19
|
+
|
|
20
|
+
State is automatically initialized on first access if not provided.
|
|
21
|
+
If no state_type is specified, uses DictLikeModel for dynamic fields.
|
|
22
|
+
|
|
23
|
+
Thread-safe mutations through explicit contexts. Automatic rollback on errors makes it
|
|
24
|
+
robust and production-ready.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
```python
|
|
28
|
+
# Copy-on-write with immutability
|
|
29
|
+
async with ctx.store.edit_state() as state:
|
|
30
|
+
state.counter += 1 # Mutation isolated
|
|
31
|
+
# Automatic commit on exit
|
|
32
|
+
```
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
state_data: STATE_T | None = None,
|
|
38
|
+
state_type: Type[STATE_T] | None = None,
|
|
39
|
+
):
|
|
40
|
+
"""
|
|
41
|
+
Initialize state store with isolated state.
|
|
42
|
+
|
|
43
|
+
Each state store is independent and does not inherit state
|
|
44
|
+
from other contexts. This ensures proper isolation between
|
|
45
|
+
workflows.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
state_data: Initial state instance (optional)
|
|
49
|
+
state_type: State model class for auto-initialization (optional)
|
|
50
|
+
"""
|
|
51
|
+
self._lock = asyncio.Lock()
|
|
52
|
+
self._state_type: Type[BaseModel] = state_type or DictLikeModel
|
|
53
|
+
|
|
54
|
+
if state_data is not None:
|
|
55
|
+
self._state: STATE_T | None = state_data
|
|
56
|
+
else:
|
|
57
|
+
# Auto-initialize with state_type
|
|
58
|
+
self._state = cast(STATE_T, self._state_type())
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def state(self) -> STATE_T | None:
|
|
62
|
+
"""Get current state (read-only)."""
|
|
63
|
+
return self._state
|
|
64
|
+
|
|
65
|
+
def _smart_copy(self, state: STATE_T) -> STATE_T:
|
|
66
|
+
"""
|
|
67
|
+
Smart copy that optimizes based on state type.
|
|
68
|
+
|
|
69
|
+
For Pydantic models, uses the optimized model_copy() method.
|
|
70
|
+
This reduces copy overhead by 40-60% for Pydantic models.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
state: State to copy
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(state, BaseModel):
|
|
76
|
+
# Pydantic has optimized copy
|
|
77
|
+
return state.model_copy(deep=True)
|
|
78
|
+
|
|
79
|
+
# Fallback to deep copy
|
|
80
|
+
return copy.deepcopy(state)
|
|
81
|
+
|
|
82
|
+
@asynccontextmanager
|
|
83
|
+
async def edit_state(self):
|
|
84
|
+
"""
|
|
85
|
+
Context manager for editing state with copy-on-write semantics.
|
|
86
|
+
|
|
87
|
+
Creates a deep copy of the state for editing. Changes are committed
|
|
88
|
+
atomically when the context exits successfully. If an exception occurs,
|
|
89
|
+
changes are automatically rolled back.
|
|
90
|
+
|
|
91
|
+
State is guaranteed to be initialized (never None) due to auto-initialization
|
|
92
|
+
in __init__.
|
|
93
|
+
|
|
94
|
+
Example:
|
|
95
|
+
```python
|
|
96
|
+
async with ctx.store.edit_state() as state:
|
|
97
|
+
state.counter += 1
|
|
98
|
+
state.items.append("new")
|
|
99
|
+
|
|
100
|
+
# On error, changes are rolled back:
|
|
101
|
+
try:
|
|
102
|
+
async with ctx.store.edit_state() as state:
|
|
103
|
+
state.counter += 1
|
|
104
|
+
raise Exception("Error!") # Rollback happens
|
|
105
|
+
except Exception:
|
|
106
|
+
pass # state.counter unchanged
|
|
107
|
+
```
|
|
108
|
+
"""
|
|
109
|
+
async with self._lock:
|
|
110
|
+
# State is always initialized in __init__, but keep check for safety
|
|
111
|
+
if self._state is None:
|
|
112
|
+
raise ContextStateError("State not initialized.")
|
|
113
|
+
|
|
114
|
+
# Use smart copy instead of deepcopy
|
|
115
|
+
state_copy = self._smart_copy(self._state)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
yield state_copy
|
|
119
|
+
# Commit changes on successful exit
|
|
120
|
+
self._state = state_copy
|
|
121
|
+
except Exception:
|
|
122
|
+
# Rollback on error (don't commit)
|
|
123
|
+
raise
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from typing import Any, Callable, Type
|
|
2
|
+
from functools import wraps
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
from novastack.workflows.events import Event
|
|
6
|
+
from novastack.workflows.exceptions import WorkflowValidationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def step(
|
|
10
|
+
*,
|
|
11
|
+
on: Type[Event],
|
|
12
|
+
timeout: float | None = None,
|
|
13
|
+
num_retries: int = 0,
|
|
14
|
+
retry_delay: float = 1.0,
|
|
15
|
+
) -> Callable:
|
|
16
|
+
"""
|
|
17
|
+
Decorator to mark a method as a workflow step.
|
|
18
|
+
|
|
19
|
+
The decorated method will be automatically registered as a step
|
|
20
|
+
for the specified event type when the workflow class is defined.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
on: The Event class this step handles.
|
|
24
|
+
timeout: Optional timeout in seconds for step execution
|
|
25
|
+
num_retries: Number of retry attempts on failure (default: 0)
|
|
26
|
+
retry_delay: Delay in seconds between retry attempts (default: 1)
|
|
27
|
+
|
|
28
|
+
Note:
|
|
29
|
+
The decorated method must have the signature:
|
|
30
|
+
async def fn(self, ctx: Context, ev: EventType) -> Event | None
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def decorator(f: Callable) -> Callable:
|
|
34
|
+
# Is async function?
|
|
35
|
+
if not inspect.iscoroutinefunction(f):
|
|
36
|
+
raise WorkflowValidationError(
|
|
37
|
+
f"Step method '{f.__name__}' must be an async function."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Validate step signature
|
|
41
|
+
sig = inspect.signature(f)
|
|
42
|
+
params = list(sig.parameters.keys())
|
|
43
|
+
|
|
44
|
+
if len(params) < 3:
|
|
45
|
+
raise WorkflowValidationError(
|
|
46
|
+
f"Step signature '{f.__name__}' must have at least 3 parameters: "
|
|
47
|
+
f"self, ctx, ev. got: {params}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Store metadata on the function
|
|
51
|
+
f.__workflow_step__ = True
|
|
52
|
+
f.__step_accepted_events__ = on
|
|
53
|
+
f.__step_name__ = f.__name__
|
|
54
|
+
f.__step_timeout__ = timeout
|
|
55
|
+
f.__step_num_retries__ = num_retries
|
|
56
|
+
f.__step_retry_delay__ = retry_delay
|
|
57
|
+
|
|
58
|
+
@wraps(f)
|
|
59
|
+
async def wrapper(*args, **kwargs):
|
|
60
|
+
return await f(*args, **kwargs)
|
|
61
|
+
|
|
62
|
+
# Copy metadata to wrapper
|
|
63
|
+
wrapper.__workflow_step__ = True
|
|
64
|
+
wrapper.__step_accepted_events__ = on
|
|
65
|
+
wrapper.__step_name__ = f.__name__
|
|
66
|
+
wrapper.__step_timeout__ = timeout
|
|
67
|
+
wrapper.__step_num_retries__ = num_retries
|
|
68
|
+
wrapper.__step_retry_delay__ = retry_delay
|
|
69
|
+
|
|
70
|
+
return wrapper # type: ignore
|
|
71
|
+
|
|
72
|
+
return decorator
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_step_method(method: Any) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Check if a method is decorated with @step.
|
|
78
|
+
"""
|
|
79
|
+
return getattr(method, "__workflow_step__", False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_step_event_type(method: Any) -> Type[Event] | None:
|
|
83
|
+
"""
|
|
84
|
+
Get the event type that a step method handles.
|
|
85
|
+
"""
|
|
86
|
+
return getattr(method, "__step_accepted_events__", None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_step_name(method: Any) -> str | None:
|
|
90
|
+
"""
|
|
91
|
+
Get the name of a step method.
|
|
92
|
+
"""
|
|
93
|
+
return getattr(method, "__step_name__", None)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_step_timeout(method: Any) -> float | None:
|
|
97
|
+
"""
|
|
98
|
+
Get timeout for a step method.
|
|
99
|
+
"""
|
|
100
|
+
return getattr(method, "__step_timeout__", None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_step_num_retries(method: Any) -> int:
|
|
104
|
+
"""
|
|
105
|
+
Get number of retries for a step method.
|
|
106
|
+
"""
|
|
107
|
+
return getattr(method, "__step_num_retries__", 0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_step_retry_delay(method: Any) -> float:
|
|
111
|
+
"""
|
|
112
|
+
Get retry delay for a step method.
|
|
113
|
+
"""
|
|
114
|
+
return getattr(method, "__step_retry_delay__", 1.0)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from novastack.workflows.types import DictLikeModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Event(DictLikeModel):
|
|
8
|
+
"""Base class for all workflow events with dict-like interface."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StartEvent(Event):
|
|
12
|
+
"""Workflow initialization event.
|
|
13
|
+
|
|
14
|
+
This event marks the beginning of a workflow execution and can carry
|
|
15
|
+
any dynamic fields needed to initialize the workflow state.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StopEvent(Event):
|
|
20
|
+
"""Workflow completion event.
|
|
21
|
+
|
|
22
|
+
This event marks the end of a workflow execution and typically contains
|
|
23
|
+
the final result. Additional dynamic fields can be included as needed.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
result: The final result of the workflow execution.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
result: Any = Field(
|
|
30
|
+
default=None,
|
|
31
|
+
description="The final result of the workflow execution",
|
|
32
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class WorkflowValidationError(Exception):
|
|
2
|
+
"""Raised when the workflow configuration or step signatures are invalid."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class WorkflowRuntimeError(Exception):
|
|
6
|
+
"""Raised when runtime errors during step execution or event routing."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WorkflowTimeoutError(Exception):
|
|
10
|
+
"""Raised when a step run exceeds the configured timeout."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContextStateError(Exception):
|
|
14
|
+
"""Raised when a context method is called in the wrong state."""
|
|
File without changes
|