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,527 @@
1
+ """
2
+ Tests for object configuration schema validation functionality.
3
+
4
+ Also Tests for the object configuration promotion logic.
5
+
6
+ This module consolidates tests for the promote_config functionality
7
+ used by the object_config_router, covering both agent and rule configurations.
8
+ """
9
+
10
+ from uuid import uuid4
11
+
12
+ import pytest
13
+ from pydantic import BaseModel
14
+ from sqlmodel import select
15
+ from sqlmodel.ext.asyncio.session import AsyncSession
16
+
17
+ from planar.ai.agent import Agent
18
+ from planar.ai.agent_utils import agent_configuration
19
+ from planar.ai.models import AgentConfig
20
+ from planar.object_config import (
21
+ DEFAULT_UUID,
22
+ ConfigNotFoundError,
23
+ ConfigurableObjectType,
24
+ ObjectConfiguration,
25
+ ObjectConfigurationIO,
26
+ )
27
+ from planar.object_registry import ObjectRegistry
28
+ from planar.rules.models import Rule, RuleEngineConfig, create_jdm_graph
29
+ from planar.rules.rule_configuration import rule_configuration
30
+
31
+
32
+ @pytest.fixture
33
+ def rule_definition():
34
+ class RuleInputOutput(BaseModel):
35
+ test: str
36
+
37
+ return Rule(
38
+ name="test_rule_promote_success",
39
+ description="Test rule for promoting configuration",
40
+ input=RuleInputOutput,
41
+ output=RuleInputOutput,
42
+ )
43
+
44
+
45
+ @pytest.fixture
46
+ def agent_definition():
47
+ class AgentInputOutput(BaseModel):
48
+ test: str
49
+
50
+ return Agent(
51
+ name="test_agent_promote_success",
52
+ system_prompt="Test agent for promoting configuration",
53
+ user_prompt="Test agent for promoting configuration",
54
+ model="gpt-4",
55
+ max_turns=1,
56
+ )
57
+
58
+
59
+ class ConfigV1(BaseModel):
60
+ """Version 1 of a test configuration schema."""
61
+
62
+ name: str
63
+ value: int
64
+
65
+
66
+ @pytest.fixture
67
+ def config_io_v1():
68
+ """Configuration IO for version 1 schema."""
69
+ return ObjectConfigurationIO(ConfigV1, ConfigurableObjectType.RULE)
70
+
71
+
72
+ async def test_schema_validation_success(session: AsyncSession, config_io_v1):
73
+ """Test that valid configurations are loaded"""
74
+ # Write a valid V1 configuration
75
+ config_v1 = ConfigV1(name="test", value=42)
76
+ await config_io_v1.write_config("test_object", config_v1)
77
+
78
+ # Read it back with validation
79
+ result = await config_io_v1._read_configs("test_object")
80
+
81
+ assert len(result) == 1
82
+ assert result[0].data.name == "test"
83
+ assert result[0].data.value == 42
84
+
85
+
86
+ async def test_no_config_returns_empty_list(session: AsyncSession, config_io_v1):
87
+ """Test that non-existent configurations return empty list"""
88
+ result = await config_io_v1._read_configs("nonexistent_object")
89
+
90
+ assert len(result) == 0
91
+
92
+
93
+ async def test_multiple_versions_ordered_by_version_desc(
94
+ session: AsyncSession, config_io_v1
95
+ ):
96
+ """Test that multiple configurations are returned ordered by version descending."""
97
+ # Write multiple configurations
98
+ config_v1_1 = ConfigV1(name="test1", value=1)
99
+ config_v1_2 = ConfigV1(name="test2", value=2)
100
+ config_v1_3 = ConfigV1(name="test3", value=3)
101
+
102
+ await config_io_v1.write_config("test_object", config_v1_1)
103
+ await config_io_v1.write_config("test_object", config_v1_2)
104
+ await config_io_v1.write_config("test_object", config_v1_3)
105
+
106
+ # Read all configurations
107
+ result = await config_io_v1._read_configs("test_object")
108
+
109
+ # Should have 3 configurations, ordered by version descending
110
+ assert len(result) == 3
111
+ assert result[0].version == 3 # Latest version first
112
+ assert result[0].data.name == "test3"
113
+ assert result[1].version == 2
114
+ assert result[1].data.name == "test2"
115
+ assert result[2].version == 1 # Oldest version last
116
+ assert result[2].data.name == "test1"
117
+
118
+
119
+ @pytest.mark.asyncio
120
+ class TestPromoteConfigurationLogic:
121
+ """Test the promote configuration logic for agents and rules."""
122
+
123
+ async def test_promote_rule_config_success(
124
+ self, session: AsyncSession, rule_definition: Rule
125
+ ):
126
+ """Test promoting a rule configuration successfully."""
127
+ ObjectRegistry.get_instance().register(rule_definition)
128
+
129
+ rule_config = RuleEngineConfig(jdm=create_jdm_graph(rule_definition))
130
+ config = await rule_configuration.write_config(
131
+ rule_definition.name, rule_config
132
+ )
133
+
134
+ await rule_configuration.promote_config(config.id)
135
+
136
+ configs = await rule_configuration.read_configs_with_default(
137
+ rule_definition.name, rule_definition.to_config()
138
+ )
139
+
140
+ assert len(configs) == 2
141
+ assert configs[0].active is True
142
+ assert configs[0].version == 1
143
+
144
+ # last config is default
145
+ assert configs[1].active is False
146
+ assert configs[1].version == 0
147
+
148
+ db_config = (
149
+ await session.exec(
150
+ select(ObjectConfiguration).where(ObjectConfiguration.id == config.id)
151
+ )
152
+ ).first()
153
+ assert db_config is not None
154
+ assert db_config.active is True
155
+
156
+ async def test_promote_agent_config_success(
157
+ self, session: AsyncSession, agent_definition: Agent
158
+ ):
159
+ """Test promoting an agent configuration successfully."""
160
+ object_name = f"test_agent_promote_success_{uuid4().hex}"
161
+ agent_config = AgentConfig(
162
+ system_prompt="Sys", user_prompt="User", model="gpt-4", max_turns=1
163
+ )
164
+ config = await agent_configuration.write_config(object_name, agent_config)
165
+
166
+ await agent_configuration.promote_config(config.id)
167
+
168
+ configs = await agent_configuration.read_configs_with_default(
169
+ object_name, agent_definition.to_config()
170
+ )
171
+
172
+ assert len(configs) == 2
173
+ assert configs[0].active is True
174
+ assert configs[0].id == config.id
175
+
176
+ db_config = (
177
+ await session.exec(
178
+ select(ObjectConfiguration).where(ObjectConfiguration.id == config.id)
179
+ )
180
+ ).first()
181
+ assert db_config is not None
182
+ assert db_config.active is True
183
+
184
+ async def test_promote_switches_active_rule_configs(
185
+ self, session: AsyncSession, rule_definition: Rule
186
+ ):
187
+ """Test that promoting a rule config switches active status correctly."""
188
+ ObjectRegistry.get_instance().register(rule_definition)
189
+
190
+ jdm_graph = create_jdm_graph(rule_definition)
191
+
192
+ config1_payload = RuleEngineConfig(jdm=jdm_graph)
193
+ config2_payload = RuleEngineConfig(jdm=jdm_graph)
194
+
195
+ config1 = await rule_configuration.write_config(
196
+ rule_definition.name, config1_payload
197
+ )
198
+ config2 = await rule_configuration.write_config(
199
+ rule_definition.name, config2_payload
200
+ )
201
+
202
+ await rule_configuration.promote_config(config1.id)
203
+ await rule_configuration.promote_config(config2.id)
204
+
205
+ configs = await rule_configuration.read_configs_with_default(
206
+ rule_definition.name, rule_definition.to_config()
207
+ )
208
+
209
+ assert len(configs) == 3
210
+ active_configs = [c for c in configs if c.active]
211
+ inactive_configs = [c for c in configs if not c.active]
212
+
213
+ assert len(active_configs) == 1
214
+ assert active_configs[0].id == config2.id
215
+ assert len(inactive_configs) == 2
216
+ assert inactive_configs[0].id == config1.id
217
+ assert inactive_configs[1].id == DEFAULT_UUID
218
+
219
+ db_config1 = (
220
+ await session.exec(
221
+ select(ObjectConfiguration).where(ObjectConfiguration.id == config1.id)
222
+ )
223
+ ).first()
224
+ db_config2 = (
225
+ await session.exec(
226
+ select(ObjectConfiguration).where(ObjectConfiguration.id == config2.id)
227
+ )
228
+ ).first()
229
+ assert db_config1 is not None and db_config1.active is False
230
+ assert db_config2 is not None and db_config2.active is True
231
+
232
+ async def test_promote_switches_active_agent_configs(
233
+ self, session: AsyncSession, agent_definition: Agent
234
+ ):
235
+ """Test that promoting an agent config switches active status correctly."""
236
+ object_name = f"test_agent_switches_active_{uuid4().hex}"
237
+ config1_payload = AgentConfig(
238
+ system_prompt="S1", user_prompt="U1", model="m1", max_turns=1
239
+ )
240
+ config2_payload = AgentConfig(
241
+ system_prompt="S2", user_prompt="U2", model="m2", max_turns=2
242
+ )
243
+
244
+ config1 = await agent_configuration.write_config(object_name, config1_payload)
245
+ config2 = await agent_configuration.write_config(object_name, config2_payload)
246
+
247
+ await agent_configuration.promote_config(config1.id)
248
+ await agent_configuration.promote_config(config2.id)
249
+
250
+ configs = await agent_configuration.read_configs_with_default(
251
+ object_name, agent_definition.to_config()
252
+ )
253
+
254
+ assert len(configs) == 3
255
+ active_configs = [c for c in configs if c.active]
256
+ inactive_configs = [c for c in configs if not c.active]
257
+
258
+ assert len(active_configs) == 1
259
+ assert active_configs[0].id == config2.id
260
+ assert len(inactive_configs) == 2
261
+
262
+ db_config1 = (
263
+ await session.exec(
264
+ select(ObjectConfiguration).where(ObjectConfiguration.id == config1.id)
265
+ )
266
+ ).first()
267
+ db_config2 = (
268
+ await session.exec(
269
+ select(ObjectConfiguration).where(ObjectConfiguration.id == config2.id)
270
+ )
271
+ ).first()
272
+ assert db_config1 is not None and db_config1.active is False
273
+ assert db_config2 is not None and db_config2.active is True
274
+
275
+ async def test_promote_default_rule_config_reverts(
276
+ self, session: AsyncSession, rule_definition: Rule
277
+ ):
278
+ """Test promoting default rule config (UUID all zeros) reverts to original."""
279
+ ObjectRegistry.get_instance().register(rule_definition)
280
+ rule_config = RuleEngineConfig(jdm=create_jdm_graph(rule_definition))
281
+ config = await rule_configuration.write_config(
282
+ rule_definition.name, rule_config
283
+ )
284
+
285
+ await rule_configuration.promote_config(config.id)
286
+ db_config_promoted = (
287
+ await session.exec(
288
+ select(ObjectConfiguration).where(ObjectConfiguration.id == config.id)
289
+ )
290
+ ).first()
291
+ assert db_config_promoted is not None and db_config_promoted.active is True
292
+
293
+ await session.commit()
294
+
295
+ await rule_configuration.promote_config(
296
+ DEFAULT_UUID, object_name=rule_definition.name
297
+ )
298
+
299
+ configs = await rule_configuration.read_configs_with_default(
300
+ rule_definition.name, rule_definition.to_config()
301
+ )
302
+
303
+ assert len(configs) == 2
304
+ assert configs[0].version == 1
305
+ assert configs[1].version == 0
306
+
307
+ assert configs[0].active is False
308
+ assert configs[1].active is True
309
+
310
+ db_config_reverted = (
311
+ await session.exec(
312
+ select(ObjectConfiguration).where(ObjectConfiguration.id == config.id)
313
+ )
314
+ ).first()
315
+ assert db_config_reverted is not None and db_config_reverted.active is False
316
+
317
+ async def test_promote_default_agent_config_reverts(
318
+ self, session: AsyncSession, agent_definition: Agent
319
+ ):
320
+ """Test promoting default agent config (UUID all zeros) reverts."""
321
+ object_name = f"test_agent_revert_default_{uuid4().hex}"
322
+ agent_config = AgentConfig(
323
+ system_prompt="S", user_prompt="U", model="m", max_turns=1
324
+ )
325
+ config = await agent_configuration.write_config(object_name, agent_config)
326
+
327
+ await agent_configuration.promote_config(config.id)
328
+ db_config_promoted = (
329
+ await session.exec(
330
+ select(ObjectConfiguration).where(ObjectConfiguration.id == config.id)
331
+ )
332
+ ).first()
333
+ assert db_config_promoted is not None and db_config_promoted.active is True
334
+
335
+ await session.commit()
336
+
337
+ await agent_configuration.promote_config(DEFAULT_UUID, object_name=object_name)
338
+
339
+ configs = await agent_configuration.read_configs_with_default(
340
+ object_name, agent_definition.to_config()
341
+ )
342
+
343
+ assert len(configs) == 2
344
+
345
+ assert configs[0].version == 1
346
+ assert configs[1].version == 0
347
+
348
+ assert configs[0].active is False
349
+ assert configs[1].active is True
350
+
351
+ db_config_reverted = (
352
+ await session.exec(
353
+ select(ObjectConfiguration).where(ObjectConfiguration.id == config.id)
354
+ )
355
+ ).first()
356
+ assert db_config_reverted is not None and db_config_reverted.active is False
357
+
358
+ async def test_promote_nonexistent_config_raises_error(self, session: AsyncSession):
359
+ """Test promoting a non-existent config ID raises ConfigNotFoundError."""
360
+ nonexistent_id = uuid4()
361
+ with pytest.raises(ConfigNotFoundError) as excinfo_rule:
362
+ await rule_configuration.promote_config(nonexistent_id)
363
+ assert excinfo_rule.value.invalid_id == nonexistent_id
364
+ assert excinfo_rule.value.object_type == ConfigurableObjectType.RULE
365
+
366
+ with pytest.raises(ConfigNotFoundError) as excinfo_agent:
367
+ await agent_configuration.promote_config(nonexistent_id)
368
+ assert excinfo_agent.value.invalid_id == nonexistent_id
369
+ assert excinfo_agent.value.object_type == ConfigurableObjectType.AGENT
370
+
371
+ async def test_promote_default_config_requires_object_name(
372
+ self, session: AsyncSession
373
+ ):
374
+ """Test promoting default UUID requires object_name parameter."""
375
+ with pytest.raises(
376
+ ValueError,
377
+ match="object_name is required when reverting to default configuration",
378
+ ):
379
+ await rule_configuration.promote_config(DEFAULT_UUID)
380
+ with pytest.raises(
381
+ ValueError,
382
+ match="object_name is required when reverting to default configuration",
383
+ ):
384
+ await agent_configuration.promote_config(DEFAULT_UUID)
385
+
386
+ async def test_default_config_is_active_when_no_other_configs_present(
387
+ self, session: AsyncSession, rule_definition: Rule, agent_definition: Agent
388
+ ):
389
+ """Test default config is active when no custom configs exist for the object_name."""
390
+ object_name_rule = f"nonexistent_rule_for_default_{uuid4().hex}"
391
+ object_name_agent = f"nonexistent_agent_for_default_{uuid4().hex}"
392
+
393
+ configs = await rule_configuration.read_configs_with_default(
394
+ object_name_rule, rule_definition.to_config()
395
+ )
396
+ assert len(configs) == 1
397
+ assert configs[0].version == 0
398
+ assert configs[0].active is True
399
+
400
+ await agent_configuration.promote_config(
401
+ DEFAULT_UUID, object_name=object_name_agent
402
+ )
403
+ configs = await agent_configuration.read_configs_with_default(
404
+ object_name_agent, agent_definition.to_config()
405
+ )
406
+ assert len(configs) == 1
407
+ assert configs[0].version == 0
408
+ assert configs[0].active is True
409
+
410
+ async def test_promote_returns_all_configs_ordered(
411
+ self, session: AsyncSession, agent_definition: Agent
412
+ ):
413
+ """Test that promote_config returns all configurations for the object_name, ordered by version."""
414
+ object_name = f"test_agent_promote_all_ordered_{uuid4().hex}"
415
+ created_configs = []
416
+ for i in range(3):
417
+ config = AgentConfig(
418
+ system_prompt=f"Config {i}",
419
+ user_prompt=f"UP {i}",
420
+ model="m",
421
+ max_turns=i + 1,
422
+ )
423
+ cfg = await agent_configuration.write_config(object_name, config)
424
+ created_configs.append(cfg)
425
+
426
+ # Promote the middle config (chronologically, so it's version 2, index 1 if created_configs is in creation order)
427
+ # Assuming versions are 1, 2, 3. Created_configs[1] is version 2.
428
+ # The versions will be 3, 2, 1 in the database after 3 writes.
429
+ # So created_configs[0] (first written) would be version 1.
430
+ # created_configs[1] (second written) would be version 2.
431
+ # created_configs[2] (third written) would be version 3.
432
+
433
+ # Let's fetch all configs to be sure about IDs and versions before promoting
434
+ all_configs_before_promote = (
435
+ await agent_configuration.read_configs_with_default(
436
+ object_name, agent_definition.to_config()
437
+ )
438
+ )
439
+ ids_by_version_desc = [
440
+ c.id for c in all_configs_before_promote
441
+ ] # Should be [id_v3, id_v2, id_v1, id_default]
442
+
443
+ # Verify that the default config is active by default
444
+ default_config = all_configs_before_promote[-1]
445
+ assert default_config.active is True, (
446
+ "Default config should be active by default"
447
+ )
448
+
449
+ # Promote the one that corresponds to original version 2 (second created)
450
+ # This ID would be ids_by_version_desc[1]
451
+ config_to_promote_id = ids_by_version_desc[1]
452
+
453
+ await agent_configuration.promote_config(config_to_promote_id)
454
+
455
+ all_configs_after_promote = await agent_configuration.read_configs_with_default(
456
+ object_name, agent_definition.to_config()
457
+ )
458
+
459
+ assert len(all_configs_after_promote) == 4
460
+
461
+ versions = [config.version for config in all_configs_after_promote]
462
+ assert versions == sorted(versions, reverse=True), (
463
+ "Configs should be sorted by version descending"
464
+ )
465
+
466
+ active_configs = [
467
+ config for config in all_configs_after_promote if config.active
468
+ ]
469
+ assert len(active_configs) == 1
470
+ assert active_configs[0].id == config_to_promote_id
471
+
472
+ async def test_promote_multiple_configs_only_one_active_rule(
473
+ self, session: AsyncSession, rule_definition: Rule
474
+ ):
475
+ """Test that when multiple rule configs exist, only the promoted one is active."""
476
+ ObjectRegistry.get_instance().register(rule_definition)
477
+
478
+ config_ids = []
479
+ for i in range(3):
480
+ rule_config = RuleEngineConfig(jdm=create_jdm_graph(rule_definition))
481
+ cfg = await rule_configuration.write_config(
482
+ rule_definition.name, rule_config
483
+ )
484
+ config_ids.append(cfg.id)
485
+
486
+ # Config_ids are in order of creation: [id_v1, id_v2, id_v3]
487
+ # After all writes, read_configs would return them ordered [v3, v2, v1]
488
+ # Let's promote the middle one created (which would be version 2)
489
+ # To get its ID robustly, let's re-fetch and pick based on version or order.
490
+ all_cfgs = await rule_configuration._read_configs(rule_definition.name)
491
+
492
+ # all_cfgs.configs is sorted by version desc (e.g. [v3, v2, v1])
493
+ # We want to promote the one with original version 2. This is all_cfgs.configs[1]
494
+ id_to_promote = all_cfgs[1].id
495
+
496
+ await rule_configuration.promote_config(id_to_promote)
497
+
498
+ configs = await rule_configuration.read_configs_with_default(
499
+ rule_definition.name, rule_definition.to_config()
500
+ )
501
+
502
+ assert len(configs) == 4
503
+ active_found = [c for c in configs if c.active]
504
+ assert len(active_found) == 1
505
+ assert active_found[0].id == id_to_promote
506
+
507
+ # Verify in database: iterate through all initially created config IDs
508
+ # and check their active status. Only id_to_promote should be active.
509
+ # config_ids was populated in order of creation: [id_for_v1, id_for_v2, id_for_v3]
510
+ # id_to_promote corresponds to the one with version 2 (the second one created).
511
+ for created_id in config_ids:
512
+ db_config = (
513
+ await session.exec(
514
+ select(ObjectConfiguration).where(
515
+ ObjectConfiguration.id == created_id
516
+ )
517
+ )
518
+ ).first()
519
+ assert db_config is not None, f"Config with ID {created_id} not found in DB"
520
+ if created_id == id_to_promote:
521
+ assert db_config.active is True, (
522
+ f"Promoted config {created_id} should be active"
523
+ )
524
+ else:
525
+ assert db_config.active is False, (
526
+ f"Non-promoted config {created_id} should be inactive"
527
+ )
@@ -0,0 +1,14 @@
1
+ from planar.registry_items import create_pydantic_model_for_workflow
2
+ from planar.workflows.decorators import workflow
3
+
4
+
5
+ @workflow()
6
+ async def sample_workflow(foo: int, bar: str = "baz"):
7
+ pass
8
+
9
+
10
+ def test_create_pydantic_model_for_workflow_strips_module_name():
11
+ model_cls = create_pydantic_model_for_workflow(sample_workflow)
12
+ assert model_cls.__name__ == "SampleWorkflowStartRequest"
13
+ assert "foo" in model_cls.model_fields
14
+ assert "bar" in model_cls.model_fields
@@ -0,0 +1,158 @@
1
+ from uuid import uuid4
2
+
3
+ import pytest
4
+ from sqlalchemy.exc import DBAPIError
5
+ from sqlalchemy.ext.asyncio import AsyncEngine
6
+ from sqlmodel import col, insert, select
7
+
8
+ from planar.db import PlanarSession, new_session
9
+ from planar.modeling.orm.planar_base_entity import PlanarBaseEntity
10
+
11
+
12
+ class SomeModel(PlanarBaseEntity, table=True):
13
+ name: str
14
+ value: int = 0
15
+
16
+
17
+ async def test_run_transaction_success(tmp_db_engine):
18
+ uuid = uuid4()
19
+ uuid2 = uuid4()
20
+
21
+ async def transaction_func(session: PlanarSession):
22
+ await session.exec(
23
+ insert(SomeModel).values(id=uuid, name="test_item", value=42) # type: ignore
24
+ )
25
+ await session.exec(
26
+ insert(SomeModel).values(id=uuid2, name="test_item2", value=42) # type: ignore
27
+ )
28
+
29
+ async with new_session(tmp_db_engine) as session:
30
+ session.max_conflict_retries = 3
31
+ await session.run_transaction(transaction_func, session)
32
+
33
+ async with new_session(tmp_db_engine) as session:
34
+ items = (
35
+ await session.exec(select(SomeModel).order_by(col(SomeModel.name)))
36
+ ).all()
37
+ assert items == [
38
+ SomeModel(id=uuid, name="test_item", value=42),
39
+ SomeModel(id=uuid2, name="test_item2", value=42),
40
+ ]
41
+
42
+
43
+ async def test_run_transaction_failure(tmp_db_engine):
44
+ async def transaction_func(session: PlanarSession):
45
+ await session.exec(insert(SomeModel).values(name="test_item", value=42)) # type: ignore
46
+ raise ValueError("Test error")
47
+ await session.exec(insert(SomeModel).values(name="test_item2", value=42)) # type: ignore
48
+
49
+ async with new_session(tmp_db_engine) as session:
50
+ with pytest.raises(ValueError, match="Test error"):
51
+ session.max_conflict_retries = 3
52
+ await session.run_transaction(transaction_func, session)
53
+
54
+ async with new_session(tmp_db_engine) as session:
55
+ items = (await session.exec(select(SomeModel))).all()
56
+ assert items == []
57
+
58
+
59
+ async def test_run_transaction_concurrent_retry_success(tmp_db_engine):
60
+ attempts = 0
61
+ uuid = uuid4()
62
+ uuid2 = uuid4()
63
+
64
+ async def transaction_func(session: PlanarSession):
65
+ nonlocal attempts
66
+ await session.exec(
67
+ insert(SomeModel).values(id=uuid, name="test_item", value=42) # type: ignore
68
+ )
69
+ if attempts == 0:
70
+ attempts += 1
71
+ raise DBAPIError(
72
+ "Test error", None, Exception("could not serialize access")
73
+ )
74
+ await session.exec(
75
+ insert(SomeModel).values(id=uuid2, name="test_item2", value=42) # type: ignore
76
+ )
77
+
78
+ async with new_session(tmp_db_engine) as session:
79
+ session.max_conflict_retries = 1
80
+ await session.run_transaction(transaction_func, session)
81
+
82
+ async with new_session(tmp_db_engine) as session:
83
+ items = (
84
+ await session.exec(select(SomeModel).order_by(col(SomeModel.name)))
85
+ ).all()
86
+ assert items == [
87
+ SomeModel(id=uuid, name="test_item", value=42),
88
+ SomeModel(id=uuid2, name="test_item2", value=42),
89
+ ]
90
+
91
+
92
+ async def test_run_transaction_concurrent_retry_failure(tmp_db_engine):
93
+ attempts = 0
94
+
95
+ async def transaction_func(session: PlanarSession):
96
+ nonlocal attempts
97
+ await session.exec(insert(SomeModel).values(name="test_item", value=42)) # type: ignore
98
+ if attempts < 2:
99
+ attempts += 1
100
+ raise DBAPIError(
101
+ "Test error", None, Exception("could not serialize access")
102
+ )
103
+ await session.exec(insert(SomeModel).values(name="test_item2", value=42)) # type: ignore
104
+
105
+ async with new_session(tmp_db_engine) as session:
106
+ with pytest.raises(DBAPIError, match="Test error"):
107
+ session.max_conflict_retries = 1
108
+ await session.run_transaction(transaction_func, session)
109
+
110
+ async with new_session(tmp_db_engine) as session:
111
+ items = (await session.exec(select(SomeModel))).all()
112
+ assert items == []
113
+
114
+
115
+ async def test_serializable_transaction_failure_1(tmp_db_engine: AsyncEngine):
116
+ if tmp_db_engine.dialect.name != "postgresql":
117
+ return pytest.skip("Test requires PostgreSQL database")
118
+
119
+ async with new_session(tmp_db_engine) as setup_session:
120
+ # Setup: Insert initial data
121
+ async with setup_session.begin():
122
+ setup_session.add(SomeModel(id=uuid4(), name="initial", value=10))
123
+
124
+ # Create two sessions
125
+ async with (
126
+ new_session(tmp_db_engine) as session1,
127
+ new_session(tmp_db_engine) as session2,
128
+ ):
129
+ # Begin transactions in both sessions
130
+ await session1.begin()
131
+ await session2.begin()
132
+
133
+ # Set serializable isolation level
134
+ await session1.set_serializable_isolation()
135
+ await session2.set_serializable_isolation()
136
+
137
+ # Session 1: Read data
138
+ item1 = (
139
+ await session1.exec(select(SomeModel).where(SomeModel.name == "initial"))
140
+ ).one()
141
+ assert item1.value == 10
142
+
143
+ # Session 2: Read the same data
144
+ item2 = (
145
+ await session2.exec(select(SomeModel).where(SomeModel.name == "initial"))
146
+ ).one()
147
+ assert item2.value == 10
148
+
149
+ # Both sessions update the same row
150
+ item1.value += 5
151
+ item2.value += 3
152
+
153
+ # Session 1: Commit should succeed
154
+ await session1.commit()
155
+
156
+ # Session 2: Commit should fail with serialization error
157
+ with pytest.raises(DBAPIError, match="could not serialize access"):
158
+ await session2.commit()