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/cli.py ADDED
@@ -0,0 +1,282 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ import uvicorn
8
+ from jinja2 import Environment as JinjaEnvironment
9
+ from jinja2 import FileSystemLoader
10
+
11
+ from planar.config import Environment
12
+
13
+ app = typer.Typer(help="Planar CLI tool")
14
+
15
+
16
+ class PlanarServer(uvicorn.Server):
17
+ """Intercept SIGINT/SIGTERM to trigger early shutdown on the app."""
18
+
19
+ def __init__(self, config: uvicorn.Config, app_import_string: str):
20
+ super().__init__(config)
21
+ self.app_import_string = app_import_string
22
+
23
+ def handle_exit(self, sig, frame):
24
+ # Import the PlanarApp instance and fire its early-shutdown hook
25
+ import asyncio
26
+ import importlib
27
+
28
+ module_name, var_name = self.app_import_string.split(":")
29
+ app_module = importlib.import_module(module_name)
30
+ planar_app = getattr(app_module, var_name, None)
31
+ if planar_app and hasattr(planar_app, "graceful_shutdown"):
32
+ asyncio.create_task(planar_app.graceful_shutdown())
33
+
34
+ # Continue with Uvicorn's normal shutdown procedure
35
+ super().handle_exit(sig, frame)
36
+
37
+
38
+ def find_default_app_path() -> Path:
39
+ """Checks for default app file paths (app.py, then main.py)."""
40
+ for filename in ["app.py", "main.py"]:
41
+ path = Path(filename)
42
+ if path.is_file():
43
+ typer.echo(f"Found default app file: {path}")
44
+ return path
45
+ typer.echo(
46
+ "Error: Could not find app.py or main.py. Please specify the app file using --path.",
47
+ err=True,
48
+ )
49
+ raise typer.Exit(code=1)
50
+
51
+
52
+ def get_module_str_from_path(app_path: Path) -> str:
53
+ """Converts a file path to a Python module import string."""
54
+ try:
55
+ # Ensure path is absolute before making it relative
56
+ abs_path = app_path.resolve()
57
+ # Find the part relative to the current working directory
58
+ rel_path = abs_path.relative_to(Path.cwd())
59
+ # Remove .py and replace path separators with dots
60
+ module_part = str(rel_path.with_suffix("")).replace(os.path.sep, ".")
61
+ # Add cwd to sys.path if not already there, uvicorn might need it
62
+ if str(Path.cwd()) not in sys.path:
63
+ sys.path.insert(0, str(Path.cwd()))
64
+ return module_part
65
+ except ValueError:
66
+ typer.echo(
67
+ f"Error: App path {app_path} is not within the current working directory structure.",
68
+ err=True,
69
+ )
70
+ typer.echo("Planar must be run from the project root directory.")
71
+ raise typer.Exit(code=1)
72
+ except Exception as e:
73
+ typer.echo(f"Error processing app path {app_path}: {e}", err=True)
74
+ raise typer.Exit(code=1)
75
+
76
+
77
+ @app.command("dev")
78
+ def dev_command(
79
+ path: Path | None = typer.Argument(
80
+ None,
81
+ help="Optional path to the Python file containing the Planar app instance. Defaults to app.py or main.py.",
82
+ show_default=False, # Hide default in help, as it's dynamic
83
+ ),
84
+ port: int | None = typer.Option(8000, help="Port to run on"),
85
+ host: str | None = typer.Option("127.0.0.1", help="Host to run on"),
86
+ config: Path | None = typer.Option(
87
+ None, help="Path to config file (default: planar.dev.yaml)"
88
+ ),
89
+ app_name: str = typer.Option(
90
+ "app", "--app", help="Name of the PlanarApp instance variable."
91
+ ),
92
+ script: bool = typer.Option(
93
+ False,
94
+ "--script",
95
+ help="Run as a script with 'uv run' instead of starting a server",
96
+ ),
97
+ ):
98
+ """Run Planar in development mode"""
99
+ run_command(Environment.DEV, port, host, config, path, app_name, script)
100
+
101
+
102
+ @app.command("prod")
103
+ def prod_command(
104
+ path: Path | None = typer.Argument(
105
+ None,
106
+ help="Optional path to the Python file containing the Planar app instance. Defaults to app.py or main.py.",
107
+ show_default=False, # Hide default in help, as it's dynamic
108
+ ),
109
+ port: int | None = typer.Option(8000, help="Port to run on"),
110
+ host: str | None = typer.Option("0.0.0.0", help="Host to run on"),
111
+ config: Path | None = typer.Option(
112
+ None, help="Path to config file (default: planar.prod.yaml)"
113
+ ),
114
+ app_name: str = typer.Option(
115
+ "app", "--app", help="Name of the PlanarApp instance variable."
116
+ ),
117
+ script: bool = typer.Option(
118
+ False,
119
+ "--script",
120
+ help="Run as a script with 'uv run' instead of starting a server",
121
+ ),
122
+ ):
123
+ """Run Planar in production mode"""
124
+ run_command(Environment.PROD, port, host, config, path, app_name, script)
125
+
126
+
127
+ def run_command(
128
+ env: Environment,
129
+ port: int | None,
130
+ host: str | None,
131
+ config_file: Path | None,
132
+ path: Path | None,
133
+ app_name: str,
134
+ script: bool = False,
135
+ ):
136
+ """Common logic for both dev and prod commands"""
137
+ os.environ["PLANAR_ENV"] = env.value
138
+
139
+ if config_file:
140
+ if not config_file.exists():
141
+ typer.echo(f"Error: Config file {config_file} not found", err=True)
142
+ raise typer.Exit(code=1)
143
+ os.environ["PLANAR_CONFIG"] = str(config_file)
144
+
145
+ # Determine the app path
146
+ if path: # Use the positional argument if provided
147
+ app_path = path
148
+ if not app_path.is_file():
149
+ typer.echo(
150
+ f"Error: Specified app path {app_path} not found or is not a file",
151
+ err=True,
152
+ )
153
+ raise typer.Exit(code=1)
154
+ else:
155
+ app_path = find_default_app_path() # Finds app.py or main.py
156
+
157
+ os.environ["PLANAR_ENTRY_POINT"] = str(app_path)
158
+
159
+ if script:
160
+ # Run as a script using uv run
161
+ typer.echo(f"Running script: {app_path}")
162
+ typer.echo(f"Environment: {env.value}")
163
+
164
+ try:
165
+ result = subprocess.run(
166
+ ["uv", "run", str(app_path)], env=os.environ.copy(), check=True
167
+ )
168
+ raise typer.Exit(code=result.returncode)
169
+ except subprocess.CalledProcessError as e:
170
+ typer.echo(f"Error running script: {e}", err=True)
171
+ raise typer.Exit(code=e.returncode)
172
+ except FileNotFoundError:
173
+ typer.echo(
174
+ "Error: 'uv' command not found. Please install uv first.", err=True
175
+ )
176
+ raise typer.Exit(code=1)
177
+ except Exception as e:
178
+ typer.echo(f"Error running script: {e}", err=True)
179
+ raise typer.Exit(code=1)
180
+ else:
181
+ # Run as a server using uvicorn
182
+ # Convert path to module string
183
+ module_part = get_module_str_from_path(app_path)
184
+ # TODO: Check that the app_name is a valid variable name in the module
185
+ app_import_string = f"{module_part}:{app_name}"
186
+
187
+ typer.echo(f"Using app: {app_import_string}")
188
+ typer.echo(f"Starting Planar in {env.value} mode")
189
+
190
+ try:
191
+ config = uvicorn.Config(
192
+ app_import_string,
193
+ host=host or ("127.0.0.1" if env == Environment.DEV else "0.0.0.0"),
194
+ port=port or 8000,
195
+ reload=True if env == Environment.DEV else False,
196
+ timeout_graceful_shutdown=4,
197
+ )
198
+
199
+ PlanarServer(config, app_import_string).run()
200
+ except Exception as e:
201
+ # Provide more context on import errors
202
+ if isinstance(e, (ImportError, AttributeError)):
203
+ typer.echo(
204
+ f"Error importing application '{app_import_string}': {e}", err=True
205
+ )
206
+ typer.echo(
207
+ f"Ensure '{app_path}' exists and contains a variable named '{app_name}'.",
208
+ err=True,
209
+ )
210
+ else:
211
+ typer.echo(f"Error starting the application: {e}", err=True)
212
+ raise typer.Exit(code=1)
213
+
214
+
215
+ @app.command("scaffold")
216
+ def scaffold_project(
217
+ name: str = typer.Option(None, "--name", help="Name of the new project"),
218
+ directory: Path = typer.Option(Path("."), "--directory", help="Target directory"),
219
+ ):
220
+ """
221
+ Creates a new Planar project with a basic structure and example workflow.
222
+ """
223
+ if not name:
224
+ name = typer.prompt("Project name", default="planar_demo")
225
+
226
+ project_dir = directory / name
227
+ if project_dir.exists():
228
+ typer.echo(f"Error: Directory {project_dir} already exists", err=True)
229
+ raise typer.Exit(code=1)
230
+
231
+ # Setup Jinja2 template environment
232
+ template_dir = Path(__file__).parent / "scaffold_templates"
233
+ jinja_env = JinjaEnvironment(loader=FileSystemLoader(template_dir))
234
+
235
+ # Template context
236
+ context = {"name": name}
237
+
238
+ # Create project structure
239
+ try:
240
+ (project_dir / "app" / "db").mkdir(parents=True)
241
+ (project_dir / "app" / "flows").mkdir(parents=True)
242
+ except OSError as exc:
243
+ typer.echo(f"Error creating project structure: {exc}", err=True)
244
+ raise typer.Exit(code=1)
245
+
246
+ # Render and write templates
247
+ templates = [
248
+ ("app/__init__.py.j2", "app/__init__.py"),
249
+ ("app/db/entities.py.j2", "app/db/entities.py"),
250
+ ("app/flows/process_invoice.py.j2", "app/flows/process_invoice.py"),
251
+ ("main.py.j2", "main.py"),
252
+ ("pyproject.toml.j2", "pyproject.toml"),
253
+ ("planar.dev.yaml.j2", "planar.dev.yaml"),
254
+ ("planar.prod.yaml.j2", "planar.prod.yaml"),
255
+ ]
256
+
257
+ for template_path, output_path in templates:
258
+ template = jinja_env.get_template(template_path)
259
+ content = template.render(context)
260
+ (project_dir / output_path).write_text(content)
261
+
262
+ typer.secho(
263
+ f"""
264
+ ✨ Project '{name}' created successfully!
265
+
266
+ To get started:
267
+ cd {name}
268
+
269
+ Set your OPENAI_API_KEY in a new .env.dev file
270
+
271
+ uv run planar dev
272
+ """,
273
+ fg=typer.colors.GREEN,
274
+ )
275
+
276
+
277
+ def main():
278
+ app()
279
+
280
+
281
+ if __name__ == "__main__":
282
+ app()