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/config.py ADDED
@@ -0,0 +1,544 @@
1
+ import json
2
+ import logging
3
+ import logging.config
4
+ import os
5
+ import sys
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Annotated, Any, Dict, Literal, Optional
9
+
10
+ import boto3
11
+ import yaml
12
+ from dotenv import load_dotenv
13
+ from pydantic import (
14
+ BaseModel,
15
+ Field,
16
+ HttpUrl,
17
+ SecretStr,
18
+ ValidationError,
19
+ model_validator,
20
+ )
21
+ from sqlalchemy import URL, make_url
22
+
23
+ from planar.files.storage.config import LocalDirectoryConfig, StorageConfig
24
+ from planar.logging import get_logger
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ class Environment(str, Enum):
30
+ DEV = "dev"
31
+ PROD = "prod"
32
+
33
+
34
+ class InvalidConfigurationError(Exception):
35
+ pass
36
+
37
+
38
+ class LogLevel(str, Enum):
39
+ NOTSET = "NOTSET"
40
+ DEBUG = "DEBUG"
41
+ INFO = "INFO"
42
+ WARNING = "WARNING"
43
+ ERROR = "ERROR"
44
+
45
+
46
+ class LoggerConfig(BaseModel):
47
+ level: LogLevel = LogLevel.INFO
48
+ propagate: Optional[bool] = False
49
+ file: Optional[str] = None
50
+
51
+
52
+ class SQLiteConfig(BaseModel):
53
+ driver: Literal["sqlite", "sqlite+aiosqlite"] = "sqlite+aiosqlite"
54
+ path: str
55
+ strict: bool = False
56
+
57
+ def connection_url(self) -> URL:
58
+ driver = self.driver
59
+ if driver == "sqlite":
60
+ # allow "sqlite" to be used as a shortcut for "sqlite+aiosqlite"
61
+ driver = "sqlite+aiosqlite"
62
+ return URL.create(drivername=driver, database=self.path)
63
+
64
+
65
+ class PostgreSQLConfig(BaseModel):
66
+ driver: Literal["postgresql", "postgresql+asyncpg"] = (
67
+ "postgresql+asyncpg" # Allow async PostgreSQL
68
+ )
69
+ host: Optional[str] = None
70
+ port: Optional[int] = None
71
+ user: Optional[str] = None
72
+ password: Optional[str] = None
73
+ db: Optional[str]
74
+
75
+ def connection_url(self) -> URL:
76
+ driver = self.driver
77
+ if driver == "postgresql":
78
+ # allow "postgresql" to be used as a shortcut for "postgresql+asyncpg"
79
+ # we only support asyncpg, but this lets users use "postgresql"
80
+ driver = "postgresql+asyncpg"
81
+ return URL.create(
82
+ drivername=driver,
83
+ host=self.host,
84
+ port=self.port,
85
+ username=self.user,
86
+ password=self.password,
87
+ database=self.db,
88
+ )
89
+
90
+
91
+ class OpenAIConfig(BaseModel):
92
+ """Configuration for OpenAI provider."""
93
+
94
+ api_key: SecretStr
95
+ base_url: Optional[str] = None
96
+ organization: Optional[str] = None
97
+
98
+
99
+ class AnthropicConfig(BaseModel):
100
+ """Configuration for Anthropic provider."""
101
+
102
+ api_key: SecretStr
103
+ base_url: Optional[str] = None
104
+
105
+
106
+ class GeminiConfig(BaseModel):
107
+ """Configuration for Google Gemini provider."""
108
+
109
+ api_key: SecretStr
110
+
111
+
112
+ class AIProvidersConfig(BaseModel):
113
+ """Configuration for AI providers."""
114
+
115
+ openai: Optional[OpenAIConfig] = None
116
+ anthropic: Optional[AnthropicConfig] = None
117
+ gemini: Optional[GeminiConfig] = None
118
+
119
+
120
+ DatabaseConfig = Annotated[
121
+ SQLiteConfig | PostgreSQLConfig, Field(discriminator="driver")
122
+ ]
123
+
124
+
125
+ class AppConfig(BaseModel):
126
+ db_connection: str
127
+ max_db_conflict_retries: Optional[int] = None
128
+
129
+
130
+ def default_storage_config() -> StorageConfig:
131
+ return LocalDirectoryConfig(backend="localdir", directory=".files")
132
+
133
+
134
+ class CorsConfig(BaseModel):
135
+ allow_origins: list[str] | str
136
+ allow_credentials: bool
137
+ allow_methods: list[str]
138
+ allow_headers: list[str]
139
+
140
+ @model_validator(mode="after")
141
+ def validate_allow_origins(cls, instance):
142
+ if instance.allow_credentials and "*" in instance.allow_origins:
143
+ raise ValueError(
144
+ "allow_credentials cannot be True if allow_origins includes '*'. Must explicitly specify allowed origins."
145
+ )
146
+ return instance
147
+
148
+
149
+ LOCAL_CORS_CONFIG = CorsConfig(
150
+ allow_origins=["http://127.0.0.1:3000"],
151
+ allow_credentials=True,
152
+ allow_methods=["*"],
153
+ allow_headers=["*"],
154
+ )
155
+
156
+ PROD_CORS_CONFIG = CorsConfig(
157
+ allow_origins=r"^https://(?:[a-zA-Z0-9-]+\.)+coplane\.(dev|com)$",
158
+ allow_credentials=True,
159
+ allow_methods=["*"],
160
+ allow_headers=["*"],
161
+ )
162
+
163
+
164
+ class JWTConfig(BaseModel):
165
+ enabled: bool = False
166
+ client_id: str | None = None
167
+ org_id: str | None = None
168
+ additional_exclusion_paths: list[str] | None = Field(default_factory=list)
169
+
170
+ @model_validator(mode="after")
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
+ )
178
+ return instance
179
+
180
+
181
+ JWT_DISABLED_CONFIG = JWTConfig(enabled=False)
182
+ JWT_COPLANE_CONFIG = JWTConfig(
183
+ enabled=True, client_id="client_01JSJHJP9Q8GZDK5Y856FEHTB0", org_id=None
184
+ )
185
+
186
+
187
+ class OtelConfig(BaseModel):
188
+ collector_endpoint: HttpUrl
189
+ resource_attributes: Optional[dict[str, str]] = None
190
+
191
+
192
+ def install_otel_provider(otel_config: OtelConfig):
193
+ try:
194
+ from planar.logging.otel import get_otel_collector_handler # noqa: PLC0415
195
+ except ImportError as e:
196
+ raise ImportError(
197
+ "OpenTelemetry is not installed. Please install it to use OpenTelemetry logging."
198
+ ) from e
199
+ return get_otel_collector_handler(
200
+ otel_config.collector_endpoint, otel_config.resource_attributes
201
+ )
202
+
203
+
204
+ class AuthzConfig(BaseModel):
205
+ enabled: bool = True
206
+ policy_file: str | None = None
207
+
208
+
209
+ class PlanarConfig(BaseModel):
210
+ db_connections: Dict[str, DatabaseConfig | str]
211
+ app: AppConfig
212
+ ai_providers: Optional[AIProvidersConfig] = None
213
+ storage: Optional[StorageConfig] = default_storage_config()
214
+ sse_hub: str | bool = False
215
+ cors: CorsConfig = PROD_CORS_CONFIG
216
+ environment: Environment = Environment.DEV
217
+ jwt: JWTConfig | None = None
218
+ logging: Optional[dict[str, LoggerConfig]] = None
219
+ use_alembic: bool | None = True
220
+ otel: Optional[OtelConfig] = None
221
+ authz: AuthzConfig | None = None
222
+
223
+ @model_validator(mode="after")
224
+ def validate_db_connection_reference(cls, instance):
225
+ if instance.app.db_connection not in instance.db_connections:
226
+ raise ValueError(
227
+ f"Invalid db_connection reference: {instance.app.db_connection}"
228
+ )
229
+ return instance
230
+
231
+ def connection_url(self) -> URL:
232
+ connection = self.db_connections[self.app.db_connection]
233
+ if isinstance(connection, str):
234
+ # treat the connection as a URL string
235
+ return make_url(connection)
236
+ return connection.connection_url()
237
+
238
+ def configure_logging(self):
239
+ loggers_config = {
240
+ # root logger default level should be INFO
241
+ "": LoggerConfig(level=LogLevel.INFO),
242
+ # force disable uvicorn's logger and let it propagate to root
243
+ "uvicorn": LoggerConfig(level=LogLevel.NOTSET, propagate=True),
244
+ }
245
+ if self.logging:
246
+ # Merge provided logging config with defaults
247
+ loggers_config.update(self.logging)
248
+
249
+ root_logger_config = None
250
+ loggers = {}
251
+ # define some standard formatters and handlers
252
+ formatters = {
253
+ "structured_console": {
254
+ "()": "planar.logging.formatter.StructuredFormatter",
255
+ "use_colors": True,
256
+ },
257
+ "structured_file": {
258
+ "()": "planar.logging.formatter.StructuredFormatter",
259
+ "use_colors": False,
260
+ },
261
+ }
262
+ filters = {
263
+ "add_attributes": {
264
+ "()": "planar.logging.attributes.ExtraAttributesFilter",
265
+ },
266
+ }
267
+ handlers = {
268
+ "console": {
269
+ "class": "logging.StreamHandler",
270
+ "formatter": "structured_console",
271
+ "stream": sys.stderr,
272
+ "filters": ["add_attributes"],
273
+ },
274
+ }
275
+
276
+ for name, cfg in loggers_config.items():
277
+ default_handler = "console"
278
+
279
+ if cfg.file:
280
+ # File was specified. Create a handler for that file if it doesn't exist already
281
+ default_handler = f"file:{cfg.file}"
282
+ if default_handler not in handlers:
283
+ handlers[default_handler] = {
284
+ "class": "logging.FileHandler",
285
+ "formatter": "structured_file",
286
+ "filename": cfg.file,
287
+ "filters": ["add_attributes"],
288
+ }
289
+
290
+ logging_module_cfg = {
291
+ "level": cfg.level.value,
292
+ "handlers": [default_handler] if not cfg.propagate else [],
293
+ "propagate": cfg.propagate,
294
+ }
295
+
296
+ if name == "":
297
+ root_logger_config = logging_module_cfg
298
+ else:
299
+ loggers[name] = logging_module_cfg
300
+
301
+ logging_config = dict(
302
+ version=1,
303
+ disable_existing_loggers=False,
304
+ root=root_logger_config,
305
+ loggers=loggers,
306
+ handlers=handlers,
307
+ formatters=formatters,
308
+ filters=filters,
309
+ )
310
+ logging.config.dictConfig(logging_config)
311
+
312
+ if self.otel:
313
+ handler = install_otel_provider(self.otel)
314
+ for k, v in loggers.items():
315
+ if not v["propagate"]:
316
+ # If the logger does not propagate, we need to add the otel handler
317
+ logger = logging.getLogger(k)
318
+ logger.handlers.append(handler)
319
+ # always add otel handler to the root logger so it forwards
320
+ # propagated logs
321
+ logging.root.addHandler(handler)
322
+
323
+
324
+ def load_config(yaml_str: str) -> PlanarConfig:
325
+ try:
326
+ raw = yaml.safe_load(yaml_str) or {}
327
+ return PlanarConfig.model_validate(raw)
328
+ except (ValidationError, yaml.YAMLError) as e:
329
+ raise InvalidConfigurationError(f"Configuration error: {e}") from e
330
+
331
+
332
+ def load_config_from_file(file_path: Path) -> PlanarConfig:
333
+ """
334
+ Load configuration from a YAML file.
335
+
336
+ Args:
337
+ file_path: Path to the YAML config file
338
+
339
+ Returns:
340
+ Parsed PlanarConfig object
341
+
342
+ Raises:
343
+ InvalidConfigurationError: If the config file cannot be loaded or is invalid
344
+ """
345
+ try:
346
+ with open(file_path, "r") as f:
347
+ yaml_str = f.read()
348
+ return load_config(yaml_str)
349
+ except FileNotFoundError:
350
+ raise InvalidConfigurationError(f"Configuration file not found: {file_path}")
351
+ except (ValidationError, yaml.YAMLError) as e:
352
+ raise InvalidConfigurationError(
353
+ f"Configuration error in {file_path}: {e}"
354
+ ) from e
355
+
356
+
357
+ def sqlite_config(db_path: str) -> PlanarConfig:
358
+ return PlanarConfig(
359
+ app=AppConfig(db_connection="app"),
360
+ db_connections={"app": SQLiteConfig(path=db_path)},
361
+ )
362
+
363
+
364
+ def aws_postgresql_config() -> PlanarConfig:
365
+ # Get the secret name from environment variable
366
+ secret_name = os.environ.get("DB_SECRET_NAME")
367
+
368
+ # Get credentials from Secrets Manager
369
+ client = boto3.client("secretsmanager")
370
+ response = client.get_secret_value(SecretId=secret_name)
371
+ credentials = json.loads(response["SecretString"])
372
+
373
+ return PlanarConfig(
374
+ app=AppConfig(db_connection="app"),
375
+ db_connections={
376
+ "app": PostgreSQLConfig(
377
+ host=credentials["host"],
378
+ port=credentials["port"],
379
+ user=credentials["username"],
380
+ password=credentials["password"],
381
+ db=credentials["dbname"],
382
+ )
383
+ },
384
+ )
385
+
386
+
387
+ def connection_string_config(connection_string: str) -> PlanarConfig:
388
+ return PlanarConfig(
389
+ app=AppConfig(db_connection="app"),
390
+ db_connections={"app": connection_string},
391
+ )
392
+
393
+
394
+ def get_environment() -> str:
395
+ """Get the current Planar environment (dev or prod), defaulting to dev."""
396
+ return os.environ.get("PLANAR_ENV", "dev")
397
+
398
+
399
+ def get_config_path() -> Path | None:
400
+ """Get the path to the config file from environment variable"""
401
+ config_path = os.environ.get("PLANAR_CONFIG")
402
+ return Path(config_path) if config_path else None
403
+
404
+
405
+ def deep_merge_dicts(
406
+ source: Dict[str, Any], destination: Dict[str, Any]
407
+ ) -> Dict[str, Any]:
408
+ """
409
+ Deeply merge dictionary `source` into `destination`.
410
+
411
+ Modifies `destination` in place.
412
+ """
413
+ for key, value in source.items():
414
+ if isinstance(value, dict):
415
+ # Get node or create one
416
+ node = destination.setdefault(key, {})
417
+ if isinstance(node, dict):
418
+ deep_merge_dicts(value, node)
419
+ else:
420
+ # If the destination node is not a dict, overwrite it
421
+ destination[key] = value
422
+ else:
423
+ destination[key] = value
424
+ return destination
425
+
426
+
427
+ def load_environment_aware_env_vars() -> None:
428
+ """
429
+ Load environment variables based on environment settings.
430
+
431
+ We look for .env file in the entry point and local directory, with environment
432
+ specific files (e.g. .env.dev, .env.prod) taking precedence.
433
+ """
434
+ env = get_environment()
435
+ paths_to_check = []
436
+ if entry_point := os.environ.get("PLANAR_ENTRY_POINT"):
437
+ entry_point_dir = Path(entry_point).parent
438
+ paths_to_check.append(entry_point_dir / f".env.{env}")
439
+ paths_to_check.append(entry_point_dir / ".env")
440
+ paths_to_check.append(Path(f".env.{env}"))
441
+ paths_to_check.append(Path(".env"))
442
+
443
+ for path in paths_to_check:
444
+ if path.exists():
445
+ load_dotenv(path)
446
+ return
447
+
448
+
449
+ def load_environment_aware_config[ConfigClass]() -> PlanarConfig:
450
+ """
451
+ Load configuration based on environment settings, using environment variables
452
+ and config files.
453
+
454
+ Priority order:
455
+ 1. Explicit path via PLANAR_CONFIG environment variable.
456
+ 2. Environment-specific file (planar.{env}.yaml) overriding defaults.
457
+ 3. Default configuration based on environment (dev/prod).
458
+
459
+ Returns:
460
+ Configured PlanarConfig object
461
+
462
+ Raises:
463
+ InvalidConfigurationError: If configuration loading or validation fails.
464
+ """
465
+ load_environment_aware_env_vars()
466
+ env = get_environment()
467
+
468
+ if env == "dev":
469
+ base_config = sqlite_config(db_path="planar_dev.db")
470
+ base_config.cors = LOCAL_CORS_CONFIG
471
+ base_config.environment = Environment.DEV
472
+ base_config.jwt = JWT_DISABLED_CONFIG
473
+ else:
474
+ base_config = sqlite_config(db_path="planar.db")
475
+ base_config.cors = PROD_CORS_CONFIG
476
+ base_config.environment = Environment.PROD
477
+ base_config.jwt = JWT_COPLANE_CONFIG
478
+
479
+ # Convert base config to dict for merging
480
+ # Use by_alias=False to work with Python field names before validation
481
+ base_dict = base_config.model_dump(mode="python", by_alias=False)
482
+
483
+ override_config_path = get_config_path()
484
+ if override_config_path:
485
+ if not override_config_path.exists():
486
+ raise InvalidConfigurationError(
487
+ f"Configuration file not found: {override_config_path}"
488
+ )
489
+ else:
490
+ paths_to_check = []
491
+ if os.environ.get("PLANAR_ENTRY_POINT"):
492
+ # Extract the directory from the entry point path
493
+ entry_point_dir = Path(os.environ["PLANAR_ENTRY_POINT"]).parent
494
+ paths_to_check = [
495
+ entry_point_dir / f"planar.{env}.yaml",
496
+ entry_point_dir / "planar.yaml",
497
+ ]
498
+ paths_to_check.append(Path(f"planar.{env}.yaml"))
499
+ paths_to_check.append(Path("planar.yaml"))
500
+
501
+ override_config_path = next(
502
+ (path for path in paths_to_check if path.exists()), None
503
+ )
504
+ if override_config_path is None:
505
+ logger.warning(
506
+ "no override config file found, using default config",
507
+ search_paths=[str(p) for p in paths_to_check],
508
+ env=env,
509
+ )
510
+
511
+ merged_dict = base_dict
512
+ if override_config_path and override_config_path.exists():
513
+ logger.info(
514
+ "using override config file", override_config_path=override_config_path
515
+ )
516
+ try:
517
+ # We can't use load_config_from_file here because we expect
518
+ # the override config to not be a fully validated PlanarConfig object,
519
+ # and we need to merge it onto the base default config.
520
+ with open(override_config_path, "r") as f:
521
+ override_yaml_str = f.read()
522
+
523
+ # Expand environment variables in the YAML string
524
+ processed_yaml_str = os.path.expandvars(override_yaml_str)
525
+ logger.debug(
526
+ "processed override yaml string", processed_yaml_str=processed_yaml_str
527
+ )
528
+
529
+ override_dict = yaml.safe_load(processed_yaml_str) or {}
530
+ logger.debug("loaded override config", override_dict=override_dict)
531
+
532
+ # Deep merge the override onto the base dictionary
533
+ merged_dict = deep_merge_dicts(override_dict, base_dict)
534
+ logger.debug("merged config dict", merged_dict=merged_dict)
535
+ except yaml.YAMLError as e:
536
+ raise InvalidConfigurationError(
537
+ f"Error parsing override configuration file {override_config_path}: {e}"
538
+ ) from e
539
+
540
+ try:
541
+ final_config = PlanarConfig.model_validate(merged_dict)
542
+ return final_config
543
+ except ValidationError as e:
544
+ raise InvalidConfigurationError(f"Configuration validation error: {e}") from e
planar/db/.db.py.un~ ADDED
Binary file
planar/db/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ from planar.db.db import (
2
+ PLANAR_FRAMEWORK_METADATA,
3
+ PLANAR_SCHEMA,
4
+ DatabaseManager,
5
+ PlanarInternalBase,
6
+ PlanarSession,
7
+ new_session,
8
+ )
9
+
10
+ __all__ = [
11
+ "DatabaseManager",
12
+ "new_session",
13
+ "PlanarInternalBase",
14
+ "PlanarSession",
15
+ "PLANAR_FRAMEWORK_METADATA",
16
+ "PLANAR_SCHEMA",
17
+ ]
@@ -0,0 +1,136 @@
1
+ from logging.config import fileConfig
2
+
3
+ from alembic import context
4
+ from sqlalchemy import Connection, engine_from_config, pool
5
+
6
+ from planar.db import PLANAR_FRAMEWORK_METADATA, PLANAR_SCHEMA
7
+
8
+ # this is the Alembic Config object, which provides
9
+ # access to the values within the .ini file in use.
10
+ config = context.config
11
+
12
+ # Interpret the config file for Python logging.
13
+ # This line sets up loggers basically.
14
+ if config.config_file_name is not None:
15
+ fileConfig(config.config_file_name)
16
+
17
+ # add your model's MetaData object here
18
+ # for 'autogenerate' support
19
+ target_metadata = PLANAR_FRAMEWORK_METADATA
20
+
21
+ # other values from the config, defined by the needs of env.py,
22
+ # can be acquired:
23
+ # my_important_option = config.get_main_option("my_important_option")
24
+ # ... etc.
25
+
26
+
27
+ def run_migrations_offline() -> None:
28
+ """Run migrations in 'offline' mode.
29
+
30
+ This configures the context with just a URL
31
+ and not an Engine, though an Engine is acceptable
32
+ here as well. By skipping the Engine creation
33
+ we don't even need a DBAPI to be available.
34
+
35
+ Calls to context.execute() here emit the given string to the
36
+ script output.
37
+
38
+ """
39
+ raise NotImplementedError(
40
+ "Offline mode is not supported for Planar system migrations."
41
+ )
42
+
43
+
44
+ def run_migrations_online() -> None:
45
+ """Run migrations in 'online' mode.
46
+
47
+ In this scenario we need to create an Engine
48
+ and associate a connection with the context.
49
+
50
+ """
51
+ # Check if we're being called programmatically (runtime) or from command line (development)
52
+ connectable = config.attributes.get("connection", None)
53
+
54
+ if isinstance(connectable, Connection):
55
+ # Runtime mode: use the connection passed by DatabaseManager
56
+ is_sqlite = connectable.dialect.name == "sqlite"
57
+
58
+ context.configure(
59
+ connection=connectable,
60
+ target_metadata=target_metadata,
61
+ # For SQLite, don't use schema since it's not supported
62
+ version_table_schema=None if is_sqlite else PLANAR_SCHEMA,
63
+ include_schemas=not is_sqlite,
64
+ compare_type=True,
65
+ # SQLite doesn't support alter table, so we need to use render_as_batch
66
+ # to create the tables in a single transaction. For other databases,
67
+ # the batch op is no-op.
68
+ # https://alembic.sqlalchemy.org/en/latest/batch.html#running-batch-migrations-for-sqlite-and-other-databases
69
+ render_as_batch=True,
70
+ )
71
+
72
+ with context.begin_transaction():
73
+ context.run_migrations()
74
+ else:
75
+ # Development mode: create engine from alembic.ini
76
+ # Used for alembic to generate migrations
77
+ # Import models to ensure they're registered with PLANAR_FRAMEWORK_METADATA
78
+ try:
79
+ from planar.files.models import PlanarFileMetadata # noqa: F401, PLC0415
80
+ from planar.human.models import HumanTask # noqa: F401, PLC0415
81
+ from planar.object_config.models import ( # noqa: F401, PLC0415
82
+ ObjectConfiguration,
83
+ )
84
+ from planar.workflows.models import ( # noqa: PLC0415
85
+ LockedResource, # noqa: F401
86
+ Workflow, # noqa: F401
87
+ WorkflowEvent, # noqa: F401
88
+ WorkflowStep, # noqa: F401
89
+ )
90
+ except ImportError as e:
91
+ raise RuntimeError(
92
+ f"Failed to import system models for migration generation: {e}"
93
+ )
94
+
95
+ config_dict = config.get_section(config.config_ini_section, {})
96
+ url = config_dict["sqlalchemy.url"]
97
+ is_sqlite = url.startswith("sqlite://")
98
+ translate_map = {"planar": None} if is_sqlite else {}
99
+ connectable = engine_from_config(
100
+ config_dict,
101
+ prefix="sqlalchemy.",
102
+ poolclass=pool.NullPool,
103
+ execution_options={
104
+ # SQLite doesn't support schemas, so we need to translate the planar schema
105
+ # name to None in order to ignore it.
106
+ "schema_translate_map": translate_map,
107
+ },
108
+ )
109
+
110
+ with connectable.connect() as connection:
111
+ is_sqlite = connection.dialect.name == "sqlite"
112
+ if is_sqlite:
113
+ connection.dialect.default_schema_name = "planar"
114
+
115
+ context.configure(
116
+ connection=connection,
117
+ target_metadata=target_metadata,
118
+ # For SQLite, don't use schema since it's not supported
119
+ version_table_schema=None if is_sqlite else PLANAR_SCHEMA,
120
+ include_schemas=not is_sqlite,
121
+ compare_type=True,
122
+ # SQLite doesn't support alter table, so we need to use render_as_batch
123
+ # to create the tables in a single transaction. For other databases,
124
+ # the batch op is no-op.
125
+ # https://alembic.sqlalchemy.org/en/latest/batch.html#running-batch-migrations-for-sqlite-and-other-databases
126
+ render_as_batch=True,
127
+ )
128
+
129
+ with context.begin_transaction():
130
+ context.run_migrations()
131
+
132
+
133
+ if context.is_offline_mode():
134
+ run_migrations_offline()
135
+ else:
136
+ run_migrations_online()