novastack-workflows 0.1.0__py3-none-any.whl

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,14 @@
1
+ # Context management
2
+ from novastack_workflows.context import Context
3
+ from novastack_workflows.decorators import step
4
+ from novastack_workflows.events import Event, StartEvent, StopEvent
5
+ from novastack_workflows.workflow import Workflow
6
+
7
+ __all__ = [
8
+ "Workflow",
9
+ "Context",
10
+ "step",
11
+ "Event",
12
+ "StartEvent",
13
+ "StopEvent",
14
+ ]
@@ -0,0 +1,3 @@
1
+ from novastack_workflows.context.context import Context
2
+
3
+ __all__ = ["Context"]
@@ -0,0 +1,133 @@
1
+ import asyncio
2
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from novastack_workflows.context.state_store import InMemoryStateStore
7
+
8
+ if TYPE_CHECKING:
9
+ from novastack_workflows.events import Event
10
+
11
+ STATE_T = TypeVar("STATE_T", bound=BaseModel)
12
+
13
+ class SerializedContext(BaseModel):
14
+
15
+ version: str = "1.0.0"
16
+ kind: str = "Context"
17
+ state: dict = Field(default_factory=dict)
18
+
19
+ class Context(Generic[STATE_T]):
20
+ """
21
+ Workflow execution context with copy-on-write state management.
22
+
23
+ Provides robust, immutable state management through 'state_store'.
24
+
25
+ State is automatically initialized based on the type annotation.
26
+ If no type is provided, uses DictState.
27
+
28
+ Example:
29
+ ```python
30
+ class RunState(BaseModel):
31
+ counter: int = 0
32
+ items: list[str] = []
33
+
34
+
35
+ # With typed state - auto-initialized
36
+ @step
37
+ async def start(self, ctx: Context[RunState], ev: StartEvent):
38
+ async with ctx.store.edit_state() as state:
39
+ state.counter += 1 # RunState auto-initialized
40
+
41
+
42
+ @step
43
+ async def start(self, ctx: Context, ev: StartEvent):
44
+ async with ctx.store.edit_state() as state:
45
+ state.counter = 1
46
+ ```
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ workflow: Any,
52
+ event_queue: asyncio.Queue["Event"] | Any | None = None,
53
+ ):
54
+ """
55
+ Initialize workflow context.
56
+
57
+ Each context is isolated and maintains its own state store.
58
+ State type is inferred from Context[StateType] annotation or defaults to DictState.
59
+
60
+ Args:
61
+ workflow: Workflow instance
62
+ event_queue: Event queue for emission
63
+ """
64
+ self._workflow = workflow
65
+
66
+ # Use provided queue or create asyncio.Queue
67
+ if event_queue is None:
68
+ self._event_queue: asyncio.Queue[Event] = asyncio.Queue()
69
+ else:
70
+ self._event_queue = event_queue
71
+
72
+ # Copy-on-write state store with auto-initialization
73
+ # Infer state type from workflow's _state_type if available
74
+ state_type = getattr(workflow, "_state_type", None)
75
+ self._store = InMemoryStateStore(state_type=state_type)
76
+
77
+ @property
78
+ def workflow(self) -> Any:
79
+ """Get workflow instance."""
80
+ return self._workflow
81
+
82
+ @property
83
+ def store(self): # type: ignore[return]
84
+ """
85
+ Copy-on-write state store.
86
+
87
+ Provides immutability guarantees and thread-safety through
88
+ explicit edit contexts.
89
+
90
+ Example:
91
+ ```python
92
+ # Edit state
93
+ async with ctx.store.edit_state() as state:
94
+ state.counter += 1
95
+ ```
96
+ """
97
+ return self._store
98
+
99
+ @property
100
+ def state(self) -> BaseModel | None:
101
+ """
102
+ Get current state (read-only).
103
+
104
+ Convenience property for accessing store state.
105
+ Equivalent to ctx.store.state.
106
+
107
+ Example:
108
+ ```python
109
+ # Read-only access
110
+ current_value = ctx.state.counter
111
+ ```
112
+ """
113
+ return self._store.state
114
+
115
+ async def send_event(self, event: "Event") -> None:
116
+ """
117
+ Send an event to the workflow queue.
118
+
119
+ Args:
120
+ event(Event): Event to send
121
+
122
+ Example:
123
+ ```python
124
+ await ctx.send_event(MyEvent(data="processed"))
125
+ ```
126
+ """
127
+ await self._event_queue.put(event)
128
+
129
+ def to_dict(self) -> dict[str, Any]:
130
+ """Serialize context to dictionary."""
131
+ return SerializedContext(
132
+ state=self._store.to_dict()
133
+ ).model_dump()
@@ -0,0 +1,193 @@
1
+ import asyncio
2
+ import copy
3
+ from contextlib import asynccontextmanager
4
+ from typing import Any, Generic, Protocol, Type, TypeVar, cast
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from novastack_workflows.exceptions import ContextStateError
9
+ from novastack_workflows.schemas import DictLikeModel
10
+
11
+ STATE_T = TypeVar("STATE_T", bound=BaseModel)
12
+
13
+
14
+ class SerializedState(BaseModel):
15
+ """
16
+ Serialized state representation for persistence.
17
+
18
+ Attributes:
19
+ state_type: Name of the state model class
20
+ store_type: Type of state store (e.g., "in_memory")
21
+ data: Serialized state data as dictionary, or None if state is not initialized
22
+ """
23
+
24
+ state_type: str = "DictState"
25
+ store_type: str = "in_memory"
26
+ data: dict = Field(default_factory=dict)
27
+
28
+
29
+ class StateStore(Protocol[STATE_T]):
30
+ """
31
+ Protocol defining the interface for state store implementations.
32
+
33
+ Each state store is independent and does not inherit state
34
+ from other contexts. This ensures proper isolation between
35
+ workflows.
36
+
37
+ All state store implementations must provide thread-safe state access via the state
38
+ property and copy-on-write mutations via the edit_state() context manager with
39
+ automatic rollback on errors.
40
+ """
41
+
42
+ @property
43
+ def state(self) -> STATE_T | None:
44
+ """Get current state (read-only)."""
45
+ ...
46
+
47
+ def edit_state(self):
48
+ """
49
+ Context manager for editing state with copy-on-write semantics.
50
+
51
+ Example:
52
+ ```python
53
+ async with store.edit_state() as state:
54
+ state.counter += 1
55
+ ```
56
+ """
57
+ ...
58
+
59
+ def to_dict(self) -> dict[str, Any]:
60
+ """
61
+ Serialize current state to dictionary.
62
+
63
+ Returns dictionary representation including store type, state type metadata,
64
+ and serialized state data.
65
+ """
66
+ ...
67
+
68
+ class DictState(DictLikeModel):
69
+ """Used as the default state model when no typed state is provided."""
70
+
71
+ def __init__(self, **params: Any):
72
+ super().__init__(**params)
73
+
74
+ class InMemoryStateStore(Generic[STATE_T]):
75
+ """
76
+ In-memory state store with copy-on-write semantics.
77
+
78
+ This is a thread-safe implementation that stores state in local memory, making it
79
+ ideal for single-process workflows and development environments. It provides
80
+ copy-on-write for immutability, thread-safe mutations via asyncio.Lock, automatic
81
+ rollback on errors, smart copying optimized for Pydantic models, and auto-initialization
82
+ with type inference.
83
+
84
+ Note that state is not shared across processes, will be lost on process restart, and
85
+ is not suitable for distributed workflows. For distributed or persistent state, consider
86
+ RedisStateStore for distributed state or PostgresStateStore for persistent state.
87
+
88
+ Example:
89
+ ```python
90
+ store = InMemoryStateStore(state_type=MyState)
91
+ async with store.edit_state() as state:
92
+ state.counter += 1
93
+ ```
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ state_type: Type[STATE_T] | None = None,
99
+ data: STATE_T | None = None,
100
+ ):
101
+ """
102
+ Initialize in-memory state store.
103
+
104
+ Args:
105
+ state_data: Initial state instance (optional)
106
+ state_type: State model class for auto-initialization (optional)
107
+ """
108
+ self._lock = asyncio.Lock()
109
+ self._state_type: Type[BaseModel] = state_type or DictState
110
+
111
+ if data is not None:
112
+ self._state: STATE_T | None = data
113
+ else:
114
+ # Auto-initialize with state_type
115
+ self._state = cast(STATE_T, self._state_type())
116
+
117
+ @property
118
+ def state(self) -> STATE_T | None:
119
+ """Get current state (read-only)."""
120
+ return self._state
121
+
122
+ def _smart_copy(self, state: STATE_T) -> STATE_T:
123
+ """
124
+ Smart copy that optimizes based on state type.
125
+
126
+ For Pydantic models, uses the optimized model_copy() method.
127
+ This reduces copy overhead by 40-60% for Pydantic models.
128
+
129
+ Args:
130
+ state: State to copy
131
+ """
132
+ if isinstance(state, BaseModel):
133
+ # Pydantic has optimized copy
134
+ return state.model_copy(deep=True)
135
+
136
+ # Fallback to deep copy
137
+ return copy.deepcopy(state)
138
+
139
+ @asynccontextmanager
140
+ async def edit_state(self):
141
+ """
142
+ Context manager for editing state with copy-on-write semantics.
143
+
144
+ Creates a deep copy of the state for editing. Changes are committed
145
+ atomically when the context exits successfully. If an exception occurs,
146
+ changes are automatically rolled back.
147
+
148
+ Example:
149
+ ```python
150
+ async with store.edit_state() as state:
151
+ state.counter += 1
152
+ state.items.append("new")
153
+
154
+ # On error, changes are rolled back:
155
+ try:
156
+ async with store.edit_state() as state:
157
+ state.counter += 1
158
+ raise Exception("Error!") # Rollback happens
159
+ except Exception:
160
+ pass # state.counter unchanged
161
+ ```
162
+ """
163
+ async with self._lock:
164
+ # State is always initialized in __init__, but keep check for safety
165
+ if self._state is None:
166
+ raise ContextStateError("State not initialized.")
167
+
168
+ # Use smart copy instead of deepcopy
169
+ state_copy = self._smart_copy(self._state)
170
+
171
+ try:
172
+ yield state_copy
173
+ # Commit changes on successful exit
174
+ self._state = state_copy
175
+ except Exception:
176
+ # Rollback on error (don't commit)
177
+ raise
178
+
179
+ def to_dict(self) -> dict[str, Any]:
180
+ """
181
+ Serialize current state to dictionary.
182
+
183
+ Returns dictionary representation including store type, state type metadata,
184
+ and serialized state data.
185
+ """
186
+ state_type_name = self._state.__class__.__name__ if self._state else self._state_type.__name__
187
+ state_data = self._state.model_dump() if self._state else {}
188
+
189
+ return SerializedState(
190
+ state_type=state_type_name,
191
+ store_type="in_memory",
192
+ data=state_data,
193
+ ).model_dump()
@@ -0,0 +1,182 @@
1
+ import inspect
2
+ from functools import wraps
3
+ from typing import Any, Callable, Type, get_origin
4
+
5
+ from novastack_workflows.events import Event
6
+ from novastack_workflows.exceptions import WorkflowValidationError
7
+
8
+
9
+ def step(
10
+ *,
11
+ when: Type[Event] | list[Type[Event]],
12
+ timeout: float | None = None,
13
+ max_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(s) when the workflow class is defined.
21
+
22
+ Args:
23
+ when: The Event class or list of Event classes this step handles.
24
+ - Single event: Step executes when that event arrives
25
+ - List of events: Step executes when all events are collected (join)
26
+ timeout: Optional timeout in seconds for step execution
27
+ max_retries: Number of retry attempts on failure (default: 0)
28
+ retry_delay: Delay in seconds between retry attempts (default: 1)
29
+
30
+ Note:
31
+ For event steps, the signature must be:
32
+ async def fn(self, ctx: Context, ev: EventType) -> Event | None
33
+
34
+ For join steps, the signature must be:
35
+ async def fn(self, ctx: Context, events: dict[type, Event]) -> Event | None
36
+ """
37
+
38
+ def decorator(f: Callable) -> Callable:
39
+ if not inspect.iscoroutinefunction(f):
40
+ raise WorkflowValidationError(
41
+ f"Step method '{f.__name__}' must be an async function."
42
+ )
43
+
44
+ # Normalize input: convert single event to list for consistency
45
+ is_join_step = isinstance(when, list)
46
+ events_list: list[Type[Event]] = when if is_join_step else [when] # type: ignore
47
+
48
+ if len(events_list) == 0:
49
+ raise WorkflowValidationError(
50
+ f"Step '{f.__name__}': 'when' cannot be empty. "
51
+ f"Must specify at least one event type."
52
+ )
53
+
54
+ seen_event_types = set()
55
+ for event_type in events_list:
56
+ if not (isinstance(event_type, type) and issubclass(event_type, Event)):
57
+ raise WorkflowValidationError(
58
+ f"Step '{f.__name__}': All event types must be Event subclasses. "
59
+ f"Got: {event_type}"
60
+ )
61
+ if event_type in seen_event_types:
62
+ raise WorkflowValidationError(
63
+ f"Step '{f.__name__}': Duplicate event types not allowed. "
64
+ f"Duplicate: {event_type.__name__}"
65
+ )
66
+ seen_event_types.add(event_type)
67
+
68
+ # Validate step signature
69
+ sig = inspect.signature(f)
70
+ params = list(sig.parameters.items())
71
+
72
+ if len(params) < 3:
73
+ raise WorkflowValidationError(
74
+ f"Step '{f.__name__}': Must have at least 3 parameters: "
75
+ f"self, ctx, ev/events. Got: {[p[0] for p in params]}"
76
+ )
77
+
78
+ # Get the third parameter (event parameter)
79
+ event_param_name, event_param = params[2]
80
+
81
+ # Validate signature based on event step vs join step
82
+ if is_join_step:
83
+ # Join step: third param should be 'events' with dict type
84
+ if event_param_name != "events":
85
+ raise WorkflowValidationError(
86
+ f"Step '{f.__name__}': Join steps must use parameter name "
87
+ f"'events' (not '{event_param_name}'). "
88
+ f"Expected signature: async def {f.__name__}(self, ctx: Context, "
89
+ f"events: dict[type, Event]) -> Event | None"
90
+ )
91
+
92
+ # Check type annotation if present
93
+ if event_param.annotation != inspect.Parameter.empty:
94
+ annotation = event_param.annotation
95
+ # Handle dict[type, Event] or dict type annotations
96
+ origin = get_origin(annotation)
97
+ if origin is not dict and annotation is not dict:
98
+ raise WorkflowValidationError(
99
+ f"Step '{f.__name__}': Join steps must annotate 'events' "
100
+ f"parameter as 'dict[type, Event]' or 'dict'. "
101
+ f"Got: {annotation}"
102
+ )
103
+ else:
104
+ # Event step: third param should be 'ev'
105
+ if event_param_name != "ev":
106
+ raise WorkflowValidationError(
107
+ f"Step '{f.__name__}': Event steps must use parameter name "
108
+ f"'ev' (not '{event_param_name}'). "
109
+ f"Expected signature: async def {f.__name__}(self, ctx: Context, "
110
+ f"ev: {events_list[0].__name__}) -> Event | None"
111
+ )
112
+
113
+ # Store metadata on the function
114
+ f._step_metadata_ = {
115
+ "when": events_list,
116
+ "is_join_step": is_join_step,
117
+ "timeout": timeout,
118
+ "max_retries": max_retries,
119
+ "retry_delay": retry_delay,
120
+ }
121
+
122
+ @wraps(f)
123
+ async def wrapper(*args, **kwargs):
124
+ return await f(*args, **kwargs)
125
+
126
+ # Copy metadata to wrapper
127
+ wrapper._step_metadata_ = f._step_metadata_
128
+
129
+ return wrapper # type: ignore
130
+
131
+ return decorator
132
+
133
+
134
+ def is_step_method(method: Any) -> bool:
135
+ """Check if a method is decorated with @step."""
136
+ return hasattr(method, "_step_metadata_")
137
+
138
+
139
+ def get_step_event_types(method: Any) -> list[Type[Event]] | None:
140
+ """Get the normalized list of event types that a step method handles."""
141
+ metadata = getattr(method, "_step_metadata_", None)
142
+ if metadata is None:
143
+ return None
144
+ return metadata.get("when")
145
+
146
+
147
+ def is_join_step(method: Any) -> bool:
148
+ metadata = getattr(method, "_step_metadata_", None)
149
+ if metadata is None:
150
+ return False
151
+ return metadata.get("is_join_step", False)
152
+
153
+
154
+ def get_step_name(method: Any) -> str | None:
155
+ """Get the name of a step method."""
156
+ if not is_step_method(method):
157
+ return None
158
+ return method.__name__ if hasattr(method, "__name__") else None
159
+
160
+
161
+ def get_step_timeout(method: Any) -> float | None:
162
+ """Get the timeout configuration for a step method."""
163
+ metadata = getattr(method, "_step_metadata_", None)
164
+ if metadata is None:
165
+ return None
166
+ return metadata.get("timeout")
167
+
168
+
169
+ def get_step_max_retries(method: Any) -> int:
170
+ """Get the max retries configuration for a step method."""
171
+ metadata = getattr(method, "_step_metadata_", None)
172
+ if metadata is None:
173
+ return 0
174
+ return metadata.get("max_retries", 0)
175
+
176
+
177
+ def get_step_retry_delay(method: Any) -> float:
178
+ """Get the retry delay configuration for a step method."""
179
+ metadata = getattr(method, "_step_metadata_", None)
180
+ if metadata is None:
181
+ return 1.0
182
+ return metadata.get("retry_delay", 1.0)
@@ -0,0 +1,7 @@
1
+ class WorkflowStatus:
2
+ """Workflow execution status."""
3
+
4
+ CANCELLED = "cancelled"
5
+ COMPLETED = "completed"
6
+ FAILED = "failed"
7
+ RUNNING = "running"
@@ -0,0 +1,35 @@
1
+ from typing import Any
2
+
3
+ from pydantic import Field
4
+
5
+ from novastack_workflows.schemas import DictLikeModel
6
+
7
+
8
+ class Event(DictLikeModel):
9
+ """Base class for all workflow events with dict-like interface."""
10
+
11
+
12
+ class StartEvent(Event):
13
+ """
14
+ Workflow initialization event.
15
+
16
+ This event marks the beginning of a workflow execution and can carry
17
+ any dynamic fields needed to initialize the workflow state.
18
+ """
19
+
20
+
21
+ class StopEvent(Event):
22
+ """
23
+ Workflow completion event.
24
+
25
+ This event marks the end of a workflow execution and typically contains
26
+ the final result. Additional dynamic fields can be included as needed.
27
+
28
+ Attributes:
29
+ result: The final result of the workflow execution.
30
+ """
31
+
32
+ result: Any = Field(
33
+ default=None,
34
+ description="The final result of the workflow execution",
35
+ )
@@ -0,0 +1,18 @@
1
+ class WorkflowError(Exception):
2
+ """Generic exception for workflow-related errors."""
3
+
4
+
5
+ class WorkflowValidationError(WorkflowError):
6
+ """Raised when the workflow configuration or step signatures are invalid."""
7
+
8
+
9
+ class WorkflowRuntimeError(WorkflowError):
10
+ """Raised when runtime errors during step execution or event routing."""
11
+
12
+
13
+ class WorkflowTimeoutError(WorkflowError):
14
+ """Raised when a step run exceeds the configured timeout."""
15
+
16
+
17
+ class ContextStateError(WorkflowError):
18
+ """Raised when a context method is called in the wrong state."""
@@ -0,0 +1,13 @@
1
+ try:
2
+ import novastack.core # noqa: F401
3
+
4
+ except ImportError as e:
5
+ raise ImportError(
6
+ "Missing dependency 'novastack-core' required for prebuilt workflows. "
7
+ "Install with `pip install novastack-workflows[prebuilt]`."
8
+ ) from e
9
+
10
+
11
+ from novastack_workflows.prebuilt.document_ingestion import DocumentIngestionWorkflow
12
+
13
+ __all__ = ["DocumentIngestionWorkflow"]