From 36973de90a7b0a59e167e168565bb61858f9d481 Mon Sep 17 00:00:00 2001 From: Mathias Schneider Date: Wed, 30 Sep 2026 13:41:19 +0200 Subject: [PATCH] initial commit --- .gitignore | 10 ++ .python-version | 1 + README.md | 0 pyproject.toml | 20 ++++ src/utils/__init__.py | 2 + src/utils/logging_config.py | 80 ++++++++++++++ src/utils/settings.py | 8 ++ test/test_logging_config.py | 210 ++++++++++++++++++++++++++++++++++++ uv.lock | 135 +++++++++++++++++++++++ 9 files changed, 466 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/utils/__init__.py create mode 100755 src/utils/logging_config.py create mode 100644 src/utils/settings.py create mode 100755 test/test_logging_config.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1a029df --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "utils" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [ + { name = "Mathias Schneider", email = "mathias.schneider@d-fine.com" } +] +requires-python = ">=3.14" +dependencies = [ + "pydantic>=2.13.5", + "uvicorn>=0.54.0", +] + +[project.scripts] +utils = "utils:main" + +[build-system] +requires = ["uv_build>=0.12.20,<0.13.0"] +build-backend = "uv_build" diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..6388406 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from utils!") diff --git a/src/utils/logging_config.py b/src/utils/logging_config.py new file mode 100755 index 0000000..0b8a4f4 --- /dev/null +++ b/src/utils/logging_config.py @@ -0,0 +1,80 @@ +import logging +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path +from typing import Any + +from pydantic import Field +from uvicorn.logging import DefaultFormatter + +from utils.settings import BaseSettingsConfig + +logger = logging.getLogger(__name__) + + +def _rotation_namer(log_path: Path, default_name: str) -> str: + """Place the rotation timestamp before the configured log file extension.""" + rotated_path = Path(default_name) + timestamp = rotated_path.name.removeprefix(f"{log_path.name}.") + rotated_name = f"{log_path.stem}.{timestamp}{log_path.suffix}" + return str(rotated_path.with_name(rotated_name)) + + +class LoggingSettings(BaseSettingsConfig): + LOG_LEVEL: str = "INFO" + LOG_FORMAT: str = "%(asctime)s | %(levelprefix)s %(message)s" + LOG_DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S" + LOG_TO_FILE: bool = True + LOG_FILE_PATH: str = "logs/monitoring_backend.log" + LOG_BACKUP_COUNT: int = Field(default=30, ge=0) + + def model_post_init(self, __context: Any) -> None: + self.setup_logging() + + def setup_logging(self) -> None: + """Configure application-wide logging with console and optional file output.""" + level_name = self.LOG_LEVEL.upper() + level = logging.getLevelNamesMapping().get(level_name) + if level is None: + logger.error("Invalid log level %r; falling back to INFO.", self.LOG_LEVEL) + level = logging.INFO + + console_formatter = DefaultFormatter( + fmt=self.LOG_FORMAT, + datefmt=self.LOG_DATE_FORMAT, + ) + + handlers: list[logging.Handler] = [] + console_handler = logging.StreamHandler() + console_handler.setFormatter(console_formatter) + handlers.append(console_handler) + + if self.LOG_TO_FILE: + log_path = Path(self.LOG_FILE_PATH) + log_path.parent.mkdir(parents=True, exist_ok=True) + file_handler = TimedRotatingFileHandler( + log_path, + when="midnight", + interval=1, + backupCount=self.LOG_BACKUP_COUNT, + encoding="utf-8", + ) + file_handler.namer = lambda default_name: _rotation_namer( + log_path, default_name + ) + file_handler.setFormatter( + logging.Formatter( + fmt="%(asctime)s | %(levelname)s | %(message)s", + datefmt=self.LOG_DATE_FORMAT, + ) + ) + handlers.append(file_handler) + + root_logger = logging.getLogger() + root_logger.setLevel(level) + + # Replace and close existing handlers to avoid duplicate output and resource leaks. + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + handler.close() + for handler in handlers: + root_logger.addHandler(handler) diff --git a/src/utils/settings.py b/src/utils/settings.py new file mode 100644 index 0000000..0c45602 --- /dev/null +++ b/src/utils/settings.py @@ -0,0 +1,8 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class BaseSettingsConfig(BaseSettings): + model_config = SettingsConfigDict( + extra="ignore", + case_sensitive=True, + ) diff --git a/test/test_logging_config.py b/test/test_logging_config.py new file mode 100755 index 0000000..5c3aab0 --- /dev/null +++ b/test/test_logging_config.py @@ -0,0 +1,210 @@ +import logging +import re +import time +from collections.abc import Generator +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path +from unittest.mock import patch + +import pytest +from uvicorn.logging import DefaultFormatter + +from dataflex_backend_common.logging_config import LoggingSettings +from pydantic import ValidationError + +SECONDS_PER_DAY = 24 * 60 * 60 + + +@pytest.fixture +def restore_root_logger() -> Generator[None, None, None]: + root = logging.getLogger() + old_handlers = root.handlers[:] + old_level = root.level + for handler in old_handlers: + root.removeHandler(handler) + + try: + yield + finally: + for handler in root.handlers: + root.removeHandler(handler) + handler.close() + for handler in old_handlers: + root.addHandler(handler) + root.setLevel(old_level) + + +def test_sets_valid_log_level(restore_root_logger: None) -> None: + LoggingSettings(LOG_LEVEL="DEBUG", LOG_TO_FILE=False) + + assert logging.getLogger().level == logging.DEBUG + + +def test_invalid_log_level_falls_back_to_info(restore_root_logger: None) -> None: + with patch("dataflex_backend_common.logging_config.logger.error") as log_error: + LoggingSettings(LOG_LEVEL="NOT_A_LEVEL", LOG_TO_FILE=False) + + assert logging.getLogger().level == logging.INFO + log_error.assert_called_once_with("Invalid log level %r; falling back to INFO.", "NOT_A_LEVEL") + + +def test_configures_console_handler_only(restore_root_logger: None) -> None: + LoggingSettings(LOG_TO_FILE=False) + + handlers = logging.getLogger().handlers + assert len(handlers) == 1 + assert type(handlers[0]) is logging.StreamHandler + assert isinstance(handlers[0].formatter, DefaultFormatter) + + +def test_configures_file_handler_and_creates_parent_dir( + tmp_path: Path, + restore_root_logger: None, +) -> None: + log_file = tmp_path / "nested" / "forecast.log" + LoggingSettings(LOG_TO_FILE=True, LOG_FILE_PATH=str(log_file)) + + handlers = logging.getLogger().handlers + assert any(type(handler) is logging.StreamHandler for handler in handlers) + file_handlers = [handler for handler in handlers if isinstance(handler, logging.FileHandler)] + assert len(file_handlers) == 1 + assert log_file.parent.exists() + assert isinstance(file_handlers[0], TimedRotatingFileHandler) + assert file_handlers[0].when == "MIDNIGHT" + assert file_handlers[0].interval == SECONDS_PER_DAY + assert file_handlers[0].backupCount == 30 + assert file_handlers[0].encoding is not None + assert file_handlers[0].encoding.lower() == "utf-8" + + +def test_configures_custom_log_backup_count(tmp_path: Path, restore_root_logger: None) -> None: + log_file = tmp_path / "app.log" + LoggingSettings(LOG_TO_FILE=True, LOG_FILE_PATH=str(log_file), LOG_BACKUP_COUNT=7) + + file_handler = next( + handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + ) + + assert file_handler.backupCount == 7 + + +def test_writes_records_to_console_and_file( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + restore_root_logger: None, +) -> None: + log_file = tmp_path / "app.log" + LoggingSettings(LOG_FILE_PATH=str(log_file)) + + logging.getLogger("test.logging_output").info("record is written") + + file_handler = next( + handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + ) + file_handler.flush() + + assert "record is written" in capsys.readouterr().err + assert "record is written" in log_file.read_text(encoding="utf-8") + + +def test_rotates_active_log_file(tmp_path: Path, restore_root_logger: None) -> None: + log_file = tmp_path / "app.log" + LoggingSettings(LOG_FILE_PATH=str(log_file)) + logger = logging.getLogger("test.logging_rotation") + file_handler = next( + handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + ) + + logger.info("before rollover") + file_handler.flush() + file_handler.doRollover() + logger.info("after rollover") + file_handler.flush() + + rotated_files = list(tmp_path.glob("app.*.log")) + assert len(rotated_files) == 1 + assert re.fullmatch(r"app\.\d{4}-\d{2}-\d{2}\.log", rotated_files[0].name) + assert "before rollover" not in log_file.read_text(encoding="utf-8") + assert "after rollover" in log_file.read_text(encoding="utf-8") + + +def test_removes_old_rotated_files_after_backup_limit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + restore_root_logger: None, +) -> None: + log_file = tmp_path / "app.log" + LoggingSettings(LOG_FILE_PATH=str(log_file), LOG_BACKUP_COUNT=2) + logger = logging.getLogger("test.logging_retention") + file_handler = next( + handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + ) + base_timestamp = int(time.time()) + + for day in range(4): + logger.info("record from day %s", day) + file_handler.flush() + rollover_timestamp = base_timestamp + day * SECONDS_PER_DAY + file_handler.rolloverAt = rollover_timestamp + SECONDS_PER_DAY + monkeypatch.setattr(time, "time", lambda: rollover_timestamp) + file_handler.doRollover() + + assert len(list(tmp_path.glob("app.*.log"))) == 2 + + +def test_rejects_negative_log_backup_count(restore_root_logger: None) -> None: + with pytest.raises(ValidationError): + LoggingSettings(LOG_TO_FILE=False, LOG_BACKUP_COUNT=-1) + + +def test_setup_logging_is_idempotent(tmp_path: Path, restore_root_logger: None) -> None: + log_file = tmp_path / "app.log" + settings = LoggingSettings(LOG_TO_FILE=True, LOG_FILE_PATH=str(log_file)) + + first_file_handler = next( + handler for handler in logging.getLogger().handlers if isinstance(handler, logging.FileHandler) + ) + first_handler_count = len(logging.getLogger().handlers) + + settings.setup_logging() + second_handler_count = len(logging.getLogger().handlers) + + assert first_handler_count == 2 + assert second_handler_count == first_handler_count + assert first_file_handler.stream is None + + +def test_applies_formatter_from_settings(restore_root_logger: None) -> None: + log_format = "%(levelname)s|%(message)s" + date_format = "%Y" + LoggingSettings( + LOG_FORMAT=log_format, + LOG_DATE_FORMAT=date_format, + LOG_TO_FILE=False, + ) + + handlers = logging.getLogger().handlers + assert len(handlers) == 1 + formatter = handlers[0].formatter + assert formatter is not None + assert formatter._fmt == log_format + assert formatter.datefmt == date_format + + +def test_logging_settings_can_be_overridden_by_environment( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + restore_root_logger: None, +) -> None: + log_file = tmp_path / "custom" / "application.log" + monkeypatch.setenv("LOG_LEVEL", "DEBUG") + monkeypatch.setenv("LOG_TO_FILE", "true") + monkeypatch.setenv("LOG_FILE_PATH", str(log_file)) + monkeypatch.setenv("LOG_BACKUP_COUNT", "90") + + settings = LoggingSettings() + + assert settings.LOG_LEVEL == "DEBUG" + assert settings.LOG_TO_FILE is True + assert settings.LOG_FILE_PATH == str(log_file) + assert settings.LOG_BACKUP_COUNT == 90 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8b663d2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,135 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "utils" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.13.5" }, + { name = "uvicorn", specifier = ">=0.54.0" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +]