feat(config): add typed resource schema

This commit is contained in:
2026-07-11 20:43:08 +02:00
parent fe8d70da47
commit 1e0911bb6a
5 changed files with 380 additions and 45 deletions
+102 -3
View File
@@ -1,10 +1,13 @@
import os
import re
import warnings
from pathlib import Path
from typing import Any, Literal
from typing import Annotated, Any, Literal
import yaml
from pydantic import BaseModel, Field, ValidationError
from pydantic import BaseModel, Field, model_validator, ValidationError
from tht.config_compat import translate_legacy_config
_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
@@ -56,12 +59,59 @@ class RestConfig(BaseModel):
ssl_ca: str | None = None # path al certificato CA (per server con CA interna)
class DatabaseIdentityConfig(BaseModel):
database: str
db_schema: str = Field(alias="schema")
model_config = {"populate_by_name": True}
class PostgresDwhConfig(BaseModel):
type: Literal["postgres_direct"]
connection: DatabaseConfig
class ThothRestDwhConfig(BaseModel):
type: Literal["thoth_rest"]
database: DatabaseIdentityConfig
endpoint: RestConfig
DwhResourceConfig = Annotated[
PostgresDwhConfig | ThothRestDwhConfig,
Field(discriminator="type"),
]
class PgvectorDirectConfig(BaseModel):
type: Literal["pgvector_direct"]
connection: DatabaseConfig
class ThothVectorHttpConfig(BaseModel):
type: Literal["thoth_vector_http"]
reader: RestConfig | None = None
writer: RestConfig | None = None
# Transitional direct loading path used by the server profile.
direct: DatabaseConfig | None = None
VectorResourceConfig = Annotated[
PgvectorDirectConfig | ThothVectorHttpConfig,
Field(discriminator="type"),
]
class PathsConfig(BaseModel):
artifacts: Path = Path("artifacts")
indexes: Path = Path("indexes")
sessions: Path = Path("sessions")
class WorkspaceRoots(PathsConfig):
pass
class ExamplesConfig(BaseModel):
max_per_column: int = 10
@@ -138,6 +188,10 @@ class ExecutionConfig(BaseModel):
class Config(BaseModel):
dwh: DwhResourceConfig
vectors: VectorResourceConfig | None = None
roots: WorkspaceRoots = WorkspaceRoots()
# Compatibility views retained until all call sites consume typed resources.
database: DatabaseConfig
# Profilo dell'installazione, letto da THT_PROFILE (.env), non dallo yaml versionato.
# server: ricostruisce i derivati (artefatti, LSH, vettori schema nel vectordb).
@@ -166,6 +220,15 @@ class Config(BaseModel):
# una API key separata dalla lettura; espone solo upsert/hash via RPC allowlist.
vector_write_rest: RestConfig | None = None
@model_validator(mode="before")
@classmethod
def accept_legacy_constructor_fields(cls, value: Any) -> Any:
if not isinstance(value, dict) or "dwh" in value:
return value
translated, _ = translate_legacy_config(value)
_populate_legacy_views(translated)
return translated
def load_config(path: Path) -> Config:
if not path.exists():
@@ -173,8 +236,11 @@ def load_config(path: Path) -> Config:
raw = yaml.safe_load(path.read_text())
if not isinstance(raw, dict):
raise ConfigError(f"Configurazione non valida (atteso un mapping YAML): {path}")
expanded = _expand_env(raw)
translated, used_legacy = translate_legacy_config(expanded)
_populate_legacy_views(translated)
try:
cfg = Config.model_validate(_expand_env(raw))
cfg = Config.model_validate(translated)
except ValidationError as e:
raise ConfigError(f"Configurazione non valida in {path}:\n{e}") from e
env_profile = os.environ.get("THT_PROFILE")
@@ -188,4 +254,37 @@ def load_config(path: Path) -> Config:
raise ConfigError(
f"transport: rest richiede la sezione `rest` (base_url, api_key) in {path}."
)
if used_legacy:
warnings.warn(
"DEPRECATION: legacy workspace resource keys are deprecated; "
"use dwh, vectors, and roots.",
FutureWarning,
stacklevel=2,
)
return cfg
def _populate_legacy_views(raw: dict[str, Any]) -> None:
"""Populate old Config attributes for command compatibility during migration."""
dwh = raw.get("dwh")
if "database" not in raw and isinstance(dwh, dict):
if dwh.get("type") == "postgres_direct":
raw["database"] = {**dwh["connection"], "transport": "direct"}
elif dwh.get("type") == "thoth_rest":
raw["database"] = {
**dwh["database"],
"user": "rest",
"password": "",
"transport": "rest",
}
raw["rest"] = dwh["endpoint"]
vectors = raw.get("vectors")
if isinstance(vectors, dict):
if vectors.get("type") == "pgvector_direct":
raw.setdefault("vector_db", vectors["connection"])
elif vectors.get("type") == "thoth_vector_http":
raw.setdefault("vector_rest", vectors.get("reader"))
raw.setdefault("vector_write_rest", vectors.get("writer"))
raw.setdefault("vector_db", vectors.get("direct"))
raw.setdefault("paths", raw.get("roots", {}))
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any
_LEGACY_RESOURCE_KEYS = {
"database",
"rest",
"vector_db",
"vector_rest",
"vector_write_rest",
"paths",
}
def _as_mapping(value: Any) -> dict[str, Any] | None:
if isinstance(value, dict):
return deepcopy(value)
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
return model_dump(by_alias=True)
return None
def translate_legacy_config(raw: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Translate the legacy flat resource keys without validating their contents."""
translated = deepcopy(raw)
legacy = any(key in raw for key in _LEGACY_RESOURCE_KEYS)
if not legacy:
return translated, False
database = _as_mapping(raw.get("database"))
rest = raw.get("rest")
if "dwh" not in translated and database is not None:
if database.get("transport", "direct") == "rest":
identity = {
key: database[key]
for key in ("database", "schema")
if key in database
}
translated["dwh"] = {
"type": "thoth_rest",
"database": identity,
"endpoint": rest,
}
else:
connection = database
connection.pop("transport", None)
translated["dwh"] = {
"type": "postgres_direct",
"connection": connection,
}
if "vectors" not in translated:
vector_db = raw.get("vector_db")
reader = raw.get("vector_rest")
writer = raw.get("vector_write_rest")
if reader is not None or writer is not None:
translated["vectors"] = {
"type": "thoth_vector_http",
"reader": reader,
"writer": writer,
"direct": vector_db,
}
elif vector_db is not None:
translated["vectors"] = {
"type": "pgvector_direct",
"connection": vector_db,
}
if "roots" not in translated and "paths" in raw:
translated["roots"] = deepcopy(raw["paths"])
return translated, True