[feat] add config yaml and env
This commit is contained in:
parent
39f28d8f30
commit
1588efb9ca
6 changed files with 328 additions and 0 deletions
0
adapter/config/__init__.py
Normal file
0
adapter/config/__init__.py
Normal file
243
adapter/config/loader.py
Normal file
243
adapter/config/loader.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from .model import (
|
||||
AppConfig,
|
||||
AppSectionConfig,
|
||||
HttpConfig,
|
||||
LoggingConfig,
|
||||
OtelConfig,
|
||||
SecurityConfig,
|
||||
)
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_CONFIG_PATH = ROOT_DIR / 'config' / 'app.yaml'
|
||||
DEFAULT_DOTENV_PATH = ROOT_DIR / '.env'
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def load_config(
|
||||
config_path: Path | str | None = None,
|
||||
env_path: Path | str | None = None,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
) -> AppConfig:
|
||||
yaml_data = _load_yaml(_path_or_default(config_path, DEFAULT_CONFIG_PATH))
|
||||
env_values = _load_env(_path_or_default(env_path, DEFAULT_DOTENV_PATH), environ)
|
||||
|
||||
app_section = _section(yaml_data, 'app')
|
||||
http_section = _section(yaml_data, 'http')
|
||||
logging_section = _section(yaml_data, 'logging')
|
||||
otel_section = _section(yaml_data, 'otel')
|
||||
security_section = _section(yaml_data, 'security')
|
||||
|
||||
return AppConfig(
|
||||
app=AppSectionConfig(
|
||||
name=_yaml_or_env_str(
|
||||
app_section, 'name', 'app.name', env_values, 'APP_NAME'
|
||||
),
|
||||
env=_yaml_or_env_str(app_section, 'env', 'app.env', env_values, 'APP_ENV'),
|
||||
),
|
||||
http=HttpConfig(
|
||||
host=_yaml_or_env_str(
|
||||
http_section,
|
||||
'host',
|
||||
'http.host',
|
||||
env_values,
|
||||
'APP_HTTP_HOST',
|
||||
),
|
||||
port=_yaml_or_env_int(
|
||||
http_section,
|
||||
'port',
|
||||
'http.port',
|
||||
env_values,
|
||||
'APP_HTTP_PORT',
|
||||
),
|
||||
api_prefix=_yaml_or_env_str(
|
||||
http_section,
|
||||
'api_prefix',
|
||||
'http.api_prefix',
|
||||
env_values,
|
||||
'APP_HTTP_API_PREFIX',
|
||||
),
|
||||
),
|
||||
logging=LoggingConfig(
|
||||
level=_yaml_or_env_str(
|
||||
logging_section,
|
||||
'level',
|
||||
'logging.level',
|
||||
env_values,
|
||||
'APP_LOGGING_LEVEL',
|
||||
),
|
||||
),
|
||||
otel=OtelConfig(
|
||||
service_name=_yaml_or_env_str(
|
||||
otel_section,
|
||||
'service_name',
|
||||
'otel.service_name',
|
||||
env_values,
|
||||
'APP_OTEL_SERVICE_NAME',
|
||||
),
|
||||
exporter_endpoint=_yaml_or_env_str(
|
||||
otel_section,
|
||||
'exporter_endpoint',
|
||||
'otel.exporter_endpoint',
|
||||
env_values,
|
||||
'APP_OTEL_EXPORTER_ENDPOINT',
|
||||
),
|
||||
metric_export_interval=_yaml_or_env_int(
|
||||
otel_section,
|
||||
'metric_export_interval',
|
||||
'otel.metric_export_interval',
|
||||
env_values,
|
||||
'OTEL_METRIC_EXPORT_INTERVAL',
|
||||
),
|
||||
),
|
||||
security=SecurityConfig(
|
||||
token_header=_yaml_or_env_str(
|
||||
security_section,
|
||||
'token_header',
|
||||
'security.token_header',
|
||||
env_values,
|
||||
'APP_API_TOKEN_HEADER',
|
||||
),
|
||||
api_token=_env_str(env_values, 'APP_API_TOKEN'),
|
||||
signing_key=_env_str(env_values, 'APP_SIGNING_KEY'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _path_or_default(path_value: Path | str | None, default: Path) -> Path:
|
||||
if path_value is None:
|
||||
return default
|
||||
return Path(path_value)
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
with path.open(encoding='utf-8') as file:
|
||||
loaded = yaml.safe_load(file)
|
||||
except FileNotFoundError as exc:
|
||||
raise ConfigError(f'missing {_display_path(path)}') from exc
|
||||
|
||||
if loaded is None:
|
||||
raise ConfigError('empty config')
|
||||
if not isinstance(loaded, dict):
|
||||
raise ConfigError('invalid root')
|
||||
|
||||
data: dict[str, object] = {}
|
||||
for key, value in loaded.items():
|
||||
if not isinstance(key, str):
|
||||
raise ConfigError('invalid root')
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
|
||||
def _load_env(
|
||||
env_path: Path,
|
||||
environ: Mapping[str, str] | None,
|
||||
) -> dict[str, str]:
|
||||
values = _read_dotenv(env_path)
|
||||
source = os.environ if environ is None else environ
|
||||
for key, value in source.items():
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def _read_dotenv(path: Path) -> dict[str, str]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
|
||||
values: dict[str, str] = {}
|
||||
for key, value in dotenv_values(path).items():
|
||||
if value is None:
|
||||
continue
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def _section(data: Mapping[str, object], name: str) -> dict[str, object]:
|
||||
value = data.get(name)
|
||||
if value is None:
|
||||
raise ConfigError(f'missing {name}')
|
||||
if not isinstance(value, dict):
|
||||
raise ConfigError(f'invalid {name}')
|
||||
|
||||
section: dict[str, object] = {}
|
||||
for key, item in value.items():
|
||||
if not isinstance(key, str):
|
||||
raise ConfigError(f'invalid {name}')
|
||||
section[key] = item
|
||||
return section
|
||||
|
||||
|
||||
def _yaml_or_env_str(
|
||||
section: Mapping[str, object],
|
||||
field_name: str,
|
||||
label: str,
|
||||
env: Mapping[str, str],
|
||||
env_name: str | None = None,
|
||||
) -> str:
|
||||
if env_name is not None and env_name in env:
|
||||
return _as_str(env[env_name], env_name)
|
||||
if field_name not in section:
|
||||
raise ConfigError(f'missing {label}')
|
||||
return _as_str(section[field_name], label)
|
||||
|
||||
|
||||
def _yaml_or_env_int(
|
||||
section: Mapping[str, object],
|
||||
field_name: str,
|
||||
label: str,
|
||||
env: Mapping[str, str],
|
||||
env_name: str | None = None,
|
||||
) -> int:
|
||||
if env_name is not None and env_name in env:
|
||||
return _as_int(env[env_name], env_name)
|
||||
if field_name not in section:
|
||||
raise ConfigError(f'missing {label}')
|
||||
return _as_int(section[field_name], label)
|
||||
|
||||
|
||||
def _env_str(env: Mapping[str, str], name: str) -> str:
|
||||
if name not in env:
|
||||
raise ConfigError(f'missing {name}')
|
||||
return _as_str(env[name], name)
|
||||
|
||||
|
||||
def _as_str(value: object, label: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ConfigError(f'invalid {label}')
|
||||
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ConfigError(f'invalid {label}')
|
||||
return text
|
||||
|
||||
|
||||
def _as_int(value: object, label: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ConfigError(f'invalid {label}')
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ConfigError(f'invalid {label}')
|
||||
try:
|
||||
return int(text)
|
||||
except ValueError as exc:
|
||||
raise ConfigError(f'invalid {label}') from exc
|
||||
raise ConfigError(f'invalid {label}')
|
||||
|
||||
|
||||
def _display_path(path: Path) -> str:
|
||||
if path.is_relative_to(ROOT_DIR):
|
||||
return str(path.relative_to(ROOT_DIR))
|
||||
return str(path)
|
||||
42
adapter/config/model.py
Normal file
42
adapter/config/model.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppSectionConfig:
|
||||
name: str
|
||||
env: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HttpConfig:
|
||||
host: str
|
||||
port: int
|
||||
api_prefix: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoggingConfig:
|
||||
level: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OtelConfig:
|
||||
service_name: str
|
||||
exporter_endpoint: str
|
||||
metric_export_interval: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SecurityConfig:
|
||||
token_header: str
|
||||
api_token: str
|
||||
signing_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppConfig:
|
||||
app: AppSectionConfig
|
||||
http: HttpConfig
|
||||
logging: LoggingConfig
|
||||
otel: OtelConfig
|
||||
security: SecurityConfig
|
||||
19
config/app.yaml
Normal file
19
config/app.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
app:
|
||||
name: web-python-skelet
|
||||
env: local
|
||||
|
||||
http:
|
||||
host: 0.0.0.0
|
||||
port: 8000
|
||||
api_prefix: /api/v1
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
|
||||
otel:
|
||||
service_name: web-python-skelet
|
||||
exporter_endpoint: http://localhost:4318
|
||||
metric_export_interval: 1000
|
||||
|
||||
security:
|
||||
token_header: X-API-Token
|
||||
|
|
@ -10,6 +10,7 @@ dependencies = [
|
|||
"opentelemetry-exporter-otlp-proto-http>=1.31.1",
|
||||
"opentelemetry-instrumentation-fastapi>=0.52b1",
|
||||
"opentelemetry-sdk>=1.31.1",
|
||||
"python-dotenv>=1.2.2",
|
||||
"PyYAML>=6.0.2",
|
||||
"uvicorn>=0.35.0",
|
||||
]
|
||||
|
|
@ -20,6 +21,7 @@ dev = [
|
|||
"mypy>=1.18.2",
|
||||
"pytest>=8.4.2",
|
||||
"ruff>=0.13.1",
|
||||
"types-pyyaml>=6.0.12.20250915",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
|
|
|||
22
uv.lock
generated
22
uv.lock
generated
|
|
@ -551,6 +551,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
|
|
@ -639,6 +648,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pyyaml"
|
||||
version = "6.0.12.20250915"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
|
@ -692,6 +710,7 @@ dependencies = [
|
|||
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||
{ name = "opentelemetry-instrumentation-fastapi" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
|
@ -701,6 +720,7 @@ dev = [
|
|||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "types-pyyaml" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
|
@ -710,6 +730,7 @@ requires-dist = [
|
|||
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.31.1" },
|
||||
{ name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.52b1" },
|
||||
{ name = "opentelemetry-sdk", specifier = ">=1.31.1" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
{ name = "uvicorn", specifier = ">=0.35.0" },
|
||||
]
|
||||
|
|
@ -719,6 +740,7 @@ dev = [
|
|||
{ name = "mypy", specifier = ">=1.18.2" },
|
||||
{ name = "pytest", specifier = ">=8.4.2" },
|
||||
{ name = "ruff", specifier = ">=0.13.1" },
|
||||
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue