planar 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. planar/.__init__.py.un~ +0 -0
  2. planar/._version.py.un~ +0 -0
  3. planar/.app.py.un~ +0 -0
  4. planar/.cli.py.un~ +0 -0
  5. planar/.config.py.un~ +0 -0
  6. planar/.context.py.un~ +0 -0
  7. planar/.db.py.un~ +0 -0
  8. planar/.di.py.un~ +0 -0
  9. planar/.engine.py.un~ +0 -0
  10. planar/.files.py.un~ +0 -0
  11. planar/.log_context.py.un~ +0 -0
  12. planar/.log_metadata.py.un~ +0 -0
  13. planar/.logging.py.un~ +0 -0
  14. planar/.object_registry.py.un~ +0 -0
  15. planar/.otel.py.un~ +0 -0
  16. planar/.server.py.un~ +0 -0
  17. planar/.session.py.un~ +0 -0
  18. planar/.sqlalchemy.py.un~ +0 -0
  19. planar/.task_local.py.un~ +0 -0
  20. planar/.test_app.py.un~ +0 -0
  21. planar/.test_config.py.un~ +0 -0
  22. planar/.test_object_config.py.un~ +0 -0
  23. planar/.test_sqlalchemy.py.un~ +0 -0
  24. planar/.test_utils.py.un~ +0 -0
  25. planar/.util.py.un~ +0 -0
  26. planar/.utils.py.un~ +0 -0
  27. planar/__init__.py +26 -0
  28. planar/_version.py +1 -0
  29. planar/ai/.__init__.py.un~ +0 -0
  30. planar/ai/._models.py.un~ +0 -0
  31. planar/ai/.agent.py.un~ +0 -0
  32. planar/ai/.agent_utils.py.un~ +0 -0
  33. planar/ai/.events.py.un~ +0 -0
  34. planar/ai/.files.py.un~ +0 -0
  35. planar/ai/.models.py.un~ +0 -0
  36. planar/ai/.providers.py.un~ +0 -0
  37. planar/ai/.pydantic_ai.py.un~ +0 -0
  38. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  39. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  40. planar/ai/.step.py.un~ +0 -0
  41. planar/ai/.test_agent.py.un~ +0 -0
  42. planar/ai/.test_agent_serialization.py.un~ +0 -0
  43. planar/ai/.test_providers.py.un~ +0 -0
  44. planar/ai/.utils.py.un~ +0 -0
  45. planar/ai/__init__.py +15 -0
  46. planar/ai/agent.py +457 -0
  47. planar/ai/agent_utils.py +205 -0
  48. planar/ai/models.py +140 -0
  49. planar/ai/providers.py +1088 -0
  50. planar/ai/test_agent.py +1298 -0
  51. planar/ai/test_agent_serialization.py +229 -0
  52. planar/ai/test_providers.py +463 -0
  53. planar/ai/utils.py +102 -0
  54. planar/app.py +494 -0
  55. planar/cli.py +282 -0
  56. planar/config.py +544 -0
  57. planar/db/.db.py.un~ +0 -0
  58. planar/db/__init__.py +17 -0
  59. planar/db/alembic/env.py +136 -0
  60. planar/db/alembic/script.py.mako +28 -0
  61. planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
  62. planar/db/alembic.ini +128 -0
  63. planar/db/db.py +318 -0
  64. planar/files/.config.py.un~ +0 -0
  65. planar/files/.local.py.un~ +0 -0
  66. planar/files/.local_filesystem.py.un~ +0 -0
  67. planar/files/.model.py.un~ +0 -0
  68. planar/files/.models.py.un~ +0 -0
  69. planar/files/.s3.py.un~ +0 -0
  70. planar/files/.storage.py.un~ +0 -0
  71. planar/files/.test_files.py.un~ +0 -0
  72. planar/files/__init__.py +2 -0
  73. planar/files/models.py +162 -0
  74. planar/files/storage/.__init__.py.un~ +0 -0
  75. planar/files/storage/.base.py.un~ +0 -0
  76. planar/files/storage/.config.py.un~ +0 -0
  77. planar/files/storage/.context.py.un~ +0 -0
  78. planar/files/storage/.local_directory.py.un~ +0 -0
  79. planar/files/storage/.test_local_directory.py.un~ +0 -0
  80. planar/files/storage/.test_s3.py.un~ +0 -0
  81. planar/files/storage/base.py +61 -0
  82. planar/files/storage/config.py +44 -0
  83. planar/files/storage/context.py +15 -0
  84. planar/files/storage/local_directory.py +188 -0
  85. planar/files/storage/s3.py +220 -0
  86. planar/files/storage/test_local_directory.py +162 -0
  87. planar/files/storage/test_s3.py +299 -0
  88. planar/files/test_files.py +283 -0
  89. planar/human/.human.py.un~ +0 -0
  90. planar/human/.test_human.py.un~ +0 -0
  91. planar/human/__init__.py +2 -0
  92. planar/human/human.py +458 -0
  93. planar/human/models.py +80 -0
  94. planar/human/test_human.py +385 -0
  95. planar/logging/.__init__.py.un~ +0 -0
  96. planar/logging/.attributes.py.un~ +0 -0
  97. planar/logging/.formatter.py.un~ +0 -0
  98. planar/logging/.logger.py.un~ +0 -0
  99. planar/logging/.otel.py.un~ +0 -0
  100. planar/logging/.tracer.py.un~ +0 -0
  101. planar/logging/__init__.py +10 -0
  102. planar/logging/attributes.py +54 -0
  103. planar/logging/context.py +14 -0
  104. planar/logging/formatter.py +113 -0
  105. planar/logging/logger.py +114 -0
  106. planar/logging/otel.py +51 -0
  107. planar/modeling/.mixin.py.un~ +0 -0
  108. planar/modeling/.storage.py.un~ +0 -0
  109. planar/modeling/__init__.py +0 -0
  110. planar/modeling/field_helpers.py +59 -0
  111. planar/modeling/json_schema_generator.py +94 -0
  112. planar/modeling/mixins/__init__.py +10 -0
  113. planar/modeling/mixins/auditable.py +52 -0
  114. planar/modeling/mixins/test_auditable.py +97 -0
  115. planar/modeling/mixins/test_timestamp.py +134 -0
  116. planar/modeling/mixins/test_uuid_primary_key.py +52 -0
  117. planar/modeling/mixins/timestamp.py +53 -0
  118. planar/modeling/mixins/uuid_primary_key.py +19 -0
  119. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  120. planar/modeling/orm/__init__.py +18 -0
  121. planar/modeling/orm/planar_base_entity.py +29 -0
  122. planar/modeling/orm/query_filter_builder.py +122 -0
  123. planar/modeling/orm/reexports.py +15 -0
  124. planar/object_config/.object_config.py.un~ +0 -0
  125. planar/object_config/__init__.py +11 -0
  126. planar/object_config/models.py +114 -0
  127. planar/object_config/object_config.py +378 -0
  128. planar/object_registry.py +100 -0
  129. planar/registry_items.py +65 -0
  130. planar/routers/.__init__.py.un~ +0 -0
  131. planar/routers/.agents_router.py.un~ +0 -0
  132. planar/routers/.crud.py.un~ +0 -0
  133. planar/routers/.decision.py.un~ +0 -0
  134. planar/routers/.event.py.un~ +0 -0
  135. planar/routers/.file_attachment.py.un~ +0 -0
  136. planar/routers/.files.py.un~ +0 -0
  137. planar/routers/.files_router.py.un~ +0 -0
  138. planar/routers/.human.py.un~ +0 -0
  139. planar/routers/.info.py.un~ +0 -0
  140. planar/routers/.models.py.un~ +0 -0
  141. planar/routers/.object_config_router.py.un~ +0 -0
  142. planar/routers/.rule.py.un~ +0 -0
  143. planar/routers/.test_object_config_router.py.un~ +0 -0
  144. planar/routers/.test_workflow_router.py.un~ +0 -0
  145. planar/routers/.workflow.py.un~ +0 -0
  146. planar/routers/__init__.py +13 -0
  147. planar/routers/agents_router.py +197 -0
  148. planar/routers/entity_router.py +143 -0
  149. planar/routers/event.py +91 -0
  150. planar/routers/files.py +142 -0
  151. planar/routers/human.py +151 -0
  152. planar/routers/info.py +131 -0
  153. planar/routers/models.py +170 -0
  154. planar/routers/object_config_router.py +133 -0
  155. planar/routers/rule.py +108 -0
  156. planar/routers/test_agents_router.py +174 -0
  157. planar/routers/test_object_config_router.py +367 -0
  158. planar/routers/test_routes_security.py +169 -0
  159. planar/routers/test_rule_router.py +470 -0
  160. planar/routers/test_workflow_router.py +274 -0
  161. planar/routers/workflow.py +468 -0
  162. planar/rules/.decorator.py.un~ +0 -0
  163. planar/rules/.runner.py.un~ +0 -0
  164. planar/rules/.test_rules.py.un~ +0 -0
  165. planar/rules/__init__.py +23 -0
  166. planar/rules/decorator.py +184 -0
  167. planar/rules/models.py +355 -0
  168. planar/rules/rule_configuration.py +191 -0
  169. planar/rules/runner.py +64 -0
  170. planar/rules/test_rules.py +750 -0
  171. planar/scaffold_templates/app/__init__.py.j2 +0 -0
  172. planar/scaffold_templates/app/db/entities.py.j2 +11 -0
  173. planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
  174. planar/scaffold_templates/main.py.j2 +13 -0
  175. planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
  176. planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
  177. planar/scaffold_templates/pyproject.toml.j2 +10 -0
  178. planar/security/.jwt_middleware.py.un~ +0 -0
  179. planar/security/auth_context.py +148 -0
  180. planar/security/authorization.py +388 -0
  181. planar/security/default_policies.cedar +77 -0
  182. planar/security/jwt_middleware.py +116 -0
  183. planar/security/security_context.py +18 -0
  184. planar/security/tests/test_authorization_context.py +78 -0
  185. planar/security/tests/test_cedar_basics.py +41 -0
  186. planar/security/tests/test_cedar_policies.py +158 -0
  187. planar/security/tests/test_jwt_principal_context.py +179 -0
  188. planar/session.py +40 -0
  189. planar/sse/.constants.py.un~ +0 -0
  190. planar/sse/.example.html.un~ +0 -0
  191. planar/sse/.hub.py.un~ +0 -0
  192. planar/sse/.model.py.un~ +0 -0
  193. planar/sse/.proxy.py.un~ +0 -0
  194. planar/sse/constants.py +1 -0
  195. planar/sse/example.html +126 -0
  196. planar/sse/hub.py +216 -0
  197. planar/sse/model.py +8 -0
  198. planar/sse/proxy.py +257 -0
  199. planar/task_local.py +37 -0
  200. planar/test_app.py +51 -0
  201. planar/test_cli.py +372 -0
  202. planar/test_config.py +512 -0
  203. planar/test_object_config.py +527 -0
  204. planar/test_object_registry.py +14 -0
  205. planar/test_sqlalchemy.py +158 -0
  206. planar/test_utils.py +105 -0
  207. planar/testing/.client.py.un~ +0 -0
  208. planar/testing/.memory_storage.py.un~ +0 -0
  209. planar/testing/.planar_test_client.py.un~ +0 -0
  210. planar/testing/.predictable_tracer.py.un~ +0 -0
  211. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  212. planar/testing/.test_memory_storage.py.un~ +0 -0
  213. planar/testing/.workflow_observer.py.un~ +0 -0
  214. planar/testing/__init__.py +0 -0
  215. planar/testing/memory_storage.py +78 -0
  216. planar/testing/planar_test_client.py +54 -0
  217. planar/testing/synchronizable_tracer.py +153 -0
  218. planar/testing/test_memory_storage.py +143 -0
  219. planar/testing/workflow_observer.py +73 -0
  220. planar/utils.py +70 -0
  221. planar/workflows/.__init__.py.un~ +0 -0
  222. planar/workflows/.builtin_steps.py.un~ +0 -0
  223. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  224. planar/workflows/.context.py.un~ +0 -0
  225. planar/workflows/.contrib.py.un~ +0 -0
  226. planar/workflows/.decorators.py.un~ +0 -0
  227. planar/workflows/.durable_test.py.un~ +0 -0
  228. planar/workflows/.errors.py.un~ +0 -0
  229. planar/workflows/.events.py.un~ +0 -0
  230. planar/workflows/.exceptions.py.un~ +0 -0
  231. planar/workflows/.execution.py.un~ +0 -0
  232. planar/workflows/.human.py.un~ +0 -0
  233. planar/workflows/.lock.py.un~ +0 -0
  234. planar/workflows/.misc.py.un~ +0 -0
  235. planar/workflows/.model.py.un~ +0 -0
  236. planar/workflows/.models.py.un~ +0 -0
  237. planar/workflows/.notifications.py.un~ +0 -0
  238. planar/workflows/.orchestrator.py.un~ +0 -0
  239. planar/workflows/.runtime.py.un~ +0 -0
  240. planar/workflows/.serialization.py.un~ +0 -0
  241. planar/workflows/.step.py.un~ +0 -0
  242. planar/workflows/.step_core.py.un~ +0 -0
  243. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  244. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  245. planar/workflows/.test_concurrency.py.un~ +0 -0
  246. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  247. planar/workflows/.test_human.py.un~ +0 -0
  248. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  249. planar/workflows/.test_orchestrator.py.un~ +0 -0
  250. planar/workflows/.test_race_conditions.py.un~ +0 -0
  251. planar/workflows/.test_serialization.py.un~ +0 -0
  252. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  253. planar/workflows/.test_workflow.py.un~ +0 -0
  254. planar/workflows/.tracing.py.un~ +0 -0
  255. planar/workflows/.types.py.un~ +0 -0
  256. planar/workflows/.util.py.un~ +0 -0
  257. planar/workflows/.utils.py.un~ +0 -0
  258. planar/workflows/.workflow.py.un~ +0 -0
  259. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  260. planar/workflows/.wrappers.py.un~ +0 -0
  261. planar/workflows/__init__.py +42 -0
  262. planar/workflows/context.py +44 -0
  263. planar/workflows/contrib.py +190 -0
  264. planar/workflows/decorators.py +217 -0
  265. planar/workflows/events.py +185 -0
  266. planar/workflows/exceptions.py +34 -0
  267. planar/workflows/execution.py +198 -0
  268. planar/workflows/lock.py +229 -0
  269. planar/workflows/misc.py +5 -0
  270. planar/workflows/models.py +154 -0
  271. planar/workflows/notifications.py +96 -0
  272. planar/workflows/orchestrator.py +383 -0
  273. planar/workflows/query.py +256 -0
  274. planar/workflows/serialization.py +409 -0
  275. planar/workflows/step_core.py +373 -0
  276. planar/workflows/step_metadata.py +357 -0
  277. planar/workflows/step_testing_utils.py +86 -0
  278. planar/workflows/sub_workflow_runner.py +191 -0
  279. planar/workflows/test_concurrency_detection.py +120 -0
  280. planar/workflows/test_lock_timeout.py +140 -0
  281. planar/workflows/test_serialization.py +1195 -0
  282. planar/workflows/test_suspend_deserialization.py +231 -0
  283. planar/workflows/test_workflow.py +1967 -0
  284. planar/workflows/tracing.py +106 -0
  285. planar/workflows/wrappers.py +41 -0
  286. planar-0.5.0.dist-info/METADATA +285 -0
  287. planar-0.5.0.dist-info/RECORD +289 -0
  288. planar-0.5.0.dist-info/WHEEL +4 -0
  289. planar-0.5.0.dist-info/entry_points.txt +3 -0
planar/test_cli.py ADDED
@@ -0,0 +1,372 @@
1
+ from pathlib import Path
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import pytest
5
+ import typer
6
+ from typer.testing import CliRunner
7
+
8
+ from planar.cli import app, find_default_app_path, get_module_str_from_path
9
+
10
+
11
+ @pytest.fixture
12
+ def cli_runner():
13
+ return CliRunner()
14
+
15
+
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
22
+
23
+
24
+ @pytest.fixture
25
+ def mock_path_exists():
26
+ with patch("pathlib.Path.exists", return_value=True) as mock_exists:
27
+ yield mock_exists
28
+
29
+
30
+ @pytest.fixture
31
+ def mock_path_is_file():
32
+ with patch("pathlib.Path.is_file", return_value=True) as mock_is_file:
33
+ yield mock_is_file
34
+
35
+
36
+ class TestDevCommand:
37
+ def test_dev_command_defaults(
38
+ self, cli_runner, mock_planar_server, mock_path_is_file
39
+ ):
40
+ """Test that 'planar dev' sets correct defaults and invokes uvicorn.run."""
41
+ env = {}
42
+ with patch("os.environ", env):
43
+ result = cli_runner.invoke(app, ["dev"])
44
+
45
+ assert result.exit_code == 0
46
+ mock_planar_server[1].run.assert_called_once()
47
+
48
+ # 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
55
+
56
+ # Verify environment variable
57
+ assert env["PLANAR_ENV"] == "dev"
58
+
59
+
60
+ class TestProdCommand:
61
+ def test_prod_command_defaults(
62
+ self, cli_runner, mock_planar_server, mock_path_is_file
63
+ ):
64
+ """Test that 'planar prod' sets correct defaults and invokes uvicorn.run."""
65
+ env = {}
66
+ with patch("os.environ", env):
67
+ result = cli_runner.invoke(app, ["prod"])
68
+
69
+ assert result.exit_code == 0
70
+ mock_planar_server[1].run.assert_called_once()
71
+
72
+ # 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
79
+
80
+ # Verify environment variable
81
+ assert env["PLANAR_ENV"] == "prod"
82
+
83
+
84
+ class TestArgumentParsing:
85
+ @pytest.mark.parametrize("command", ["dev", "prod"])
86
+ def test_custom_port(
87
+ self, cli_runner, mock_planar_server, mock_path_is_file, command
88
+ ):
89
+ """Test custom port settings for both dev and prod commands."""
90
+ port = "9999"
91
+ with patch("os.environ", {}):
92
+ result = cli_runner.invoke(app, [command, "--port", port])
93
+
94
+ assert result.exit_code == 0
95
+ mock_planar_server[1].run.assert_called_once()
96
+
97
+ # Verify config arguments
98
+ run_args = mock_planar_server[0].call_args[0]
99
+ config = run_args[0]
100
+ assert config.port == int(port)
101
+
102
+ @pytest.mark.parametrize("command", ["dev", "prod"])
103
+ def test_custom_host(
104
+ self, cli_runner, mock_planar_server, mock_path_is_file, command
105
+ ):
106
+ """Test custom host settings for both dev and prod commands."""
107
+ host = "1.1.1.1"
108
+ with patch("os.environ", {}):
109
+ result = cli_runner.invoke(app, [command, "--host", host])
110
+
111
+ assert result.exit_code == 0
112
+ mock_planar_server[1].run.assert_called_once()
113
+
114
+ # Verify config arguments
115
+ run_args = mock_planar_server[0].call_args[0]
116
+ config = run_args[0]
117
+ assert config.host == host
118
+
119
+ @pytest.mark.parametrize("command", ["dev", "prod"])
120
+ def test_custom_app_name(
121
+ self, cli_runner, mock_planar_server, mock_path_is_file, command
122
+ ):
123
+ """Test specifying --app custom_app generates the correct import string."""
124
+ with (
125
+ patch("os.environ", {}),
126
+ patch("planar.cli.get_module_str_from_path", return_value="module"),
127
+ ):
128
+ result = cli_runner.invoke(app, [command, "--app", "custom_app"])
129
+
130
+ assert result.exit_code == 0
131
+ mock_planar_server[1].run.assert_called_once()
132
+
133
+ # 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"
137
+
138
+
139
+ class TestAppPathResolution:
140
+ @pytest.mark.parametrize("command", ["dev", "prod"])
141
+ def test_explicit_path(
142
+ self, cli_runner, mock_planar_server, mock_path_is_file, command
143
+ ):
144
+ """Test command --path sets PLANAR_ENTRY_POINT and generates correct import string."""
145
+ env = {}
146
+ with (
147
+ patch("os.environ", env),
148
+ patch("planar.cli.get_module_str_from_path", return_value="path.to.app"),
149
+ ):
150
+ result = cli_runner.invoke(app, [command, "path/to/app.py"])
151
+
152
+ assert result.exit_code == 0
153
+ assert env["PLANAR_ENTRY_POINT"] == "path/to/app.py"
154
+ mock_planar_server[1].run.assert_called_once()
155
+
156
+ # 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"
160
+
161
+ @pytest.mark.parametrize(
162
+ "path,error_text",
163
+ [
164
+ ("non_existent/app.py", "not found or is not a file"),
165
+ ("directory/", "not found or is not a file"),
166
+ ],
167
+ )
168
+ def test_invalid_paths(self, cli_runner, path, error_text):
169
+ """Test invalid paths exit with error."""
170
+ with patch("pathlib.Path.is_file", return_value=False):
171
+ result = cli_runner.invoke(app, ["dev", path])
172
+
173
+ assert result.exit_code == 1
174
+ assert error_text in result.output
175
+
176
+
177
+ class TestDefaultPathDiscovery:
178
+ @pytest.mark.parametrize(
179
+ "file_exists,expected_path",
180
+ [
181
+ (lambda p: p.name == "app.py", Path("app.py")),
182
+ (lambda p: p.name == "main.py", Path("main.py")),
183
+ ],
184
+ )
185
+ def test_default_files(
186
+ self, cli_runner, mock_planar_server, file_exists, expected_path
187
+ ):
188
+ """Test default file discovery works for app.py and main.py."""
189
+ env = {}
190
+ expected_name = expected_path.stem
191
+ with (
192
+ patch("os.environ", env),
193
+ patch("pathlib.Path.is_file", file_exists),
194
+ patch("planar.cli.get_module_str_from_path", return_value=expected_name),
195
+ ):
196
+ result = cli_runner.invoke(app, ["dev"])
197
+
198
+ assert result.exit_code == 0
199
+ assert env["PLANAR_ENTRY_POINT"] == str(expected_path)
200
+ mock_planar_server[1].run.assert_called_once()
201
+
202
+ def test_no_default_files(self, cli_runner):
203
+ """Test with neither app.py nor main.py existing: Verify exit code 1 and error message."""
204
+ with patch("pathlib.Path.is_file", return_value=False):
205
+ result = cli_runner.invoke(app, ["dev"])
206
+
207
+ assert result.exit_code == 1
208
+ assert "Could not find app.py or main.py" in result.output
209
+
210
+
211
+ class TestModuleStringConversion:
212
+ @pytest.mark.parametrize(
213
+ "path,expected",
214
+ [
215
+ (Path("app.py"), "app"),
216
+ (Path("src/api/main.py"), "src.api.main"),
217
+ ],
218
+ )
219
+ def test_valid_paths(self, path, expected):
220
+ """Test module string conversion for valid paths."""
221
+ abs_path = Path.cwd() / path
222
+ with (
223
+ patch("pathlib.Path.resolve", return_value=abs_path),
224
+ patch("pathlib.Path.relative_to", return_value=path),
225
+ ):
226
+ result = get_module_str_from_path(path)
227
+
228
+ assert result == expected
229
+
230
+ def test_outside_cwd(self):
231
+ """Test conversion for a path outside CWD results in exit code 1 and error."""
232
+ path = Path("/tmp/app.py")
233
+ with (
234
+ patch("pathlib.Path.resolve", return_value=Path("/tmp/app.py")),
235
+ patch("pathlib.Path.relative_to", side_effect=ValueError("Not relative")),
236
+ ):
237
+ with pytest.raises(typer.Exit) as exc_info:
238
+ get_module_str_from_path(path)
239
+
240
+ assert exc_info.value.exit_code == 1
241
+
242
+
243
+ class TestConfigFileHandling:
244
+ def test_config_handling(self, cli_runner, mock_planar_server, mock_path_is_file):
245
+ """Test config file handling."""
246
+ env = {}
247
+ with patch("os.environ", env):
248
+ with patch("pathlib.Path.exists", return_value=True):
249
+ result = cli_runner.invoke(
250
+ app, ["dev", "--config", "valid/config.yaml"]
251
+ )
252
+
253
+ assert result.exit_code == 0
254
+ assert env["PLANAR_CONFIG"] == "valid/config.yaml"
255
+ mock_planar_server[1].run.assert_called_once()
256
+
257
+ # Test invalid config
258
+ with patch("pathlib.Path.exists", return_value=False):
259
+ result = cli_runner.invoke(app, ["dev", "--config", "invalid/config.yaml"])
260
+
261
+ assert result.exit_code == 1
262
+ assert "Config file invalid/config.yaml not found" in result.output
263
+
264
+
265
+ class TestUvicornInteraction:
266
+ def test_successful_run(self, cli_runner, mock_planar_server, mock_path_is_file):
267
+ """Test successful uvicorn.run with correct parameters."""
268
+ with (
269
+ patch("os.environ", {}),
270
+ patch("planar.cli.get_module_str_from_path", return_value="src.main"),
271
+ ):
272
+ result = cli_runner.invoke(app, ["dev", "src/main.py"])
273
+
274
+ assert result.exit_code == 0
275
+ mock_planar_server[1].run.assert_called_once()
276
+
277
+ # 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
284
+
285
+
286
+ class TestScaffoldCommand:
287
+ def test_scaffold_creates_project_structure(self, cli_runner, tmp_path):
288
+ """Test that scaffold command creates the correct directory structure and files."""
289
+ project_name = "test_project"
290
+
291
+ result = cli_runner.invoke(
292
+ app, ["scaffold", "--name", project_name, "--directory", str(tmp_path)]
293
+ )
294
+
295
+ assert result.exit_code == 0
296
+ assert "created successfully" in result.output
297
+
298
+ # Check project directory exists
299
+ project_dir = tmp_path / project_name
300
+ assert project_dir.exists()
301
+ assert project_dir.is_dir()
302
+
303
+ # Check directory structure
304
+ assert (project_dir / "app").exists()
305
+ assert (project_dir / "app" / "db").exists()
306
+ assert (project_dir / "app" / "flows").exists()
307
+
308
+ # Check expected files exist
309
+ expected_files = [
310
+ "app/__init__.py",
311
+ "app/db/entities.py",
312
+ "app/flows/process_invoice.py",
313
+ "main.py",
314
+ "pyproject.toml",
315
+ "planar.dev.yaml",
316
+ "planar.prod.yaml",
317
+ ]
318
+
319
+ for file_path in expected_files:
320
+ assert (project_dir / file_path).exists(), f"File {file_path} should exist"
321
+ assert (project_dir / file_path).is_file(), f"{file_path} should be a file"
322
+
323
+ def test_scaffold_fails_if_directory_exists(self, cli_runner, tmp_path):
324
+ """Test that scaffold command fails if target directory already exists."""
325
+ project_name = "existing_project"
326
+ existing_dir = tmp_path / project_name
327
+ existing_dir.mkdir()
328
+
329
+ result = cli_runner.invoke(
330
+ app, ["scaffold", "--name", project_name, "--directory", str(tmp_path)]
331
+ )
332
+
333
+ assert result.exit_code == 1
334
+ assert "already exists" in result.output
335
+
336
+ def test_scaffold_with_default_directory(self, cli_runner, tmp_path, monkeypatch):
337
+ """Test scaffold command with default directory (current directory)."""
338
+ project_name = "test_project"
339
+
340
+ # Change to tmp_path to test default directory behavior
341
+ monkeypatch.chdir(tmp_path)
342
+
343
+ result = cli_runner.invoke(app, ["scaffold", "--name", project_name])
344
+
345
+ assert result.exit_code == 0
346
+
347
+ # Check project directory exists in current directory
348
+ project_dir = tmp_path / project_name
349
+ assert project_dir.exists()
350
+ assert (project_dir / "main.py").exists()
351
+
352
+
353
+ class TestUtilityFunctions:
354
+ @pytest.mark.parametrize(
355
+ "file_exists,expected",
356
+ [
357
+ (lambda p: p.name == "app.py", Path("app.py")),
358
+ (lambda p: p.name == "main.py", Path("main.py")),
359
+ ],
360
+ )
361
+ def test_find_default_app_path(self, file_exists, expected):
362
+ """Test find_default_app_path function."""
363
+ with patch("pathlib.Path.is_file", file_exists):
364
+ path = find_default_app_path()
365
+ assert path == expected
366
+
367
+ def test_find_default_app_path_no_files(self):
368
+ """Test find_default_app_path raises typer.Exit when no default files exist."""
369
+ with patch("pathlib.Path.is_file", return_value=False):
370
+ with pytest.raises(typer.Exit) as exc_info:
371
+ find_default_app_path()
372
+ assert exc_info.value.exit_code == 1