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,385 @@
1
+ from datetime import datetime, timedelta
2
+ from unittest.mock import AsyncMock, patch
3
+ from uuid import UUID, uuid4
4
+
5
+ import pytest
6
+ from pydantic import BaseModel, Field
7
+ from sqlmodel import col, select
8
+ from sqlmodel.ext.asyncio.session import AsyncSession
9
+
10
+ from planar.human.human import (
11
+ Human,
12
+ HumanTask,
13
+ HumanTaskStatus,
14
+ Timeout,
15
+ complete_human_task,
16
+ )
17
+ from planar.workflows import suspend
18
+ from planar.workflows.decorators import workflow
19
+ from planar.workflows.execution import execute
20
+ from planar.workflows.models import StepType, Workflow, WorkflowStatus, WorkflowStep
21
+ from planar.workflows.step_core import Suspend
22
+
23
+
24
+ # Test data models
25
+ class ExpenseRequest(BaseModel):
26
+ """An expense request submitted by an employee."""
27
+
28
+ request_id: str = Field(description="Unique identifier for the request")
29
+ amount: float = Field(description="Amount requested in dollars")
30
+ requester: str = Field(description="Name of the person requesting")
31
+ department: str = Field(description="Department the requester belongs to")
32
+ purpose: str = Field(description="Purpose of the expense")
33
+
34
+
35
+ class ExpenseDecision(BaseModel):
36
+ """A decision made by a human approver on an expense request."""
37
+
38
+ approved: bool = Field(description="Whether the expense is approved")
39
+ approved_amount: float = Field(
40
+ description="Amount approved (may be less than requested)"
41
+ )
42
+ notes: str = Field(description="Explanation for decision", default="")
43
+
44
+
45
+ class HumanResponse(BaseModel):
46
+ response: str = Field(description="A message from the human")
47
+
48
+
49
+ @pytest.fixture
50
+ def expense_approval():
51
+ """Returns a Human task definition for expense approval testing."""
52
+ return Human(
53
+ name="expense_approval",
54
+ title="Expense Approval",
55
+ description="Review expense request and approve, adjust, or reject",
56
+ input_type=ExpenseRequest,
57
+ output_type=ExpenseDecision,
58
+ timeout=Timeout(timedelta(hours=24)),
59
+ )
60
+
61
+
62
+ @pytest.fixture
63
+ def expense_approval_no_input():
64
+ """Returns a Human task definition for expense approval testing."""
65
+ return Human(
66
+ name="expense_approval_no_input",
67
+ title="Expense Approval (No Input)",
68
+ description="Review expense request and approve, adjust, or reject",
69
+ output_type=ExpenseDecision,
70
+ timeout=Timeout(timedelta(hours=24)),
71
+ )
72
+
73
+
74
+ # Create a fixture for sample expense request data
75
+ @pytest.fixture
76
+ def expense_request_data():
77
+ """Returns sample expense request data for testing."""
78
+ return {
79
+ "request_id": "EXP-123",
80
+ "amount": 750.00,
81
+ "requester": "Jane Smith",
82
+ "department": "Engineering",
83
+ "purpose": "Conference travel expenses",
84
+ }
85
+
86
+
87
+ async def test_human_initialization():
88
+ """Test that the Human class initializes with correct parameters."""
89
+ human = Human(
90
+ name="test_human",
91
+ title="Test Human Task",
92
+ output_type=ExpenseDecision,
93
+ description="Test description",
94
+ input_type=ExpenseRequest,
95
+ timeout=Timeout(timedelta(hours=1)),
96
+ )
97
+
98
+ # Verify initialization
99
+ assert human.name == "test_human"
100
+ assert human.title == "Test Human Task"
101
+ assert human.description == "Test description"
102
+ assert human.input_type == ExpenseRequest
103
+ assert human.output_type == ExpenseDecision
104
+ assert human.timeout is not None
105
+ assert human.timeout.get_seconds() == 3600 # 1 hour in seconds
106
+
107
+
108
+ async def test_human_initialization_validation():
109
+ """Test that the Human class validates output_type is a Pydantic model."""
110
+ with pytest.raises(ValueError, match="output_type must be a Pydantic model"):
111
+ Human(
112
+ name="test_human",
113
+ title="Test Human Task",
114
+ # Invalid: not a Pydantic model
115
+ output_type=str, # type: ignore
116
+ )
117
+
118
+
119
+ async def test_human_initialization_validation_no_input(session: AsyncSession):
120
+ human_no_input = Human(
121
+ name="test_human",
122
+ title="Test Human Task",
123
+ output_type=HumanResponse,
124
+ )
125
+
126
+ @workflow()
127
+ async def expense_workflow():
128
+ result = await human_no_input(message="Hello, world!")
129
+ return result.output.response
130
+
131
+ wf = await expense_workflow.start()
132
+ result = await execute(wf)
133
+ assert isinstance(result, Suspend)
134
+
135
+ steps = (
136
+ await session.exec(select(WorkflowStep).order_by(col(WorkflowStep.step_id)))
137
+ ).all()
138
+ assert len(steps) == 3
139
+ assert "Create Human Task" in [s.display_name for s in steps]
140
+ assert "Wait for event" in [s.display_name for s in steps]
141
+
142
+ assert StepType.HUMAN_IN_THE_LOOP in [s.step_type for s in steps]
143
+ assert steps[0].args == [None, "Hello, world!", None]
144
+
145
+ # Get HumanTask from database
146
+ human_task = (await session.exec(select(HumanTask))).one()
147
+ assert human_task is not None
148
+ assert human_task.name == "test_human"
149
+ assert human_task.title == "Test Human Task"
150
+ assert human_task.output_schema == HumanResponse.model_json_schema()
151
+ assert human_task.input_schema is None
152
+ assert human_task.message == "Hello, world!"
153
+
154
+ await complete_human_task(human_task.id, {"response": "Approved"})
155
+ result = await execute(wf)
156
+ assert result == "Approved"
157
+
158
+
159
+ async def test_human_basic_workflow(
160
+ session: AsyncSession, expense_approval, expense_request_data
161
+ ):
162
+ """Test that a Human step can be used in a workflow with input data."""
163
+
164
+ @workflow()
165
+ async def expense_workflow(request_data: dict):
166
+ request = ExpenseRequest(**request_data)
167
+ result = await expense_approval(request)
168
+ # Add a suspend to ensure the workflow correctly
169
+ # deserializes the result of human task on subsequent executions
170
+ await suspend(interval=timedelta(seconds=0))
171
+ return {
172
+ "request_id": request.request_id,
173
+ "approved": result.output.approved,
174
+ "amount": result.output.approved_amount,
175
+ "notes": result.output.notes,
176
+ }
177
+
178
+ # Start the workflow and run until it suspends
179
+ wf = await expense_workflow.start(expense_request_data)
180
+ result = await execute(wf)
181
+ assert isinstance(result, Suspend)
182
+
183
+ # Query workflows and steps from the database
184
+ updated_wf = await session.get(Workflow, wf.id)
185
+ assert updated_wf is not None
186
+ assert updated_wf.status == WorkflowStatus.PENDING
187
+ assert updated_wf.waiting_for_event is not None
188
+ assert "human_task_completed:" in updated_wf.waiting_for_event
189
+
190
+ steps = (
191
+ await session.exec(select(WorkflowStep).order_by(col(WorkflowStep.step_id)))
192
+ ).all()
193
+ assert len(steps) == 4
194
+ assert "expense_approval" in [s.display_name for s in steps]
195
+
196
+ # Get HumanTask from database and verify fields
197
+ human_task = (await session.exec(select(HumanTask))).one()
198
+ assert human_task is not None
199
+ assert human_task.name == "expense_approval"
200
+ assert human_task.title == "Expense Approval"
201
+ assert human_task.workflow_id == wf.id
202
+ assert human_task.status == HumanTaskStatus.PENDING
203
+ assert human_task.input_schema == ExpenseRequest.model_json_schema()
204
+ assert human_task.input_data is not None
205
+ assert human_task.input_data["request_id"] == "EXP-123"
206
+ assert human_task.input_data["amount"] == 750.00
207
+ assert human_task.message is None
208
+ assert human_task.output_schema == ExpenseDecision.model_json_schema()
209
+ assert human_task.output_data is None
210
+
211
+ # Complete the human task
212
+ output_data = {
213
+ "approved": True,
214
+ "approved_amount": 700.00,
215
+ "notes": "Approved with reduced amount",
216
+ }
217
+ await complete_human_task(human_task.id, output_data, completed_by="test_user")
218
+
219
+ # Check the human task was updated correctly
220
+ await session.refresh(human_task)
221
+ assert human_task.status == HumanTaskStatus.COMPLETED
222
+ assert human_task.output_data == output_data
223
+ assert human_task.completed_by == "test_user"
224
+ assert human_task.completed_at is not None
225
+
226
+ # Resume and complete the workflow
227
+ result = await execute(wf)
228
+ assert isinstance(result, Suspend)
229
+ result = await execute(wf)
230
+
231
+ # Verify workflow completed successfully with expected result
232
+ updated_wf = await session.get(Workflow, wf.id)
233
+ assert updated_wf is not None
234
+ assert updated_wf.status == WorkflowStatus.SUCCEEDED
235
+ expected_result = {
236
+ "request_id": expense_request_data["request_id"],
237
+ "approved": output_data["approved"],
238
+ "amount": output_data["approved_amount"],
239
+ "notes": output_data["notes"],
240
+ }
241
+ assert updated_wf.result == expected_result
242
+
243
+
244
+ async def test_human_task_completion_validation(session: AsyncSession):
245
+ """Test validation when completing a human task."""
246
+
247
+ workflow = Workflow(
248
+ function_name="test_workflow",
249
+ status=WorkflowStatus.PENDING,
250
+ args=[],
251
+ kwargs={},
252
+ )
253
+ session.add(workflow)
254
+ await session.commit()
255
+ # Create a human task
256
+ task = HumanTask(
257
+ id=uuid4(),
258
+ name="test_task",
259
+ title="Test Task",
260
+ workflow_id=workflow.id,
261
+ workflow_name="test_workflow",
262
+ output_schema=ExpenseDecision.model_json_schema(),
263
+ status=HumanTaskStatus.PENDING,
264
+ )
265
+
266
+ session.add(task)
267
+ await session.commit()
268
+ task_id = task.id
269
+
270
+ # Test completing a non-existent task
271
+ with pytest.raises(ValueError, match="not found"):
272
+ await complete_human_task(UUID("00000000-0000-0000-0000-000000000000"), {})
273
+
274
+ # Test completing a task that's not in pending state
275
+ task.status = HumanTaskStatus.CANCELLED
276
+ session.add(task)
277
+ await session.commit()
278
+
279
+ with pytest.raises(ValueError, match="not pending"):
280
+ await complete_human_task(task_id, {})
281
+
282
+ # Reset to pending for the next test
283
+ task.status = HumanTaskStatus.PENDING
284
+ session.add(task)
285
+ await session.commit()
286
+
287
+ # Mock emit_event for normal completion test
288
+ with patch("planar.workflows.events.emit_event", AsyncMock()):
289
+ # Complete with valid data
290
+ output_data = {
291
+ "approved": True,
292
+ "approved_amount": 150.00,
293
+ "notes": "Approved",
294
+ }
295
+
296
+ await complete_human_task(task_id, output_data)
297
+
298
+ # Verify task state
299
+ await session.refresh(task)
300
+ assert task.status == HumanTaskStatus.COMPLETED
301
+ assert task.output_data == output_data
302
+
303
+
304
+ async def test_timeout_class():
305
+ """Test the Timeout helper class functionality."""
306
+ # Test with various durations
307
+ one_hour = Timeout(timedelta(hours=1))
308
+ assert one_hour.get_seconds() == 3600
309
+ assert one_hour.get_timedelta() == timedelta(hours=1)
310
+
311
+ five_minutes = Timeout(timedelta(minutes=5))
312
+ assert five_minutes.get_seconds() == 300
313
+ assert five_minutes.get_timedelta() == timedelta(minutes=5)
314
+
315
+
316
+ async def test_human_task_with_suggested_data(session: AsyncSession):
317
+ """Test that a Human step can be used with suggested_data."""
318
+ human_with_suggestions = Human(
319
+ name="test_human_suggestions",
320
+ title="Test Human Task with Suggestions",
321
+ output_type=ExpenseDecision,
322
+ )
323
+
324
+ @workflow()
325
+ async def expense_workflow():
326
+ result = await human_with_suggestions(
327
+ message="Please review the expense",
328
+ suggested_data=ExpenseDecision(
329
+ approved=True,
330
+ approved_amount=500.0,
331
+ notes="Pre-approved amount",
332
+ ),
333
+ )
334
+ return result.output.notes
335
+
336
+ wf = await expense_workflow.start()
337
+ result = await execute(wf)
338
+ assert isinstance(result, Suspend)
339
+
340
+ # Get HumanTask from database and verify suggested_data is stored
341
+ human_task = (await session.exec(select(HumanTask))).one()
342
+ assert human_task is not None
343
+ assert human_task.name == "test_human_suggestions"
344
+ assert human_task.suggested_data is not None
345
+ assert human_task.suggested_data["approved"] is True
346
+ assert human_task.suggested_data["approved_amount"] == 500.0
347
+ assert human_task.suggested_data["notes"] == "Pre-approved amount"
348
+
349
+ # Complete the human task
350
+ await complete_human_task(
351
+ human_task.id,
352
+ {"approved": False, "approved_amount": 0.0, "notes": "Rejected after review"},
353
+ )
354
+
355
+ result = await execute(wf)
356
+ assert result == "Rejected after review"
357
+
358
+
359
+ async def test_deadline_calculation():
360
+ """Test that deadlines are calculated correctly based on timeout."""
361
+ # Create a human task with a deadline
362
+ with patch("planar.human.human.utc_now") as mock_datetime:
363
+ # Mock the current time
364
+ now = datetime(2025, 1, 1, 12, 0, 0)
365
+ mock_datetime.return_value = now
366
+
367
+ # Calculate deadlines with different timeouts
368
+ one_hour_timeout = Human(
369
+ name="one_hour",
370
+ title="One Hour Timeout",
371
+ output_type=ExpenseDecision,
372
+ timeout=Timeout(timedelta(hours=1)),
373
+ )
374
+
375
+ deadline = one_hour_timeout._calculate_deadline()
376
+ assert deadline == datetime(2025, 1, 1, 13, 0, 0)
377
+
378
+ # Test with no timeout
379
+ no_timeout = Human(
380
+ name="no_timeout",
381
+ title="No Timeout",
382
+ output_type=ExpenseDecision,
383
+ )
384
+
385
+ assert no_timeout._calculate_deadline() is None
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,10 @@
1
+ from .context import get_context_metadata, set_context_metadata
2
+ from .formatter import StructuredFormatter
3
+ from .logger import get_logger
4
+
5
+ __all__ = [
6
+ "get_logger",
7
+ "get_context_metadata",
8
+ "set_context_metadata",
9
+ "StructuredFormatter",
10
+ ]
@@ -0,0 +1,54 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import threading
5
+
6
+ from planar.workflows.context import get_context, in_context
7
+
8
+ from .context import get_context_metadata
9
+
10
+ pid = os.getpid()
11
+
12
+
13
+ def _in_event_loop_task() -> bool:
14
+ """
15
+ Checks if the current thread is the main thread and an asyncio event loop is running.
16
+ """
17
+ try:
18
+ return (
19
+ threading.main_thread() == threading.current_thread()
20
+ and asyncio.get_running_loop() is not None
21
+ )
22
+ except RuntimeError:
23
+ return False
24
+
25
+
26
+ class ExtraAttributesFilter(logging.Filter):
27
+ """
28
+ A logging filter that adds extra contextual attributes to log records.
29
+
30
+ Attributes added:
31
+ - pid: The process ID.
32
+ - workflow_id: The ID of the current workflow, if in a workflow context.
33
+ - step_id: The ID of the current step, if in a workflow context.
34
+ - task_name: The name of the current asyncio task.
35
+ - Other attributes from the logging context.
36
+ """
37
+
38
+ def filter(self, record: logging.LogRecord) -> bool:
39
+ """
40
+ Adds extra attributes to the log record.
41
+ """
42
+ setattr(record, "pid", pid)
43
+
44
+ context_metadata = get_context_metadata()
45
+ for key, value in context_metadata.items():
46
+ setattr(record, key, value)
47
+
48
+ if _in_event_loop_task():
49
+ if in_context():
50
+ ctx = get_context()
51
+ setattr(record, "workflow_id", str(ctx.workflow_id))
52
+ setattr(record, "step_id", ctx.current_step_id)
53
+
54
+ return True
@@ -0,0 +1,14 @@
1
+ from contextvars import ContextVar
2
+ from typing import Any
3
+
4
+ context_metadata: ContextVar[dict[str, Any]] = ContextVar("context_metadata")
5
+
6
+
7
+ def set_context_metadata(key: str, value: Any):
8
+ if not context_metadata.get(False):
9
+ context_metadata.set({})
10
+ context_metadata.get()[key] = value
11
+
12
+
13
+ def get_context_metadata() -> dict[str, Any]:
14
+ return context_metadata.get({})
@@ -0,0 +1,113 @@
1
+ import json
2
+ import logging
3
+ from typing import Any, Dict
4
+
5
+ from pygments import formatters, highlight, lexers
6
+
7
+ # A set of standard LogRecord attributes that should not be included in the extra fields.
8
+ # Copied from logging/__init__.py and added a few more that are sometimes present.
9
+ STANDARD_LOG_RECORD_ATTRS = {
10
+ "args",
11
+ "asctime",
12
+ "created",
13
+ "color_message",
14
+ "exc_info",
15
+ "exc_text",
16
+ "filename",
17
+ "funcName",
18
+ "levelname",
19
+ "levelno",
20
+ "lineno",
21
+ "message",
22
+ "module",
23
+ "msecs",
24
+ "msg",
25
+ "name",
26
+ "pathname",
27
+ "pid",
28
+ "process",
29
+ "processName",
30
+ "relativeCreated",
31
+ "stack_info",
32
+ "thread",
33
+ "threadName",
34
+ "taskName",
35
+ }
36
+
37
+
38
+ COLORS: Dict[int, str] = {
39
+ logging.DEBUG: "\033[94m", # Blue
40
+ logging.INFO: "\033[92m", # Green
41
+ logging.WARNING: "\033[93m", # Yellow
42
+ logging.ERROR: "\033[91m", # Red
43
+ logging.CRITICAL: "\033[91m\033[1m", # Bold Red
44
+ }
45
+ RESET = "\033[0m"
46
+ DARK_GRAY = "\033[90m"
47
+
48
+
49
+ def json_print(value: Any, use_colors: bool = False) -> str:
50
+ if not isinstance(value, (dict, list, int, bool, float, str)):
51
+ value = str(value)
52
+ stringified = json.dumps(value)
53
+ if use_colors:
54
+ lexer = lexers.JsonLexer()
55
+ formatter = formatters.TerminalFormatter()
56
+ return highlight(stringified, lexer, formatter)
57
+ else:
58
+ return stringified
59
+
60
+
61
+ def dictionary_print(value: Dict[str, Any], use_colors: bool = False) -> str:
62
+ result = []
63
+ for key, val in value.items():
64
+ val_str = json_print(val, use_colors).strip()
65
+ result.append(f"{key}={val_str}")
66
+ return ",".join(result)
67
+
68
+
69
+ class StructuredFormatter(logging.Formatter):
70
+ """
71
+ A logging formatter that formats logs in a structured way with key-value pairs,
72
+ and adds color to log levels when connected to a TTY.
73
+ """
74
+
75
+ def __init__(self, use_colors: bool = False):
76
+ super().__init__()
77
+ self.use_colors = use_colors
78
+
79
+ def format(self, record: logging.LogRecord) -> str:
80
+ message = record.getMessage()
81
+ levelname = record.levelname
82
+
83
+ padding_len = 10 - len(levelname)
84
+ padding_len = max(1, padding_len)
85
+ padded_colon = f"{':':<{padding_len}}"
86
+
87
+ if self.use_colors:
88
+ color = COLORS.get(record.levelno, "")
89
+ levelname = f"{color}{levelname}{RESET}"
90
+ record_name = f"{DARK_GRAY}{record.name}{RESET}"
91
+ else:
92
+ record_name = record.name
93
+
94
+ extra_attrs = self._format_extra_attrs(record)
95
+
96
+ log_message = f"{levelname}{padded_colon}{message} [{record_name}]"
97
+ if extra_attrs:
98
+ log_message += f" [{extra_attrs}]"
99
+
100
+ if record.exc_info:
101
+ log_message += "\n" + self.formatException(record.exc_info)
102
+
103
+ return log_message
104
+
105
+ def _format_extra_attrs(self, record: logging.LogRecord) -> str:
106
+ extra = {
107
+ (key[1:] if key.startswith("$") else key): value
108
+ for key, value in record.__dict__.items()
109
+ if key not in STANDARD_LOG_RECORD_ATTRS
110
+ }
111
+ if not extra:
112
+ return ""
113
+ return dictionary_print(extra, self.use_colors)