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.
@@ -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,3 @@
1
+ from novastack.workflows.context.context import Context
2
+
3
+ __all__ = ["Context"]
@@ -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,10 @@
1
+ from enum import Enum
2
+
3
+
4
+ class WorkflowStatus(str, Enum):
5
+ """Workflow execution status."""
6
+
7
+ CANCELLED = "cancelled"
8
+ COMPLETED = "completed"
9
+ FAILED = "failed"
10
+ RUNNING = "running"
@@ -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."""