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/app.py CHANGED
@@ -1,6 +1,8 @@
1
1
  import asyncio
2
+ import signal
2
3
  from asyncio import CancelledError
3
4
  from contextlib import asynccontextmanager
5
+ from types import FrameType
4
6
  from typing import Any, Callable, Coroutine, Type
5
7
 
6
8
  from fastapi import APIRouter, FastAPI, HTTPException, Request
@@ -11,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine
11
13
  from typing_extensions import TypeVar
12
14
 
13
15
  from planar.ai import Agent
14
- from planar.config import PlanarConfig, load_environment_aware_config
16
+ from planar.config import Environment, PlanarConfig, load_environment_aware_config
15
17
  from planar.db import DatabaseManager
16
18
  from planar.files.storage.base import Storage
17
19
  from planar.files.storage.config import create_from_config
@@ -30,8 +32,8 @@ from planar.routers.entity_router import create_entities_router
30
32
  from planar.routers.object_config_router import create_object_config_router
31
33
  from planar.routers.rule import create_rule_router
32
34
  from planar.rules.decorator import RULE_REGISTRY
35
+ from planar.security.auth_middleware import AuthMiddleware
33
36
  from planar.security.authorization import PolicyService, policy_service_context
34
- from planar.security.jwt_middleware import JWTMiddleware
35
37
  from planar.session import config_var, session_context
36
38
  from planar.sse.proxy import SSEProxy
