MediaRouter / app /templates /loader.py
basyx's picture
Upload 142 files
fba6023 verified
Raw
History Blame Contribute Delete
4.31 kB
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from pydantic import ValidationError
from app.core.exceptions import TemplateValidationError
from app.core.logger import get_logger
from app.templates.schema import TemplateDefinition
from app.templates.validator import TemplateValidator
logger = get_logger(__name__)
class TemplateLoader:
"""Recursively loads and validates safe YAML template documents."""
MAX_TEMPLATE_BYTES = 1_048_576
def __init__(self, root: Path, validator: TemplateValidator) -> None:
self.root = root.resolve()
self.validator = validator
def load(self) -> list[TemplateDefinition]:
"""Scan the configured directory and return every valid definition."""
if not self.root.is_dir():
raise TemplateValidationError(
"Template directory does not exist", details={"path": str(self.root)}
)
definitions: list[TemplateDefinition] = []
try:
paths = sorted((*self.root.rglob("*.yaml"), *self.root.rglob("*.yml")))
except OSError as exc:
raise TemplateValidationError(
"Template directory could not be scanned",
details={"path": str(self.root)},
) from exc
for path in paths:
definitions.extend(self._load_file(path))
if not definitions:
raise TemplateValidationError(
"No YAML templates were found", details={"path": str(self.root)}
)
logger.info(
"templates loaded",
extra={"path": str(self.root), "templates": len(definitions)},
)
return definitions
def _load_file(self, path: Path) -> list[TemplateDefinition]:
resolved_path = path.resolve()
if self.root not in resolved_path.parents:
raise TemplateValidationError(
"Template YAML path escapes TEMPLATE_DIR",
details={"path": str(path)},
)
try:
size = resolved_path.stat().st_size
except OSError as exc:
raise TemplateValidationError(
"Template YAML file could not be inspected",
details={"path": str(path)},
) from exc
if size > self.MAX_TEMPLATE_BYTES:
raise TemplateValidationError(
"Template YAML file is too large", details={"path": str(path)}
)
try:
documents = list(yaml.safe_load_all(resolved_path.read_text(encoding="utf-8")))
except (OSError, UnicodeError, yaml.YAMLError) as exc:
raise TemplateValidationError(
"Template YAML syntax is invalid", details={"path": str(path)}
) from exc
raw_templates: list[Any] = []
for document in documents:
if document is None:
continue
if isinstance(document, dict) and set(document) == {"templates"}:
collection = document["templates"]
if not isinstance(collection, list):
raise TemplateValidationError(
"The templates YAML key must contain a list",
details={"path": str(path)},
)
raw_templates.extend(collection)
elif isinstance(document, list):
raw_templates.extend(document)
else:
raw_templates.append(document)
definitions: list[TemplateDefinition] = []
for index, raw in enumerate(raw_templates):
try:
definition = TemplateDefinition.model_validate(raw)
self.validator.validate_definition(definition)
except ValidationError as exc:
raise TemplateValidationError(
"Template YAML does not match the schema",
details={
"path": str(path),
"document": index,
"errors": exc.errors(
include_url=False, include_context=False, include_input=False
),
},
) from exc
definitions.append(definition)
return definitions