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,750 @@
1
+ import json
2
+ from datetime import datetime, timezone
3
+ from enum import Enum
4
+ from typing import Any, Dict
5
+ from unittest.mock import patch
6
+ from uuid import UUID
7
+
8
+ import pytest
9
+ from pydantic import BaseModel, Field, ValidationError
10
+ from sqlmodel import select
11
+ from sqlmodel.ext.asyncio.session import AsyncSession
12
+
13
+ from planar.object_registry import ObjectRegistry
14
+ from planar.rules.decorator import RULE_REGISTRY, rule, serialize_for_rule_evaluation
15
+ from planar.rules.models import JDMGraph, Rule, RuleEngineConfig, create_jdm_graph
16
+ from planar.rules.rule_configuration import rule_configuration
17
+ from planar.rules.runner import EvaluateError, EvaluateResponse, evaluate_rule
18
+ from planar.workflows.decorators import workflow
19
+ from planar.workflows.execution import lock_and_execute
20
+ from planar.workflows.models import StepType, WorkflowStatus, WorkflowStep
21
+
22
+
23
+ # Test Enums
24
+ class CustomerTier(str, Enum):
25
+ """Customer tier enumeration."""
26
+
27
+ STANDARD = "standard"
28
+ PREMIUM = "premium"
29
+ VIP = "vip"
30
+
31
+
32
+ # Test data models
33
+ class PriceCalculationInput(BaseModel):
34
+ """Input for a price calculation rule."""
35
+
36
+ product_id: str = Field(description="Product identifier")
37
+ base_price: float = Field(description="Base price of the product")
38
+ quantity: int = Field(description="Quantity ordered")
39
+ customer_tier: CustomerTier = Field(description="Customer tier")
40
+
41
+
42
+ class PriceCalculationOutput(BaseModel):
43
+ """Output from a price calculation rule."""
44
+
45
+ final_price: float = Field(description="Final calculated price")
46
+ discount_applied: float = Field(description="Discount percentage applied")
47
+ discount_reason: str = Field(description="Reason for the discount")
48
+
49
+
50
+ # Default rule implementation for testing
51
+ DEFAULT_PRICE_CALCULATION = PriceCalculationOutput(
52
+ final_price=95.0, discount_applied=5.0, discount_reason="Standard 5% discount"
53
+ )
54
+
55
+
56
+ # Sample JDM graph for overriding the rule
57
+ PRICE_RULE_JDM_OVERRIDE = {
58
+ "nodes": [
59
+ {
60
+ "id": "input-node",
61
+ "type": "inputNode",
62
+ "name": "Input",
63
+ "content": {
64
+ "schema": json.dumps(PriceCalculationInput.model_json_schema())
65
+ },
66
+ "position": {"x": 100, "y": 100},
67
+ },
68
+ {
69
+ "id": "output-node",
70
+ "type": "outputNode",
71
+ "name": "Output",
72
+ "content": {
73
+ "schema": json.dumps(PriceCalculationOutput.model_json_schema())
74
+ },
75
+ "position": {"x": 700, "y": 100},
76
+ },
77
+ {
78
+ "id": "function-node",
79
+ "type": "functionNode",
80
+ "name": "Custom Pricing Logic",
81
+ "content": {
82
+ "source": """
83
+ export const handler = async (input) => {
84
+ let discount = 0;
85
+ let reason = "No discount applied";
86
+
87
+ if (input.customer_tier === "premium") {
88
+ discount = 10;
89
+ reason = "Premium customer discount";
90
+ } else if (input.customer_tier === "vip") {
91
+ discount = 15;
92
+ reason = "VIP customer discount";
93
+ }
94
+
95
+ if (input.quantity > 10) {
96
+ discount += 5;
97
+ reason += " + bulk order discount";
98
+ }
99
+
100
+ const finalPrice = input.base_price * input.quantity * (1 - discount/100);
101
+
102
+ return {
103
+ final_price: finalPrice,
104
+ discount_applied: discount,
105
+ discount_reason: reason
106
+ };
107
+ };
108
+ """
109
+ },
110
+ "position": {"x": 400, "y": 100},
111
+ },
112
+ ],
113
+ "edges": [
114
+ {
115
+ "id": "edge1",
116
+ "sourceId": "input-node",
117
+ "targetId": "function-node",
118
+ "type": "edge",
119
+ },
120
+ {
121
+ "id": "edge2",
122
+ "sourceId": "function-node",
123
+ "targetId": "output-node",
124
+ "type": "edge",
125
+ },
126
+ ],
127
+ }
128
+
129
+
130
+ @pytest.fixture
131
+ def price_calculation_rule():
132
+ """Returns a rule definition for price calculation testing."""
133
+
134
+ @rule(
135
+ description="Calculate the final price based on product, quantity, and customer tier"
136
+ )
137
+ def calculate_price(input: PriceCalculationInput) -> PriceCalculationOutput:
138
+ # In a real implementation, this would contain business logic
139
+ # For testing, simply return the default output
140
+ return DEFAULT_PRICE_CALCULATION
141
+
142
+ ObjectRegistry.get_instance().register(calculate_price.__rule__) # type: ignore
143
+
144
+ return calculate_price
145
+
146
+
147
+ @pytest.fixture
148
+ def price_calculation_rule_with_body_variables():
149
+ """Returns a rule definition for price calculation testing."""
150
+
151
+ @rule(
152
+ description="Calculate the final price based on product, quantity, and customer tier"
153
+ )
154
+ def calculate_price(input: PriceCalculationInput) -> PriceCalculationOutput:
155
+ some_variable = 10
156
+ return PriceCalculationOutput(
157
+ final_price=input.base_price * some_variable,
158
+ discount_applied=0,
159
+ discount_reason="No discount applied",
160
+ )
161
+
162
+ return calculate_price
163
+
164
+
165
+ @pytest.fixture
166
+ def price_calculation_input():
167
+ """Returns sample price calculation input for testing."""
168
+ return {
169
+ "product_id": "PROD-123",
170
+ "base_price": 100.0,
171
+ "quantity": 1,
172
+ "customer_tier": "standard",
173
+ }
174
+
175
+
176
+ async def test_rule_initialization():
177
+ """Test that a rule function is properly initialized with the @rule decorator."""
178
+
179
+ @rule(description="Test rule initialization")
180
+ def test_rule(input: PriceCalculationInput) -> PriceCalculationOutput:
181
+ return DEFAULT_PRICE_CALCULATION
182
+
183
+ # The rule should be registered in the RULE_REGISTRY
184
+ assert "test_rule" in RULE_REGISTRY
185
+ registered_rule = RULE_REGISTRY["test_rule"]
186
+
187
+ # Verify initialization
188
+ assert registered_rule.name == "test_rule"
189
+ assert registered_rule.description == "Test rule initialization"
190
+ assert registered_rule.input == PriceCalculationInput
191
+ assert registered_rule.output == PriceCalculationOutput
192
+
193
+
194
+ async def test_rule_type_validation():
195
+ """Test that the rule decorator properly validates input and output types."""
196
+
197
+ # Should raise ValueError when input type is not a Pydantic model
198
+ with pytest.raises(ValueError):
199
+ # Using Any to avoid the actual type check in pytest itself
200
+ # The validation function in the decorator will still catch this
201
+ @rule(description="Invalid input type")
202
+ def invalid_input_rule(input: Any) -> PriceCalculationOutput:
203
+ return DEFAULT_PRICE_CALCULATION
204
+
205
+ # Should raise ValueError when output type is not a Pydantic model
206
+ with pytest.raises(ValueError):
207
+ # Using Any to avoid the actual type check in pytest itself
208
+ @rule(description="Invalid output type")
209
+ def invalid_output_rule(input: PriceCalculationInput) -> Any:
210
+ return "Invalid"
211
+
212
+ # Should raise ValueError when missing type annotations
213
+ with pytest.raises(ValueError):
214
+ # Missing type annotation for input
215
+ @rule(description="Missing annotations")
216
+ def missing_annotations_rule(input):
217
+ return DEFAULT_PRICE_CALCULATION
218
+
219
+ # Should raise ValueError when missing return type
220
+ with pytest.raises(ValueError):
221
+ # The decorator function should catch this
222
+ @rule(description="Missing return type")
223
+ def missing_return_type(input: PriceCalculationInput):
224
+ return DEFAULT_PRICE_CALCULATION
225
+
226
+
227
+ async def test_rule_in_workflow(session: AsyncSession, price_calculation_rule):
228
+ """Test that a rule can be used in a workflow."""
229
+
230
+ @workflow()
231
+ async def pricing_workflow(input_data: Dict):
232
+ input_model = PriceCalculationInput(**input_data)
233
+ result = await price_calculation_rule(input_model)
234
+ return result
235
+
236
+ # Start the workflow and run it
237
+ input_data = {
238
+ "product_id": "PROD-123",
239
+ "base_price": 100.0,
240
+ "quantity": 1,
241
+ "customer_tier": "standard",
242
+ }
243
+
244
+ wf = await pricing_workflow.start(input_data)
245
+ result = await lock_and_execute(wf)
246
+
247
+ # Verify workflow completed successfully
248
+ assert wf.status == WorkflowStatus.SUCCEEDED
249
+ assert wf.result == DEFAULT_PRICE_CALCULATION.model_dump()
250
+
251
+ assert isinstance(result, PriceCalculationOutput)
252
+ assert result.final_price == DEFAULT_PRICE_CALCULATION.final_price
253
+ assert result.discount_applied == DEFAULT_PRICE_CALCULATION.discount_applied
254
+ assert result.discount_reason == DEFAULT_PRICE_CALCULATION.discount_reason
255
+
256
+ # Verify steps were recorded correctly
257
+ steps = (
258
+ await session.exec(
259
+ select(WorkflowStep).where(WorkflowStep.workflow_id == wf.id)
260
+ )
261
+ ).all()
262
+ assert len(steps) >= 1
263
+
264
+ # Find the rule step
265
+ rule_step = next((step for step in steps if step.step_type == StepType.RULE), None)
266
+ assert rule_step is not None
267
+ assert price_calculation_rule.__name__ in rule_step.function_name
268
+
269
+
270
+ async def test_rule_in_workflow_with_body_variables(
271
+ session: AsyncSession, price_calculation_rule_with_body_variables
272
+ ):
273
+ """Test that a rule can be used in a workflow."""
274
+
275
+ @workflow()
276
+ async def pricing_workflow(input_data: Dict):
277
+ input_model = PriceCalculationInput(**input_data)
278
+ result = await price_calculation_rule_with_body_variables(input_model)
279
+ return result
280
+
281
+ # Start the workflow and run it
282
+ input_data = {
283
+ "product_id": "PROD-123",
284
+ "base_price": 10.0,
285
+ "quantity": 1,
286
+ "customer_tier": "standard",
287
+ }
288
+
289
+ wf = await pricing_workflow.start(input_data)
290
+ result = await lock_and_execute(wf)
291
+
292
+ # Verify workflow completed successfully
293
+ assert wf.status == WorkflowStatus.SUCCEEDED
294
+ assert (
295
+ wf.result
296
+ == PriceCalculationOutput(
297
+ final_price=100.0, discount_applied=0, discount_reason="No discount applied"
298
+ ).model_dump()
299
+ )
300
+
301
+ assert isinstance(result, PriceCalculationOutput)
302
+ assert result.final_price == 100.0
303
+ assert result.discount_applied == 0
304
+ assert result.discount_reason == "No discount applied"
305
+
306
+
307
+ async def test_rule_override(session: AsyncSession, price_calculation_rule):
308
+ """Test that a rule can be overridden with a JDM graph."""
309
+
310
+ # Create and save an override
311
+ override = RuleEngineConfig(jdm=JDMGraph.model_validate(PRICE_RULE_JDM_OVERRIDE))
312
+
313
+ cfg = await rule_configuration.write_config(
314
+ price_calculation_rule.__name__, override
315
+ )
316
+ await rule_configuration.promote_config(cfg.id)
317
+
318
+ @workflow()
319
+ async def pricing_workflow(input_data: Dict):
320
+ input_model = PriceCalculationInput(**input_data)
321
+ result = await price_calculation_rule(input_model)
322
+ return result
323
+
324
+ # Start the workflow with premium customer input
325
+ premium_input = {
326
+ "product_id": "PROD-456",
327
+ "base_price": 100.0,
328
+ "quantity": 5,
329
+ "customer_tier": "premium",
330
+ }
331
+
332
+ wf = await pricing_workflow.start(premium_input)
333
+ _ = await lock_and_execute(wf)
334
+
335
+ # Verify the workflow used the override logic
336
+ assert wf.status == WorkflowStatus.SUCCEEDED
337
+ assert wf.result is not None
338
+ assert wf.result != DEFAULT_PRICE_CALCULATION.model_dump()
339
+ assert wf.result["discount_applied"] == 10.0
340
+ assert "Premium customer discount" in wf.result["discount_reason"]
341
+
342
+ # Now test with VIP customer and bulk order
343
+ vip_bulk_input = {
344
+ "product_id": "PROD-789",
345
+ "base_price": 100.0,
346
+ "quantity": 15,
347
+ "customer_tier": "vip",
348
+ }
349
+
350
+ wf2 = await pricing_workflow.start(vip_bulk_input)
351
+ _ = await lock_and_execute(wf2)
352
+
353
+ # Verify the workflow used the override logic with both discounts
354
+ assert wf2.status == WorkflowStatus.SUCCEEDED
355
+ assert wf2.result is not None
356
+ assert wf2.result["discount_applied"] == 20.0 # 15% VIP + 5% bulk
357
+ assert "VIP customer discount" in wf2.result["discount_reason"]
358
+ assert "bulk order discount" in wf2.result["discount_reason"]
359
+
360
+
361
+ async def test_evaluate_rule_function():
362
+ """Test the evaluate_rule function directly."""
363
+
364
+ # Create test input data
365
+ input_data = {
366
+ "product_id": "PROD-123",
367
+ "base_price": 100.0,
368
+ "quantity": 5,
369
+ "customer_tier": "premium",
370
+ }
371
+
372
+ # Test error handling
373
+ with patch("planar.rules.runner.ZenEngine") as MockZenEngine:
374
+ mock_decision = MockZenEngine.return_value.create_decision.return_value
375
+ error_json = json.dumps(
376
+ {
377
+ "type": "RuleEvaluationError",
378
+ "source": json.dumps({"error": "Invalid rule logic"}),
379
+ "nodeId": "decision-table-node",
380
+ }
381
+ )
382
+ mock_decision.evaluate.side_effect = RuntimeError(error_json)
383
+
384
+ result = evaluate_rule(
385
+ JDMGraph.model_validate(PRICE_RULE_JDM_OVERRIDE), input_data
386
+ )
387
+
388
+ assert isinstance(result, EvaluateError)
389
+ assert result.success is False
390
+ assert result.title == "RuleEvaluationError"
391
+ assert result.message == {"error": "Invalid rule logic"}
392
+ assert result.data["nodeId"] == "decision-table-node"
393
+
394
+
395
+ async def test_rule_override_validation(session: AsyncSession, price_calculation_rule):
396
+ """Test validation when creating a rule override."""
397
+
398
+ ObjectRegistry.get_instance().register(price_calculation_rule.__rule__)
399
+
400
+ # Test with valid JDMGraph
401
+ valid_jdm = create_jdm_graph(price_calculation_rule.__rule__)
402
+ valid_override = RuleEngineConfig(jdm=valid_jdm)
403
+ assert valid_override is not None
404
+ assert isinstance(valid_override.jdm, JDMGraph)
405
+ await rule_configuration.write_config(
406
+ price_calculation_rule.__name__, valid_override
407
+ )
408
+
409
+ # Query back and verify
410
+ configs = await rule_configuration._read_configs(price_calculation_rule.__name__)
411
+ assert len(configs) == 1
412
+ assert configs[0].object_name == price_calculation_rule.__name__
413
+ assert JDMGraph.model_validate(configs[0].data.jdm) == valid_jdm
414
+
415
+ # Test with invalid JDMGraph (missing required fields)
416
+ with pytest.raises(ValidationError):
417
+ # Test with incomplete dictionary
418
+ invalid_dict = {"invalid": "structure"}
419
+ JDMGraph.model_validate(invalid_dict)
420
+
421
+ # Test with invalid JDMGraph type
422
+ with pytest.raises(ValidationError):
423
+ # Test with completely wrong type
424
+ RuleEngineConfig(jdm="invalid_string") # type: ignore
425
+
426
+
427
+ def test_serialize_for_rule_evaluation_dict():
428
+ """Test serialization of dictionaries with nested datetime and UUID objects."""
429
+
430
+ test_uuid = UUID("12345678-1234-5678-1234-567812345678")
431
+ naive_dt = datetime(2023, 12, 25, 14, 30, 45)
432
+ aware_dt = datetime(2023, 12, 25, 14, 30, 45, tzinfo=timezone.utc)
433
+
434
+ test_dict = {
435
+ "id": test_uuid,
436
+ "created_at": naive_dt,
437
+ "updated_at": aware_dt,
438
+ "name": "test_item",
439
+ "count": 42,
440
+ "nested": {"another_id": test_uuid, "another_date": naive_dt},
441
+ }
442
+
443
+ serialized = serialize_for_rule_evaluation(test_dict)
444
+
445
+ assert serialized["id"] == "12345678-1234-5678-1234-567812345678"
446
+ assert serialized["created_at"] == "2023-12-25T14:30:45Z"
447
+ assert serialized["updated_at"] == "2023-12-25T14:30:45+00:00"
448
+ assert serialized["name"] == "test_item"
449
+ assert serialized["count"] == 42
450
+ assert serialized["nested"]["another_id"] == "12345678-1234-5678-1234-567812345678"
451
+ assert serialized["nested"]["another_date"] == "2023-12-25T14:30:45Z"
452
+
453
+
454
+ def test_serialize_for_rule_evaluation():
455
+ """Test serialization of complex nested structures."""
456
+
457
+ test_uuid1 = UUID("12345678-1234-5678-1234-567812345678")
458
+ test_uuid2 = UUID("87654321-4321-8765-4321-876543218765")
459
+ naive_dt = datetime(2023, 12, 25, 14, 30, 45, 123456)
460
+ aware_dt = datetime(2023, 12, 25, 14, 30, 45, 123456, timezone.utc)
461
+
462
+ complex_data = {
463
+ "metadata": {
464
+ "id": test_uuid1,
465
+ "created_at": naive_dt,
466
+ "updated_at": aware_dt,
467
+ "tags": ["tag1", "tag2", test_uuid2],
468
+ },
469
+ "items": [
470
+ {
471
+ "item_id": test_uuid1,
472
+ "timestamp": naive_dt,
473
+ "values": (1, 2, 3, aware_dt),
474
+ },
475
+ {
476
+ "item_id": test_uuid2,
477
+ "timestamp": aware_dt,
478
+ "nested_list": [{"deep_uuid": test_uuid1, "deep_date": naive_dt}],
479
+ },
480
+ ],
481
+ "enum_values": [CustomerTier.STANDARD],
482
+ "simple_values": [1, "test", True, None],
483
+ }
484
+
485
+ serialized = serialize_for_rule_evaluation(complex_data)
486
+
487
+ # Verify metadata
488
+ assert serialized["metadata"]["id"] == "12345678-1234-5678-1234-567812345678"
489
+ assert serialized["metadata"]["created_at"] == "2023-12-25T14:30:45.123456Z"
490
+ assert serialized["metadata"]["updated_at"] == "2023-12-25T14:30:45.123456+00:00"
491
+ assert serialized["metadata"]["tags"][2] == "87654321-4321-8765-4321-876543218765"
492
+
493
+ # Verify items
494
+ assert serialized["items"][0]["item_id"] == "12345678-1234-5678-1234-567812345678"
495
+ assert serialized["items"][0]["timestamp"] == "2023-12-25T14:30:45.123456Z"
496
+ assert serialized["items"][0]["values"][3] == "2023-12-25T14:30:45.123456+00:00"
497
+
498
+ assert serialized["items"][1]["item_id"] == "87654321-4321-8765-4321-876543218765"
499
+ assert serialized["items"][1]["timestamp"] == "2023-12-25T14:30:45.123456+00:00"
500
+ assert (
501
+ serialized["items"][1]["nested_list"][0]["deep_uuid"]
502
+ == "12345678-1234-5678-1234-567812345678"
503
+ )
504
+ assert (
505
+ serialized["items"][1]["nested_list"][0]["deep_date"]
506
+ == "2023-12-25T14:30:45.123456Z"
507
+ )
508
+
509
+ # Verify simple values remain unchanged
510
+ assert serialized["simple_values"] == [1, "test", True, None]
511
+
512
+
513
+ class DateTimeTestModel(BaseModel):
514
+ """Test model with datetime fields for integration testing."""
515
+
516
+ id: UUID = Field(description="Unique identifier")
517
+ created_at: datetime = Field(description="Creation timestamp")
518
+ updated_at: datetime | None = Field(default=None, description="Update timestamp")
519
+ name: str = Field(description="Name of the item")
520
+
521
+
522
+ def test_serialize_pydantic_model_with_datetime():
523
+ """Test serialization of Pydantic models containing datetime fields."""
524
+
525
+ test_uuid = UUID("12345678-1234-5678-1234-567812345678")
526
+ naive_dt = datetime(2023, 12, 25, 14, 30, 45, 123456)
527
+ aware_dt = datetime(2023, 12, 25, 14, 30, 45, 123456, timezone.utc)
528
+
529
+ model = DateTimeTestModel(
530
+ id=test_uuid, created_at=naive_dt, updated_at=aware_dt, name="test_model"
531
+ )
532
+
533
+ # Serialize the model's dict representation
534
+ model_dict = model.model_dump()
535
+ serialized = serialize_for_rule_evaluation(model_dict)
536
+
537
+ assert serialized["id"] == "12345678-1234-5678-1234-567812345678"
538
+ assert serialized["created_at"] == "2023-12-25T14:30:45.123456Z"
539
+ assert serialized["updated_at"] == "2023-12-25T14:30:45.123456+00:00"
540
+ assert serialized["name"] == "test_model"
541
+
542
+
543
+ async def test_rule_with_complex_types_serialization(session: AsyncSession):
544
+ """Integration test: Test that complex types serialization works in rule evaluation."""
545
+
546
+ class ComplexTypesInput(BaseModel):
547
+ event_id: UUID
548
+ event_time: datetime
549
+ event_name: str
550
+ enum_value: CustomerTier
551
+
552
+ class ComplexTypesOutput(BaseModel):
553
+ processed_id: UUID
554
+ processed_time: datetime
555
+ enum_value: CustomerTier
556
+ message: str
557
+
558
+ @rule(description="Process datetime input")
559
+ def process_datetime_rule(input: ComplexTypesInput) -> ComplexTypesOutput:
560
+ # Should actually be using the rule override below.
561
+ return ComplexTypesOutput(
562
+ processed_id=UUID("12345678-1234-5678-1234-567812345678"),
563
+ processed_time=datetime.now(timezone.utc),
564
+ enum_value=CustomerTier.STANDARD,
565
+ message="Should not be using this default rule",
566
+ )
567
+
568
+ ObjectRegistry.get_instance().register(process_datetime_rule.__rule__) # type: ignore
569
+
570
+ # Create a JDM override that uses the datetime fields
571
+ datetime_jdm_override = {
572
+ "nodes": [
573
+ {
574
+ "id": "input-node",
575
+ "type": "inputNode",
576
+ "name": "Input",
577
+ "content": {
578
+ "schema": json.dumps(ComplexTypesInput.model_json_schema())
579
+ },
580
+ "position": {"x": 100, "y": 100},
581
+ },
582
+ {
583
+ "id": "output-node",
584
+ "type": "outputNode",
585
+ "name": "Output",
586
+ "content": {
587
+ "schema": json.dumps(ComplexTypesOutput.model_json_schema())
588
+ },
589
+ "position": {"x": 700, "y": 100},
590
+ },
591
+ {
592
+ "id": "function-node",
593
+ "type": "functionNode",
594
+ "name": "DateTime Processing",
595
+ "content": {
596
+ "source": """
597
+ export const handler = async (input) => {
598
+ return {
599
+ processed_id: input.event_id,
600
+ processed_time: input.event_time,
601
+ enum_value: input.enum_value,
602
+ message: `Override processed ${input.event_name}`
603
+ };
604
+ };
605
+ """
606
+ },
607
+ "position": {"x": 400, "y": 100},
608
+ },
609
+ ],
610
+ "edges": [
611
+ {
612
+ "id": "edge1",
613
+ "sourceId": "input-node",
614
+ "targetId": "function-node",
615
+ "type": "edge",
616
+ },
617
+ {
618
+ "id": "edge2",
619
+ "sourceId": "function-node",
620
+ "targetId": "output-node",
621
+ "type": "edge",
622
+ },
623
+ ],
624
+ }
625
+
626
+ # Create and save an override
627
+ override = RuleEngineConfig(jdm=JDMGraph.model_validate(datetime_jdm_override))
628
+ cfg = await rule_configuration.write_config(
629
+ process_datetime_rule.__name__, override
630
+ )
631
+ await rule_configuration.promote_config(cfg.id)
632
+
633
+ @workflow()
634
+ async def datetime_workflow(input: ComplexTypesInput):
635
+ result = await process_datetime_rule(input)
636
+ return result
637
+
638
+ # Test with naive datetime
639
+ test_uuid = UUID("12345678-1234-5678-1234-567812345678")
640
+ naive_dt = datetime(2023, 12, 25, 14, 30, 45, 123456)
641
+
642
+ input = ComplexTypesInput(
643
+ event_id=test_uuid,
644
+ event_time=naive_dt,
645
+ event_name="test_event",
646
+ enum_value=CustomerTier.STANDARD,
647
+ )
648
+
649
+ wf = await datetime_workflow.start(input)
650
+ await lock_and_execute(wf)
651
+
652
+ # Verify the workflow completed successfully
653
+ assert wf.status == WorkflowStatus.SUCCEEDED
654
+ assert wf.result is not None
655
+ assert ComplexTypesOutput.model_validate(wf.result) == ComplexTypesOutput(
656
+ processed_id=test_uuid,
657
+ processed_time=naive_dt.replace(tzinfo=timezone.utc),
658
+ enum_value=CustomerTier.STANDARD,
659
+ message="Override processed test_event",
660
+ )
661
+
662
+
663
+ async def test_create_jdm_graph():
664
+ """Test JDM graph generation from rule schemas."""
665
+ rule = Rule(
666
+ name="test_price_rule",
667
+ description="Test price calculation rule",
668
+ input=PriceCalculationInput,
669
+ output=PriceCalculationOutput,
670
+ )
671
+
672
+ # Generate the JDM graph
673
+ jdm_graph = create_jdm_graph(rule)
674
+
675
+ # Verify the structure
676
+ assert len(jdm_graph.nodes) == 3 # input, decision table, output
677
+ assert len(jdm_graph.edges) == 2 # input->table, table->output
678
+
679
+ # Verify node types
680
+ node_types = {node.type for node in jdm_graph.nodes}
681
+ assert node_types == {"inputNode", "decisionTableNode", "outputNode"}
682
+
683
+ # Find the decision table node
684
+ decision_table = next(
685
+ node for node in jdm_graph.nodes if node.type == "decisionTableNode"
686
+ )
687
+
688
+ # Verify output columns match the output schema
689
+ output_columns = decision_table.content.outputs
690
+ assert len(output_columns) == 3 # final_price, discount_applied, discount_reason
691
+
692
+ output_fields = {col.field for col in output_columns}
693
+ assert output_fields == {"final_price", "discount_applied", "discount_reason"}
694
+
695
+ # Verify rule values have correct default types
696
+ rule_values = decision_table.content.rules[0]
697
+
698
+ # Find column IDs for each field
699
+ final_price_col = next(col for col in output_columns if col.field == "final_price")
700
+ discount_applied_col = next(
701
+ col for col in output_columns if col.field == "discount_applied"
702
+ )
703
+ discount_reason_col = next(
704
+ col for col in output_columns if col.field == "discount_reason"
705
+ )
706
+
707
+ assert getattr(rule_values, final_price_col.id) == "0" # number default
708
+ assert getattr(rule_values, discount_applied_col.id) == "0" # number default
709
+ assert (
710
+ getattr(rule_values, discount_reason_col.id) == '"default value"'
711
+ ) # string default
712
+
713
+ # Verify input and output nodes have proper schemas
714
+ input_node = next(node for node in jdm_graph.nodes if node.type == "inputNode")
715
+ output_node = next(node for node in jdm_graph.nodes if node.type == "outputNode")
716
+
717
+ input_schema = json.loads(input_node.content.schema_)
718
+ output_schema = json.loads(output_node.content.schema_)
719
+
720
+ assert input_schema == PriceCalculationInput.model_json_schema()
721
+ assert output_schema == PriceCalculationOutput.model_json_schema()
722
+
723
+
724
+ async def test_jdm_graph_evaluation():
725
+ """Test evaluating a JDM graph with a simple rule."""
726
+
727
+ # Create a rule and generate its JDM graph
728
+ @rule(description="Test JDM evaluation")
729
+ def simple_rule(input: PriceCalculationInput) -> PriceCalculationOutput:
730
+ return DEFAULT_PRICE_CALCULATION
731
+
732
+ jdm_graph = create_jdm_graph(RULE_REGISTRY[simple_rule.__name__])
733
+
734
+ # Test input data
735
+ test_input = {
736
+ "product_id": "PROD-EVAL",
737
+ "base_price": 200.0,
738
+ "quantity": 2,
739
+ "customer_tier": "vip",
740
+ }
741
+
742
+ # Evaluate the rule
743
+ result = evaluate_rule(jdm_graph, test_input)
744
+
745
+ # Verify the result
746
+ assert isinstance(result, EvaluateResponse)
747
+ assert result.success is True
748
+ assert result.result["final_price"] == 0.0
749
+ assert result.result["discount_applied"] == 0.0
750
+ assert "default value" in result.result["discount_reason"]