37
39
  from planar.workflows import (
@@ -92,7 +94,7 @@ class PlanarApp:
92
94
  setup_orchestrator_middleware(self)
93
95
  setup_workflow_notification_middleware(self)
94
96
  setup_tracer_middleware(self)
95
- setup_jwt_middleware(self)
97
+ setup_auth_middleware(self)
96
98
  setup_http_exception_handler(self)
97
99
  setup_authorization_policy_service(self)
98
100
 
@@ -202,6 +204,27 @@ class PlanarApp:
202
204
 
203
205
  @asynccontextmanager
204
206
  async def _lifespan(self, app: FastAPI):
207
+ # We manually capture SIGINT/SIGTERM to trigger our own graceful shutdown.
208
+ # This is necessary because long-lived connections, such as from the SSE
209
+ # proxy, can cause uvicorn's default graceful shutdown to hang, preventing
210
+ # the lifespan shutdown logic (after the yield) from ever being reached.
211
+ # Our handler starts the shutdown of these components and then chains to the
212
+ # original uvicorn handler to allow it to proceed with its own shutdown.
213
+ original_handlers = {
214
+ signal.SIGINT: signal.getsignal(signal.SIGINT),
215
+ signal.SIGTERM: signal.getsignal(signal.SIGTERM),
216
+ }
217
+
218
+ def terminate_now(signum: int, frame: FrameType | None = None):
219
+ asyncio.create_task(self.graceful_shutdown())
220
+ handler = original_handlers.get(signal.Signals(signum))
221
+ if callable(handler):
222
+ handler(signum, frame)
223
+
224
+ signal.signal(signal.SIGINT, terminate_now)
225
+ signal.signal(signal.SIGTERM, terminate_now)
226
+
227
+ # Begin the normal lifespan logic
205
228
  self.db_manager.connect()
206
229
  await self.db_manager.migrate(
207
230
  self.config.use_alembic if self.config.use_alembic is not None else True
@@ -240,6 +263,10 @@ class PlanarApp:
240
263
  config_var.reset(config_tok)
241
264
 
242
265
  await self.db_manager.disconnect()
266
+
267
+ if self.storage:
268
+ await self.storage.close()
269
+
243
270
  logger.info("stopping sse")
244
271
  await self.stop_sse()
245
272
  logger.info("lifespan completed")
@@ -435,15 +462,15 @@ def setup_http_exception_handler(app: PlanarApp):
435
462
 
436
463
  def setup_cors_middleware(app: PlanarApp):
437
464
  opts = {
438
- "allow_headers": app.config.cors.allow_headers,
439
- "allow_methods": app.config.cors.allow_methods,
440
- "allow_credentials": app.config.cors.allow_credentials,
465
+ "allow_headers": app.config.security.cors.allow_headers,
466
+ "allow_methods": app.config.security.cors.allow_methods,
467
+ "allow_credentials": app.config.security.cors.allow_credentials,
441
468
  }
442
469
 
443
- if isinstance(app.config.cors.allow_origins, str):
444
- opts["allow_origin_regex"] = app.config.cors.allow_origins
470
+ if isinstance(app.config.security.cors.allow_origins, str):
471
+ opts["allow_origin_regex"] = app.config.security.cors.allow_origins
445
472
  else:
446
- opts["allow_origins"] = app.config.cors.allow_origins
473
+ opts["allow_origins"] = app.config.security.cors.allow_origins
447
474
 
448
475
  app.fastapi.add_middleware(
449
476
  CORSMiddleware,
@@ -451,32 +478,49 @@ def setup_cors_middleware(app: PlanarApp):
451
478
  )
452
479
 
453
480
 
454
- def setup_jwt_middleware(app: PlanarApp):
455
- if app.config.jwt and app.config.jwt.enabled and app.config.jwt.client_id:
456
- client_id = app.config.jwt.client_id
457
- org_id = app.config.jwt.org_id
458
- additional_exclusion_paths = app.config.jwt.additional_exclusion_paths
481
+ def setup_auth_middleware(app: PlanarApp):
482
+ if (
483
+ app.config.security
484
+ and app.config.security.jwt
485
+ and app.config.security.jwt.client_id
486
+ and app.config.security.jwt.org_id
487
+ ):
488
+ client_id = app.config.security.jwt.client_id
489
+ org_id = app.config.security.jwt.org_id
490
+ additional_exclusion_paths = app.config.security.jwt.additional_exclusion_paths
459
491
  app.fastapi.add_middleware(
460
- JWTMiddleware, # type: ignore
492
+ AuthMiddleware, # type: ignore
461
493
  client_id,
462
494
  org_id,
463
495
  additional_exclusion_paths,
496
+ service_token=app.config.security.service_token.token
497
+ if app.config.security.service_token
498
+ and app.config.security.service_token.token
499
+ else None,
464
500
  )
465
501
  logger.info(
466
- "jwt middleware enabled",
502
+ "Auth middleware enabled",
467
503
  client_id=client_id,
468
504
  org_id=org_id,
469
505
  additional_exclusion_paths=additional_exclusion_paths,
470
506
  )
507
+ elif app.config.environment == Environment.PROD:
508
+ raise ValueError(
509
+ "Auth middleware is required in production. Please set the JWT config and optionally service token config."
510
+ )
471
511
  else:
472
- logger.warning("JWT middleware disabled")
512
+ logger.warning("Auth middleware disabled")
473
513
 
474
514
 
475
515
  def setup_authorization_policy_service(app: PlanarApp):
476
- if app.config.authz and app.config.authz.enabled:
516
+ if (
517
+ app.config.security
518
+ and app.config.security.authz
519
+ and app.config.security.authz.enabled
520
+ ):
477
521
  app.policy_service = PolicyService(
478
- policy_file_path=app.config.authz.policy_file
479
- if app.config.authz.policy_file
522
+ policy_file_path=app.config.security.authz.policy_file
523
+ if app.config.security.authz.policy_file
480
524
  else None
481
525
  )
482
526
  logger.info(
planar/cli.py CHANGED
@@ -13,28 +13,6 @@ from planar.config import Environment
13
13
  app = typer.Typer(help="Planar CLI tool")
14
14
 
15
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
16
  def find_default_app_path() -> Path:
39
17
  """Checks for default app file paths (app.py, then main.py)."""
40
18
  for filename in ["app.py", "main.py"]:
@@ -94,9 +72,25 @@ def dev_command(
94
72
  "--script",
95
73
  help="Run as a script with 'uv run' instead of starting a server",
96
74
  ),
75
+ ssl_keyfile: str | None = typer.Option(
76
+ None, "--ssl-keyfile", help="Path to SSL key file"
77
+ ),
78
+ ssl_certfile: str | None = typer.Option(
79
+ None, "--ssl-certfile", help="Path to SSL cert file"
80
+ ),
97
81
  ):
98
82
  """Run Planar in development mode"""
99
- run_command(Environment.DEV, port, host, config, path, app_name, script)
83
+ run_command(
84
+ Environment.DEV,
85
+ port,
86
+ host,
87
+ config,
88
+ path,
89
+ app_name,
90
+ script,
91
+ ssl_keyfile,
92
+ ssl_certfile,
93
+ )
100
94
 
101
95
 
102
96
  @app.command("prod")
@@ -119,9 +113,25 @@ def prod_command(
119
113
  "--script",
120
114
  help="Run as a script with 'uv run' instead of starting a server",
121
115
  ),
116
+ ssl_keyfile: str | None = typer.Option(
117
+ None, "--ssl-keyfile", help="Path to SSL key file"
118
+ ),
119
+ ssl_certfile: str | None = typer.Option(
120
+ None, "--ssl-certfile", help="Path to SSL cert file"
121
+ ),
122
122
  ):
123
123
  """Run Planar in production mode"""
124
- run_command(Environment.PROD, port, host, config, path, app_name, script)
124
+ run_command(
125
+ Environment.PROD,
126
+ port,
127
+ host,
128
+ config,
129
+ path,
130
+ app_name,
131
+ script,
132
+ ssl_keyfile,
133
+ ssl_certfile,
134
+ )
125
135
 
126
136
 
127
137
  def run_command(
@@ -132,6 +142,8 @@ def run_command(
132
142
  path: Path | None,
133
143
  app_name: str,
134
144
  script: bool = False,
145
+ ssl_keyfile: str | None = None,
146
+ ssl_certfile: str | None = None,
135
147
  ):
136
148
  """Common logic for both dev and prod commands"""
137
149
  os.environ["PLANAR_ENV"] = env.value
@@ -188,15 +200,15 @@ def run_command(
188
200
  typer.echo(f"Starting Planar in {env.value} mode")
189
201
 
190
202
  try:
191
- config = uvicorn.Config(
203
+ uvicorn.run(
192
204
  app_import_string,
193
205
  host=host or ("127.0.0.1" if env == Environment.DEV else "0.0.0.0"),
194
206
  port=port or 8000,
195
207
  reload=True if env == Environment.DEV else False,
196
208
  timeout_graceful_shutdown=4,
209
+ ssl_keyfile=ssl_keyfile,
210
+ ssl_certfile=ssl_certfile,
197
211
  )
198
-
199
- PlanarServer(config, app_import_string).run()
200
212
  except Exception as e:
201
213
  # Provide more context on import errors
202
214
  if isinstance(e, (ImportError, AttributeError)):
planar/config.py CHANGED
@@ -5,13 +5,14 @@ import os
5
5
  import sys
6
6
  from enum import Enum
7
7
  from pathlib import Path
8
- from typing import Annotated, Any, Dict, Literal, Optional
8
+ from typing import Annotated, Any, Dict, Literal
9
9
 
10
10
  import boto3
11
11
  import yaml
12
12
  from dotenv import load_dotenv
13
13
  from pydantic import (
14
14
  BaseModel,
15
+ ConfigDict,
15
16
  Field,
16
17
  HttpUrl,
17
18
  SecretStr,
@@ -45,8 +46,8 @@ class LogLevel(str, Enum):
45
46
 
46
47
  class LoggerConfig(BaseModel):
47
48
  level: LogLevel = LogLevel.INFO
48
- propagate: Optional[bool] = False
49
- file: Optional[str] = None
49
+ propagate: bool | None = False
50
+ file: str | None = None
50
51
 
51
52
 
52
53
  class SQLiteConfig(BaseModel):
@@ -66,11 +67,11 @@ class PostgreSQLConfig(BaseModel):
66
67
  driver: Literal["postgresql", "postgresql+asyncpg"] = (
67
68
  "postgresql+asyncpg" # Allow async PostgreSQL
68
69
  )
69
- host: Optional[str] = None
70
- port: Optional[int] = None
71
- user: Optional[str] = None
72
- password: Optional[str] = None
73
- db: Optional[str]
70
+ host: str | None = None
71
+ port: int | None = None
72
+ user: str | None = None
73
+ password: str | None = None
74
+ db: str | None
74
75
 
75
76
  def connection_url(self) -> URL:
76
77
  driver = self.driver
@@ -92,15 +93,15 @@ class OpenAIConfig(BaseModel):
92
93
  """Configuration for OpenAI provider."""
93
94
 
94
95
  api_key: SecretStr
95
- base_url: Optional[str] = None
96
- organization: Optional[str] = None
96
+ base_url: str | None = None
97
+ organization: str | None = None
97
98
 
98
99
 
99
100
  class AnthropicConfig(BaseModel):
100
101
  """Configuration for Anthropic provider."""
101
102
 
102
103
  api_key: SecretStr
103
- base_url: Optional[str] = None
104
+ base_url: str | None = None
104
105
 
105
106
 
106
107
  class GeminiConfig(BaseModel):
@@ -112,9 +113,9 @@ class GeminiConfig(BaseModel):
112
113
  class AIProvidersConfig(BaseModel):
113
114
  """Configuration for AI providers."""
114
115
 
115
- openai: Optional[OpenAIConfig] = None
116
- anthropic: Optional[AnthropicConfig] = None
117
- gemini: Optional[GeminiConfig] = None
116
+ openai: OpenAIConfig | None = None
117
+ anthropic: AnthropicConfig | None = None
118
+ gemini: GeminiConfig | None = None
118
119
 
119
120
 
120
121
  DatabaseConfig = Annotated[
@@ -124,7 +125,7 @@ DatabaseConfig = Annotated[
124
125
 
125
126
  class AppConfig(BaseModel):
126
127
  db_connection: str
127
- max_db_conflict_retries: Optional[int] = None
128
+ max_db_conflict_retries: int | None = None
128
129
 
129
130
 
130
131
  def default_storage_config() -> StorageConfig:
@@ -162,31 +163,27 @@ PROD_CORS_CONFIG = CorsConfig(
162
163
 
163
164
 
164
165
  class JWTConfig(BaseModel):
165
- enabled: bool = False
166
166
  client_id: str | None = None
167
167
  org_id: str | None = None
168
168
  additional_exclusion_paths: list[str] | None = Field(default_factory=list)
169
169
 
170
170
  @model_validator(mode="after")
171
171
  def validate_client_id(cls, instance):
172
- if instance.enabled and not instance.client_id:
173
- raise ValueError("client_id is required when JWT is enabled")
174
- if instance.client_id and not instance.enabled:
175
- raise ValueError(
176
- "You cannot specify a client_id without enabling JWT - did you mean to set enabled=True?"
177
- )
172
+ if not instance.client_id or not instance.org_id:
173
+ raise ValueError("Both client_id and org_id required to enable JWT")
178
174
  return instance
179
175
 
180
176
 
181
- JWT_DISABLED_CONFIG = JWTConfig(enabled=False)
177
+ # Coplane ORG JWT config
182
178
  JWT_COPLANE_CONFIG = JWTConfig(
183
- enabled=True, client_id="client_01JSJHJP9Q8GZDK5Y856FEHTB0", org_id=None
179
+ client_id="client_01JSJHJPKG09TMSK6NHJP0S180",
180
+ org_id="org_01JY4QP57Y7H4EQ7HT3BGN7TNK",
184
181
  )
185
182
 
186
183
 
187
184
  class OtelConfig(BaseModel):
188
185
  collector_endpoint: HttpUrl
189
- resource_attributes: Optional[dict[str, str]] = None
186
+ resource_attributes: dict[str, str] | None = None
190
187
 
191
188
 
192
189
  def install_otel_provider(otel_config: OtelConfig):
@@ -206,19 +203,31 @@ class AuthzConfig(BaseModel):
206
203
  policy_file: str | None = None
207
204
 
208
205
 
206
+ class ServiceTokenConfig(BaseModel):
207
+ token: str | None = Field(None, min_length=1)
208
+
209
+
210
+ class SecurityConfig(BaseModel):
211
+ cors: CorsConfig = PROD_CORS_CONFIG
212
+ jwt: JWTConfig | None = None
213
+ service_token: ServiceTokenConfig | None = None
214
+ authz: AuthzConfig | None = None
215
+
216
+
209
217
  class PlanarConfig(BaseModel):
210
218
  db_connections: Dict[str, DatabaseConfig | str]
211
219
  app: AppConfig
212
- ai_providers: Optional[AIProvidersConfig] = None
213
- storage: Optional[StorageConfig] = default_storage_config()
220
+ ai_providers: AIProvidersConfig | None = None
221
+ storage: StorageConfig | None = default_storage_config()
214
222
  sse_hub: str | bool = False
215
- cors: CorsConfig = PROD_CORS_CONFIG
216
223
  environment: Environment = Environment.DEV
217
- jwt: JWTConfig | None = None
218
- logging: Optional[dict[str, LoggerConfig]] = None
224
+ security: SecurityConfig = SecurityConfig()
225
+ logging: dict[str, LoggerConfig] | None = None
219
226
  use_alembic: bool | None = True
220
- otel: Optional[OtelConfig] = None
221
- authz: AuthzConfig | None = None
227
+ otel: OtelConfig | None = None
228
+
229
+ # forbid extra keys in the config to prevent accidental misconfiguration
230
+ model_config = ConfigDict(extra="forbid")
222
231
 
223
232
  @model_validator(mode="after")
224
233
  def validate_db_connection_reference(cls, instance):
@@ -467,14 +476,14 @@ def load_environment_aware_config[ConfigClass]() -> PlanarConfig:
467
476
 
468
477
  if env == "dev":
469
478
  base_config = sqlite_config(db_path="planar_dev.db")
470
- base_config.cors = LOCAL_CORS_CONFIG
479
+ base_config.security = SecurityConfig(cors=LOCAL_CORS_CONFIG)
471
480
  base_config.environment = Environment.DEV
472
- base_config.jwt = JWT_DISABLED_CONFIG
473
481
  else:
474
482
  base_config = sqlite_config(db_path="planar.db")
475
- base_config.cors = PROD_CORS_CONFIG
476
483
  base_config.environment = Environment.PROD
477
- base_config.jwt = JWT_COPLANE_CONFIG
484
+ base_config.security = SecurityConfig(
485
+ cors=PROD_CORS_CONFIG, jwt=JWT_COPLANE_CONFIG
486
+ )
478
487
 
479
488
  # Convert base config to dict for merging
480
489
  # Use by_alias=False to work with Python field names before validation
planar/db/db.py CHANGED
@@ -176,7 +176,8 @@ class DatabaseManager:
176
176
 
177
177
  def _create_sqlite_engine(self, url: URL) -> AsyncEngine:
178
178
  # in practice this high timeout is only use
179
- timeout = int(str(url.query.get("timeout", 10)))
179
+ timeout = int(str(url.query.get("timeout", 60)))
180
+ logger.info("Setting up SQLite engine with timeout", timeout=timeout)
180
181
 
181
182
  engine = create_async_engine(
182
183
  url,