planar 0.5.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.
Files changed (289) hide show
  1. planar/.__init__.py.un~ +0 -0
  2. planar/._version.py.un~ +0 -0
  3. planar/.app.py.un~ +0 -0
  4. planar/.cli.py.un~ +0 -0
  5. planar/.config.py.un~ +0 -0
  6. planar/.context.py.un~ +0 -0
  7. planar/.db.py.un~ +0 -0
  8. planar/.di.py.un~ +0 -0
  9. planar/.engine.py.un~ +0 -0
  10. planar/.files.py.un~ +0 -0
  11. planar/.log_context.py.un~ +0 -0
  12. planar/.log_metadata.py.un~ +0 -0
  13. planar/.logging.py.un~ +0 -0
  14. planar/.object_registry.py.un~ +0 -0
  15. planar/.otel.py.un~ +0 -0
  16. planar/.server.py.un~ +0 -0
  17. planar/.session.py.un~ +0 -0
  18. planar/.sqlalchemy.py.un~ +0 -0
  19. planar/.task_local.py.un~ +0 -0
  20. planar/.test_app.py.un~ +0 -0
  21. planar/.test_config.py.un~ +0 -0
  22. planar/.test_object_config.py.un~ +0 -0
  23. planar/.test_sqlalchemy.py.un~ +0 -0
  24. planar/.test_utils.py.un~ +0 -0
  25. planar/.util.py.un~ +0 -0
  26. planar/.utils.py.un~ +0 -0
  27. planar/__init__.py +26 -0
  28. planar/_version.py +1 -0
  29. planar/ai/.__init__.py.un~ +0 -0
  30. planar/ai/._models.py.un~ +0 -0
  31. planar/ai/.agent.py.un~ +0 -0
  32. planar/ai/.agent_utils.py.un~ +0 -0
  33. planar/ai/.events.py.un~ +0 -0
  34. planar/ai/.files.py.un~ +0 -0
  35. planar/ai/.models.py.un~ +0 -0
  36. planar/ai/.providers.py.un~ +0 -0
  37. planar/ai/.pydantic_ai.py.un~ +0 -0
  38. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  39. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  40. planar/ai/.step.py.un~ +0 -0
  41. planar/ai/.test_agent.py.un~ +0 -0
  42. planar/ai/.test_agent_serialization.py.un~ +0 -0
  43. planar/ai/.test_providers.py.un~ +0 -0
  44. planar/ai/.utils.py.un~ +0 -0
  45. planar/ai/__init__.py +15 -0
  46. planar/ai/agent.py +457 -0
  47. planar/ai/agent_utils.py +205 -0
  48. planar/ai/models.py +140 -0
  49. planar/ai/providers.py +1088 -0
  50. planar/ai/test_agent.py +1298 -0
  51. planar/ai/test_agent_serialization.py +229 -0
  52. planar/ai/test_providers.py +463 -0
  53. planar/ai/utils.py +102 -0
  54. planar/app.py +494 -0
  55. planar/cli.py +282 -0
  56. planar/config.py +544 -0
  57. planar/db/.db.py.un~ +0 -0
  58. planar/db/__init__.py +17 -0
  59. planar/db/alembic/env.py +136 -0
  60. planar/db/alembic/script.py.mako +28 -0
  61. planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
  62. planar/db/alembic.ini +128 -0
  63. planar/db/db.py +318 -0
  64. planar/files/.config.py.un~ +0 -0
  65. planar/files/.local.py.un~ +0 -0
  66. planar/files/.local_filesystem.py.un~ +0 -0
  67. planar/files/.model.py.un~ +0 -0
  68. planar/files/.models.py.un~ +0 -0
  69. planar/files/.s3.py.un~ +0 -0
  70. planar/files/.storage.py.un~ +0 -0
  71. planar/files/.test_files.py.un~ +0 -0
  72. planar/files/__init__.py +2 -0
  73. planar/files/models.py +162 -0
  74. planar/files/storage/.__init__.py.un~ +0 -0
  75. planar/files/storage/.base.py.un~ +0 -0
  76. planar/files/storage/.config.py.un~ +0 -0
  77. planar/files/storage/.context.py.un~ +0 -0
  78. planar/files/storage/.local_directory.py.un~ +0 -0
  79. planar/files/storage/.test_local_directory.py.un~ +0 -0
  80. planar/files/storage/.test_s3.py.un~ +0 -0
  81. planar/files/storage/base.py +61 -0
  82. planar/files/storage/config.py +44 -0
  83. planar/files/storage/context.py +15 -0
  84. planar/files/storage/local_directory.py +188 -0
  85. planar/files/storage/s3.py +220 -0
  86. planar/files/storage/test_local_directory.py +162 -0
  87. planar/files/storage/test_s3.py +299 -0
  88. planar/files/test_files.py +283 -0
  89. planar/human/.human.py.un~ +0 -0
  90. planar/human/.test_human.py.un~ +0 -0
  91. planar/human/__init__.py +2 -0
  92. planar/human/human.py +458 -0
  93. planar/human/models.py +80 -0
  94. planar/human/test_human.py +385 -0
  95. planar/logging/.__init__.py.un~ +0 -0
  96. planar/logging/.attributes.py.un~ +0 -0
  97. planar/logging/.formatter.py.un~ +0 -0
  98. planar/logging/.logger.py.un~ +0 -0
  99. planar/logging/.otel.py.un~ +0 -0
  100. planar/logging/.tracer.py.un~ +0 -0
  101. planar/logging/__init__.py +10 -0
  102. planar/logging/attributes.py +54 -0
  103. planar/logging/context.py +14 -0
  104. planar/logging/formatter.py +113 -0
  105. planar/logging/logger.py +114 -0
  106. planar/logging/otel.py +51 -0
  107. planar/modeling/.mixin.py.un~ +0 -0
  108. planar/modeling/.storage.py.un~ +0 -0
  109. planar/modeling/__init__.py +0 -0
  110. planar/modeling/field_helpers.py +59 -0
  111. planar/modeling/json_schema_generator.py +94 -0
  112. planar/modeling/mixins/__init__.py +10 -0
  113. planar/modeling/mixins/auditable.py +52 -0
  114. planar/modeling/mixins/test_auditable.py +97 -0
  115. planar/modeling/mixins/test_timestamp.py +134 -0
  116. planar/modeling/mixins/test_uuid_primary_key.py +52 -0
  117. planar/modeling/mixins/timestamp.py +53 -0
  118. planar/modeling/mixins/uuid_primary_key.py +19 -0
  119. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  120. planar/modeling/orm/__init__.py +18 -0
  121. planar/modeling/orm/planar_base_entity.py +29 -0
  122. planar/modeling/orm/query_filter_builder.py +122 -0
  123. planar/modeling/orm/reexports.py +15 -0
  124. planar/object_config/.object_config.py.un~ +0 -0
  125. planar/object_config/__init__.py +11 -0
  126. planar/object_config/models.py +114 -0
  127. planar/object_config/object_config.py +378 -0
  128. planar/object_registry.py +100 -0
  129. planar/registry_items.py +65 -0
  130. planar/routers/.__init__.py.un~ +0 -0
  131. planar/routers/.agents_router.py.un~ +0 -0
  132. planar/routers/.crud.py.un~ +0 -0
  133. planar/routers/.decision.py.un~ +0 -0
  134. planar/routers/.event.py.un~ +0 -0
  135. planar/routers/.file_attachment.py.un~ +0 -0
  136. planar/routers/.files.py.un~ +0 -0
  137. planar/routers/.files_router.py.un~ +0 -0
  138. planar/routers/.human.py.un~ +0 -0
  139. planar/routers/.info.py.un~ +0 -0
  140. planar/routers/.models.py.un~ +0 -0
  141. planar/routers/.object_config_router.py.un~ +0 -0
  142. planar/routers/.rule.py.un~ +0 -0
  143. planar/routers/.test_object_config_router.py.un~ +0 -0
  144. planar/routers/.test_workflow_router.py.un~ +0 -0
  145. planar/routers/.workflow.py.un~ +0 -0
  146. planar/routers/__init__.py +13 -0
  147. planar/routers/agents_router.py +197 -0
  148. planar/routers/entity_router.py +143 -0
  149. planar/routers/event.py +91 -0
  150. planar/routers/files.py +142 -0
  151. planar/routers/human.py +151 -0
  152. planar/routers/info.py +131 -0
  153. planar/routers/models.py +170 -0
  154. planar/routers/object_config_router.py +133 -0
  155. planar/routers/rule.py +108 -0
  156. planar/routers/test_agents_router.py +174 -0
  157. planar/routers/test_object_config_router.py +367 -0
  158. planar/routers/test_routes_security.py +169 -0
  159. planar/routers/test_rule_router.py +470 -0
  160. planar/routers/test_workflow_router.py +274 -0
  161. planar/routers/workflow.py +468 -0
  162. planar/rules/.decorator.py.un~ +0 -0
  163. planar/rules/.runner.py.un~ +0 -0
  164. planar/rules/.test_rules.py.un~ +0 -0
  165. planar/rules/__init__.py +23 -0
  166. planar/rules/decorator.py +184 -0
  167. planar/rules/models.py +355 -0
  168. planar/rules/rule_configuration.py +191 -0
  169. planar/rules/runner.py +64 -0
  170. planar/rules/test_rules.py +750 -0
  171. planar/scaffold_templates/app/__init__.py.j2 +0 -0
  172. planar/scaffold_templates/app/db/entities.py.j2 +11 -0
  173. planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
  174. planar/scaffold_templates/main.py.j2 +13 -0
  175. planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
  176. planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
  177. planar/scaffold_templates/pyproject.toml.j2 +10 -0
  178. planar/security/.jwt_middleware.py.un~ +0 -0
  179. planar/security/auth_context.py +148 -0
  180. planar/security/authorization.py +388 -0
  181. planar/security/default_policies.cedar +77 -0
  182. planar/security/jwt_middleware.py +116 -0
  183. planar/security/security_context.py +18 -0
  184. planar/security/tests/test_authorization_context.py +78 -0
  185. planar/security/tests/test_cedar_basics.py +41 -0
  186. planar/security/tests/test_cedar_policies.py +158 -0
  187. planar/security/tests/test_jwt_principal_context.py +179 -0
  188. planar/session.py +40 -0
  189. planar/sse/.constants.py.un~ +0 -0
  190. planar/sse/.example.html.un~ +0 -0
  191. planar/sse/.hub.py.un~ +0 -0
  192. planar/sse/.model.py.un~ +0 -0
  193. planar/sse/.proxy.py.un~ +0 -0
  194. planar/sse/constants.py +1 -0
  195. planar/sse/example.html +126 -0
  196. planar/sse/hub.py +216 -0
  197. planar/sse/model.py +8 -0
  198. planar/sse/proxy.py +257 -0
  199. planar/task_local.py +37 -0
  200. planar/test_app.py +51 -0
  201. planar/test_cli.py +372 -0
  202. planar/test_config.py +512 -0
  203. planar/test_object_config.py +527 -0
  204. planar/test_object_registry.py +14 -0
  205. planar/test_sqlalchemy.py +158 -0
  206. planar/test_utils.py +105 -0
  207. planar/testing/.client.py.un~ +0 -0
  208. planar/testing/.memory_storage.py.un~ +0 -0
  209. planar/testing/.planar_test_client.py.un~ +0 -0
  210. planar/testing/.predictable_tracer.py.un~ +0 -0
  211. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  212. planar/testing/.test_memory_storage.py.un~ +0 -0
  213. planar/testing/.workflow_observer.py.un~ +0 -0
  214. planar/testing/__init__.py +0 -0
  215. planar/testing/memory_storage.py +78 -0
  216. planar/testing/planar_test_client.py +54 -0
  217. planar/testing/synchronizable_tracer.py +153 -0
  218. planar/testing/test_memory_storage.py +143 -0
  219. planar/testing/workflow_observer.py +73 -0
  220. planar/utils.py +70 -0
  221. planar/workflows/.__init__.py.un~ +0 -0
  222. planar/workflows/.builtin_steps.py.un~ +0 -0
  223. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  224. planar/workflows/.context.py.un~ +0 -0
  225. planar/workflows/.contrib.py.un~ +0 -0
  226. planar/workflows/.decorators.py.un~ +0 -0
  227. planar/workflows/.durable_test.py.un~ +0 -0
  228. planar/workflows/.errors.py.un~ +0 -0
  229. planar/workflows/.events.py.un~ +0 -0
  230. planar/workflows/.exceptions.py.un~ +0 -0
  231. planar/workflows/.execution.py.un~ +0 -0
  232. planar/workflows/.human.py.un~ +0 -0
  233. planar/workflows/.lock.py.un~ +0 -0
  234. planar/workflows/.misc.py.un~ +0 -0
  235. planar/workflows/.model.py.un~ +0 -0
  236. planar/workflows/.models.py.un~ +0 -0
  237. planar/workflows/.notifications.py.un~ +0 -0
  238. planar/workflows/.orchestrator.py.un~ +0 -0
  239. planar/workflows/.runtime.py.un~ +0 -0
  240. planar/workflows/.serialization.py.un~ +0 -0
  241. planar/workflows/.step.py.un~ +0 -0
  242. planar/workflows/.step_core.py.un~ +0 -0
  243. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  244. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  245. planar/workflows/.test_concurrency.py.un~ +0 -0
  246. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  247. planar/workflows/.test_human.py.un~ +0 -0
  248. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  249. planar/workflows/.test_orchestrator.py.un~ +0 -0
  250. planar/workflows/.test_race_conditions.py.un~ +0 -0
  251. planar/workflows/.test_serialization.py.un~ +0 -0
  252. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  253. planar/workflows/.test_workflow.py.un~ +0 -0
  254. planar/workflows/.tracing.py.un~ +0 -0
  255. planar/workflows/.types.py.un~ +0 -0
  256. planar/workflows/.util.py.un~ +0 -0
  257. planar/workflows/.utils.py.un~ +0 -0
  258. planar/workflows/.workflow.py.un~ +0 -0
  259. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  260. planar/workflows/.wrappers.py.un~ +0 -0
  261. planar/workflows/__init__.py +42 -0
  262. planar/workflows/context.py +44 -0
  263. planar/workflows/contrib.py +190 -0
  264. planar/workflows/decorators.py +217 -0
  265. planar/workflows/events.py +185 -0
  266. planar/workflows/exceptions.py +34 -0
  267. planar/workflows/execution.py +198 -0
  268. planar/workflows/lock.py +229 -0
  269. planar/workflows/misc.py +5 -0
  270. planar/workflows/models.py +154 -0
  271. planar/workflows/notifications.py +96 -0
  272. planar/workflows/orchestrator.py +383 -0
  273. planar/workflows/query.py +256 -0
  274. planar/workflows/serialization.py +409 -0
  275. planar/workflows/step_core.py +373 -0
  276. planar/workflows/step_metadata.py +357 -0
  277. planar/workflows/step_testing_utils.py +86 -0
  278. planar/workflows/sub_workflow_runner.py +191 -0
  279. planar/workflows/test_concurrency_detection.py +120 -0
  280. planar/workflows/test_lock_timeout.py +140 -0
  281. planar/workflows/test_serialization.py +1195 -0
  282. planar/workflows/test_suspend_deserialization.py +231 -0
  283. planar/workflows/test_workflow.py +1967 -0
  284. planar/workflows/tracing.py +106 -0
  285. planar/workflows/wrappers.py +41 -0
  286. planar-0.5.0.dist-info/METADATA +285 -0
  287. planar-0.5.0.dist-info/RECORD +289 -0
  288. planar-0.5.0.dist-info/WHEEL +4 -0
  289. planar-0.5.0.dist-info/entry_points.txt +3 -0
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,42 @@
1
+ from .decorators import (
2
+ WorkflowWrapper,
3
+ as_step,
4
+ step,
5
+ workflow,
6
+ )
7
+ from .execution import (
8
+ execute,
9
+ )
10
+ from .models import (
11
+ LockedResource,
12
+ StepStatus,
13
+ Workflow,
14
+ WorkflowStep,
15
+ )
16
+ from .notifications import (
17
+ Notification,
18
+ WorkflowNotification,
19
+ WorkflowNotificationCallback,
20
+ workflow_notification_context,
21
+ )
22
+ from .orchestrator import WorkflowOrchestrator, orchestrator_context
23
+ from .step_core import suspend
24
+
25
+ __all__ = [
26
+ "WorkflowWrapper",
27
+ "workflow",
28
+ "step",
29
+ "as_step",
30
+ "suspend",
31
+ "execute",
32
+ "orchestrator_context",
33
+ "workflow_notification_context",
34
+ "Workflow",
35
+ "WorkflowStep",
36
+ "LockedResource",
37
+ "StepStatus",
38
+ "WorkflowOrchestrator",
39
+ "WorkflowNotificationCallback",
40
+ "WorkflowNotification",
41
+ "Notification",
42
+ ]
@@ -0,0 +1,44 @@
1
+ from dataclasses import dataclass, field
2
+ from uuid import UUID
3
+
4
+ from planar.task_local import TaskLocal
5
+ from planar.workflows.models import Workflow, WorkflowStep
6
+
7
+
8
+ @dataclass(kw_only=True)
9
+ class ExecutionContext:
10
+ workflow: Workflow
11
+ # This might seem redundant, but it is actually necessary to prevent
12
+ # implicit DB calls when accessing ctx.workflow.id which causes greenlet
13
+ # I/O errors with async SQLAlchemy. This can happen for example when a
14
+ # rollback is issued, which causes SQLAlchemy to expire the objects managed
15
+ # by the session.
16
+ workflow_id: UUID
17
+ current_step_id: int = 0
18
+ step_stack: list[WorkflowStep] = field(default_factory=list)
19
+ # The start_workflow helper (decorators.py) has
20
+ # the same parameters as the original function,
21
+ # so we can't pass this as an argument. Instead
22
+ # we use a context variable to signal that the
23
+ # started workflow should set this one as the
24
+ # parent.
25
+ bind_parent_workflow: bool = False
26
+
27
+
28
+ data: TaskLocal[ExecutionContext] = TaskLocal()
29
+
30
+
31
+ def in_context() -> bool:
32
+ return data.is_set()
33
+
34
+
35
+ def get_context() -> ExecutionContext:
36
+ return data.get()
37
+
38
+
39
+ def set_context(ctx: ExecutionContext):
40
+ return data.set(ctx)
41
+
42
+
43
+ def delete_context():
44
+ return data.clear()
@@ -0,0 +1,190 @@
1
+ from datetime import datetime, timedelta
2
+ from functools import wraps
3
+ from typing import Any, Callable, Coroutine, Dict
4
+
5
+ from planar.logging import get_logger
6
+ from planar.session import get_session
7
+ from planar.utils import P, T, U, utc_now
8
+ from planar.workflows import step
9
+ from planar.workflows.context import get_context
10
+ from planar.workflows.events import check_event_exists, get_latest_event
11
+ from planar.workflows.step_core import Suspend, suspend_workflow
12
+ from planar.workflows.tracing import trace
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ @step()
18
+ async def get_deadline(max_wait_time: float) -> datetime:
19
+ return utc_now() + timedelta(seconds=max_wait_time)
20
+
21
+
22
+ @step(display_name="Wait for event")
23
+ async def wait_for_event(
24
+ event_key: str,
25
+ max_wait_time: float = -1,
26
+ ) -> Dict[str, Any]:
27
+ """
28
+ Creates a durable step that waits for a specific event to be emitted.
29
+
30
+ Args:
31
+ event_key: The event identifier to wait for
32
+
33
+ Returns:
34
+ The event payload as a dictionary
35
+ """
36
+ logger.debug("waiting for event", event_key=event_key, max_wait_time=max_wait_time)
37
+ await trace("enter", event_key=event_key)
38
+
39
+ # Get workflow context
40
+ ctx = get_context()
41
+ workflow_id = ctx.workflow.id
42
+
43
+ deadline = None
44
+ if max_wait_time >= 0:
45
+ deadline = await get_deadline(max_wait_time)
46
+ logger.debug(
47
+ "calculated deadline for event", event_key=event_key, deadline=deadline
48
+ )
49
+ await trace(
50
+ "deadline",
51
+ event_key=event_key,
52
+ max_wait_time=max_wait_time,
53
+ deadline=deadline,
54
+ )
55
+
56
+ async def transaction():
57
+ # Check if the event already exists
58
+ event_exists = await check_event_exists(event_key, workflow_id=workflow_id)
59
+ logger.debug(
60
+ "event exists check for workflow",
61
+ event_key=event_key,
62
+ exists=event_exists,
63
+ )
64
+ await trace("check-event-exists", event_key=event_key, exists=event_exists)
65
+
66
+ if event_exists:
67
+ # Event exists, get the event data and continue execution immediately
68
+ event = await get_latest_event(event_key, workflow_id=workflow_id)
69
+ logger.info(
70
+ "event already exists, proceeding with payload",
71
+ event_key=event_key,
72
+ payload=event.payload if event else None,
73
+ )
74
+ await trace("existing-event", event_key=event_key)
75
+ return event.payload if event and event.payload else {}
76
+
77
+ # If deadline has passed, raise an exception
78
+ now = utc_now()
79
+ if deadline is not None and now > deadline:
80
+ logger.warning(
81
+ "timeout waiting for event",
82
+ event_key=event_key,
83
+ deadline=deadline,
84
+ current_time=now,
85
+ )
86
+ await trace("deadline-timeout", event_key=event_key)
87
+ raise ValueError(f"Timeout waiting for event ${event_key}")
88
+
89
+ logger.info(
90
+ "event not found, suspending workflow",
91
+ event_key=event_key,
92
+ )
93
+ return suspend_workflow(
94
+ interval=timedelta(seconds=max_wait_time) if max_wait_time > 0 else None,
95
+ event_key=event_key,
96
+ )
97
+
98
+ session = get_session()
99
+ result = await session.run_transaction(transaction)
100
+ if isinstance(result, Suspend):
101
+ # Suspend until event is emitted
102
+ logger.debug(
103
+ "workflow suspended, waiting for event",
104
+ event_key=event_key,
105
+ )
106
+ await trace("suspend", event_key=event_key)
107
+ await (
108
+ result
109
+ ) # This will re-raise the Suspend object's exception or re-enter the generator
110
+ assert False, "Suspend should never return normally" # Should not be reached
111
+ logger.info(
112
+ "event received or processed for workflow",
113
+ event_key=event_key,
114
+ result=result,
115
+ )
116
+ return result
117
+
118
+
119
+ def wait(
120
+ poll_interval: float = 60.0,
121
+ max_wait_time: float = 3600.0,
122
+ ):
123
+ """
124
+ Creates a durable step that repeatedly checks a condition until it returns True.
125
+
126
+ This decorator wraps a function that returns a boolean. The function will be
127
+ called repeatedly until it returns True or until max_wait_time is reached.
128
+
129
+ Args:
130
+ poll_interval: How often to check the condition
131
+ max_wait_time: Maximum time to wait before failing
132
+
133
+ Returns:
134
+ A decorator that converts a boolean-returning function into a step
135
+ that waits for the condition to be true
136
+ """
137
+
138
+ def decorator(
139
+ func: Callable[P, Coroutine[T, U, bool]],
140
+ ) -> Callable[P, Coroutine[T, U, None]]:
141
+ @step()
142
+ @wraps(func)
143
+ async def wait_step(*args: P.args, **kwargs: P.kwargs) -> None:
144
+ logger.debug(
145
+ "wait step called",
146
+ func_name=func.__name__,
147
+ poll_interval=poll_interval,
148
+ max_wait_time=max_wait_time,
149
+ )
150
+ # Set up deadline for timeout
151
+ deadline = None
152
+ if max_wait_time >= 0:
153
+ deadline = await get_deadline(max_wait_time)
154
+ logger.debug(
155
+ "calculated deadline for wait step",
156
+ func_name=func.__name__,
157
+ deadline=deadline,
158
+ )
159
+
160
+ # Check the condition
161
+ result = await func(*args, **kwargs)
162
+ logger.debug(
163
+ "condition check returned", func_name=func.__name__, result=result
164
+ )
165
+
166
+ # If condition is met, return immediately
167
+ if result:
168
+ logger.info("condition met, proceeding", func_name=func.__name__)
169
+ return
170
+
171
+ # If deadline has passed, raise an exception
172
+ if deadline is not None and utc_now() > deadline:
173
+ logger.warning(
174
+ "timeout waiting for condition to be met",
175
+ func_name=func.__name__,
176
+ deadline=deadline,
177
+ )
178
+ raise ValueError("Timeout waiting for condition to be met")
179
+
180
+ # Otherwise, suspend the workflow to retry later
181
+ logger.info(
182
+ "condition not met, suspending",
183
+ func_name=func.__name__,
184
+ poll_interval_seconds=poll_interval,
185
+ )
186
+ await suspend_workflow(interval=timedelta(seconds=poll_interval))
187
+
188
+ return wait_step
189
+
190
+ return decorator
@@ -0,0 +1,217 @@
1
+ import inspect
2
+ import weakref
3
+ from datetime import timedelta
4
+ from functools import wraps
5
+ from typing import Callable, Coroutine, Type, cast
6
+ from uuid import UUID
7
+ from weakref import WeakKeyDictionary
8
+
9
+ import planar.workflows.notifications as notifications
10
+ from planar.logging import get_logger
11
+ from planar.session import get_session
12
+ from planar.utils import P, R, T, U
13
+ from planar.workflows.context import (
14
+ get_context,
15
+ in_context,
16
+ )
17
+ from planar.workflows.execution import register_workflow_function
18
+ from planar.workflows.models import StepType, Workflow, WorkflowStatus
19
+ from planar.workflows.orchestrator import WorkflowOrchestrator
20
+ from planar.workflows.serialization import serialize_args
21
+ from planar.workflows.step_core import _step, suspend_workflow
22
+ from planar.workflows.wrappers import StepWrapper, WorkflowWrapper
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ def step(
28
+ *,
29
+ max_retries: int = 0,
30
+ step_type: StepType = StepType.COMPUTE,
31
+ return_type: Type | None = None,
32
+ display_name: str | None = None,
33
+ ):
34
+ """
35
+ Decorator to define a step in a workflow.
36
+
37
+ This decorator is used to define a step function.
38
+ It will register the function with the workflow engine and allow it to be executed.
39
+
40
+ """
41
+ step_decorator = _step(max_retries=max_retries, return_type=return_type)
42
+
43
+ def decorator(
44
+ func: Callable[P, Coroutine[T, U, R]],
45
+ step_type: StepType = step_type,
46
+ display_name: str | None = display_name,
47
+ ) -> StepWrapper[P, T, U, R]:
48
+ wrapper = step_decorator(func, step_type=step_type, display_name=display_name)
49
+
50
+ @workflow(name=func.__name__ + ".auto_workflow")
51
+ async def auto_workflow(*args: P.args, **kwargs: P.kwargs) -> R:
52
+ """
53
+ This is a special workflow that is used to run a step in a separate asyncio task
54
+ """
55
+ result = await wrapper(*args, **kwargs)
56
+ return result
57
+
58
+ @wraps(func)
59
+ def run_step(*args: P.args, **kwargs: P.kwargs) -> Coroutine[T, U, R]:
60
+ """
61
+ If not in workflow context, then simply call the function directly.
62
+
63
+ This allows users to use their workflow code within and outside of
64
+ workflows.
65
+ """
66
+ if not in_context():
67
+ return func(*args, **kwargs)
68
+ return wrapper(*args, **kwargs)
69
+
70
+ step_wrapper = StepWrapper(
71
+ original_fn=func,
72
+ wrapper=wrapper,
73
+ wrapped_fn=run_step,
74
+ auto_workflow=auto_workflow,
75
+ )
76
+ return step_wrapper
77
+
78
+ return decorator
79
+
80
+
81
+ def workflow(*, name: str | None = None):
82
+ """
83
+ Decorator to define a workflow.
84
+
85
+ This decorator is used to define a workflow function.
86
+ It will register the function with the workflow engine and allow it to be executed.
87
+
88
+ """
89
+
90
+ def decorator(func: Callable[P, Coroutine[T, U, R]]) -> WorkflowWrapper[P, T, U, R]:
91
+ if not inspect.iscoroutinefunction(func):
92
+ raise TypeError("Workflow functions must be coroutines")
93
+
94
+ function_name = name or func.__name__
95
+
96
+ @wraps(func)
97
+ async def start_workflow(*args: P.args, **kwargs: P.kwargs) -> Workflow:
98
+ session = get_session()
99
+ serialized_args, serialized_kwargs = serialize_args(func, args, kwargs)
100
+ workflow = Workflow(
101
+ function_name=function_name,
102
+ args=serialized_args,
103
+ kwargs=serialized_kwargs,
104
+ )
105
+
106
+ if in_context():
107
+ ctx = get_context()
108
+ if ctx.bind_parent_workflow:
109
+ logger.debug(
110
+ "binding parent workflow", parent_workflow_id=ctx.workflow_id
111
+ )
112
+ workflow.parent_id = ctx.workflow.id
113
+
114
+ session.add(workflow)
115
+ await session.commit()
116
+ notifications.workflow_started(workflow)
117
+ if WorkflowOrchestrator.is_set():
118
+ orchestrator = WorkflowOrchestrator.get()
119
+ orchestrator.poll_soon()
120
+ return workflow
121
+
122
+ @_step()
123
+ async def start_workflow_step(*args: P.args, **kwargs: P.kwargs) -> UUID:
124
+ workflow = await start_workflow(*args, **kwargs)
125
+ return workflow.id
126
+
127
+ async def wait_for_completion(workflow_id: UUID):
128
+ orchestrator = WorkflowOrchestrator.get()
129
+ return cast(R, await orchestrator.wait_for_completion(workflow_id))
130
+
131
+ async def run_workflow_in_new_context(*args: P.args, **kwargs: P.kwargs) -> R:
132
+ async with WorkflowOrchestrator.ensure_started():
133
+ # Running outside of a workflow execution context.
134
+ # Start workflow normally and wait for completion.
135
+ workflow = await start_workflow(*args, **kwargs)
136
+ workflow_id = workflow.id
137
+ return await wait_for_completion(workflow_id)
138
+
139
+ async def run_child_workflow(*args: P.args, **kwargs: P.kwargs) -> R:
140
+ ctx = get_context()
141
+ # Since the parent will wait, we have to
142
+ # create the association when creating
143
+ # the child
144
+ ctx.bind_parent_workflow = True
145
+ workflow_id = await start_workflow_step(*args, **kwargs)
146
+ # Considering the current workflow will
147
+ # suspend (this lose the context) it is
148
+ # not necessary to reset the flag here.
149
+ # Leaving it only for documenting intent
150
+ # that this is a temporary setting.
151
+ ctx.bind_parent_workflow = False
152
+ session = get_session()
153
+ async with session.begin_read():
154
+ workflow = await session.get(Workflow, workflow_id)
155
+ assert workflow
156
+
157
+ if workflow.status == WorkflowStatus.PENDING:
158
+ # Suspend for 0 seconds. Since the poll query only selects
159
+ # workflows that have no children, suspending for 0 seconds
160
+ # mean this workflow will wakeup as soon as all children finish
161
+ await suspend_workflow(interval=timedelta(seconds=0))
162
+
163
+ return await wait_for_completion(workflow_id)
164
+
165
+ @wraps(func)
166
+ def run_workflow(*args: P.args, **kwargs: P.kwargs) -> Coroutine[T, U, R]:
167
+ if not in_context():
168
+ return run_workflow_in_new_context(*args, **kwargs)
169
+ return run_child_workflow(*args, **kwargs)
170
+
171
+ register_workflow_function(function_name, func)
172
+
173
+ wf_wrapper = WorkflowWrapper(
174
+ function_name=function_name,
175
+ original_fn=func,
176
+ start=start_workflow,
177
+ start_step=start_workflow_step,
178
+ wait_for_completion=wait_for_completion,
179
+ wrapped_fn=run_workflow,
180
+ )
181
+
182
+ return wf_wrapper
183
+
184
+ return decorator
185
+
186
+
187
+ __AS_STEP_CACHE = WeakKeyDictionary()
188
+
189
+
190
+ def __is_workflow_step(callable: Callable[P, Coroutine[T, U, R]]) -> bool:
191
+ return isinstance(callable, StepWrapper)
192
+
193
+
194
+ def as_step(
195
+ func: Callable[P, Coroutine[T, U, R]],
196
+ step_type: StepType,
197
+ display_name: str | None = None,
198
+ return_type: Type[R] | None = None,
199
+ ) -> Callable[P, Coroutine[T, U, R]]:
200
+ """
201
+ This utility fn is for treating async functions as steps without modifying
202
+ the original function.
203
+
204
+ Only use this where it doesn't make sense to use the @step decorator (such as third
205
+ party functions)
206
+ """
207
+ if __is_workflow_step(func):
208
+ return func
209
+ # cache the result to avoid reapplying the step decorator for this callable in the future
210
+ weak_step_callable = __AS_STEP_CACHE.get(func, None)
211
+ step_callable = weak_step_callable() if weak_step_callable is not None else None
212
+ if step_callable is None:
213
+ step_callable = step(return_type=return_type)(
214
+ func, step_type=step_type, display_name=display_name
215
+ )
216
+ __AS_STEP_CACHE[func] = weakref.ref(step_callable)
217
+ return step_callable