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.
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/__init__.py +26 -0
- planar/_version.py +1 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/__init__.py +15 -0
- planar/ai/agent.py +457 -0
- planar/ai/agent_utils.py +205 -0
- planar/ai/models.py +140 -0
- planar/ai/providers.py +1088 -0
- planar/ai/test_agent.py +1298 -0
- planar/ai/test_agent_serialization.py +229 -0
- planar/ai/test_providers.py +463 -0
- planar/ai/utils.py +102 -0
- planar/app.py +494 -0
- planar/cli.py +282 -0
- planar/config.py +544 -0
- planar/db/.db.py.un~ +0 -0
- planar/db/__init__.py +17 -0
- planar/db/alembic/env.py +136 -0
- planar/db/alembic/script.py.mako +28 -0
- planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
- planar/db/alembic.ini +128 -0
- planar/db/db.py +318 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/__init__.py +2 -0
- planar/files/models.py +162 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/files/storage/base.py +61 -0
- planar/files/storage/config.py +44 -0
- planar/files/storage/context.py +15 -0
- planar/files/storage/local_directory.py +188 -0
- planar/files/storage/s3.py +220 -0
- planar/files/storage/test_local_directory.py +162 -0
- planar/files/storage/test_s3.py +299 -0
- planar/files/test_files.py +283 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/human/__init__.py +2 -0
- planar/human/human.py +458 -0
- planar/human/models.py +80 -0
- planar/human/test_human.py +385 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/logging/__init__.py +10 -0
- planar/logging/attributes.py +54 -0
- planar/logging/context.py +14 -0
- planar/logging/formatter.py +113 -0
- planar/logging/logger.py +114 -0
- planar/logging/otel.py +51 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/__init__.py +0 -0
- planar/modeling/field_helpers.py +59 -0
- planar/modeling/json_schema_generator.py +94 -0
- planar/modeling/mixins/__init__.py +10 -0
- planar/modeling/mixins/auditable.py +52 -0
- planar/modeling/mixins/test_auditable.py +97 -0
- planar/modeling/mixins/test_timestamp.py +134 -0
- planar/modeling/mixins/test_uuid_primary_key.py +52 -0
- planar/modeling/mixins/timestamp.py +53 -0
- planar/modeling/mixins/uuid_primary_key.py +19 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/modeling/orm/__init__.py +18 -0
- planar/modeling/orm/planar_base_entity.py +29 -0
- planar/modeling/orm/query_filter_builder.py +122 -0
- planar/modeling/orm/reexports.py +15 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/object_config/__init__.py +11 -0
- planar/object_config/models.py +114 -0
- planar/object_config/object_config.py +378 -0
- planar/object_registry.py +100 -0
- planar/registry_items.py +65 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/routers/__init__.py +13 -0
- planar/routers/agents_router.py +197 -0
- planar/routers/entity_router.py +143 -0
- planar/routers/event.py +91 -0
- planar/routers/files.py +142 -0
- planar/routers/human.py +151 -0
- planar/routers/info.py +131 -0
- planar/routers/models.py +170 -0
- planar/routers/object_config_router.py +133 -0
- planar/routers/rule.py +108 -0
- planar/routers/test_agents_router.py +174 -0
- planar/routers/test_object_config_router.py +367 -0
- planar/routers/test_routes_security.py +169 -0
- planar/routers/test_rule_router.py +470 -0
- planar/routers/test_workflow_router.py +274 -0
- planar/routers/workflow.py +468 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/rules/__init__.py +23 -0
- planar/rules/decorator.py +184 -0
- planar/rules/models.py +355 -0
- planar/rules/rule_configuration.py +191 -0
- planar/rules/runner.py +64 -0
- planar/rules/test_rules.py +750 -0
- planar/scaffold_templates/app/__init__.py.j2 +0 -0
- planar/scaffold_templates/app/db/entities.py.j2 +11 -0
- planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
- planar/scaffold_templates/main.py.j2 +13 -0
- planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
- planar/scaffold_templates/pyproject.toml.j2 +10 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/security/auth_context.py +148 -0
- planar/security/authorization.py +388 -0
- planar/security/default_policies.cedar +77 -0
- planar/security/jwt_middleware.py +116 -0
- planar/security/security_context.py +18 -0
- planar/security/tests/test_authorization_context.py +78 -0
- planar/security/tests/test_cedar_basics.py +41 -0
- planar/security/tests/test_cedar_policies.py +158 -0
- planar/security/tests/test_jwt_principal_context.py +179 -0
- planar/session.py +40 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/sse/constants.py +1 -0
- planar/sse/example.html +126 -0
- planar/sse/hub.py +216 -0
- planar/sse/model.py +8 -0
- planar/sse/proxy.py +257 -0
- planar/task_local.py +37 -0
- planar/test_app.py +51 -0
- planar/test_cli.py +372 -0
- planar/test_config.py +512 -0
- planar/test_object_config.py +527 -0
- planar/test_object_registry.py +14 -0
- planar/test_sqlalchemy.py +158 -0
- planar/test_utils.py +105 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/testing/__init__.py +0 -0
- planar/testing/memory_storage.py +78 -0
- planar/testing/planar_test_client.py +54 -0
- planar/testing/synchronizable_tracer.py +153 -0
- planar/testing/test_memory_storage.py +143 -0
- planar/testing/workflow_observer.py +73 -0
- planar/utils.py +70 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar/workflows/__init__.py +42 -0
- planar/workflows/context.py +44 -0
- planar/workflows/contrib.py +190 -0
- planar/workflows/decorators.py +217 -0
- planar/workflows/events.py +185 -0
- planar/workflows/exceptions.py +34 -0
- planar/workflows/execution.py +198 -0
- planar/workflows/lock.py +229 -0
- planar/workflows/misc.py +5 -0
- planar/workflows/models.py +154 -0
- planar/workflows/notifications.py +96 -0
- planar/workflows/orchestrator.py +383 -0
- planar/workflows/query.py +256 -0
- planar/workflows/serialization.py +409 -0
- planar/workflows/step_core.py +373 -0
- planar/workflows/step_metadata.py +357 -0
- planar/workflows/step_testing_utils.py +86 -0
- planar/workflows/sub_workflow_runner.py +191 -0
- planar/workflows/test_concurrency_detection.py +120 -0
- planar/workflows/test_lock_timeout.py +140 -0
- planar/workflows/test_serialization.py +1195 -0
- planar/workflows/test_suspend_deserialization.py +231 -0
- planar/workflows/test_workflow.py +1967 -0
- planar/workflows/tracing.py +106 -0
- planar/workflows/wrappers.py +41 -0
- planar-0.5.0.dist-info/METADATA +285 -0
- planar-0.5.0.dist-info/RECORD +289 -0
- planar-0.5.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|
planar/db/alembic/env.py
ADDED
@@ -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()
|