planar 0.5.0__py3-none-any.whl → 0.8.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 (211) hide show
  1. planar/_version.py +1 -1
  2. planar/ai/agent.py +155 -283
  3. planar/ai/agent_base.py +170 -0
  4. planar/ai/agent_utils.py +7 -0
  5. planar/ai/pydantic_ai.py +638 -0
  6. planar/ai/test_agent_serialization.py +1 -1
  7. planar/app.py +64 -20
  8. planar/cli.py +39 -27
  9. planar/config.py +45 -36
  10. planar/db/db.py +2 -1
  11. planar/files/storage/azure_blob.py +343 -0
  12. planar/files/storage/base.py +7 -0
  13. planar/files/storage/config.py +70 -7
  14. planar/files/storage/s3.py +6 -6
  15. planar/files/storage/test_azure_blob.py +435 -0
  16. planar/logging/formatter.py +17 -4
  17. planar/logging/test_formatter.py +327 -0
  18. planar/registry_items.py +2 -1
  19. planar/routers/agents_router.py +3 -1
  20. planar/routers/files.py +11 -2
  21. planar/routers/models.py +14 -1
  22. planar/routers/test_agents_router.py +1 -1
  23. planar/routers/test_files_router.py +49 -0
  24. planar/routers/test_routes_security.py +5 -7
  25. planar/routers/test_workflow_router.py +270 -3
  26. planar/routers/workflow.py +95 -36
  27. planar/rules/models.py +36 -39
  28. planar/rules/test_data/account_dormancy_management.json +223 -0
  29. planar/rules/test_data/airline_loyalty_points_calculator.json +262 -0
  30. planar/rules/test_data/applicant_risk_assessment.json +435 -0
  31. planar/rules/test_data/booking_fraud_detection.json +407 -0
  32. planar/rules/test_data/cellular_data_rollover_system.json +258 -0
  33. planar/rules/test_data/clinical_trial_eligibility_screener.json +437 -0
  34. planar/rules/test_data/customer_lifetime_value.json +143 -0
  35. planar/rules/test_data/import_duties_calculator.json +289 -0
  36. planar/rules/test_data/insurance_prior_authorization.json +443 -0
  37. planar/rules/test_data/online_check_in_eligibility_system.json +254 -0
  38. planar/rules/test_data/order_consolidation_system.json +375 -0
  39. planar/rules/test_data/portfolio_risk_monitor.json +471 -0
  40. planar/rules/test_data/supply_chain_risk.json +253 -0
  41. planar/rules/test_data/warehouse_cross_docking.json +237 -0
  42. planar/rules/test_rules.py +750 -6
  43. planar/scaffold_templates/planar.dev.yaml.j2 +6 -6
  44. planar/scaffold_templates/planar.prod.yaml.j2 +9 -5
  45. planar/scaffold_templates/pyproject.toml.j2 +1 -1
  46. planar/security/auth_context.py +21 -0
  47. planar/security/{jwt_middleware.py → auth_middleware.py} +70 -17
  48. planar/security/authorization.py +9 -15
  49. planar/security/tests/test_auth_middleware.py +162 -0
  50. planar/sse/proxy.py +4 -9
  51. planar/test_app.py +92 -1
  52. planar/test_cli.py +81 -59
  53. planar/test_config.py +17 -14
  54. planar/testing/fixtures.py +325 -0
  55. planar/testing/planar_test_client.py +5 -2
  56. planar/utils.py +41 -1
  57. planar/workflows/execution.py +1 -1
  58. planar/workflows/orchestrator.py +5 -0
  59. planar/workflows/serialization.py +12 -6
  60. planar/workflows/step_core.py +3 -1
  61. planar/workflows/test_serialization.py +9 -1
  62. {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/METADATA +30 -5
  63. planar-0.8.0.dist-info/RECORD +166 -0
  64. planar/.__init__.py.un~ +0 -0
  65. planar/._version.py.un~ +0 -0
  66. planar/.app.py.un~ +0 -0
  67. planar/.cli.py.un~ +0 -0
  68. planar/.config.py.un~ +0 -0
  69. planar/.context.py.un~ +0 -0
  70. planar/.db.py.un~ +0 -0
  71. planar/.di.py.un~ +0 -0
  72. planar/.engine.py.un~ +0 -0
  73. planar/.files.py.un~ +0 -0
  74. planar/.log_context.py.un~ +0 -0
  75. planar/.log_metadata.py.un~ +0 -0
  76. planar/.logging.py.un~ +0 -0
  77. planar/.object_registry.py.un~ +0 -0
  78. planar/.otel.py.un~ +0 -0
  79. planar/.server.py.un~ +0 -0
  80. planar/.session.py.un~ +0 -0
  81. planar/.sqlalchemy.py.un~ +0 -0
  82. planar/.task_local.py.un~ +0 -0
  83. planar/.test_app.py.un~ +0 -0
  84. planar/.test_config.py.un~ +0 -0
  85. planar/.test_object_config.py.un~ +0 -0
  86. planar/.test_sqlalchemy.py.un~ +0 -0
  87. planar/.test_utils.py.un~ +0 -0
  88. planar/.util.py.un~ +0 -0
  89. planar/.utils.py.un~ +0 -0
  90. planar/ai/.__init__.py.un~ +0 -0
  91. planar/ai/._models.py.un~ +0 -0
  92. planar/ai/.agent.py.un~ +0 -0
  93. planar/ai/.agent_utils.py.un~ +0 -0
  94. planar/ai/.events.py.un~ +0 -0
  95. planar/ai/.files.py.un~ +0 -0
  96. planar/ai/.models.py.un~ +0 -0
  97. planar/ai/.providers.py.un~ +0 -0
  98. planar/ai/.pydantic_ai.py.un~ +0 -0
  99. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  100. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  101. planar/ai/.step.py.un~ +0 -0
  102. planar/ai/.test_agent.py.un~ +0 -0
  103. planar/ai/.test_agent_serialization.py.un~ +0 -0
  104. planar/ai/.test_providers.py.un~ +0 -0
  105. planar/ai/.utils.py.un~ +0 -0
  106. planar/ai/providers.py +0 -1088
  107. planar/ai/test_agent.py +0 -1298
  108. planar/ai/test_providers.py +0 -463
  109. planar/db/.db.py.un~ +0 -0
  110. planar/files/.config.py.un~ +0 -0
  111. planar/files/.local.py.un~ +0 -0
  112. planar/files/.local_filesystem.py.un~ +0 -0
  113. planar/files/.model.py.un~ +0 -0
  114. planar/files/.models.py.un~ +0 -0
  115. planar/files/.s3.py.un~ +0 -0
  116. planar/files/.storage.py.un~ +0 -0
  117. planar/files/.test_files.py.un~ +0 -0
  118. planar/files/storage/.__init__.py.un~ +0 -0
  119. planar/files/storage/.base.py.un~ +0 -0
  120. planar/files/storage/.config.py.un~ +0 -0
  121. planar/files/storage/.context.py.un~ +0 -0
  122. planar/files/storage/.local_directory.py.un~ +0 -0
  123. planar/files/storage/.test_local_directory.py.un~ +0 -0
  124. planar/files/storage/.test_s3.py.un~ +0 -0
  125. planar/human/.human.py.un~ +0 -0
  126. planar/human/.test_human.py.un~ +0 -0
  127. planar/logging/.__init__.py.un~ +0 -0
  128. planar/logging/.attributes.py.un~ +0 -0
  129. planar/logging/.formatter.py.un~ +0 -0
  130. planar/logging/.logger.py.un~ +0 -0
  131. planar/logging/.otel.py.un~ +0 -0
  132. planar/logging/.tracer.py.un~ +0 -0
  133. planar/modeling/.mixin.py.un~ +0 -0
  134. planar/modeling/.storage.py.un~ +0 -0
  135. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  136. planar/object_config/.object_config.py.un~ +0 -0
  137. planar/routers/.__init__.py.un~ +0 -0
  138. planar/routers/.agents_router.py.un~ +0 -0
  139. planar/routers/.crud.py.un~ +0 -0
  140. planar/routers/.decision.py.un~ +0 -0
  141. planar/routers/.event.py.un~ +0 -0
  142. planar/routers/.file_attachment.py.un~ +0 -0
  143. planar/routers/.files.py.un~ +0 -0
  144. planar/routers/.files_router.py.un~ +0 -0
  145. planar/routers/.human.py.un~ +0 -0
  146. planar/routers/.info.py.un~ +0 -0
  147. planar/routers/.models.py.un~ +0 -0
  148. planar/routers/.object_config_router.py.un~ +0 -0
  149. planar/routers/.rule.py.un~ +0 -0
  150. planar/routers/.test_object_config_router.py.un~ +0 -0
  151. planar/routers/.test_workflow_router.py.un~ +0 -0
  152. planar/routers/.workflow.py.un~ +0 -0
  153. planar/rules/.decorator.py.un~ +0 -0
  154. planar/rules/.runner.py.un~ +0 -0
  155. planar/rules/.test_rules.py.un~ +0 -0
  156. planar/security/.jwt_middleware.py.un~ +0 -0
  157. planar/sse/.constants.py.un~ +0 -0
  158. planar/sse/.example.html.un~ +0 -0
  159. planar/sse/.hub.py.un~ +0 -0
  160. planar/sse/.model.py.un~ +0 -0
  161. planar/sse/.proxy.py.un~ +0 -0
  162. planar/testing/.client.py.un~ +0 -0
  163. planar/testing/.memory_storage.py.un~ +0 -0
  164. planar/testing/.planar_test_client.py.un~ +0 -0
  165. planar/testing/.predictable_tracer.py.un~ +0 -0
  166. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  167. planar/testing/.test_memory_storage.py.un~ +0 -0
  168. planar/testing/.workflow_observer.py.un~ +0 -0
  169. planar/workflows/.__init__.py.un~ +0 -0
  170. planar/workflows/.builtin_steps.py.un~ +0 -0
  171. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  172. planar/workflows/.context.py.un~ +0 -0
  173. planar/workflows/.contrib.py.un~ +0 -0
  174. planar/workflows/.decorators.py.un~ +0 -0
  175. planar/workflows/.durable_test.py.un~ +0 -0
  176. planar/workflows/.errors.py.un~ +0 -0
  177. planar/workflows/.events.py.un~ +0 -0
  178. planar/workflows/.exceptions.py.un~ +0 -0
  179. planar/workflows/.execution.py.un~ +0 -0
  180. planar/workflows/.human.py.un~ +0 -0
  181. planar/workflows/.lock.py.un~ +0 -0
  182. planar/workflows/.misc.py.un~ +0 -0
  183. planar/workflows/.model.py.un~ +0 -0
  184. planar/workflows/.models.py.un~ +0 -0
  185. planar/workflows/.notifications.py.un~ +0 -0
  186. planar/workflows/.orchestrator.py.un~ +0 -0
  187. planar/workflows/.runtime.py.un~ +0 -0
  188. planar/workflows/.serialization.py.un~ +0 -0
  189. planar/workflows/.step.py.un~ +0 -0
  190. planar/workflows/.step_core.py.un~ +0 -0
  191. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  192. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  193. planar/workflows/.test_concurrency.py.un~ +0 -0
  194. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  195. planar/workflows/.test_human.py.un~ +0 -0
  196. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  197. planar/workflows/.test_orchestrator.py.un~ +0 -0
  198. planar/workflows/.test_race_conditions.py.un~ +0 -0
  199. planar/workflows/.test_serialization.py.un~ +0 -0
  200. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  201. planar/workflows/.test_workflow.py.un~ +0 -0
  202. planar/workflows/.tracing.py.un~ +0 -0
  203. planar/workflows/.types.py.un~ +0 -0
  204. planar/workflows/.util.py.un~ +0 -0
  205. planar/workflows/.utils.py.un~ +0 -0
  206. planar/workflows/.workflow.py.un~ +0 -0
  207. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  208. planar/workflows/.wrappers.py.un~ +0 -0
  209. planar-0.5.0.dist-info/RECORD +0 -289
  210. {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/WHEEL +0 -0
  211. {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/entry_points.txt +0 -0
planar/test_cli.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
- from unittest.mock import MagicMock, patch
2
+ from unittest.mock import patch
3
3
 
4
4
  import pytest
5
5
  import typer
@@ -14,11 +14,9 @@ def cli_runner():
14
14
 
15
15
 
16
16
  @pytest.fixture
17
- def mock_planar_server():
18
- with patch("planar.cli.PlanarServer") as mock_server_class:
19
- mock_server_instance = MagicMock()
20
- mock_server_class.return_value = mock_server_instance
21
- yield mock_server_class, mock_server_instance
17
+ def mock_uvicorn_run():
18
+ with patch("planar.cli.uvicorn.run") as mock_run:
19
+ yield mock_run
22
20
 
23
21
 
24
22
  @pytest.fixture
@@ -35,7 +33,7 @@ def mock_path_is_file():
35
33
 
36
34
  class TestDevCommand:
37
35
  def test_dev_command_defaults(
38
- self, cli_runner, mock_planar_server, mock_path_is_file
36
+ self, cli_runner, mock_uvicorn_run, mock_path_is_file
39
37
  ):
40
38
  """Test that 'planar dev' sets correct defaults and invokes uvicorn.run."""
41
39
  env = {}
@@ -43,15 +41,15 @@ class TestDevCommand:
43
41
  result = cli_runner.invoke(app, ["dev"])
44
42
 
45
43
  assert result.exit_code == 0
46
- mock_planar_server[1].run.assert_called_once()
44
+ mock_uvicorn_run.assert_called_once()
47
45
 
48
46
  # Verify uvicorn.run arguments
49
- run_args = mock_planar_server[0].call_args[0]
50
- config = run_args[0]
51
- assert config.app == "app:app"
52
- assert config.reload is True
53
- assert config.host == "127.0.0.1"
54
- assert config.port == 8000
47
+ call_args = mock_uvicorn_run.call_args
48
+ assert call_args[0][0] == "app:app"
49
+ assert call_args.kwargs["reload"] is True
50
+ assert call_args.kwargs["host"] == "127.0.0.1"
51
+ assert call_args.kwargs["port"] == 8000
52
+ assert call_args.kwargs["timeout_graceful_shutdown"] == 4
55
53
 
56
54
  # Verify environment variable
57
55
  assert env["PLANAR_ENV"] == "dev"
@@ -59,7 +57,7 @@ class TestDevCommand:
59
57
 
60
58
  class TestProdCommand:
61
59
  def test_prod_command_defaults(
62
- self, cli_runner, mock_planar_server, mock_path_is_file
60
+ self, cli_runner, mock_uvicorn_run, mock_path_is_file
63
61
  ):
64
62
  """Test that 'planar prod' sets correct defaults and invokes uvicorn.run."""
65
63
  env = {}
@@ -67,15 +65,15 @@ class TestProdCommand:
67
65
  result = cli_runner.invoke(app, ["prod"])
68
66
 
69
67
  assert result.exit_code == 0
70
- mock_planar_server[1].run.assert_called_once()
68
+ mock_uvicorn_run.assert_called_once()
71
69
 
72
70
  # Verify uvicorn.run arguments
73
- run_args = mock_planar_server[0].call_args[0]
74
- config = run_args[0]
75
- assert config.app == "app:app"
76
- assert config.reload is False
77
- assert config.host == "0.0.0.0"
78
- assert config.port == 8000
71
+ call_args = mock_uvicorn_run.call_args
72
+ assert call_args[0][0] == "app:app"
73
+ assert call_args.kwargs["reload"] is False
74
+ assert call_args.kwargs["host"] == "0.0.0.0"
75
+ assert call_args.kwargs["port"] == 8000
76
+ assert call_args.kwargs["timeout_graceful_shutdown"] == 4
79
77
 
80
78
  # Verify environment variable
81
79
  assert env["PLANAR_ENV"] == "prod"
@@ -84,7 +82,7 @@ class TestProdCommand:
84
82
  class TestArgumentParsing:
85
83
  @pytest.mark.parametrize("command", ["dev", "prod"])
86
84
  def test_custom_port(
87
- self, cli_runner, mock_planar_server, mock_path_is_file, command
85
+ self, cli_runner, mock_uvicorn_run, mock_path_is_file, command
88
86
  ):
89
87
  """Test custom port settings for both dev and prod commands."""
90
88
  port = "9999"
@@ -92,54 +90,80 @@ class TestArgumentParsing:
92
90
  result = cli_runner.invoke(app, [command, "--port", port])
93
91
 
94
92
  assert result.exit_code == 0
95
- mock_planar_server[1].run.assert_called_once()
93
+ mock_uvicorn_run.assert_called_once()
96
94
 
97
95
  # Verify config arguments
98
- run_args = mock_planar_server[0].call_args[0]
99
- config = run_args[0]
100
- assert config.port == int(port)
96
+ call_args = mock_uvicorn_run.call_args
97
+ assert call_args.kwargs["port"] == int(port)
101
98
 
102
99
  @pytest.mark.parametrize("command", ["dev", "prod"])
103
100
  def test_custom_host(
104
- self, cli_runner, mock_planar_server, mock_path_is_file, command
101
+ self, cli_runner, mock_uvicorn_run, mock_path_is_file, command
105
102
  ):
106
- """Test custom host settings for both dev and prod commands."""
107
- host = "1.1.1.1"
103
+ """Test custom host settings."""
104
+ host = "0.0.0.0"
108
105
  with patch("os.environ", {}):
109
106
  result = cli_runner.invoke(app, [command, "--host", host])
110
107
 
111
108
  assert result.exit_code == 0
112
- mock_planar_server[1].run.assert_called_once()
109
+ mock_uvicorn_run.assert_called_once()
113
110
 
114
111
  # Verify config arguments
115
- run_args = mock_planar_server[0].call_args[0]
116
- config = run_args[0]
117
- assert config.host == host
112
+ call_args = mock_uvicorn_run.call_args
113
+ assert call_args.kwargs["host"] == host
118
114
 
119
115
  @pytest.mark.parametrize("command", ["dev", "prod"])
120
116
  def test_custom_app_name(
121
- self, cli_runner, mock_planar_server, mock_path_is_file, command
117
+ self, cli_runner, mock_uvicorn_run, mock_path_is_file, command
122
118
  ):
123
- """Test specifying --app custom_app generates the correct import string."""
119
+ """Test custom app instance name."""
124
120
  with (
125
121
  patch("os.environ", {}),
126
- patch("planar.cli.get_module_str_from_path", return_value="module"),
122
+ patch("planar.cli.get_module_str_from_path", return_value="app"),
127
123
  ):
128
- result = cli_runner.invoke(app, [command, "--app", "custom_app"])
124
+ result = cli_runner.invoke(app, [command, "--app", "server"])
129
125
 
130
126
  assert result.exit_code == 0
131
- mock_planar_server[1].run.assert_called_once()
127
+ mock_uvicorn_run.assert_called_once()
132
128
 
133
129
  # Verify config arguments
134
- run_args = mock_planar_server[0].call_args[0]
135
- config = run_args[0]
136
- assert config.app == "module:custom_app"
130
+ call_args = mock_uvicorn_run.call_args
131
+ assert call_args[0][0] == "app:server"
132
+
133
+ @pytest.mark.parametrize("command", ["dev", "prod"])
134
+ def test_custom_path(
135
+ self, cli_runner, mock_uvicorn_run, mock_path_is_file, command
136
+ ):
137
+ """Test custom application path."""
138
+ path = "custom/app.py"
139
+ with (
140
+ patch("os.environ", {}),
141
+ patch("planar.cli.get_module_str_from_path", return_value="custom.app"),
142
+ ):
143
+ result = cli_runner.invoke(app, [command, path])
144
+
145
+ assert result.exit_code == 0
146
+ mock_uvicorn_run.assert_called_once()
147
+
148
+ # Verify config arguments
149
+ call_args = mock_uvicorn_run.call_args
150
+ assert call_args[0][0] == "custom.app:app"
151
+
152
+ @pytest.mark.parametrize("command", ["dev", "prod"])
153
+ def test_invalid_path(self, cli_runner, mock_uvicorn_run, command):
154
+ """Test handling of invalid path."""
155
+ with patch("pathlib.Path.is_file", return_value=False):
156
+ result = cli_runner.invoke(app, [command, "nonexistent/app.py"])
157
+
158
+ assert result.exit_code == 1
159
+ assert "not found" in result.output
160
+ mock_uvicorn_run.assert_not_called()
137
161
 
138
162
 
139
163
  class TestAppPathResolution:
140
164
  @pytest.mark.parametrize("command", ["dev", "prod"])
141
165
  def test_explicit_path(
142
- self, cli_runner, mock_planar_server, mock_path_is_file, command
166
+ self, cli_runner, mock_uvicorn_run, mock_path_is_file, command
143
167
  ):
144
168
  """Test command --path sets PLANAR_ENTRY_POINT and generates correct import string."""
145
169
  env = {}
@@ -151,12 +175,11 @@ class TestAppPathResolution:
151
175
 
152
176
  assert result.exit_code == 0
153
177
  assert env["PLANAR_ENTRY_POINT"] == "path/to/app.py"
154
- mock_planar_server[1].run.assert_called_once()
178
+ mock_uvicorn_run.assert_called_once()
155
179
 
156
180
  # Verify config arguments
157
- run_args = mock_planar_server[0].call_args[0]
158
- config = run_args[0]
159
- assert config.app == "path.to.app:app"
181
+ call_args = mock_uvicorn_run.call_args
182
+ assert call_args[0][0] == "path.to.app:app"
160
183
 
161
184
  @pytest.mark.parametrize(
162
185
  "path,error_text",
@@ -183,7 +206,7 @@ class TestDefaultPathDiscovery:
183
206
  ],
184
207
  )
185
208
  def test_default_files(
186
- self, cli_runner, mock_planar_server, file_exists, expected_path
209
+ self, cli_runner, mock_uvicorn_run, file_exists, expected_path
187
210
  ):
188
211
  """Test default file discovery works for app.py and main.py."""
189
212
  env = {}
@@ -197,7 +220,7 @@ class TestDefaultPathDiscovery:
197
220
 
198
221
  assert result.exit_code == 0
199
222
  assert env["PLANAR_ENTRY_POINT"] == str(expected_path)
200
- mock_planar_server[1].run.assert_called_once()
223
+ mock_uvicorn_run.assert_called_once()
201
224
 
202
225
  def test_no_default_files(self, cli_runner):
203
226
  """Test with neither app.py nor main.py existing: Verify exit code 1 and error message."""
@@ -241,7 +264,7 @@ class TestModuleStringConversion:
241
264
 
242
265
 
243
266
  class TestConfigFileHandling:
244
- def test_config_handling(self, cli_runner, mock_planar_server, mock_path_is_file):
267
+ def test_config_handling(self, cli_runner, mock_uvicorn_run, mock_path_is_file):
245
268
  """Test config file handling."""
246
269
  env = {}
247
270
  with patch("os.environ", env):
@@ -252,7 +275,7 @@ class TestConfigFileHandling:
252
275
 
253
276
  assert result.exit_code == 0
254
277
  assert env["PLANAR_CONFIG"] == "valid/config.yaml"
255
- mock_planar_server[1].run.assert_called_once()
278
+ mock_uvicorn_run.assert_called_once()
256
279
 
257
280
  # Test invalid config
258
281
  with patch("pathlib.Path.exists", return_value=False):
@@ -263,7 +286,7 @@ class TestConfigFileHandling:
263
286
 
264
287
 
265
288
  class TestUvicornInteraction:
266
- def test_successful_run(self, cli_runner, mock_planar_server, mock_path_is_file):
289
+ def test_successful_run(self, cli_runner, mock_uvicorn_run, mock_path_is_file):
267
290
  """Test successful uvicorn.run with correct parameters."""
268
291
  with (
269
292
  patch("os.environ", {}),
@@ -272,15 +295,14 @@ class TestUvicornInteraction:
272
295
  result = cli_runner.invoke(app, ["dev", "src/main.py"])
273
296
 
274
297
  assert result.exit_code == 0
275
- mock_planar_server[1].run.assert_called_once()
298
+ mock_uvicorn_run.assert_called_once()
276
299
 
277
300
  # Verify config arguments
278
- run_args = mock_planar_server[0].call_args[0]
279
- config = run_args[0]
280
- assert config.app == "src.main:app"
281
- assert config.host == "127.0.0.1"
282
- assert config.port == 8000
283
- assert config.reload is True
301
+ call_args = mock_uvicorn_run.call_args
302
+ assert call_args[0][0] == "src.main:app"
303
+ assert call_args.kwargs["host"] == "127.0.0.1"
304
+ assert call_args.kwargs["port"] == 8000
305
+ assert call_args.kwargs["reload"] is True
284
306
 
285
307
 
286
308
  class TestScaffoldCommand:
planar/test_config.py CHANGED
@@ -7,11 +7,11 @@ import pytest
7
7
 
8
8
  from planar.config import (
9
9
  JWT_COPLANE_CONFIG,
10
- JWT_DISABLED_CONFIG,
11
10
  LOCAL_CORS_CONFIG,
12
11
  PROD_CORS_CONFIG,
13
12
  InvalidConfigurationError,
14
13
  PostgreSQLConfig,
14
+ SecurityConfig,
15
15
  SQLiteConfig,
16
16
  load_config,
17
17
  load_environment_aware_config,
@@ -236,11 +236,12 @@ db_connections:
236
236
  app:
237
237
  db_connection: custom_db
238
238
 
239
- cors:
240
- allow_origins: ["https://custom.example.com"]
241
- allow_credentials: true
242
- allow_methods: ["GET", "POST"]
243
- allow_headers: ["Authorization"]
239
+ security:
240
+ cors:
241
+ allow_origins: ["https://custom.example.com"]
242
+ allow_credentials: true
243
+ allow_methods: ["GET", "POST"]
244
+ allow_headers: ["Authorization"]
244
245
  """
245
246
 
246
247
 
@@ -250,8 +251,8 @@ def test_load_environment_aware_config_dev_default():
250
251
  config = load_environment_aware_config()
251
252
 
252
253
  assert config.environment == "dev"
253
- assert config.cors == LOCAL_CORS_CONFIG
254
- assert config.jwt == JWT_DISABLED_CONFIG
254
+ assert config.security.cors == LOCAL_CORS_CONFIG
255
+ assert config.security.jwt is None
255
256
  assert config.app.db_connection == "app"
256
257
  assert isinstance(config.db_connections["app"], SQLiteConfig)
257
258
  assert config.db_connections["app"].path == "planar_dev.db"
@@ -265,8 +266,9 @@ def test_load_environment_aware_config_prod_default():
265
266
  config = load_environment_aware_config()
266
267
 
267
268
  assert config.environment == "prod"
268
- assert config.cors == PROD_CORS_CONFIG
269
- assert config.jwt == JWT_COPLANE_CONFIG
269
+ assert config.security == SecurityConfig(
270
+ cors=PROD_CORS_CONFIG, jwt=JWT_COPLANE_CONFIG
271
+ )
270
272
  assert config.app.db_connection == "app"
271
273
  assert isinstance(config.db_connections["app"], SQLiteConfig)
272
274
  assert config.db_connections["app"].path == "planar.db"
@@ -368,8 +370,9 @@ db_connections:
368
370
  def test_load_environment_aware_config_partial_override(temp_config_file):
369
371
  """Test that partial override configs merge correctly with defaults."""
370
372
  partial_override = """
371
- cors:
372
- allow_origins: ["https://custom.example.com"]
373
+ security:
374
+ cors:
375
+ allow_origins: ["https://custom.example.com"]
373
376
  """
374
377
 
375
378
  with open(temp_config_file, "w") as f:
@@ -383,11 +386,11 @@ cors:
383
386
  config = load_environment_aware_config()
384
387
 
385
388
  # Should override specific fields
386
- assert config.cors.allow_origins == ["https://custom.example.com"]
389
+ assert config.security.cors.allow_origins == ["https://custom.example.com"]
387
390
 
388
391
  # Should keep defaults for non-overridden fields
389
392
  assert config.app.db_connection == "app" # default
390
- assert config.cors.allow_credentials # from LOCAL_CORS_CONFIG
393
+ assert config.security.cors.allow_credentials # from LOCAL_CORS_CONFIG
391
394
  assert config.environment == "dev"
392
395
  assert isinstance(config.db_connections["app"], SQLiteConfig)
393
396
 
@@ -0,0 +1,325 @@
1
+ """
2
+ Pytest fixtures for the Planar library.
3
+
4
+ This module provides pytest fixtures that can be used both internally by the planar library
5
+ and by external users who want to test their code that uses planar.
6
+
7
+ Usage in external projects:
8
+ - By default, these fixtures are auto-loaded when planar is installed
9
+ - To disable auto-loading: set PYTEST_DISABLE_PLUGIN_AUTOLOAD=1
10
+ - To manually enable: use `uv run pytest -p planar` or add to conftest.py:
11
+ `pytest_plugins = ["planar.testing.fixtures"]`
12
+ or add to pyproject.toml:
13
+ `[project.entry-points.pytest11]
14
+ planar = "planar.testing.fixtures"
15
+ `
16
+
17
+ Available fixtures:
18
+ - storage: In-memory file storage for tests
19
+ - tmp_db_url: Parametrized database URL (SQLite/PostgreSQL)
20
+ - session: Database session
21
+ - client: Planar test client
22
+ - observer: Workflow observer for testing
23
+ - tracer: Tracer for workflow testing
24
+ """
25
+
26
+ import asyncio
27
+ import os
28
+ import subprocess
29
+ import time
30
+ import uuid
31
+ from contextlib import asynccontextmanager
32
+ from pathlib import Path
33
+
34
+ import pytest
35
+
36
+ from planar.config import load_config
37
+ from planar.db import DatabaseManager, new_session
38
+ from planar.files.storage.context import set_storage
39
+ from planar.logging import set_context_metadata
40
+ from planar.object_registry import ObjectRegistry
41
+ from planar.session import engine_var, session_var
42
+ from planar.testing.memory_storage import MemoryStorage
43
+ from planar.testing.planar_test_client import (
44
+ planar_test_client,
45
+ )
46
+ from planar.testing.workflow_observer import WorkflowObserver
47
+ from planar.workflows.tracing import Tracer
48
+
49
+ _TEST_DATABASES = []
50
+ if os.getenv("PLANAR_TEST_SQLITE", "1") == "1":
51
+ _TEST_DATABASES.append("sqlite")
52
+ if os.getenv("PLANAR_TEST_POSTGRESQL", "0") == "1":
53
+ _TEST_DATABASES.append("postgresql")
54
+
55
+
56
+ def load_logging_config():
57
+ logging_config = os.getenv("PLANAR_TEST_LOGGING_CONFIG", None)
58
+ if logging_config is None:
59
+ return None
60
+ f = Path(logging_config)
61
+ if not f.exists():
62
+ print("Logging configuration file does not exist:", f)
63
+ return None
64
+ try:
65
+ text = f.read_text()
66
+ return load_config(text)
67
+ except Exception as e:
68
+ print("Failed to load logging configuration:", e)
69
+ return None
70
+
71
+
72
+ @pytest.fixture(autouse=True, scope="session")
73
+ def configure_session_logging():
74
+ test_config = load_logging_config()
75
+ if test_config:
76
+ if test_config.otel:
77
+ test_config.otel.resource_attributes = {
78
+ **(test_config.otel.resource_attributes or {}),
79
+ "service.name": f"{time.strftime('%Y-%m-%d %H:%M:%S')}-planar-test-run",
80
+ }
81
+ test_config.configure_logging()
82
+
83
+
84
+ @pytest.fixture(autouse=True)
85
+ async def configure_test_logging(request):
86
+ # get the current test name
87
+ set_context_metadata("test.id", request.node.name)
88
+
89
+
90
+ @pytest.fixture(autouse=True)
91
+ def reset_object_registry():
92
+ """
93
+ Reset the ObjectRegistry singleton before each test to ensure clean state.
94
+ """
95
+ ObjectRegistry.get_instance().reset()
96
+ yield # Run the test
97
+
98
+
99
+ @pytest.fixture()
100
+ def tmp_db_path(tmp_path_factory):
101
+ """
102
+ Create a temporary SQLite database file and return its URL. The database
103
+ file is created in a temporary directory managed by pytest.
104
+ """
105
+ tmp_dir = tmp_path_factory.mktemp("sqlite_db")
106
+ db_file = tmp_dir / "test.db"
107
+ return str(db_file)
108
+
109
+
110
+ @pytest.fixture()
111
+ async def storage():
112
+ storage = MemoryStorage()
113
+ set_storage(storage)
114
+ yield storage
115
+
116
+
117
+ @pytest.fixture()
118
+ def tmp_sqlite_url(tmp_db_path: str):
119
+ return f"sqlite+aiosqlite:///{tmp_db_path}"
120
+
121
+
122
+ @pytest.fixture(scope="session")
123
+ def tmp_postgresql_container():
124
+ container_name = "planar-postgres-" + uuid.uuid4().hex
125
+
126
+ # Start the postgres container.
127
+ container_process = subprocess.Popen(
128
+ [
129
+ "docker",
130
+ "run",
131
+ "--rm",
132
+ "--name",
133
+ container_name,
134
+ "-e",
135
+ "POSTGRES_PASSWORD=123",
136
+ "-p",
137
+ "127.0.0.1:5432:5432",
138
+ "docker.io/library/postgres",
139
+ ],
140
+ stdout=subprocess.DEVNULL,
141
+ stderr=subprocess.DEVNULL,
142
+ )
143
+
144
+ # Wait until Postgres is ready to accept connections.
145
+ remaining_tries = 10
146
+ while remaining_tries > 0:
147
+ process = subprocess.run(
148
+ [
149
+ "docker",
150
+ "exec",
151
+ container_name,
152
+ "psql",
153
+ "-U",
154
+ "postgres",
155
+ "-c",
156
+ "CREATE DATABASE dummy;",
157
+ ],
158
+ stdout=subprocess.PIPE,
159
+ stderr=subprocess.PIPE,
160
+ )
161
+ if process.returncode != 0:
162
+ remaining_tries -= 1
163
+ if remaining_tries == 0:
164
+ # Terminate container if it fails to start.
165
+ container_process.terminate()
166
+ container_process.wait()
167
+ raise Exception("Failed to create database")
168
+ time.sleep(10)
169
+ else:
170
+ break
171
+
172
+ try:
173
+ yield container_name
174
+ finally:
175
+ container_process.terminate()
176
+ container_process.wait()
177
+
178
+
179
+ @pytest.fixture()
180
+ def tmp_postgresql_url(request):
181
+ container_name = os.getenv("PLANAR_TEST_POSTGRESQL_CONTAINER", None)
182
+
183
+ if container_name is None:
184
+ # lazy load the session-scoped container fixture, which will
185
+ # create a temporary docker container for the duration of the session
186
+ container_name = request.getfixturevalue("tmp_postgresql_container")
187
+
188
+ db_name = "test_" + uuid.uuid4().hex
189
+
190
+ process = subprocess.run(
191
+ [
192
+ "docker",
193
+ "exec",
194
+ container_name,
195
+ "psql",
196
+ "-U",
197
+ "postgres",
198
+ "-c",
199
+ f"CREATE DATABASE {db_name};",
200
+ ],
201
+ stdout=subprocess.DEVNULL,
202
+ stderr=subprocess.DEVNULL,
203
+ )
204
+
205
+ if process.returncode != 0:
206
+ raise Exception("Failed to create database")
207
+
208
+ url = f"postgresql+asyncpg://postgres:123@127.0.0.1:5432/{db_name}"
209
+
210
+ try:
211
+ yield url
212
+ finally:
213
+ kill_all_sessions = f"""
214
+ SELECT
215
+ pg_terminate_backend(pid)
216
+ FROM
217
+ pg_stat_activity
218
+ WHERE
219
+ -- don't kill my own connection!
220
+ pid <> pg_backend_pid()
221
+ -- don't kill the connections to other databases
222
+ AND datname = '{db_name}'
223
+ ;
224
+ """
225
+ process = subprocess.run(
226
+ [
227
+ "docker",
228
+ "exec",
229
+ container_name,
230
+ "psql",
231
+ "-U",
232
+ "postgres",
233
+ "-c",
234
+ kill_all_sessions,
235
+ "-c",
236
+ f"DROP DATABASE {db_name};",
237
+ ],
238
+ stdout=asyncio.subprocess.PIPE,
239
+ stderr=asyncio.subprocess.PIPE,
240
+ )
241
+ if process.returncode != 0:
242
+ raise Exception("Failed to drop database")
243
+
244
+
245
+ @pytest.fixture(params=_TEST_DATABASES)
246
+ def tmp_db_url(request):
247
+ fixture_name = f"tmp_{request.param}_url"
248
+ return request.getfixturevalue(fixture_name)
249
+
250
+
251
+ @pytest.fixture()
252
+ async def tmp_db_engine(tmp_db_url: str):
253
+ async with engine_context(tmp_db_url) as engine:
254
+ yield engine
255
+
256
+
257
+ @pytest.fixture()
258
+ async def mem_db_engine(tmp_db_engine):
259
+ # Memory databases don't work well with aiosqlite due to the "database
260
+ # table is locked" error (which doesn't respect timeouts), so just use a
261
+ # temporary db file for now until we figure out a fix.
262
+ yield tmp_db_engine
263
+
264
+ # name = uuid4()
265
+ # async with engine_context(
266
+ # f"sqlite+aiosqlite:///file:{name}?mode=memory&cache=shared&uri=true"
267
+ # ) as engine:
268
+ # yield engine
269
+
270
+
271
+ @pytest.fixture(name="session")
272
+ async def session_fixture(mem_db_engine):
273
+ async with new_session(mem_db_engine) as session:
274
+ tok = session_var.set(session)
275
+ yield session
276
+ session_var.reset(tok)
277
+
278
+
279
+ @pytest.fixture(name="observer")
280
+ def workflow_observer_fixture():
281
+ yield WorkflowObserver()
282
+
283
+
284
+ @pytest.fixture(name="client")
285
+ async def planar_test_client_fixture(
286
+ request,
287
+ tmp_db_url: str,
288
+ tracer: Tracer,
289
+ observer: WorkflowObserver,
290
+ storage: MemoryStorage,
291
+ ):
292
+ """
293
+ Create a PlanarTestClient for testing.
294
+
295
+ This fixture requires an 'app' fixture to be defined in your test file
296
+ or conftest.py that returns a PlanarApp instance.
297
+
298
+ Example:
299
+ @pytest.fixture(name="app")
300
+ def app_fixture():
301
+ app = PlanarApp()
302
+ app.register_workflow(my_workflow)
303
+ return app
304
+ """
305
+ app = request.getfixturevalue("app")
306
+ app.tracer = tracer
307
+ app.storage = storage
308
+ async with planar_test_client(app, tmp_db_url, observer) as client:
309
+ yield client
310
+
311
+
312
+ @pytest.fixture(name="tracer")
313
+ async def tracer_fixture():
314
+ yield None
315
+
316
+
317
+ @asynccontextmanager
318
+ async def engine_context(url: str):
319
+ db_manager = DatabaseManager(url)
320
+ db_manager.connect()
321
+ await db_manager.migrate(use_alembic=True)
322
+ engine = db_manager.get_engine()
323
+ tok = engine_var.set(engine)
324
+ yield engine
325
+ engine_var.reset(tok)