Files
template-fastapi/{{project_slug}}/config.py.jinja
Aleksei Sokol 53f14a8624
All checks were successful
Run linters on applied template / Python 3.13 lint and build (push) Successful in 1m40s
Version 0.4.0
Changes:
- put ObservabilityMiddleware before ExceptionHandlerMiddleware to avoid repetative code
- add application startup and last metrics update metrics along with CPU usage metric and threads count
- move host and port to new uvicorn section at config along with new reload and forwarded_allow_ips
- add request_id and remove trace_id/span_id generation if tracing is disabled
- move logging logic from utils to observability
- pass trace_id/span_id in HEX form
2026-01-03 16:29:58 +03:00

192 lines
6.8 KiB
Django/Jinja

"""Application configuration class is defined here."""
from collections import OrderedDict
from dataclasses import asdict, dataclass, field, fields
from pathlib import Path
from types import NoneType, UnionType
from typing import Any, Literal, TextIO, Type, Union, get_origin
import yaml
from {{project_slug}}.db.config import DBConfig, MultipleDBsConfig
from {{project_slug}}.observability.config import (
ExporterConfig,
FileLogger,
JaegerConfig,
LoggingConfig,
ObservabilityConfig,
PrometheusConfig,
)
from {{project_slug}}.utils.secrets import SecretStr, representSecretStrYAML
@dataclass
class CORSConfig:
allow_origins: list[str]
allow_methods: list[str]
allow_headers: list[str]
allow_credentials: bool
@dataclass
class UvicornConfig:
host: str
port: int
reload: bool = False
forwarded_allow_ips: list[str] = field(default_factory=list)
@dataclass
class AppConfig:
uvicorn: UvicornConfig
debug: bool
cors: CORSConfig
@dataclass
class {{ProjectName}}Config:
app: AppConfig
db: MultipleDBsConfig
observability: ObservabilityConfig
def to_order_dict(self) -> OrderedDict:
"""OrderDict transformer."""
def to_ordered_dict_recursive(obj) -> OrderedDict:
"""Recursive OrderDict transformer."""
if isinstance(obj, (dict, OrderedDict)):
return OrderedDict((k, to_ordered_dict_recursive(v)) for k, v in obj.items())
if isinstance(obj, list):
return [to_ordered_dict_recursive(item) for item in obj]
if hasattr(obj, "__dataclass_fields__"):
return OrderedDict(
(field, to_ordered_dict_recursive(getattr(obj, field))) for field in obj.__dataclass_fields__
)
return obj
return OrderedDict([(section, to_ordered_dict_recursive(getattr(self, section))) for section in asdict(self)])
def dump(self, file: str | Path | TextIO) -> None:
"""Export current configuration to a file"""
class OrderedDumper(yaml.SafeDumper):
def represent_dict_preserve_order(self, data):
return self.represent_dict(data.items())
def increase_indent(self, flow=False, indentless=False):
return super().increase_indent(flow, False)
OrderedDumper.add_representer(OrderedDict, OrderedDumper.represent_dict_preserve_order)
OrderedDumper.add_representer(SecretStr, representSecretStrYAML)
if isinstance(file, (str, Path)):
with open(str(file), "w", encoding="utf-8") as file_w:
yaml.dump(self.to_order_dict(), file_w, Dumper=OrderedDumper)
else:
yaml.dump(self.to_order_dict(), file, Dumper=OrderedDumper)
@classmethod
def get_example(cls) -> "{{ProjectName}}Config":
"""Generate an example of configuration."""
res = cls(
app=AppConfig(
uvicorn=UvicornConfig(host="0.0.0.0", port=8080, reload=True, forwarded_allow_ips=["127.0.0.1"]),
debug=True,
cors=CORSConfig(["*"], ["*"], ["*"], True),
),
db=MultipleDBsConfig(
master=DBConfig(
host="localhost",
port=5432,
database="{{project_slug}}_db",
user="postgres",
password="postgres",
pool_size=15,
),
replicas=[
DBConfig(
host="localhost",
port=5433,
user="readonly",
password="readonly",
database="{{project_slug}}_db",
pool_size=8,
)
],
),
observability=ObservabilityConfig(
logging=LoggingConfig(
stderr_level="INFO",
root_logger_level="INFO",
exporter=ExporterConfig(endpoint="http://127.0.0.1:4317", level="INFO", tls_insecure=True),
files=[FileLogger(filename="logs/info.log", level="INFO")],
),
prometheus=PrometheusConfig(host="0.0.0.0", port=9090),
jaeger=JaegerConfig(endpoint="http://127.0.0.1:4318/v1/traces"),
),
)
return res
@classmethod
def load(cls, file: str | Path | TextIO) -> "{{ProjectName}}Config":
"""Import config from the given filename or raise `ValueError` on error."""
try:
if isinstance(file, (str, Path)):
with open(file, "r", encoding="utf-8") as file_r:
data = yaml.safe_load(file_r)
else:
data: dict = yaml.safe_load(file)
return {{ProjectName}}Config._initialize_from_dict({{ProjectName}}Config, data)
except TypeError as exc:
raise ValueError(f"Seems like config file is invalid: {file}") from exc
except Exception as exc:
raise ValueError(f"Could not read app config file: {file}") from exc
@staticmethod
def _initialize_from_dict(t: Type, data: Any) -> Any:
"""Try to initialize given type field-by-field recursively with data from dictionary substituting {} and None
if no value provided.
"""
if get_origin(t) is Union or get_origin(t) is UnionType: # both actually required
for inner_type in t.__args__:
if inner_type is NoneType and data is None:
return None
try:
return {{ProjectName}}Config._initialize_from_dict(inner_type, data)
except Exception: # pylint: disable=broad-except
pass
raise ValueError(f"Cannot instanciate type '{t}' from {data}")
if hasattr(t, "__origin__") and t.__origin__ is dict:
return data
if not isinstance(data, dict):
if hasattr(t, "__origin__") and t.__origin__ is Literal and data in t.__args__:
return data
return t(data)
init_dict = {}
for fld in fields(t):
inner_data = data.get(fld.name)
if inner_data is None:
if isinstance(fld.type, UnionType) and NoneType in fld.type.__args__:
init_dict[fld.name] = None
continue
inner_data = {}
else:
init_dict[fld.name] = {{ProjectName}}Config._initialize_from_dict(fld.type, inner_data)
return t(**init_dict)
@classmethod
def from_file(cls, config_path: str) -> "{{ProjectName}}Config":
"""Load configuration from the given path."""
if not config_path or not Path(config_path).is_file():
raise ValueError(f"Requested config is not a valid file: {config_path}")
return cls.load(config_path)