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
@@ -0,0 +1,468 @@
1
+ from datetime import timedelta
2
+ from uuid import UUID
3
+
4
+ from fastapi import APIRouter, Body, Depends, HTTPException
5
+ from sqlmodel import select
6
+
7
+ from planar.modeling.orm.query_filter_builder import build_paginated_query
8
+ from planar.object_registry import ObjectRegistry
9
+ from planar.routers.event import create_workflow_event_routes
10
+ from planar.routers.models import (
11
+ SortDirection,
12
+ WorkflowDefinition,
13
+ WorkflowList,
14
+ WorkflowRun,
15
+ WorkflowRunList,
16
+ WorkflowRunStatusCounts,
17
+ WorkflowStartResponse,
18
+ WorkflowStatusResponse,
19
+ WorkflowStepInfo,
20
+ WorkflowStepList,
21
+ )
22
+ from planar.security.authorization import (
23
+ WorkflowAction,
24
+ WorkflowResource,
25
+ validate_authorization_for,
26
+ )
27
+ from planar.session import get_session
28
+ from planar.utils import utc_now
29
+ from planar.workflows import LockedResource, Workflow, WorkflowStep
30
+ from planar.workflows.models import (
31
+ StepStatus,
32
+ WorkflowStatus,
33
+ workflow_lock_join_cond,
34
+ )
35
+ from planar.workflows.query import (
36
+ build_effective_status_case,
37
+ calculate_bulk_workflow_duration_stats,
38
+ calculate_effective_status,
39
+ calculate_workflow_duration_stats,
40
+ get_bulk_workflow_run_statuses,
41
+ get_workflow_run_statuses,
42
+ )
43
+ from planar.workflows.step_metadata import get_steps_metadata
44
+
45
+
46
+ def create_workflow_router(
47
+ registry: ObjectRegistry,
48
+ ) -> APIRouter:
49
+ router = APIRouter(tags=["Workflow Management"])
50
+
51
+ create_workflow_event_routes(router)
52
+
53
+ async def get_validated_body(workflow_name: str, body: dict = Body(...)):
54
+ workflow = next(
55
+ (wf for wf in registry.get_workflows() if wf.name == workflow_name), None
56
+ )
57
+ if not workflow:
58
+ raise HTTPException(
59
+ status_code=404, detail=f"Workflow '{workflow_name}' not found"
60
+ )
61
+
62
+ try:
63
+ # Validate the request body against the model
64
+ validated_body = workflow.pydantic_model(**body)
65
+
66
+ return {"body": validated_body, "start_fn": workflow.obj.start}
67
+ except Exception as e:
68
+ raise HTTPException(
69
+ status_code=422,
70
+ detail=f"Invalid request body for workflow '{workflow_name}': {str(e)}\nJSON Schema:\n{workflow.pydantic_model.model_json_schema()}",
71
+ )
72
+
73
+ @router.post("/{workflow_name}/start", response_model=WorkflowStartResponse)
74
+ async def start_workflow(workflow_name: str, wf_data=Depends(get_validated_body)):
75
+ validate_authorization_for(
76
+ WorkflowResource(function_name=workflow_name), WorkflowAction.WORKFLOW_RUN
77
+ )
78
+ workflow = await wf_data["start_fn"](**wf_data["body"].model_dump(mode="json"))
79
+ return WorkflowStartResponse(id=workflow.id)
80
+
81
+ @router.get("/{run_id}/status", response_model=WorkflowStatusResponse)
82
+ async def get_workflow_status(run_id: UUID):
83
+ session = get_session()
84
+ workflow = await session.get(Workflow, run_id)
85
+ if not workflow:
86
+ raise HTTPException(
87
+ status_code=404, detail=f"Workflow run with id {run_id} not found"
88
+ )
89
+ validate_authorization_for(
90
+ WorkflowResource(function_name=workflow.function_name),
91
+ WorkflowAction.WORKFLOW_VIEW_DETAILS,
92
+ )
93
+ return WorkflowStatusResponse(workflow=workflow)
94
+
95
+ @router.get("/", response_model=WorkflowList)
96
+ async def list_workflows(
97
+ seconds_ago: int | None = None,
98
+ offset: int | None = 0,
99
+ limit: int | None = 10,
100
+ ):
101
+ """
102
+ Note that workflows are registered with the app using the `.register_workflow` method.
103
+
104
+ Hence, we do not need to query the database to get the list of workflows.
105
+
106
+ The workflow database tables track workflow RUNS.
107
+ """
108
+ # Check list permission on any workflow since we're listing all workflows
109
+ validate_authorization_for(WorkflowResource(), WorkflowAction.WORKFLOW_LIST)
110
+
111
+ session = get_session()
112
+
113
+ # Prepare filters
114
+ filters = []
115
+
116
+ if seconds_ago:
117
+ filters.append(
118
+ (
119
+ Workflow.created_at,
120
+ ">=",
121
+ utc_now() - timedelta(seconds=seconds_ago),
122
+ )
123
+ )
124
+
125
+ # Get stats for each workflow
126
+ items = []
127
+ all_workflows = registry.get_workflows()
128
+
129
+ end_offset = limit
130
+ if offset is not None:
131
+ end_offset = offset + (limit or 0)
132
+
133
+ workflows = all_workflows[offset or 0 : end_offset]
134
+
135
+ # Bulk fetch all status counts and duration stats in 2 queries instead of 2*N queries
136
+ workflow_names = [wf.name for wf in workflows]
137
+ bulk_run_statuses = await get_bulk_workflow_run_statuses(
138
+ workflow_names, session, filters
139
+ )
140
+ bulk_duration_stats = await calculate_bulk_workflow_duration_stats(
141
+ workflow_names, session, filters
142
+ )
143
+
144
+ for workflow in workflows:
145
+ # Get docstring description if available
146
+ name = workflow.name.split(".")[-1] # Get last part of function name
147
+ description = workflow.description
148
+
149
+ run_statuses = bulk_run_statuses.get(workflow.name, {})
150
+ duration_stats = bulk_duration_stats.get(workflow.name)
151
+
152
+ items.append(
153
+ WorkflowDefinition(
154
+ fully_qualified_name=workflow.name,
155
+ name=name,
156
+ description=description,
157
+ input_schema=workflow.input_schema,
158
+ output_schema=workflow.output_schema,
159
+ total_runs=sum(run_statuses.values()),
160
+ run_statuses=WorkflowRunStatusCounts(
161
+ **{
162
+ status.value: count
163
+ for status, count in run_statuses.items()
164
+ }
165
+ ),
166
+ durations=duration_stats,
167
+ )
168
+ )
169
+
170
+ return WorkflowList(items=items, total=len(items), offset=offset, limit=limit)
171
+
172
+ @router.get("/{workflow_name}", response_model=WorkflowDefinition)
173
+ async def get_workflow_stats(workflow_name: str):
174
+ validate_authorization_for(
175
+ WorkflowResource(function_name=workflow_name),
176
+ WorkflowAction.WORKFLOW_VIEW_DETAILS,
177
+ )
178
+ session = get_session()
179
+
180
+ wf = next(
181
+ (wf for wf in registry.get_workflows() if wf.name == workflow_name), None
182
+ )
183
+
184
+ # Check if workflow exists in registry
185
+ if not wf:
186
+ raise HTTPException(
187
+ status_code=404, detail=f"Workflow '{workflow_name}' not found"
188
+ )
189
+
190
+ description = wf.description
191
+ name = wf.name.split(".")[-1] # Get last part of function name
192
+
193
+ run_statuses = await get_workflow_run_statuses(workflow_name, session)
194
+ duration_stats = await calculate_workflow_duration_stats(session, workflow_name)
195
+
196
+ return WorkflowDefinition(
197
+ fully_qualified_name=workflow_name,
198
+ name=name,
199
+ description=description,
200
+ input_schema=wf.input_schema,
201
+ output_schema=wf.output_schema,
202
+ total_runs=sum(run_statuses.values()),
203
+ run_statuses=WorkflowRunStatusCounts(
204
+ **{status.value: count for status, count in run_statuses.items()}
205
+ ),
206
+ durations=duration_stats,
207
+ )
208
+
209
+ @router.get("/{workflow_name}/runs", response_model=WorkflowRunList)
210
+ async def list_workflow_runs(
211
+ workflow_name: str,
212
+ status: WorkflowStatus | None = None,
213
+ offset: int | None = 0,
214
+ limit: int | None = 10,
215
+ ):
216
+ validate_authorization_for(
217
+ WorkflowResource(function_name=workflow_name),
218
+ WorkflowAction.WORKFLOW_VIEW_DETAILS,
219
+ )
220
+ session = get_session()
221
+
222
+ # Build query with virtual status calculation
223
+ effective_status_expr = build_effective_status_case().label("effective_status")
224
+ base_query = (
225
+ select( # type: ignore[misc]
226
+ Workflow.id,
227
+ Workflow.args,
228
+ Workflow.kwargs,
229
+ Workflow.result,
230
+ Workflow.error,
231
+ Workflow.created_at,
232
+ Workflow.updated_at,
233
+ effective_status_expr,
234
+ )
235
+ .select_from(Workflow)
236
+ .outerjoin(LockedResource, workflow_lock_join_cond())
237
+ .where(Workflow.function_name == workflow_name)
238
+ )
239
+
240
+ # Prepare filters - can filter on effective status using SQL
241
+ filters = []
242
+ if status:
243
+ # Add a filter on the effective_status expression directly
244
+ effective_status_expr = build_effective_status_case()
245
+ filters.append((effective_status_expr, "==", status.value))
246
+
247
+ # Apply filtering, pagination and ordering
248
+ query, total_query = build_paginated_query(
249
+ base_query,
250
+ filters=filters,
251
+ offset=offset,
252
+ limit=limit,
253
+ order_by=Workflow.created_at,
254
+ order_direction=SortDirection.DESC,
255
+ )
256
+
257
+ # Calculate total count
258
+ total = (await session.exec(total_query)).one()
259
+
260
+ # Execute the query
261
+ results = (await session.exec(query)).all()
262
+
263
+ items = [
264
+ WorkflowRun(
265
+ id=row.id,
266
+ status=row.effective_status,
267
+ args=row.args,
268
+ kwargs=row.kwargs,
269
+ result=row.result,
270
+ error=row.error,
271
+ created_at=row.created_at,
272
+ updated_at=row.updated_at,
273
+ )
274
+ for row in results
275
+ ]
276
+
277
+ return WorkflowRunList(items=items, total=total, offset=offset, limit=limit)
278
+
279
+ @router.get("/{workflow_name}/runs/{run_id}", response_model=WorkflowRun)
280
+ async def get_workflow_run(workflow_name: str, run_id: UUID):
281
+ validate_authorization_for(
282
+ WorkflowResource(function_name=workflow_name),
283
+ WorkflowAction.WORKFLOW_VIEW_DETAILS,
284
+ )
285
+ session = get_session()
286
+ workflow = (
287
+ await session.exec(
288
+ select(Workflow).where(
289
+ Workflow.function_name == workflow_name, Workflow.id == run_id
290
+ )
291
+ )
292
+ ).first()
293
+
294
+ if not workflow:
295
+ raise HTTPException(
296
+ status_code=404,
297
+ detail=f"Workflow run with id {run_id} not found for workflow {workflow_name}",
298
+ )
299
+
300
+ effective_status = await calculate_effective_status(session, workflow)
301
+
302
+ return WorkflowRun(
303
+ id=workflow.id,
304
+ status=effective_status,
305
+ args=workflow.args,
306
+ kwargs=workflow.kwargs,
307
+ result=workflow.result,
308
+ error=workflow.error,
309
+ created_at=workflow.created_at,
310
+ updated_at=workflow.updated_at,
311
+ )
312
+
313
+ @router.get("/{workflow_name}/runs/{run_id}/steps", response_model=WorkflowStepList)
314
+ async def list_workflow_steps(
315
+ workflow_name: str,
316
+ run_id: UUID,
317
+ status: StepStatus | None = None,
318
+ step_type: str | None = None,
319
+ offset: int | None = 0,
320
+ limit: int | None = 10,
321
+ ):
322
+ validate_authorization_for(
323
+ WorkflowResource(function_name=workflow_name),
324
+ WorkflowAction.WORKFLOW_VIEW_DETAILS,
325
+ )
326
+ """
327
+ List workflow steps with optional filtering.
328
+
329
+ Returns rich metadata for each step based on its type in the 'meta' field.
330
+ """
331
+ session = get_session()
332
+
333
+ # First verify the workflow exists
334
+ workflow = (
335
+ await session.exec(
336
+ select(Workflow).where(
337
+ Workflow.function_name == workflow_name, Workflow.id == run_id
338
+ )
339
+ )
340
+ ).first()
341
+
342
+ if not workflow:
343
+ raise HTTPException(
344
+ status_code=404,
345
+ detail=f"Workflow run with id {run_id} not found for workflow {workflow_name}",
346
+ )
347
+
348
+ # Build base query for steps
349
+ base_query = select(WorkflowStep).where(WorkflowStep.workflow_id == run_id)
350
+
351
+ # Prepare filters
352
+ filters = []
353
+
354
+ # Add status filter if provided
355
+ if status:
356
+ filters.append((WorkflowStep.status, "==", status))
357
+
358
+ # Add step type filter if provided
359
+ if step_type:
360
+ filters.append((WorkflowStep.step_type, "==", step_type))
361
+
362
+ # Apply filtering, pagination and ordering
363
+ query, total_query = build_paginated_query(
364
+ base_query,
365
+ filters=filters,
366
+ offset=offset,
367
+ limit=limit,
368
+ order_by=WorkflowStep.step_id,
369
+ order_direction=SortDirection.ASC,
370
+ )
371
+
372
+ # Calculate total count
373
+ total = (await session.exec(total_query)).one()
374
+
375
+ steps: list[WorkflowStep] = (await session.exec(query)).all()
376
+
377
+ # Create step info objects with metadata
378
+ items = []
379
+ for step in steps:
380
+ # Create the base step info object
381
+ step_info = WorkflowStepInfo(
382
+ step_id=step.step_id,
383
+ parent_step_id=step.parent_step_id,
384
+ workflow_id=step.workflow_id,
385
+ function_name=step.function_name,
386
+ display_name=WorkflowStepInfo.get_display_name(
387
+ step.display_name, step.function_name
388
+ ),
389
+ description=None, # get_function_docs(step.function_name),
390
+ status=step.status,
391
+ step_type=step.step_type,
392
+ args=step.args,
393
+ kwargs=step.kwargs,
394
+ result=step.result,
395
+ error=step.error,
396
+ retry_count=step.retry_count,
397
+ created_at=step.created_at,
398
+ updated_at=step.updated_at,
399
+ meta=None,
400
+ )
401
+
402
+ items.append(step_info)
403
+
404
+ return WorkflowStepList(items=items, total=total, offset=offset, limit=limit)
405
+
406
+ @router.get(
407
+ "/{workflow_name}/runs/{run_id}/steps/{step_id}",
408
+ response_model=WorkflowStepInfo,
409
+ )
410
+ async def get_workflow_step(
411
+ workflow_name: str,
412
+ run_id: UUID,
413
+ step_id: int,
414
+ ):
415
+ validate_authorization_for(
416
+ WorkflowResource(function_name=workflow_name),
417
+ WorkflowAction.WORKFLOW_VIEW_DETAILS,
418
+ )
419
+ """
420
+ Get metadata for a specific workflow step.
421
+ """
422
+ session = get_session()
423
+
424
+ # Build base query for steps
425
+ async with session.begin_read():
426
+ step = (
427
+ await session.exec(
428
+ select(WorkflowStep).where(
429
+ WorkflowStep.workflow_id == run_id,
430
+ WorkflowStep.step_id == step_id,
431
+ )
432
+ )
433
+ ).first()
434
+
435
+ if not step:
436
+ raise HTTPException(
437
+ status_code=404,
438
+ detail=f"Workflow step with id {step_id} not found for workflow run {run_id}",
439
+ )
440
+
441
+ metadata = await get_steps_metadata([step], registry)
442
+
443
+ # Create step info objects with metadata
444
+ step_info = WorkflowStepInfo(
445
+ step_id=step.step_id,
446
+ parent_step_id=step.parent_step_id,
447
+ workflow_id=step.workflow_id,
448
+ function_name=step.function_name,
449
+ display_name=WorkflowStepInfo.get_display_name(
450
+ step.display_name, step.function_name
451
+ ),
452
+ description=None, # get_function_docs(step.function_name),
453
+ status=step.status,
454
+ step_type=step.step_type,
455
+ args=step.args,
456
+ kwargs=step.kwargs,
457
+ result=step.result,
458
+ error=step.error,
459
+ retry_count=step.retry_count,
460
+ created_at=step.created_at,
461
+ updated_at=step.updated_at,
462
+ # Compute steps do not produce metadata, so use .get to avoid KeyError
463
+ meta=metadata.get(step.step_id),
464
+ )
465
+
466
+ return step_info
467
+
468
+ return router
Binary file
Binary file
Binary file
@@ -0,0 +1,23 @@
1
+ import importlib
2
+ from typing import Any
3
+
4
+ _DEFERRED_IMPORTS = {
5
+ "rule": ".decorator",
6
+ "Rule": ".models",
7
+ "RuleSerializeable": ".models",
8
+ }
9
+
10
+
11
+ def __getattr__(name: str) -> Any:
12
+ """
13
+ Lazily import modules to avoid circular dependencies.
14
+ This is called by the Python interpreter when a module attribute is accessed
15
+ that cannot be found in the module's __dict__.
16
+ PEP 562
17
+ """
18
+ if name in _DEFERRED_IMPORTS:
19
+ module_path = _DEFERRED_IMPORTS[name]
20
+ module = importlib.import_module(module_path, __name__)
21
+ return getattr(module, name)
22
+
23
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from datetime import datetime
5
+ from functools import wraps
6
+ from typing import Any, Callable, Coroutine, Type, TypeVar, cast
7
+ from uuid import UUID
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from planar.logging import get_logger
12
+ from planar.rules.models import Rule
13
+ from planar.rules.rule_configuration import rule_configuration
14
+ from planar.rules.runner import EvaluateResponse, evaluate_rule
15
+ from planar.workflows.decorators import step
16
+ from planar.workflows.models import StepType
17
+
18
+ logger = get_logger(__name__)
19
+
20
+ RULE_REGISTRY = {}
21
+
22
+ # Define type variables for input and output BaseModel types
23
+ T = TypeVar("T", bound=BaseModel)
24
+ U = TypeVar("U", bound=BaseModel)
25
+
26
+
27
+ def serialize_for_rule_evaluation(obj: Any) -> Any:
28
+ """
29
+ Custom serializer that converts Pydantic model_dump() to a format that can be
30
+ interpreted by the rule engine.
31
+ """
32
+ if isinstance(obj, UUID):
33
+ return str(obj)
34
+ if isinstance(obj, datetime):
35
+ # Zen rule engine throws an error if the datetime does not include timezone
36
+ # ie. `"2025-05-27T00:21:44.802433" is not a "date-time"`
37
+ return obj.isoformat() + "Z" if obj.tzinfo is None else obj.isoformat()
38
+ elif isinstance(obj, dict):
39
+ return {key: serialize_for_rule_evaluation(value) for key, value in obj.items()}
40
+ elif isinstance(obj, (list, tuple)):
41
+ return [serialize_for_rule_evaluation(item) for item in obj]
42
+ else:
43
+ return obj
44
+
45
+
46
+ #### Decorator
47
+ def rule(*, description: str):
48
+ def _get_input_and_return_types(
49
+ func: Callable,
50
+ ) -> tuple[Type[BaseModel], Type[BaseModel]]:
51
+ """
52
+ Validates that a rule method has proper type annotations.
53
+ Returns a tuple of (input_type, return_type).
54
+ """
55
+
56
+ # Get function parameters using inspect module
57
+ signature = inspect.signature(func)
58
+ params = list(signature.parameters.keys())
59
+
60
+ if len(params) != 1 or "self" in params:
61
+ err_msg = (
62
+ "@rule method must have exactly one input argument (and cannot be self)"
63
+ )
64
+ logger.warning(
65
+ "rule definition error", function_name=func.__name__, error=err_msg
66
+ )
67
+ raise ValueError(err_msg)
68
+
69
+ # Check for missing annotations using signature
70
+ missing_annotations = [
71
+ p
72
+ for p in params
73
+ if signature.parameters[p].annotation == inspect.Parameter.empty
74
+ ]
75
+ if missing_annotations:
76
+ err_msg = (
77
+ f"Missing annotations for parameters: {', '.join(missing_annotations)}"
78
+ )
79
+ logger.warning(
80
+ "rule definition error", function_name=func.__name__, error=err_msg
81
+ )
82
+ raise ValueError(err_msg)
83
+
84
+ if signature.return_annotation == inspect.Signature.empty:
85
+ err_msg = "@rule method must have a return type annotation"
86
+ logger.warning(
87
+ "rule definition error", function_name=func.__name__, error=err_msg
88
+ )
89
+ raise ValueError(err_msg)
90
+
91
+ param_name = params[0]
92
+ input_type = signature.parameters[param_name].annotation
93
+ return_type = signature.return_annotation
94
+
95
+ # Ensure both input and return types are pydantic BaseModels
96
+ if not issubclass(input_type, BaseModel):
97
+ err_msg = f"Input type {input_type.__name__} must be a pydantic BaseModel"
98
+ logger.warning(
99
+ "rule definition error", function_name=func.__name__, error=err_msg
100
+ )
101
+ raise ValueError(err_msg)
102
+ if not issubclass(return_type, BaseModel):
103
+ err_msg = f"Return type {return_type.__name__} must be a pydantic BaseModel"
104
+ logger.warning(
105
+ "rule definition error", function_name=func.__name__, error=err_msg
106
+ )
107
+ raise ValueError(err_msg)
108
+
109
+ return input_type, return_type
110
+
111
+ def decorator(func: Callable[[T], U]) -> Callable[[T], Coroutine[Any, Any, U]]:
112
+ input_type, return_type = _get_input_and_return_types(func)
113
+
114
+ rule = Rule(
115
+ name=func.__name__,
116
+ description=description,
117
+ input=input_type,
118
+ output=return_type,
119
+ )
120
+
121
+ RULE_REGISTRY[func.__name__] = rule
122
+ logger.debug("registered rule", rule_name=func.__name__)
123
+
124
+ @step(step_type=StepType.RULE)
125
+ @wraps(func)
126
+ async def wrapper(input: T) -> U:
127
+ logger.debug(
128
+ "executing rule", rule_name=func.__name__, input_type=type(input)
129
+ )
130
+ # Look up any existing decision override for this function name
131
+ override_result = await rule_configuration.read_configs_with_default(
132
+ func.__name__, rule.to_config()
133
+ )
134
+
135
+ active_config = next(
136
+ (config for config in override_result if config.active), None
137
+ )
138
+
139
+ if not active_config:
140
+ raise ValueError(
141
+ f"No active configuration found for rule {func.__name__}"
142
+ )
143
+
144
+ logger.debug(
145
+ "active config for rule",
146
+ rule_name=func.__name__,
147
+ version=active_config.version,
148
+ )
149
+
150
+ if active_config.version == 0:
151
+ logger.info(
152
+ "using default python implementation for rule",
153
+ rule_name=func.__name__,
154
+ )
155
+ # default implementation
156
+ return func(input)
157
+ else:
158
+ logger.info(
159
+ "using jdm override for rule",
160
+ version=active_config.version,
161
+ rule_name=func.__name__,
162
+ )
163
+ serialized_input = serialize_for_rule_evaluation(input.model_dump())
164
+ evaluation_response = evaluate_rule(
165
+ active_config.data.jdm, serialized_input
166
+ )
167
+ if isinstance(evaluation_response, EvaluateResponse):
168
+ result_model = return_type.model_validate(
169
+ evaluation_response.result
170
+ )
171
+ return cast(U, result_model)
172
+ else:
173
+ logger.warning(
174
+ "rule evaluation error",
175
+ rule_name=func.__name__,
176
+ message=evaluation_response.message,
177
+ )
178
+ raise Exception(evaluation_response.message)
179
+
180
+ wrapper.__rule__ = rule # type: ignore
181
+
182
+ return wrapper
183
+
184
+ return decorator