diff --git a/adapter/config/__init__.py b/adapter/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapter/config/loader.py b/adapter/config/loader.py new file mode 100644 index 0000000..017e1be --- /dev/null +++ b/adapter/config/loader.py @@ -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) diff --git a/adapter/config/model.py b/adapter/config/model.py new file mode 100644 index 0000000..6b24288 --- /dev/null +++ b/adapter/config/model.py @@ -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 diff --git a/config/app.yaml b/config/app.yaml new file mode 100644 index 0000000..4cb282c --- /dev/null +++ b/config/app.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 04fd237..062cb93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/uv.lock b/uv.lock index 1197f05..dcd0a7e 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]