pyworkflow-engine 0.1.7__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.
Files changed (196) hide show
  1. dashboard/backend/app/__init__.py +1 -0
  2. dashboard/backend/app/config.py +32 -0
  3. dashboard/backend/app/controllers/__init__.py +6 -0
  4. dashboard/backend/app/controllers/run_controller.py +86 -0
  5. dashboard/backend/app/controllers/workflow_controller.py +33 -0
  6. dashboard/backend/app/dependencies/__init__.py +5 -0
  7. dashboard/backend/app/dependencies/storage.py +50 -0
  8. dashboard/backend/app/repositories/__init__.py +6 -0
  9. dashboard/backend/app/repositories/run_repository.py +80 -0
  10. dashboard/backend/app/repositories/workflow_repository.py +27 -0
  11. dashboard/backend/app/rest/__init__.py +8 -0
  12. dashboard/backend/app/rest/v1/__init__.py +12 -0
  13. dashboard/backend/app/rest/v1/health.py +33 -0
  14. dashboard/backend/app/rest/v1/runs.py +133 -0
  15. dashboard/backend/app/rest/v1/workflows.py +41 -0
  16. dashboard/backend/app/schemas/__init__.py +23 -0
  17. dashboard/backend/app/schemas/common.py +16 -0
  18. dashboard/backend/app/schemas/event.py +24 -0
  19. dashboard/backend/app/schemas/hook.py +25 -0
  20. dashboard/backend/app/schemas/run.py +54 -0
  21. dashboard/backend/app/schemas/step.py +28 -0
  22. dashboard/backend/app/schemas/workflow.py +31 -0
  23. dashboard/backend/app/server.py +87 -0
  24. dashboard/backend/app/services/__init__.py +6 -0
  25. dashboard/backend/app/services/run_service.py +240 -0
  26. dashboard/backend/app/services/workflow_service.py +155 -0
  27. dashboard/backend/main.py +18 -0
  28. docs/concepts/cancellation.mdx +362 -0
  29. docs/concepts/continue-as-new.mdx +434 -0
  30. docs/concepts/events.mdx +266 -0
  31. docs/concepts/fault-tolerance.mdx +370 -0
  32. docs/concepts/hooks.mdx +552 -0
  33. docs/concepts/limitations.mdx +167 -0
  34. docs/concepts/schedules.mdx +775 -0
  35. docs/concepts/sleep.mdx +312 -0
  36. docs/concepts/steps.mdx +301 -0
  37. docs/concepts/workflows.mdx +255 -0
  38. docs/guides/cli.mdx +942 -0
  39. docs/guides/configuration.mdx +560 -0
  40. docs/introduction.mdx +155 -0
  41. docs/quickstart.mdx +279 -0
  42. examples/__init__.py +1 -0
  43. examples/celery/__init__.py +1 -0
  44. examples/celery/durable/docker-compose.yml +55 -0
  45. examples/celery/durable/pyworkflow.config.yaml +12 -0
  46. examples/celery/durable/workflows/__init__.py +122 -0
  47. examples/celery/durable/workflows/basic.py +87 -0
  48. examples/celery/durable/workflows/batch_processing.py +102 -0
  49. examples/celery/durable/workflows/cancellation.py +273 -0
  50. examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
  51. examples/celery/durable/workflows/child_workflows.py +202 -0
  52. examples/celery/durable/workflows/continue_as_new.py +260 -0
  53. examples/celery/durable/workflows/fault_tolerance.py +210 -0
  54. examples/celery/durable/workflows/hooks.py +211 -0
  55. examples/celery/durable/workflows/idempotency.py +112 -0
  56. examples/celery/durable/workflows/long_running.py +99 -0
  57. examples/celery/durable/workflows/retries.py +101 -0
  58. examples/celery/durable/workflows/schedules.py +209 -0
  59. examples/celery/transient/01_basic_workflow.py +91 -0
  60. examples/celery/transient/02_fault_tolerance.py +257 -0
  61. examples/celery/transient/__init__.py +20 -0
  62. examples/celery/transient/pyworkflow.config.yaml +25 -0
  63. examples/local/__init__.py +1 -0
  64. examples/local/durable/01_basic_workflow.py +94 -0
  65. examples/local/durable/02_file_storage.py +132 -0
  66. examples/local/durable/03_retries.py +169 -0
  67. examples/local/durable/04_long_running.py +119 -0
  68. examples/local/durable/05_event_log.py +145 -0
  69. examples/local/durable/06_idempotency.py +148 -0
  70. examples/local/durable/07_hooks.py +334 -0
  71. examples/local/durable/08_cancellation.py +233 -0
  72. examples/local/durable/09_child_workflows.py +198 -0
  73. examples/local/durable/10_child_workflow_patterns.py +265 -0
  74. examples/local/durable/11_continue_as_new.py +249 -0
  75. examples/local/durable/12_schedules.py +198 -0
  76. examples/local/durable/__init__.py +1 -0
  77. examples/local/transient/01_quick_tasks.py +87 -0
  78. examples/local/transient/02_retries.py +130 -0
  79. examples/local/transient/03_sleep.py +141 -0
  80. examples/local/transient/__init__.py +1 -0
  81. pyworkflow/__init__.py +256 -0
  82. pyworkflow/aws/__init__.py +68 -0
  83. pyworkflow/aws/context.py +234 -0
  84. pyworkflow/aws/handler.py +184 -0
  85. pyworkflow/aws/testing.py +310 -0
  86. pyworkflow/celery/__init__.py +41 -0
  87. pyworkflow/celery/app.py +198 -0
  88. pyworkflow/celery/scheduler.py +315 -0
  89. pyworkflow/celery/tasks.py +1746 -0
  90. pyworkflow/cli/__init__.py +132 -0
  91. pyworkflow/cli/__main__.py +6 -0
  92. pyworkflow/cli/commands/__init__.py +1 -0
  93. pyworkflow/cli/commands/hooks.py +640 -0
  94. pyworkflow/cli/commands/quickstart.py +495 -0
  95. pyworkflow/cli/commands/runs.py +773 -0
  96. pyworkflow/cli/commands/scheduler.py +130 -0
  97. pyworkflow/cli/commands/schedules.py +794 -0
  98. pyworkflow/cli/commands/setup.py +703 -0
  99. pyworkflow/cli/commands/worker.py +413 -0
  100. pyworkflow/cli/commands/workflows.py +1257 -0
  101. pyworkflow/cli/output/__init__.py +1 -0
  102. pyworkflow/cli/output/formatters.py +321 -0
  103. pyworkflow/cli/output/styles.py +121 -0
  104. pyworkflow/cli/utils/__init__.py +1 -0
  105. pyworkflow/cli/utils/async_helpers.py +30 -0
  106. pyworkflow/cli/utils/config.py +130 -0
  107. pyworkflow/cli/utils/config_generator.py +344 -0
  108. pyworkflow/cli/utils/discovery.py +53 -0
  109. pyworkflow/cli/utils/docker_manager.py +651 -0
  110. pyworkflow/cli/utils/interactive.py +364 -0
  111. pyworkflow/cli/utils/storage.py +115 -0
  112. pyworkflow/config.py +329 -0
  113. pyworkflow/context/__init__.py +63 -0
  114. pyworkflow/context/aws.py +230 -0
  115. pyworkflow/context/base.py +416 -0
  116. pyworkflow/context/local.py +930 -0
  117. pyworkflow/context/mock.py +381 -0
  118. pyworkflow/core/__init__.py +0 -0
  119. pyworkflow/core/exceptions.py +353 -0
  120. pyworkflow/core/registry.py +313 -0
  121. pyworkflow/core/scheduled.py +328 -0
  122. pyworkflow/core/step.py +494 -0
  123. pyworkflow/core/workflow.py +294 -0
  124. pyworkflow/discovery.py +248 -0
  125. pyworkflow/engine/__init__.py +0 -0
  126. pyworkflow/engine/events.py +879 -0
  127. pyworkflow/engine/executor.py +682 -0
  128. pyworkflow/engine/replay.py +273 -0
  129. pyworkflow/observability/__init__.py +19 -0
  130. pyworkflow/observability/logging.py +234 -0
  131. pyworkflow/primitives/__init__.py +33 -0
  132. pyworkflow/primitives/child_handle.py +174 -0
  133. pyworkflow/primitives/child_workflow.py +372 -0
  134. pyworkflow/primitives/continue_as_new.py +101 -0
  135. pyworkflow/primitives/define_hook.py +150 -0
  136. pyworkflow/primitives/hooks.py +97 -0
  137. pyworkflow/primitives/resume_hook.py +210 -0
  138. pyworkflow/primitives/schedule.py +545 -0
  139. pyworkflow/primitives/shield.py +96 -0
  140. pyworkflow/primitives/sleep.py +100 -0
  141. pyworkflow/runtime/__init__.py +21 -0
  142. pyworkflow/runtime/base.py +179 -0
  143. pyworkflow/runtime/celery.py +310 -0
  144. pyworkflow/runtime/factory.py +101 -0
  145. pyworkflow/runtime/local.py +706 -0
  146. pyworkflow/scheduler/__init__.py +9 -0
  147. pyworkflow/scheduler/local.py +248 -0
  148. pyworkflow/serialization/__init__.py +0 -0
  149. pyworkflow/serialization/decoder.py +146 -0
  150. pyworkflow/serialization/encoder.py +162 -0
  151. pyworkflow/storage/__init__.py +54 -0
  152. pyworkflow/storage/base.py +612 -0
  153. pyworkflow/storage/config.py +185 -0
  154. pyworkflow/storage/dynamodb.py +1315 -0
  155. pyworkflow/storage/file.py +827 -0
  156. pyworkflow/storage/memory.py +549 -0
  157. pyworkflow/storage/postgres.py +1161 -0
  158. pyworkflow/storage/schemas.py +486 -0
  159. pyworkflow/storage/sqlite.py +1136 -0
  160. pyworkflow/utils/__init__.py +0 -0
  161. pyworkflow/utils/duration.py +177 -0
  162. pyworkflow/utils/schedule.py +391 -0
  163. pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
  164. pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
  165. pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
  166. pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
  167. pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
  168. pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
  169. tests/examples/__init__.py +0 -0
  170. tests/integration/__init__.py +0 -0
  171. tests/integration/test_cancellation.py +330 -0
  172. tests/integration/test_child_workflows.py +439 -0
  173. tests/integration/test_continue_as_new.py +428 -0
  174. tests/integration/test_dynamodb_storage.py +1146 -0
  175. tests/integration/test_fault_tolerance.py +369 -0
  176. tests/integration/test_schedule_storage.py +484 -0
  177. tests/unit/__init__.py +0 -0
  178. tests/unit/backends/__init__.py +1 -0
  179. tests/unit/backends/test_dynamodb_storage.py +1554 -0
  180. tests/unit/backends/test_postgres_storage.py +1281 -0
  181. tests/unit/backends/test_sqlite_storage.py +1460 -0
  182. tests/unit/conftest.py +41 -0
  183. tests/unit/test_cancellation.py +364 -0
  184. tests/unit/test_child_workflows.py +680 -0
  185. tests/unit/test_continue_as_new.py +441 -0
  186. tests/unit/test_event_limits.py +316 -0
  187. tests/unit/test_executor.py +320 -0
  188. tests/unit/test_fault_tolerance.py +334 -0
  189. tests/unit/test_hooks.py +495 -0
  190. tests/unit/test_registry.py +261 -0
  191. tests/unit/test_replay.py +420 -0
  192. tests/unit/test_schedule_schemas.py +285 -0
  193. tests/unit/test_schedule_utils.py +286 -0
  194. tests/unit/test_scheduled_workflow.py +274 -0
  195. tests/unit/test_step.py +353 -0
  196. tests/unit/test_workflow.py +243 -0
