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
planar/rules/models.py ADDED
@@ -0,0 +1,355 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Annotated, Literal, Type
5
+ from uuid import uuid4
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from planar.modeling.field_helpers import JsonSchema
10
+ from planar.object_config.object_config import (
11
+ ObjectConfigurationBase,
12
+ )
13
+
14
+
15
+ class Position(BaseModel):
16
+ """Represents the x,y coordinates of a node in the graph"""
17
+
18
+ x: int
19
+ y: int
20
+
21
+
22
+ class InputOutputNodeContent(BaseModel):
23
+ """Content structure for input or output nodes containing JSON schema"""
24
+
25
+ schema_: str = Field(
26
+ alias="schema"
27
+ ) # JSON string representation of the input schema
28
+
29
+
30
+ class RuleTableInput(BaseModel):
31
+ """Represents an input column in a decision table"""
32
+
33
+ id: str
34
+ field: str # The field name from the input schema
35
+ name: str
36
+
37
+
38
+ class RuleTableOutput(BaseModel):
39
+ """Represents an output column in a decision table"""
40
+
41
+ id: str
42
+ field: str # The field name from the output schema
43
+ name: str
44
+
45
+
46
+ class DecisionRule(BaseModel):
47
+ """Represents a single rule row in a decision table with dynamic values"""
48
+
49
+ rule_id: str = Field(
50
+ alias="_id"
51
+ ) # Using alias to maintain compatibility with _id field
52
+ # Note: Additional dynamic keys will be present based on input/output column IDs
53
+ # These are handled using model_extra="allow" configuration
54
+
55
+ model_config = {"extra": "allow", "populate_by_name": True}
56
+
57
+
58
+ class DecisionTableNodeContent(BaseModel):
59
+ """Content structure for decision table nodes"""
60
+
61
+ hitPolicy: Literal["first"] # Policy for rule evaluation
62
+ rules: list[DecisionRule] # List of rule rows
63
+ inputs: list[RuleTableInput] # Input column definitions
64
+ outputs: list[RuleTableOutput] # Output column definitions
65
+
66
+ passThrough: bool | None = None # Whether to pass through input data
67
+ passThorough: bool | None = (
68
+ None # Note: typo preserved from original implementation, see https://github.com/gorules/jdm-editor/issues/186
69
+ )
70
+
71
+ inputField: str | None = None # Field mapping for input (currently unused)
72
+ outputPath: str | None = None # Path mapping for output (currently unused)
73
+ executionMode: Literal["single", "loop"] | None # Execution mode for the table
74
+
75
+
76
+ class InputNode(BaseModel):
77
+ """Represents an input node in the JDM graph"""
78
+
79
+ id: str
80
+ type: Literal["inputNode"]
81
+ name: str
82
+ content: InputOutputNodeContent
83
+ position: Position
84
+
85
+
86
+ class DecisionTableNode(BaseModel):
87
+ """Represents a decision table node in the JDM graph"""
88
+
89
+ id: str
90
+ type: Literal["decisionTableNode"]
91
+ name: str
92
+ content: DecisionTableNodeContent
93
+ position: Position
94
+
95
+
96
+ class OutputNode(BaseModel):
97
+ """Represents an output node in the JDM graph"""
98
+
99
+ id: str
100
+ type: Literal["outputNode"]
101
+ name: str
102
+ content: InputOutputNodeContent
103
+ position: Position
104
+
105
+
106
+ class FunctionNodeContent(BaseModel):
107
+ """Content structure for a function node containing JavaScript source code."""
108
+
109
+ source: str
110
+
111
+
112
+ class FunctionNode(BaseModel):
113
+ """Represents a JavaScript function node in the JDM graph."""
114
+
115
+ id: str
116
+ type: Literal["functionNode"]
117
+ name: str
118
+ content: FunctionNodeContent
119
+ position: Position
120
+
121
+
122
+ # class LegacyFunctionNode(BaseModel):
123
+ # """Represents a legacy JavaScript function node in the JDM graph.
124
+ # The GoRules examples (such as https://editor.gorules.io/?template=shipping-fees use a legacy fn node)
125
+ # """
126
+
127
+ # id: str
128
+ # type: Literal["functionNode"]
129
+ # name: str
130
+ # content: str
131
+ # position: Position
132
+
133
+
134
+ class SwitchStatement(BaseModel):
135
+ """Represents a statement in a switch node."""
136
+
137
+ id: str
138
+ condition: str
139
+
140
+
141
+ class SwitchNodeContent(BaseModel):
142
+ """Content structure for a switch node."""
143
+
144
+ hitPolicy: Literal["collect", "first"]
145
+ statements: list[SwitchStatement]
146
+
147
+
148
+ class SwitchNode(BaseModel):
149
+ """Represents a switch node in the JDM graph."""
150
+
151
+ id: str
152
+ type: Literal["switchNode"]
153
+ name: str
154
+ content: SwitchNodeContent
155
+ position: Position
156
+
157
+
158
+ class Expression(BaseModel):
159
+ """Represents a single expression in an expression node."""
160
+
161
+ id: str
162
+ key: str
163
+ value: str
164
+
165
+
166
+ class ExpressionNodeContent(BaseModel):
167
+ """Content structure for an expression node."""
168
+
169
+ expressions: list[Expression]
170
+ passThrough: bool | None = None # Whether to pass through input data
171
+ inputField: str | None = None # Field mapping for input (currently unused)
172
+ outputPath: str | None = None # Path mapping for output (currently unused)
173
+ executionMode: Literal["single", "loop"] | None = (
174
+ None # Execution mode for the node
175
+ )
176
+
177
+
178
+ class ExpressionNode(BaseModel):
179
+ """Represents an expression node in the JDM graph."""
180
+
181
+ id: str
182
+ type: Literal["expressionNode"]
183
+ name: str
184
+ content: ExpressionNodeContent
185
+ position: Position
186
+
187
+
188
+ JDMNode = (
189
+ InputNode
190
+ | DecisionTableNode
191
+ | OutputNode
192
+ | FunctionNode
193
+ # | LegacyFunctionNode
194
+ | SwitchNode
195
+ | ExpressionNode
196
+ )
197
+
198
+ JDMNodeWithType = Annotated[JDMNode, Field(discriminator="type")]
199
+
200
+
201
+ class JDMEdge(BaseModel):
202
+ """Represents an edge connecting nodes in the JDM graph"""
203
+
204
+ id: str
205
+ type: Literal["edge"]
206
+ sourceId: str # ID of the source node
207
+ targetId: str # ID of the target node
208
+
209
+
210
+ class JDMGraph(BaseModel):
211
+ """
212
+ Complete JDM (JSON Decision Model) graph structure.
213
+ This represents a GoRules decision flow with input, processing, and output nodes.
214
+ """
215
+
216
+ nodes: list[JDMNodeWithType] # All nodes in the graph
217
+ edges: list[JDMEdge] # All edges connecting the nodes
218
+
219
+
220
+ def create_jdm_graph(rule: Rule) -> JDMGraph:
221
+ """
222
+ Create a JDM (JSON Decision Model) graph based on input and output JSON schemas.
223
+
224
+ Args:
225
+ rule: A Rule object containing input and output Pydantic models
226
+
227
+ Returns:
228
+ A dictionary representing the JDM graph with nodes and edges
229
+ """
230
+ # Get JSON schemas from the Pydantic models
231
+ input_json_schema = rule.input.model_json_schema()
232
+ output_json_schema = rule.output.model_json_schema()
233
+
234
+ # Generate UUIDs for nodes and edges
235
+ input_node_id = str(uuid4())
236
+ output_node_id = str(uuid4())
237
+ rule_table_node_id = str(uuid4())
238
+ input_to_table_edge_id = str(uuid4())
239
+ table_to_output_edge_id = str(uuid4())
240
+
241
+ # Create output columns for rule table based on output schema properties
242
+ output_columns = []
243
+ output_properties = output_json_schema.get("properties", {})
244
+
245
+ for field_name, field_info in output_properties.items():
246
+ column = {
247
+ "id": str(uuid4()),
248
+ "field": field_name,
249
+ "name": field_name,
250
+ }
251
+ output_columns.append(column)
252
+
253
+ # Create a rule with default values for each output field
254
+ rule_values = {}
255
+ input_column_id = str(uuid4())
256
+ rule_values[input_column_id] = ""
257
+
258
+ # Add values for each output column based on field type
259
+ for column in output_columns:
260
+ field_name = column["field"]
261
+ field_type = output_properties.get(field_name, {}).get("type")
262
+
263
+ if field_type == "string":
264
+ rule_values[column["id"]] = '"default value"'
265
+ elif field_type == "boolean":
266
+ rule_values[column["id"]] = "true"
267
+ elif field_type == "number":
268
+ rule_values[column["id"]] = "0"
269
+ elif field_type == "integer":
270
+ rule_values[column["id"]] = "0"
271
+ else:
272
+ rule_values[column["id"]] = '""'
273
+
274
+ # Create the JDM graph structure using Pydantic models
275
+ return JDMGraph(
276
+ nodes=[
277
+ InputNode(
278
+ id=input_node_id,
279
+ type="inputNode",
280
+ name="Input",
281
+ content=InputOutputNodeContent(schema=json.dumps(input_json_schema)),
282
+ position=Position(x=100, y=100),
283
+ ),
284
+ DecisionTableNode(
285
+ id=rule_table_node_id,
286
+ type="decisionTableNode",
287
+ name="decisionTable1",
288
+ content=DecisionTableNodeContent(
289
+ hitPolicy="first",
290
+ rules=[DecisionRule(_id=str(uuid4()), **rule_values)],
291
+ inputs=[
292
+ RuleTableInput(
293
+ id=input_column_id,
294
+ name="Input",
295
+ field=next(
296
+ iter(input_json_schema.get("properties", {})), ""
297
+ ),
298
+ )
299
+ ],
300
+ outputs=[RuleTableOutput(**col) for col in output_columns],
301
+ passThrough=True,
302
+ inputField=None,
303
+ outputPath=None,
304
+ executionMode="single",
305
+ passThorough=False, # Note: keeping the typo from original JS code
306
+ ),
307
+ position=Position(x=400, y=100),
308
+ ),
309
+ OutputNode(
310
+ id=output_node_id,
311
+ type="outputNode",
312
+ name="Output",
313
+ content=InputOutputNodeContent(schema=json.dumps(output_json_schema)),
314
+ position=Position(x=700, y=100),
315
+ ),
316
+ ],
317
+ edges=[
318
+ JDMEdge(
319
+ id=input_to_table_edge_id,
320
+ type="edge",
321
+ sourceId=input_node_id,
322
+ targetId=rule_table_node_id,
323
+ ),
324
+ JDMEdge(
325
+ id=table_to_output_edge_id,
326
+ type="edge",
327
+ sourceId=rule_table_node_id,
328
+ targetId=output_node_id,
329
+ ),
330
+ ],
331
+ )
332
+
333
+
334
+ class RuleBase(BaseModel):
335
+ name: str
336
+ description: str
337
+
338
+
339
+ class Rule(RuleBase):
340
+ input: Type[BaseModel]
341
+ output: Type[BaseModel]
342
+
343
+ def to_config(self) -> RuleEngineConfig:
344
+ jdm_graph = create_jdm_graph(self)
345
+ return RuleEngineConfig(jdm=jdm_graph)
346
+
347
+
348
+ class RuleEngineConfig(BaseModel):
349
+ jdm: JDMGraph = Field()
350
+
351
+
352
+ class RuleSerializeable(RuleBase):
353
+ input_schema: JsonSchema
354
+ output_schema: JsonSchema
355
+ configs: list[ObjectConfigurationBase[RuleEngineConfig]]
@@ -0,0 +1,191 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ from planar.object_config import ConfigurableObjectType, ObjectConfigurationIO
5
+ from planar.object_config.models import (
6
+ ConfigDiagnosticIssue,
7
+ ConfigDiagnostics,
8
+ DiffErrorCode,
9
+ )
10
+ from planar.object_registry import ObjectRegistry
11
+ from planar.rules.models import JDMGraph, JDMNode, RuleEngineConfig, create_jdm_graph
12
+
13
+
14
+ def node_diff(
15
+ reference: JDMNode | dict,
16
+ current: JDMNode | dict,
17
+ for_object: str,
18
+ path: str = "",
19
+ ) -> list[ConfigDiagnosticIssue]:
20
+ """
21
+ Recursively compare two dictionaries and return a list of diagnostics.
22
+
23
+ Args:
24
+ reference: The reference dictionary to compare against
25
+ current: The current dictionary being validated
26
+ path: The current field path (used for nested objects)
27
+
28
+ Returns:
29
+ List of DiffDiagnostic objects describing the differences found
30
+ """
31
+ diagnostics = []
32
+
33
+ reference_dict = (
34
+ reference.model_dump() if isinstance(reference, JDMNode) else reference
35
+ )
36
+ current_dict = current.model_dump() if isinstance(current, JDMNode) else current
37
+
38
+ # First pass: Check reference against current
39
+ for key, ref_value in reference_dict.items():
40
+ current_path = f"{path}.{key}" if path else key
41
+
42
+ if key not in current_dict:
43
+ # Missing field in current
44
+ diagnostics.append(
45
+ ConfigDiagnosticIssue.model_validate(
46
+ {
47
+ "error_code": DiffErrorCode.MISSING_FIELD,
48
+ "field_path": current_path,
49
+ "message": f"Field '{current_path}' is missing in current node",
50
+ "reference_value": ref_value,
51
+ "current_value": None,
52
+ "for_object": for_object,
53
+ }
54
+ )
55
+ )
56
+ else:
57
+ current_value = current_dict[key]
58
+
59
+ # Both are dictionaries - recurse
60
+ if (isinstance(ref_value, dict) and isinstance(current_value, dict)) or (
61
+ isinstance(ref_value, JDMNode) and isinstance(current_value, JDMNode)
62
+ ):
63
+ nested_diagnostics = node_diff(
64
+ ref_value,
65
+ current_value,
66
+ for_object,
67
+ current_path,
68
+ )
69
+ diagnostics.extend(nested_diagnostics)
70
+
71
+ # Values are different (and not both dicts)
72
+ elif ref_value != current_value:
73
+ diagnostics.append(
74
+ ConfigDiagnosticIssue.model_validate(
75
+ {
76
+ "error_code": DiffErrorCode.VALUE_MISMATCH,
77
+ "field_path": current_path,
78
+ "message": f"Value mismatch at '{current_path}': expected {ref_value}, got {current_value}",
79
+ "reference_value": ref_value,
80
+ "current_value": current_value,
81
+ "for_object": for_object,
82
+ }
83
+ )
84
+ )
85
+
86
+ # Second pass: Check current for extra fields not in reference
87
+ for key, current_value in current_dict.items():
88
+ current_path = f"{path}.{key}" if path else key
89
+
90
+ if key not in reference_dict:
91
+ diagnostics.append(
92
+ ConfigDiagnosticIssue.model_validate(
93
+ {
94
+ "error_code": DiffErrorCode.EXTRA_FIELD,
95
+ "field_path": current_path,
96
+ "message": f"Extra field '{current_path}' found in current node",
97
+ "reference_value": None,
98
+ "current_value": current_value,
99
+ "for_object": for_object,
100
+ }
101
+ )
102
+ )
103
+
104
+ return diagnostics
105
+
106
+
107
+ def get_input_and_output_node_schemas(
108
+ jdm: JDMGraph,
109
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
110
+ nodes = jdm.nodes
111
+
112
+ input_node = next((node for node in nodes if node.type == "inputNode"), None)
113
+ output_node = next((node for node in nodes if node.type == "outputNode"), None)
114
+
115
+ # if a jdm graph was uploaded from a 3rd party site such as https://editor.gorules.io/?template=shipping-fees
116
+ # then the schema will just be "", and json.loads will raise an error trying to parse an empty string
117
+ input_schema = (
118
+ json.loads(input_node.content.schema_)
119
+ if input_node and input_node.content.schema_
120
+ else {}
121
+ )
122
+ output_schema = (
123
+ json.loads(output_node.content.schema_)
124
+ if output_node and output_node.content.schema_
125
+ else {}
126
+ )
127
+
128
+ return input_schema, output_schema
129
+
130
+
131
+ def validate_config(
132
+ name: str,
133
+ config: RuleEngineConfig,
134
+ ) -> ConfigDiagnostics:
135
+ """
136
+ Validate a configuration against a default configuration using JDM nodes comparison.
137
+
138
+ Args:
139
+ config: The configuration to validate
140
+ default: The default/reference configuration
141
+
142
+ Returns:
143
+ True if configuration is valid, False otherwise
144
+ """
145
+
146
+ rules = ObjectRegistry.get_instance().get_rules()
147
+ rule = next((rule for rule in rules if rule.name == name), None)
148
+
149
+ if rule is None:
150
+ raise ValueError(f"Rule with name {name} not found")
151
+
152
+ rule_input_schema = rule.input.model_json_schema()
153
+ rule_output_schema = rule.output.model_json_schema()
154
+
155
+ config_input_node_schema, config_output_node_schema = (
156
+ get_input_and_output_node_schemas(config.jdm)
157
+ )
158
+
159
+ input_node_diagnostics = node_diff(
160
+ rule_input_schema.get("properties", {}),
161
+ config_input_node_schema.get("properties", {}),
162
+ "inputNode",
163
+ )
164
+ output_node_diagnostics = node_diff(
165
+ rule_output_schema.get("properties", {}),
166
+ config_output_node_schema.get("properties", {}),
167
+ "outputNode",
168
+ )
169
+
170
+ if len(input_node_diagnostics) > 0 or len(output_node_diagnostics) > 0:
171
+ return ConfigDiagnostics.model_validate(
172
+ {
173
+ "is_valid": False,
174
+ "suggested_fix": RuleEngineConfig(jdm=create_jdm_graph(rule)),
175
+ "issues": input_node_diagnostics + output_node_diagnostics,
176
+ }
177
+ )
178
+
179
+ return ConfigDiagnostics.model_validate(
180
+ {
181
+ "is_valid": True,
182
+ "issues": [],
183
+ }
184
+ )
185
+
186
+
187
+ rule_configuration = ObjectConfigurationIO(
188
+ RuleEngineConfig,
189
+ ConfigurableObjectType.RULE,
190
+ validate_config=validate_config,
191
+ )
planar/rules/runner.py ADDED
@@ -0,0 +1,64 @@
1
+ import json
2
+ from typing import Any, Literal, TypeVar, cast
3
+
4
+ from pydantic import BaseModel
5
+ from zen import ZenDecisionContent, ZenEngine
6
+
7
+ from planar.logging import get_logger
8
+ from planar.rules.models import JDMGraph
9
+
10
+ T = TypeVar("T", bound=BaseModel)
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class EvaluateResponse(BaseModel):
16
+ success: Literal[True]
17
+ performance: str
18
+ result: dict
19
+ trace: dict | None
20
+
21
+
22
+ class EvaluateError(BaseModel):
23
+ success: Literal[False]
24
+ title: str
25
+ message: dict
26
+ data: dict
27
+
28
+
29
+ def evaluate_rule(
30
+ jdm: JDMGraph, input: dict[str, Any]
31
+ ) -> EvaluateResponse | EvaluateError:
32
+ logger.debug(
33
+ "evaluating rule",
34
+ input_keys=list(input.keys()),
35
+ node_count=len(jdm.nodes),
36
+ )
37
+ engine = ZenEngine()
38
+ zen_decision = engine.create_decision(cast(ZenDecisionContent, jdm.model_dump()))
39
+ try:
40
+ result = zen_decision.evaluate(input, {"trace": True})
41
+ logger.info("rule evaluation successful")
42
+ return EvaluateResponse.model_validate(
43
+ {
44
+ "success": True,
45
+ "performance": result["performance"],
46
+ "result": result["result"],
47
+ "trace": result.get("trace", None),
48
+ }
49
+ )
50
+ except RuntimeError as e:
51
+ logger.exception("rule evaluation failed")
52
+ error_data = json.loads(str(e))
53
+ message = json.loads(error_data["source"])
54
+
55
+ return EvaluateError.model_validate(
56
+ {
57
+ "success": False,
58
+ "title": error_data["type"],
59
+ "message": message,
60
+ "data": {
61
+ "nodeId": error_data["nodeId"],
62
+ },
63
+ }
64
+ )