Source code for kelvin.config.common
from __future__ import annotations
import json
from enum import Enum
from pathlib import Path
from typing import Any, ClassVar, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
from typing_extensions import Annotated, override
NameDNS = Annotated[str, StringConstraints(pattern="^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")]
VersionStr = Annotated[
str,
StringConstraints(
pattern="^([0-9]+)\\.([0-9]+)\\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+[0-9A-Za-z-]+)?$"
),
]
CustomActionTypeStr = Annotated[str, StringConstraints(pattern="^[a-zA-Z0-9]([-_ .a-zA-Z0-9]*[a-zA-Z0-9])?$")]
[docs]
class ConfigError(Exception):
pass
[docs]
class ConfigBaseModel(BaseModel):
"""Base model for configuration classes with sensible serialization defaults."""
model_config: ClassVar[ConfigDict] = ConfigDict(populate_by_name=True)
[docs]
@override
def model_dump(
self,
*,
mode: Literal["json", "python"] | str = "json",
by_alias: Optional[bool] = True,
exclude_unset: bool = True,
exclude_none: bool = True,
**kwargs: Any,
) -> dict[str, Any]:
"""Serialize model with config-friendly defaults.
Changes from BaseModel defaults:
- mode: "json" (was "python")
- by_alias: True (was False)
- exclude_unset: True (was False)
- exclude_none: True (was False)
"""
return super().model_dump(
mode=mode,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_none=exclude_none,
**kwargs,
)
[docs]
class AppTypes(str, Enum):
importer = "importer"
exporter = "exporter"
app = "app"
docker = "docker"
# legacy - deprecated
kelvin_app = "kelvin"
bridge = "bridge"
legacy_docker = "legacy_docker"
[docs]
class PrimitiveTypes(str, Enum):
number = "number"
string = "string"
boolean = "boolean"
object = "object"
[docs]
class CustomActionDef(ConfigBaseModel):
type: CustomActionTypeStr
[docs]
class CustomActionsIO(ConfigBaseModel):
inputs: list[CustomActionDef] = Field(default_factory=list)
outputs: list[CustomActionDef] = Field(default_factory=list)
[docs]
class AppBaseConfig(ConfigBaseModel):
name: NameDNS
title: str
description: str
type: AppTypes
version: VersionStr
category: Optional[str] = None
[docs]
def read_schema_file(file_path: Path) -> dict[str, Any]:
if not file_path.exists():
raise ConfigError(f"schema file {file_path} does not exist")
try:
with file_path.open(encoding="utf-8") as file:
content = file.read()
except OSError as exc:
raise ConfigError(f"failed to read schema file {file_path}: {exc}") from exc
if not content.strip():
return {}
try:
return json.loads(content)
except json.JSONDecodeError as exc:
raise ConfigError(f"schema file {file_path} contains invalid JSON") from exc
[docs]
def resolve_schema_path(workdir: Path, schema_path: str) -> Path:
"""Resolve a schema path under workdir, preventing path traversal."""
resolved_workdir = workdir.resolve()
candidate = Path(schema_path)
resolved_path = candidate.resolve() if candidate.is_absolute() else (resolved_workdir / candidate).resolve()
try:
_ = resolved_path.relative_to(resolved_workdir)
except ValueError as exc:
raise ConfigError(f"schema path {schema_path} escapes workdir {workdir}") from exc
if not resolved_path.exists():
raise ConfigError(f"schema file {schema_path} does not exist")
return resolved_path