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.
- planar/_version.py +1 -1
- planar/ai/agent.py +155 -283
- planar/ai/agent_base.py +170 -0
- planar/ai/agent_utils.py +7 -0
- planar/ai/pydantic_ai.py +638 -0
- planar/ai/test_agent_serialization.py +1 -1
- planar/app.py +64 -20
- planar/cli.py +39 -27
- planar/config.py +45 -36
- planar/db/db.py +2 -1
- planar/files/storage/azure_blob.py +343 -0
- planar/files/storage/base.py +7 -0
- planar/files/storage/config.py +70 -7
- planar/files/storage/s3.py +6 -6
- planar/files/storage/test_azure_blob.py +435 -0
- planar/logging/formatter.py +17 -4
- planar/logging/test_formatter.py +327 -0
- planar/registry_items.py +2 -1
- planar/routers/agents_router.py +3 -1
- planar/routers/files.py +11 -2
- planar/routers/models.py +14 -1
- planar/routers/test_agents_router.py +1 -1
- planar/routers/test_files_router.py +49 -0
- planar/routers/test_routes_security.py +5 -7
- planar/routers/test_workflow_router.py +270 -3
- planar/routers/workflow.py +95 -36
- planar/rules/models.py +36 -39
- planar/rules/test_data/account_dormancy_management.json +223 -0
- planar/rules/test_data/airline_loyalty_points_calculator.json +262 -0
- planar/rules/test_data/applicant_risk_assessment.json +435 -0
- planar/rules/test_data/booking_fraud_detection.json +407 -0
- planar/rules/test_data/cellular_data_rollover_system.json +258 -0
- planar/rules/test_data/clinical_trial_eligibility_screener.json +437 -0
- planar/rules/test_data/customer_lifetime_value.json +143 -0
- planar/rules/test_data/import_duties_calculator.json +289 -0
- planar/rules/test_data/insurance_prior_authorization.json +443 -0
- planar/rules/test_data/online_check_in_eligibility_system.json +254 -0
- planar/rules/test_data/order_consolidation_system.json +375 -0
- planar/rules/test_data/portfolio_risk_monitor.json +471 -0
- planar/rules/test_data/supply_chain_risk.json +253 -0
- planar/rules/test_data/warehouse_cross_docking.json +237 -0
- planar/rules/test_rules.py +750 -6
- planar/scaffold_templates/planar.dev.yaml.j2 +6 -6
- planar/scaffold_templates/planar.prod.yaml.j2 +9 -5
- planar/scaffold_templates/pyproject.toml.j2 +1 -1
- planar/security/auth_context.py +21 -0
- planar/security/{jwt_middleware.py → auth_middleware.py} +70 -17
- planar/security/authorization.py +9 -15
- planar/security/tests/test_auth_middleware.py +162 -0
- planar/sse/proxy.py +4 -9
- planar/test_app.py +92 -1
- planar/test_cli.py +81 -59
- planar/test_config.py +17 -14
- planar/testing/fixtures.py +325 -0
- planar/testing/planar_test_client.py +5 -2
- planar/utils.py +41 -1
- planar/workflows/execution.py +1 -1
- planar/workflows/orchestrator.py +5 -0
- planar/workflows/serialization.py +12 -6
- planar/workflows/step_core.py +3 -1
- planar/workflows/test_serialization.py +9 -1
- {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/METADATA +30 -5
- planar-0.8.0.dist-info/RECORD +166 -0
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/providers.py +0 -1088
- planar/ai/test_agent.py +0 -1298
- planar/ai/test_providers.py +0 -463
- planar/db/.db.py.un~ +0 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar-0.5.0.dist-info/RECORD +0 -289
- {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/WHEEL +0 -0
- {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
|
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
|
18
|
-
with patch("planar.cli.
|
19
|
-
|
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,
|
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
|
-
|
44
|
+
mock_uvicorn_run.assert_called_once()
|
47
45
|
|
48
46
|
# Verify uvicorn.run arguments
|
49
|
-
|
50
|
-
|
51
|
-
assert
|
52
|
-
assert
|
53
|
-
assert
|
54
|
-
assert
|
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,
|
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
|
-
|
68
|
+
mock_uvicorn_run.assert_called_once()
|
71
69
|
|
72
70
|
# Verify uvicorn.run arguments
|
73
|
-
|
74
|
-
|
75
|
-
assert
|
76
|
-
assert
|
77
|
-
assert
|
78
|
-
assert
|
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,
|
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
|
-
|
93
|
+
mock_uvicorn_run.assert_called_once()
|
96
94
|
|
97
95
|
# Verify config arguments
|
98
|
-
|
99
|
-
|
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,
|
101
|
+
self, cli_runner, mock_uvicorn_run, mock_path_is_file, command
|
105
102
|
):
|
106
|
-
"""Test custom host settings
|
107
|
-
host = "
|
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
|
-
|
109
|
+
mock_uvicorn_run.assert_called_once()
|
113
110
|
|
114
111
|
# Verify config arguments
|
115
|
-
|
116
|
-
|
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,
|
117
|
+
self, cli_runner, mock_uvicorn_run, mock_path_is_file, command
|
122
118
|
):
|
123
|
-
"""Test
|
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="
|
122
|
+
patch("planar.cli.get_module_str_from_path", return_value="app"),
|
127
123
|
):
|
128
|
-
result = cli_runner.invoke(app, [command, "--app", "
|
124
|
+
result = cli_runner.invoke(app, [command, "--app", "server"])
|
129
125
|
|
130
126
|
assert result.exit_code == 0
|
131
|
-
|
127
|
+
mock_uvicorn_run.assert_called_once()
|
132
128
|
|
133
129
|
# Verify config arguments
|
134
|
-
|
135
|
-
|
136
|
-
|
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,
|
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
|
-
|
178
|
+
mock_uvicorn_run.assert_called_once()
|
155
179
|
|
156
180
|
# Verify config arguments
|
157
|
-
|
158
|
-
|
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,
|
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
|
-
|
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,
|
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
|
-
|
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,
|
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
|
-
|
298
|
+
mock_uvicorn_run.assert_called_once()
|
276
299
|
|
277
300
|
# Verify config arguments
|
278
|
-
|
279
|
-
|
280
|
-
assert
|
281
|
-
assert
|
282
|
-
assert
|
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
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
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
|
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.
|
269
|
-
|
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
|
-
|
372
|
-
|
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)
|