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,274 @@
1
+ from uuid import UUID, uuid4
2
+
3
+ import pytest
4
+ from pydantic import BaseModel, Field
5
+ from sqlmodel import select
6
+ from sqlmodel.ext.asyncio.session import AsyncSession
7
+
8
+ from examples.expense_approval_workflow.models import (
9
+ Expense,
10
+ ExpenseStatus,
11
+ )
12
+ from planar import PlanarApp, get_session, sqlite_config
13
+ from planar.files.models import PlanarFile, PlanarFileMetadata
14
+ from planar.files.storage.base import Storage
15
+ from planar.testing.planar_test_client import PlanarTestClient
16
+ from planar.testing.workflow_observer import WorkflowObserver
17
+ from planar.workflows import step, workflow
18
+ from planar.workflows.models import StepType, Workflow, WorkflowStatus, WorkflowStep
19
+
20
+ # ------ SETUP ------
21
+
22
+
23
+ async def get_expense(expense_id: str) -> Expense:
24
+ session = get_session()
25
+ expense = (
26
+ await session.exec(select(Expense).where(Expense.id == UUID(expense_id)))
27
+ ).first()
28
+ if not expense:
29
+ raise ValueError(f"Expense {expense_id} not found")
30
+ return expense
31
+
32
+
33
+ @workflow(name="test_expense_approval_workflow")
34
+ async def expense_approval_workflow(expense_id: str):
35
+ """
36
+ Main workflow that orchestrates the expense approval process
37
+ """
38
+ await validate_expense(expense_id)
39
+
40
+ expense = await get_expense(expense_id)
41
+
42
+ return expense
43
+
44
+
45
+ @step()
46
+ async def validate_expense(expense_id: str):
47
+ expense = await get_expense(expense_id)
48
+
49
+ if expense.status != ExpenseStatus.SUBMITTED:
50
+ raise ValueError(f"Expense {expense_id} is not in SUBMITTED status")
51
+
52
+
53
+ class FileProcessingResult(BaseModel):
54
+ """Result of processing a text file."""
55
+
56
+ filename: str = Field(description="Original filename")
57
+ character_count: int = Field(description="Number of characters in the file")
58
+ content_preview: str = Field(description="Preview of the file content")
59
+ file_id: UUID = Field(description="ID of the processed file")
60
+
61
+
62
+ @workflow(name="test_file_processing_workflow")
63
+ async def file_processing_workflow(file: PlanarFile):
64
+ """
65
+ Workflow that processes a text file and returns basic information about it.
66
+ """
67
+ file_content = await file.get_content()
68
+ char_count = len(file_content)
69
+ preview = file_content[:100].decode("utf-8")
70
+
71
+ # Return structured result
72
+ return FileProcessingResult(
73
+ filename=file.filename,
74
+ character_count=char_count,
75
+ content_preview=preview,
76
+ file_id=file.id,
77
+ )
78
+
79
+
80
+ app = PlanarApp(
81
+ config=sqlite_config("test_workflow_router.db"),
82
+ title="Test Workflow Router API",
83
+ description="API for testing workflow routers",
84
+ )
85
+
86
+
87
+ # ------ TESTS ------
88
+
89
+
90
+ @pytest.fixture(name="app")
91
+ def app_fixture():
92
+ # Re-register workflows since ObjectRegistry gets reset before each test
93
+ app.register_workflow(expense_approval_workflow)
94
+ app.register_workflow(file_processing_workflow)
95
+ yield app
96
+
97
+
98
+ @pytest.fixture
99
+ async def planar_file(storage: Storage) -> PlanarFile:
100
+ """Create a PlanarFile instance for testing."""
101
+ # Store test content
102
+ test_data = b"This is a test file for the workflow router API test."
103
+ mime_type = "text/plain"
104
+
105
+ # Store the file and get a reference
106
+ storage_ref = await storage.put_bytes(test_data, mime_type=mime_type)
107
+
108
+ # Create and store the file metadata
109
+ session = get_session()
110
+ file_metadata = PlanarFileMetadata(
111
+ filename="router_test_file.txt",
112
+ content_type=mime_type,
113
+ size=len(test_data),
114
+ storage_ref=storage_ref,
115
+ )
116
+ session.add(file_metadata)
117
+ await session.commit()
118
+
119
+ # Return a PlanarFile reference (not the full metadata)
120
+ return PlanarFile(
121
+ id=file_metadata.id,
122
+ filename=file_metadata.filename,
123
+ content_type=file_metadata.content_type,
124
+ size=file_metadata.size,
125
+ )
126
+
127
+
128
+ async def test_list_workflows(client: PlanarTestClient):
129
+ """
130
+ Test that the workflow management router correctly lists registered workflows.
131
+ """
132
+ # Call the workflow management endpoint to list workflows
133
+ response = await client.get("/planar/v1/workflows/")
134
+
135
+ # Verify the response status code
136
+ assert response.status_code == 200
137
+
138
+ # Parse the response data
139
+ data = response.json()
140
+
141
+ # Verify that two workflows are returned
142
+ assert data["total"] == 2
143
+ assert len(data["items"]) == 2
144
+
145
+ assert data["offset"] == 0
146
+ assert data["limit"] == 10
147
+
148
+ # Verify the expense workflow details
149
+ expense_workflow = next(
150
+ item
151
+ for item in data["items"]
152
+ if item["name"] == "test_expense_approval_workflow"
153
+ )
154
+ assert expense_workflow["fully_qualified_name"] == "test_expense_approval_workflow"
155
+ assert (
156
+ "Main workflow that orchestrates the expense approval process"
157
+ in expense_workflow["description"]
158
+ )
159
+
160
+ # Verify the file workflow details
161
+ file_workflow = next(
162
+ item
163
+ for item in data["items"]
164
+ if item["name"] == "test_file_processing_workflow"
165
+ )
166
+ assert file_workflow["fully_qualified_name"] == "test_file_processing_workflow"
167
+ assert "Workflow that processes a text file" in file_workflow["description"]
168
+
169
+ # Verify that the workflows have input and output schemas
170
+ assert "input_schema" in expense_workflow
171
+ assert "output_schema" in expense_workflow
172
+ assert "input_schema" in file_workflow
173
+ assert "output_schema" in file_workflow
174
+
175
+ # Verify that the file workflow input schema includes file parameter
176
+ assert "file" in file_workflow["input_schema"]["properties"]
177
+
178
+ # Verify run statistics are present
179
+ assert "total_runs" in expense_workflow
180
+ assert "run_statuses" in expense_workflow
181
+ assert "total_runs" in file_workflow
182
+ assert "run_statuses" in file_workflow
183
+
184
+
185
+ async def test_start_file_workflow(
186
+ client: PlanarTestClient,
187
+ planar_file: PlanarFile,
188
+ observer: WorkflowObserver,
189
+ session: AsyncSession,
190
+ ):
191
+ """Test starting a workflow with a PlanarFile through the API."""
192
+ # Prepare the request payload with the file reference
193
+ payload = {
194
+ "file": {
195
+ "id": str(planar_file.id),
196
+ "filename": planar_file.filename,
197
+ "content_type": planar_file.content_type,
198
+ "size": planar_file.size,
199
+ }
200
+ }
201
+
202
+ response = await client.post(
203
+ "/planar/v1/workflows/test_file_processing_workflow/start",
204
+ json=payload,
205
+ )
206
+
207
+ # Verify the response status code
208
+ assert response.status_code == 200
209
+
210
+ data = response.json()
211
+
212
+ assert "id" in data
213
+ workflow_id = data["id"]
214
+
215
+ await observer.wait("workflow-succeeded", workflow_id=workflow_id)
216
+
217
+ workflow = await session.get(Workflow, UUID(workflow_id))
218
+ await session.commit()
219
+ assert workflow
220
+
221
+ # Verify the workflow completed successfully
222
+ assert workflow.status == WorkflowStatus.SUCCEEDED
223
+
224
+ # Check the workflow result
225
+ result = workflow.result
226
+ assert result
227
+ assert result["filename"] == planar_file.filename
228
+ assert result["character_count"] == planar_file.size
229
+ assert "This is a test file" in result["content_preview"]
230
+ assert result["file_id"] == str(planar_file.id)
231
+
232
+
233
+ async def test_get_compute_step(
234
+ client: PlanarTestClient, session: AsyncSession, observer: WorkflowObserver
235
+ ):
236
+ """Ensure compute steps can be retrieved without metadata."""
237
+
238
+ expense = Expense(
239
+ title="Test Expense",
240
+ amount=100.0,
241
+ description="test",
242
+ status=ExpenseStatus.SUBMITTED,
243
+ submitter_id=uuid4(),
244
+ category="misc",
245
+ )
246
+ session.add(expense)
247
+ await session.commit()
248
+
249
+ payload = {"expense_id": str(expense.id)}
250
+ resp = await client.post(
251
+ "/planar/v1/workflows/test_expense_approval_workflow/start",
252
+ json=payload,
253
+ )
254
+ assert resp.status_code == 200
255
+ wf_id = resp.json()["id"]
256
+
257
+ await observer.wait("workflow-succeeded", workflow_id=wf_id)
258
+
259
+ step = (
260
+ await session.exec(
261
+ select(WorkflowStep).where(WorkflowStep.workflow_id == UUID(wf_id))
262
+ )
263
+ ).first()
264
+ await session.commit()
265
+ assert step
266
+ assert step.step_type == StepType.COMPUTE
267
+
268
+ resp = await client.get(
269
+ f"/planar/v1/workflows/test_expense_approval_workflow/runs/{wf_id}/steps/{step.step_id}"
270
+ )
271
+ assert resp.status_code == 200
272
+ data = resp.json()
273
+ assert "meta" in data
274
+ assert data["meta"] is None