Files
social-app/backend/src/core/config/settings.py
T

238 lines
7.0 KiB
Python
Raw Normal View History

from __future__ import annotations
from pathlib import Path
from typing import ClassVar, Literal
from urllib.parse import quote
from pydantic import BaseModel, Field, computed_field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
def _resolve_project_root() -> Path:
current = Path(__file__).resolve()
for parent in current.parents:
if (
(parent / "pyproject.toml").is_file()
and (parent / "backend").is_dir()
and (parent / "infra").is_dir()
):
return parent
for parent in current.parents:
if parent.name == "backend":
return parent.parent
return Path.cwd().resolve()
class RuntimeSettings(BaseModel):
environment: Literal["dev", "test", "prod"] = "dev"
service_name: str = "app"
debug: bool = True
log_level: str = "INFO"
log_json: bool = True
log_rotation: Literal["time", "size", "none"] = "time"
log_rotation_when: str = "midnight"
log_rotation_interval: int = 1
log_rotation_backup_count: int = 14
log_rotation_max_bytes: int = 10_000_000
log_dir: str = "logs"
log_error_dir: str = "logs/errors"
log_file_name: str = ""
log_error_file_name: str = ""
log_sensitive_fields: list[str] = Field(
default_factory=lambda: [
"password",
"secret",
"token",
"api_key",
"authorization",
"cookie",
"client_ip",
"user_id",
]
)
sql_log_queries: bool = False
@field_validator("log_dir", mode="before")
@classmethod
def lock_log_dir(cls, _: object) -> str:
return "logs"
@field_validator("log_error_dir", mode="before")
@classmethod
def lock_log_error_dir(cls, _: object) -> str:
return "logs/errors"
@model_validator(mode="after")
def ensure_service_scoped_log_file_names(self) -> "RuntimeSettings":
service = "".join(
char if char.isalnum() or char in {"-", "_"} else "-"
for char in self.service_name
).strip("-_")
service_name = service or "app"
if not self.log_file_name.strip():
self.log_file_name = f"{service_name}.log"
if not self.log_error_file_name.strip():
self.log_error_file_name = f"{service_name}.error.log"
return self
class TaskiqSettings(BaseModel):
broker_url: str | None = None
result_backend_url: str | None = None
class CorsSettings(BaseModel):
allow_origins: list[str] = Field(
default_factory=lambda: [
"http://localhost",
"http://localhost:3000",
]
)
allow_credentials: bool = True
allow_methods: list[str] = Field(default_factory=lambda: ["*"])
allow_headers: list[str] = Field(default_factory=lambda: ["*"])
class RedisSettings(BaseModel):
host: str = "redis"
port: int = 6379
password: str | None = None
db: int = 0
socket_connect_timeout: float = 1.0
socket_timeout: float = 1.0
max_connections: int = 10
@computed_field
@property
def url(self) -> str:
if self.password:
password = quote(self.password, safe="")
return f"redis://:{password}@{self.host}:{self.port}/{self.db}"
return f"redis://{self.host}:{self.port}/{self.db}"
class SupabaseSettings(BaseModel):
public_scheme: str = "http"
public_host: str = "localhost"
kong_http_port: int = 8000
site_url: str = "http://localhost:3000"
additional_redirect_urls: list[str] = Field(default_factory=list)
anon_key: str = "CHANGE_ME"
service_role_key: str = "CHANGE_ME"
jwt_secret: str | None = None
@field_validator("additional_redirect_urls", mode="before")
@classmethod
def normalize_redirect_urls(cls, value: object) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
return []
@computed_field
@property
def public_url(self) -> str:
return f"{self.public_scheme}://{self.public_host}:{self.kong_http_port}"
@computed_field
@property
def url(self) -> str:
return self.public_url
class StorageSettings(BaseModel):
provider: Literal["supabase"] = "supabase"
bucket: str = Field(default="agent-chat-attachments", min_length=3, max_length=63)
signed_url_ttl_seconds: int = Field(default=600, ge=60, le=3600)
max_file_size_mb: int = Field(default=20, ge=1, le=200)
retention_days: int = Field(default=30, ge=1, le=3650)
class AgentRuntimeSettings(BaseModel):
redis_stream_prefix: str = "agent:events"
redis_stream_read_count: int = Field(default=100, ge=1, le=1000)
redis_stream_block_ms: int = Field(default=5000, ge=1, le=60000)
user_context_cache_prefix: str = "agent:user-context"
user_context_cache_ttl_seconds: int = Field(default=600, ge=60, le=86400)
user_context_cache_max_turns: int = Field(default=6, ge=1, le=100)
default_model_code: str = ""
streaming_enabled: bool = True
class LlmSettings(BaseModel):
provider_keys: dict[str, str] = Field(default_factory=dict)
class DatabaseSettings(BaseModel):
host: str = "localhost"
port: int = 5432
name: str = "postgres"
user: str = "postgres"
password: str = "CHANGE_ME"
@computed_field
@property
def url(self) -> str:
password = quote(self.password, safe="")
return (
f"postgresql+asyncpg://{self.user}:{password}"
f"@{self.host}:{self.port}/{self.name}"
)
def _resolve_env_file() -> str:
current = Path(__file__).resolve()
for parent in [current, *current.parents]:
candidate = parent / ".env"
if candidate.is_file():
return str(candidate)
return ".env"
PROJECT_ROOT = _resolve_project_root()
class Settings(BaseSettings):
runtime: RuntimeSettings = RuntimeSettings()
cors: CorsSettings = CorsSettings()
redis: RedisSettings = RedisSettings()
supabase: SupabaseSettings = SupabaseSettings()
storage: StorageSettings = StorageSettings()
llm: LlmSettings = LlmSettings()
agent_runtime: AgentRuntimeSettings = AgentRuntimeSettings()
taskiq: TaskiqSettings = TaskiqSettings()
database: DatabaseSettings = DatabaseSettings()
@computed_field
@property
def database_url(self) -> str:
return self.database.url
@computed_field
@property
def taskiq_broker_url(self) -> str:
return self.taskiq.broker_url or self.redis.url
@computed_field
@property
def taskiq_result_backend_url(self) -> str:
return self.taskiq.result_backend_url or self.redis.url
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
env_file=_resolve_env_file(),
env_prefix="SOCIAL_",
env_nested_delimiter="__",
case_sensitive=False,
extra="ignore",
)
config = Settings()