@@ -0,0 +1,273 @@
1
+ """
2
+ Event replay engine for deterministic workflow state reconstruction.
3
+
4
+ The replay engine processes the event log to rebuild workflow state,
5
+ enabling fault tolerance and resumption after crashes or suspensions.
6
+ """
7
+
8
+ from loguru import logger
9
+
10
+ from pyworkflow.context import LocalContext
11
+ from pyworkflow.engine.events import Event, EventType
12
+
13
+
14
+ class EventReplayer:
15
+ """
16
+ Replays events to reconstruct workflow state.
17
+
18
+ The replayer processes events in sequence order to restore:
19
+ - Completed step results
20
+ - Hook payloads
21
+ - Sleep completion status
22
+ """
23
+
24
+ async def replay(self, ctx: LocalContext, events: list[Event]) -> None:
25
+ """
26
+ Replay events to restore workflow state.
27
+
28
+ This enables deterministic execution - same events always produce
29
+ same state.
30
+
31
+ Args:
32
+ ctx: Workflow context to populate
33
+ events: List of events ordered by sequence
34
+ """
35
+ if not events:
36
+ logger.debug(f"No events to replay for run {ctx.run_id}")
37
+ return
38
+
39
+ logger.debug(
40
+ f"Replaying {len(events)} events for run {ctx.run_id}",
41
+ run_id=ctx.run_id,
42
+ workflow_name=ctx.workflow_name,
43
+ )
44
+
45
+ ctx.is_replaying = True
46
+ ctx.event_log = events
47
+
48
+ for event in sorted(events, key=lambda e: e.sequence or 0):
49
+ await self._apply_event(ctx, event)
50
+
51
+ ctx.is_replaying = False
52
+
53
+ logger.debug(
54
+ f"Replay complete: {len(ctx.step_results)} steps, "
55
+ f"{len(ctx.hook_results)} hooks, "
56
+ f"{len(ctx.pending_sleeps)} pending sleeps, "
57
+ f"{len(ctx.retry_state)} pending retries",
58
+ run_id=ctx.run_id,
59
+ )
60
+
61
+ async def _apply_event(self, ctx: LocalContext, event: Event) -> None:
62
+ """
63
+ Apply a single event to the context.
64
+
65
+ Args:
66
+ ctx: Workflow context
67
+ event: Event to apply
68
+ """
69
+ if event.type == EventType.STEP_COMPLETED:
70
+ await self._apply_step_completed(ctx, event)
71
+
72
+ elif event.type == EventType.SLEEP_STARTED:
73
+ await self._apply_sleep_started(ctx, event)
74
+
75
+ elif event.type == EventType.SLEEP_COMPLETED:
76
+ await self._apply_sleep_completed(ctx, event)
77
+
78
+ elif event.type == EventType.HOOK_CREATED:
79
+ await self._apply_hook_created(ctx, event)
80
+
81
+ elif event.type == EventType.HOOK_RECEIVED:
82
+ await self._apply_hook_received(ctx, event)
83
+
84
+ elif event.type == EventType.HOOK_EXPIRED:
85
+ await self._apply_hook_expired(ctx, event)
86
+
87
+ elif event.type == EventType.STEP_RETRYING:
88
+ await self._apply_step_retrying(ctx, event)
89
+
90
+ elif event.type == EventType.WORKFLOW_INTERRUPTED:
91
+ await self._apply_workflow_interrupted(ctx, event)
92
+
93
+ elif event.type == EventType.CANCELLATION_REQUESTED:
94
+ await self._apply_cancellation_requested(ctx, event)
95
+
96
+ # Other event types don't affect replay state
97
+ # (workflow_started, step_started, step_failed, etc. are informational)
98
+
99
+ async def _apply_step_completed(self, ctx: LocalContext, event: Event) -> None:
100
+ """Apply step_completed event - cache the result."""
101
+ from pyworkflow.serialization.decoder import deserialize
102
+
103
+ step_id = event.data.get("step_id")
104
+ result_json = event.data.get("result")
105
+
106
+ if step_id and result_json:
107
+ # Deserialize the result before caching
108
+ result = deserialize(result_json)
109
+ ctx.cache_step_result(step_id, result)
110
+ logger.debug(
111
+ f"Cached step result: {step_id}",
112
+ run_id=ctx.run_id,
113
+ step_id=step_id,
114
+ )
115
+
116
+ async def _apply_sleep_started(self, ctx: LocalContext, event: Event) -> None:
117
+ """Apply sleep_started event - mark sleep as pending."""
118
+ from datetime import datetime
119
+
120
+ sleep_id = event.data.get("sleep_id")
121
+ resume_at_str = event.data.get("resume_at")
122
+
123
+ if sleep_id and resume_at_str:
124
+ # Parse resume_at from ISO format
125
+ resume_at = datetime.fromisoformat(resume_at_str)
126
+ ctx.add_pending_sleep(sleep_id, resume_at)
127
+ logger.debug(
128
+ f"Sleep pending: {sleep_id}",
129
+ run_id=ctx.run_id,
130
+ sleep_id=sleep_id,
131
+ resume_at=resume_at_str,
132
+ )
133
+
134
+ async def _apply_sleep_completed(self, ctx: LocalContext, event: Event) -> None:
135
+ """Apply sleep_completed event - mark sleep as done."""
136
+ sleep_id = event.data.get("sleep_id")
137
+
138
+ if sleep_id:
139
+ ctx.mark_sleep_completed(sleep_id)
140
+ logger.debug(
141
+ f"Sleep completed: {sleep_id}",
142
+ run_id=ctx.run_id,
143
+ sleep_id=sleep_id,
144
+ )
145
+
146
+ async def _apply_hook_created(self, ctx: LocalContext, event: Event) -> None:
147
+ """Apply hook_created event - mark hook as pending."""
148
+ hook_id = event.data.get("hook_id")
149
+
150
+ if hook_id:
151
+ ctx.add_pending_hook(hook_id, event.data)
152
+ logger.debug(
153
+ f"Hook pending: {hook_id}",
154
+ run_id=ctx.run_id,
155
+ hook_id=hook_id,
156
+ )
157
+
158
+ async def _apply_hook_received(self, ctx: LocalContext, event: Event) -> None:
159
+ """Apply hook_received event - cache the payload."""
160
+ hook_id = event.data.get("hook_id")
161
+ payload = event.data.get("payload")
162
+
163
+ if hook_id:
164
+ ctx.cache_hook_result(hook_id, payload)
165
+ logger.debug(
166
+ f"Cached hook result: {hook_id}",
167
+ run_id=ctx.run_id,
168
+ hook_id=hook_id,
169
+ )
170
+
171
+ async def _apply_hook_expired(self, ctx: LocalContext, event: Event) -> None:
172
+ """Apply hook_expired event - remove from pending."""
173
+ hook_id = event.data.get("hook_id")
174
+
175
+ if hook_id:
176
+ ctx.pending_hooks.pop(hook_id, None)
177
+ logger.debug(
178
+ f"Hook expired: {hook_id}",
179
+ run_id=ctx.run_id,
180
+ hook_id=hook_id,
181
+ )
182
+
183
+ async def _apply_step_retrying(self, ctx: LocalContext, event: Event) -> None:
184
+ """Apply step_retrying event - restore retry state for resumption."""
185
+ from datetime import datetime
186
+
187
+ step_id = event.data.get("step_id")
188
+ next_attempt = event.data.get("attempt")
189
+ resume_at_str = event.data.get("resume_at")
190
+ event.data.get("retry_after")
191
+ max_retries = event.data.get("max_retries", 3)
192
+ retry_delay = event.data.get("retry_strategy", "exponential")
193
+ last_error = event.data.get("error", "")
194
+
195
+ if step_id and next_attempt:
196
+ # Parse resume_at from ISO format
197
+ resume_at = datetime.fromisoformat(resume_at_str) if resume_at_str else None
198
+
199
+ # Restore retry state to context
200
+ ctx.set_retry_state(
201
+ step_id=step_id,
202
+ attempt=next_attempt,
203
+ resume_at=resume_at,
204
+ max_retries=max_retries,
205
+ retry_delay=retry_delay,
206
+ last_error=last_error,
207
+ )
208
+
209
+ logger.debug(
210
+ f"Retry pending: {step_id}",
211
+ run_id=ctx.run_id,
212
+ step_id=step_id,
213
+ next_attempt=next_attempt,
214
+ resume_at=resume_at_str,
215
+ )
216
+
217
+ async def _apply_workflow_interrupted(self, ctx: LocalContext, event: Event) -> None:
218
+ """
219
+ Apply workflow_interrupted event - log the interruption.
220
+
221
+ This event is informational for the replay - it doesn't change state
222
+ since the workflow will continue from the last completed step.
223
+ The event records that an interruption occurred for auditing purposes.
224
+ """
225
+ reason = event.data.get("reason", "unknown")
226
+ recovery_attempt = event.data.get("recovery_attempt", 0)
227
+ last_event_sequence = event.data.get("last_event_sequence")
228
+
229
+ logger.info(
230
+ f"Workflow was interrupted: {reason}",
231
+ run_id=ctx.run_id,
232
+ reason=reason,
233
+ recovery_attempt=recovery_attempt,
234
+ last_event_sequence=last_event_sequence,
235
+ )
236
+
237
+ async def _apply_cancellation_requested(self, ctx: LocalContext, event: Event) -> None:
238
+ """
239
+ Apply cancellation_requested event - mark workflow for cancellation.
240
+
241
+ This event signals that cancellation was requested. During replay,
242
+ we set the cancellation flag so the workflow will raise CancellationError
243
+ at the next check point.
244
+ """
245
+ reason = event.data.get("reason")
246
+ requested_by = event.data.get("requested_by")
247
+
248
+ # Set cancellation flag in context
249
+ ctx.request_cancellation(reason=reason)
250
+
251
+ logger.info(
252
+ "Cancellation requested for workflow",
253
+ run_id=ctx.run_id,
254
+ reason=reason,
255
+ requested_by=requested_by,
256
+ )
257
+
258
+
259
+ # Singleton instance
260
+ _replayer = EventReplayer()
261
+
262
+
263
+ async def replay_events(ctx: LocalContext, events: list[Event]) -> None:
264
+ """
265
+ Replay events to restore workflow state.
266
+
267
+ Public API for event replay.
268
+
269
+ Args:
270
+ ctx: Workflow context to populate
271
+ events: List of events to replay
272
+ """
273
+ await _replayer.replay(ctx, events)
@@ -0,0 +1,19 @@
1
+ """
2
+ Observability and logging for PyWorkflow.
3
+
4
+ Provides structured logging, metrics, and tracing capabilities for workflows.
5
+ """
6
+
7
+ from pyworkflow.observability.logging import (
8
+ bind_step_context,
9
+ bind_workflow_context,
10
+ configure_logging,
11
+ get_logger,
12
+ )
13
+
14
+ __all__ = [
15
+ "configure_logging",
16
+ "get_logger",
17
+ "bind_workflow_context",
18
+ "bind_step_context",
19
+ ]
@@ -0,0 +1,234 @@
1
+ """
2
+ Loguru logging configuration for PyWorkflow.
3
+
4
+ Provides structured logging with context-aware formatting for workflows, steps,
5
+ and events. Integrates with loguru for powerful logging capabilities.
6
+ """
7
+
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from loguru import logger
13
+
14
+
15
+ def configure_logging(
16
+ level: str = "INFO",
17
+ log_file: str | None = None,
18
+ json_logs: bool = False,
19
+ show_context: bool = True,
20
+ ) -> None:
21
+ """
22
+ Configure PyWorkflow logging with loguru.
23
+
24
+ This sets up structured logging with workflow context (run_id, step_id, etc.)
25
+ and flexible output formats.
26
+
27
+ Args:
28
+ level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
29
+ log_file: Optional file path for log output
30
+ json_logs: If True, output logs in JSON format (useful for production)
31
+ show_context: If True, include workflow context in log messages
32
+
33
+ Examples:
34
+ # Basic configuration (console output only)
35
+ configure_logging()
36
+
37
+ # Debug mode with file output
38
+ configure_logging(level="DEBUG", log_file="workflow.log")
39
+
40
+ # Production mode with JSON logs
41
+ configure_logging(
42
+ level="INFO",
43
+ log_file="production.log",
44
+ json_logs=True
45
+ )
46
+
47
+ # Minimal logs without context
48
+ configure_logging(level="WARNING", show_context=False)
49
+ """
50
+ # Remove default logger
51
+ logger.remove()
52
+
53
+ # Console format
54
+ if json_logs:
55
+ # JSON format for structured logging
56
+ console_format = _get_json_format()
57
+ else:
58
+ # Human-readable format
59
+ if show_context:
60
+ # Include extra context when available (run_id, step_id, etc.)
61
+ console_format = (
62
+ "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
63
+ "<level>{level: <8}</level> | "
64
+ "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
65
+ "<level>{message}</level>"
66
+ )
67
+ else:
68
+ console_format = (
69
+ "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
70
+ "<level>{level: <8}</level> | "
71
+ "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
72
+ "<level>{message}</level>"
73
+ )
74
+
75
+ # Add console handler with filter to inject context
76
+ def format_with_context(record: dict[str, Any]) -> bool:
77
+ """Add context fields to the format string dynamically."""
78
+ extra_str = ""
79
+ if show_context and record["extra"]:
80
+ # Build context string from extra fields
81
+ context_parts = []
82
+ if "run_id" in record["extra"]:
83
+ context_parts.append(f"run_id={record['extra']['run_id']}")
84
+ if "step_id" in record["extra"]:
85
+ context_parts.append(f"step_id={record['extra']['step_id']}")
86
+ if "workflow_name" in record["extra"]:
87
+ context_parts.append(f"workflow={record['extra']['workflow_name']}")
88
+ if context_parts:
89
+ extra_str = " | " + " ".join(context_parts)
90
+ record["extra"]["_context"] = extra_str
91
+ return True
92
+
93
+ logger.add(
94
+ sys.stderr,
95
+ format=console_format + "{extra[_context]}",
96
+ level=level,
97
+ colorize=not json_logs,
98
+ serialize=json_logs,
99
+ filter=format_with_context, # type: ignore[arg-type]
100
+ )
101
+
102
+ # Add file handler if requested
103
+ if log_file:
104
+ log_path = Path(log_file)
105
+ log_path.parent.mkdir(parents=True, exist_ok=True)
106
+
107
+ if json_logs:
108
+ # JSON format for file
109
+ logger.add(
110
+ log_file,
111
+ format=_get_json_format(),
112
+ level=level,
113
+ rotation="100 MB",
114
+ retention="30 days",
115
+ compression="gz",
116
+ serialize=True,
117
+ )
118
+ else:
119
+ # Human-readable format for file
120
+ logger.add(
121
+ log_file,
122
+ format=(
123
+ "{time:YYYY-MM-DD HH:mm:ss.SSS} | "
124
+ "{level: <8} | "
125
+ "{name}:{function}:{line} | "
126
+ "{message} | "
127
+ "{extra}"
128
+ ),
129
+ level=level,
130
+ rotation="100 MB",
131
+ retention="30 days",
132
+ compression="gz",
133
+ )
134
+
135
+ logger.info(f"PyWorkflow logging configured at level {level}")
136
+
137
+
138
+ def _get_json_format() -> str:
139
+ """
140
+ Get JSON log format string.
141
+
142
+ Returns:
143
+ Format string for JSON structured logging
144
+ """
145
+ return (
146
+ '{{"timestamp":"{time:YYYY-MM-DD HH:mm:ss.SSS}",'
147
+ '"level":"{level}",'
148
+ '"logger":"{name}",'
149
+ '"function":"{function}",'
150
+ '"line":{line},'
151
+ '"message":"{message}",'
152
+ '"extra":{extra}}}'
153
+ )
154
+
155
+
156
+ def get_logger(name: str | None = None) -> Any:
157
+ """
158
+ Get a logger instance.
159
+
160
+ This is a convenience function that returns the configured loguru logger
161
+ with optional context binding.
162
+
163
+ Args:
164
+ name: Optional logger name (for filtering)
165
+
166
+ Returns:
167
+ Configured logger instance
168
+
169
+ Examples:
170
+ # Get logger for a module
171
+ log = get_logger(__name__)
172
+ log.info("Processing workflow")
173
+
174
+ # Use with context
175
+ log = get_logger().bind(run_id="run_123")
176
+ log.info("Step started")
177
+ """
178
+ if name:
179
+ return logger.bind(module=name)
180
+ return logger
181
+
182
+
183
+ def bind_workflow_context(run_id: str, workflow_name: str) -> Any:
184
+ """
185
+ Bind workflow context to logger.
186
+
187
+ This adds run_id and workflow_name to all subsequent log messages.
188
+
189
+ Args:
190
+ run_id: Workflow run identifier
191
+ workflow_name: Workflow name
192
+
193
+ Returns:
194
+ Logger with bound context
195
+
196
+ Example:
197
+ log = bind_workflow_context("run_123", "process_order")
198
+ log.info("Workflow started")
199
+ # Output includes run_id and workflow_name
200
+ """
201
+ return logger.bind(run_id=run_id, workflow_name=workflow_name)
202
+
203
+
204
+ def bind_step_context(run_id: str, step_id: str, step_name: str) -> Any:
205
+ """
206
+ Bind step context to logger.
207
+
208
+ This adds run_id, step_id, and step_name to all subsequent log messages.
209
+
210
+ Args:
211
+ run_id: Workflow run identifier
212
+ step_id: Step identifier
213
+ step_name: Step name
214
+
215
+ Returns:
216
+ Logger with bound context
217
+
218
+ Example:
219
+ log = bind_step_context("run_123", "step_abc", "validate_order")
220
+ log.info("Step executing")
221
+ # Output includes run_id, step_id, and step_name
222
+ """
223
+ return logger.bind(run_id=run_id, step_id=step_id, step_name=step_name)
224
+
225
+
226
+ # Default configuration on import
227
+ # Users can override by calling configure_logging()
228
+ try:
229
+ # Only configure if logger doesn't have handlers
230
+ if len(logger._core.handlers) == 0: # type: ignore[attr-defined]
231
+ configure_logging(level="INFO", show_context=False)
232
+ except Exception:
233
+ # If configuration fails, just use default loguru
234
+ pass
@@ -0,0 +1,33 @@
1
+ """
2
+ Workflow primitives for durable execution.
3
+
4
+ Primitives provide building blocks for workflow orchestration:
5
+ - sleep: Durable delays without holding resources
6
+ - hook: Wait for external events (webhooks, approvals, callbacks)
7
+ - define_hook: Create typed hooks with Pydantic validation
8
+ - resume_hook: Resume suspended workflows from external systems
9
+ - shield: Protection from cancellation for critical sections
10
+ - continue_as_new: Continue workflow with fresh event history
11
+ """
12
+
13
+ from pyworkflow.primitives.continue_as_new import continue_as_new
14
+ from pyworkflow.primitives.define_hook import TypedHook, define_hook
15
+ from pyworkflow.primitives.hooks import hook
16
+ from pyworkflow.primitives.resume_hook import ResumeResult, resume_hook
17
+ from pyworkflow.primitives.shield import shield
18
+ from pyworkflow.primitives.sleep import sleep
19
+
20
+ __all__ = [
21
+ # Sleep
22
+ "sleep",
23
+ # Hooks
24
+ "hook",
25
+ "define_hook",
26
+ "TypedHook",
27
+ "resume_hook",
28
+ "ResumeResult",
29
+ # Cancellation
30
+ "shield",
31
+ # Continue-as-new
32
+ "continue_as_new",
33
+ ]