From dfa9cfd45bd0f2b93afa24e0aaaae2f9cf27cad8 Mon Sep 17 00:00:00 2001 From: treaditup Date: Sun, 21 Dec 2025 04:04:26 +0300 Subject: [PATCH 1/4] feat: add logger and docker --- .dockerignore | 83 +++++++++++++ .gitignore | 3 + config/template.config.yaml | 8 ++ config/template.env | 2 + docker/Dockerfile | 44 +++++++ docker/docker-compose.yaml | 14 +++ justfile | 8 ++ pyproject.toml | 8 +- src/pyproject/__init__.py | 1 - src/pyproject/config.py | 8 ++ src/pyproject/infrastructure/__init__.py | 3 + src/pyproject/infrastructure/config_loader.py | 18 +++ .../infrastructure/logger/__init__.py | 4 + src/pyproject/infrastructure/logger/config.py | 27 +++++ src/pyproject/infrastructure/logger/setup.py | 111 ++++++++++++++++++ src/pyproject/main.py | 14 ++- tests/unit/test_version.py | 16 ++- uv.lock | 70 ++++++++++- 18 files changed, 434 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 config/template.config.yaml create mode 100644 config/template.env create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yaml create mode 100644 src/pyproject/config.py create mode 100644 src/pyproject/infrastructure/__init__.py create mode 100644 src/pyproject/infrastructure/config_loader.py create mode 100644 src/pyproject/infrastructure/logger/__init__.py create mode 100644 src/pyproject/infrastructure/logger/config.py create mode 100644 src/pyproject/infrastructure/logger/setup.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..25d13fa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,83 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +junitxml.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Sphinx documentation +docs/_build/ +/docs/build/ +/docs-build/ + +# pdm +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# ruff +/.ruff_cache/ + +# IDE +.idea/ +.vscode/ + +# Logs +*.log + +# Config +config.yaml diff --git a/.gitignore b/.gitignore index 03aa625..25d13fa 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,6 @@ dmypy.json # Logs *.log + +# Config +config.yaml diff --git a/config/template.config.yaml b/config/template.config.yaml new file mode 100644 index 0000000..0fe1484 --- /dev/null +++ b/config/template.config.yaml @@ -0,0 +1,8 @@ +logger: + level: INFO + json: true + file: + enabled: true + path: app.log + max_size_mb: 10 + backup_count: 5 diff --git a/config/template.env b/config/template.env new file mode 100644 index 0000000..4e7d976 --- /dev/null +++ b/config/template.env @@ -0,0 +1,2 @@ +# App config +CONFIG_PATH=/etc/pyproject/config.yaml diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..a4a1e82 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,44 @@ +FROM docker.io/library/python:3.13-slim@sha256:baf66684c5fcafbda38a54b227ee30ec41e40af1e4073edee3a7110a417756ba AS base + +ENV PATH="/venv/bin:$PATH" \ + VIRTUAL_ENV="/venv" \ + APP_HOME="/home/app" + +RUN set -eux; apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl && \ + rm -rf /var/lib/apt/lists/* + +FROM base AS build + +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_PYTHON_DOWNLOADS=0 \ + UV_PROJECT_ENVIRONMENT="/venv" + +COPY --from=ghcr.io/astral-sh/uv:0.9.18 /uv /uvx /usr/local/bin/ +RUN set -eux; apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +RUN uv venv /venv +WORKDIR $APP_HOME + +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-install-project + +COPY src ./src +RUN --mount=type=cache,target=/root/.cache/uv \ + uv build --wheel && \ + uv pip install --no-deps dist/*.whl + +FROM base + +RUN useradd --system --no-create-home nonroot + +COPY --from=build /venv /venv +COPY --from=build /home/app/src /home/app/src + +WORKDIR /home/app + +USER nonroot diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 0000000..bf6e0c3 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,14 @@ +services: + pyproject: + container_name: pyproject + hostname: pyproject + restart: unless-stopped + command: [ "python3", "-m", "pyproject.main" ] + build: + context: ../ + dockerfile: docker/Dockerfile + env_file: + - "../config/.env" + volumes: + - "../config/config.yaml:/etc/pyproject/config.yaml" + - "../logs:/home/app/logs" diff --git a/justfile b/justfile index f4d5f32..a6792e2 100644 --- a/justfile +++ b/justfile @@ -22,6 +22,14 @@ set dotenv-load := true @run: python3 -m pyproject.main +# Up docker compose +@up: + docker compose -f docker/docker-compose.yaml --env-file=./config/.env up -d --build + +# Down docker compose +@down: + docker compose -f docker/docker-compose.yaml down + # Rename project @rename name: chmod +x ./rename.sh diff --git a/pyproject.toml b/pyproject.toml index 7fe1ebb..61df7ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,12 @@ classifiers = [ "Operating System :: OS Independent", ] requires-python = ">=3.13" -dependencies = [] +dependencies = [ + "adaptix>=3.0.0b11", + "orjson>=3.11.5", + "pyyaml>=6.0.3", + "structlog>=25.5.0", +] [project.urls] "Source" = "https://github.com/draincoder/pyproject" @@ -42,6 +47,7 @@ lint.select = [ lint.ignore = [ "ANN401", # allow any-type "D", # ignore docstring requirements + "PTH123" # allow use open() ] [tool.ruff.lint.per-file-ignores] diff --git a/src/pyproject/__init__.py b/src/pyproject/__init__.py index 3dc1f76..e69de29 100644 --- a/src/pyproject/__init__.py +++ b/src/pyproject/__init__.py @@ -1 +0,0 @@ -__version__ = "0.1.0" diff --git a/src/pyproject/config.py b/src/pyproject/config.py new file mode 100644 index 0000000..51feea9 --- /dev/null +++ b/src/pyproject/config.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from pyproject.infrastructure.logger import LoggerConfig + + +@dataclass(frozen=True, slots=True) +class AppConfig: + logger: LoggerConfig diff --git a/src/pyproject/infrastructure/__init__.py b/src/pyproject/infrastructure/__init__.py new file mode 100644 index 0000000..8a9ca9e --- /dev/null +++ b/src/pyproject/infrastructure/__init__.py @@ -0,0 +1,3 @@ +from .config_loader import load_config + +__all__ = ["load_config"] diff --git a/src/pyproject/infrastructure/config_loader.py b/src/pyproject/infrastructure/config_loader.py new file mode 100644 index 0000000..e92ac6c --- /dev/null +++ b/src/pyproject/infrastructure/config_loader.py @@ -0,0 +1,18 @@ +import os +from pathlib import Path + +import yaml +from adaptix import Retort + + +def load_config[T](config_type: type[T], path: str | Path | None = None) -> T: + path = path or os.getenv("CONFIG_PATH") + if path is None: + msg = "Environment variable 'CONFIG_PATH' must be set." + raise RuntimeError(msg) + + with open(path) as f: + data = yaml.safe_load(f) + + retort = Retort(strict_coercion=False) + return retort.load(data, config_type) # type: ignore[no-any-return] diff --git a/src/pyproject/infrastructure/logger/__init__.py b/src/pyproject/infrastructure/logger/__init__.py new file mode 100644 index 0000000..eeebd61 --- /dev/null +++ b/src/pyproject/infrastructure/logger/__init__.py @@ -0,0 +1,4 @@ +from .config import LoggerConfig +from .setup import setup_logger + +__all__ = ["LoggerConfig", "setup_logger"] diff --git a/src/pyproject/infrastructure/logger/config.py b/src/pyproject/infrastructure/logger/config.py new file mode 100644 index 0000000..29bd849 --- /dev/null +++ b/src/pyproject/infrastructure/logger/config.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from typing import Literal + +DEFAULT_MAX_FILE_SIZE = 10 +DEFAULT_BACKUP_COUNT = 10 +MEGABYTE = 1024 * 1024 + +LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + + +@dataclass(frozen=True, slots=True) +class FileLoggerConfig: + enabled: bool = False + path: str = "app.log" + max_size_mb: int = DEFAULT_MAX_FILE_SIZE + backup_count: int = DEFAULT_BACKUP_COUNT + + @property + def max_size_bytes(self) -> int: + return self.max_size_mb * MEGABYTE + + +@dataclass(frozen=True, slots=True) +class LoggerConfig: + file: FileLoggerConfig + level: LogLevel = "INFO" + json: bool = False diff --git a/src/pyproject/infrastructure/logger/setup.py b/src/pyproject/infrastructure/logger/setup.py new file mode 100644 index 0000000..853f685 --- /dev/null +++ b/src/pyproject/infrastructure/logger/setup.py @@ -0,0 +1,111 @@ +import logging +import sys +import uuid +from logging.handlers import RotatingFileHandler +from typing import Any + +import orjson +import structlog +from structlog.dev import ConsoleRenderer +from structlog.processors import JSONRenderer +from structlog.typing import EventDict, WrappedLogger + +from .config import LoggerConfig + + +def setup_logger(config: LoggerConfig) -> None: + _setup_structlog(config) + _setup_logging(config) + + +def _setup_structlog(config: LoggerConfig) -> None: + processors = [ + *_build_default_processors(config), + structlog.processors.StackInfoRenderer(), + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.UnicodeDecoder(), # convert bytes to str + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, # for integration with default logging + ] + + structlog.configure_once( + processors=processors, + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + +def _setup_logging(config: LoggerConfig) -> None: + renderer_processor = JSONRenderer(_serialize_to_json) if config.json else ConsoleRenderer() + default_processors = _build_default_processors(config) + + logging_processors = [ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer_processor, + ] + + formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=default_processors, + processors=logging_processors, + ) + + handler = logging.StreamHandler(stream=sys.stdout) + handler.set_name("default") + handler.setLevel(config.level) + handler.setFormatter(formatter) + handlers: list[logging.Handler] = [handler] + + if config.file: + file_handler = RotatingFileHandler( + filename=config.file.path, + maxBytes=config.file.max_size_bytes, + backupCount=config.file.backup_count, + encoding="utf-8", + ) + file_handler.set_name("file") + file_handler.setLevel(config.level) + file_handler.setFormatter(formatter) + handlers.append(file_handler) + + logging.basicConfig(handlers=handlers, level=config.level) + + +def additional_serialize(_logger: WrappedLogger, _name: str, event_dict: EventDict) -> EventDict: + for key, value in event_dict.items(): + if isinstance(value, uuid.UUID): + event_dict[key] = str(value) + + return event_dict + + +def _build_default_processors(config: LoggerConfig) -> list[Any]: + pr = [ + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.contextvars.merge_contextvars, + structlog.stdlib.ExtraAdder(), + additional_serialize, + structlog.dev.set_exc_info, + structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S.%f", utc=True), + structlog.processors.dict_tracebacks, + structlog.processors.CallsiteParameterAdder( + { + structlog.processors.CallsiteParameter.PATHNAME, + structlog.processors.CallsiteParameter.FILENAME, + structlog.processors.CallsiteParameter.MODULE, + structlog.processors.CallsiteParameter.FUNC_NAME, + structlog.processors.CallsiteParameter.THREAD, + structlog.processors.CallsiteParameter.THREAD_NAME, + structlog.processors.CallsiteParameter.PROCESS, + structlog.processors.CallsiteParameter.PROCESS_NAME, + }, + ), + ] + if config.json: + pr.insert(0, structlog.processors.format_exc_info) + + return pr + + +def _serialize_to_json(data: Any, default: Any) -> str: + return orjson.dumps(data, default=default).decode("utf-8") # type: ignore[no-any-return] diff --git a/src/pyproject/main.py b/src/pyproject/main.py index 23a13e1..9b7044a 100644 --- a/src/pyproject/main.py +++ b/src/pyproject/main.py @@ -1,8 +1,18 @@ -from pyproject import __version__ +import logging + +from pyproject.config import AppConfig +from pyproject.infrastructure import load_config +from pyproject.infrastructure.logger import setup_logger + +logger = logging.getLogger(__name__) def main() -> None: - print(__version__) # noqa: T201 + config = load_config(AppConfig) + setup_logger(config.logger) + + logger.info("Application started") + logger.info("Application stopped") if __name__ == "__main__": diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py index 7b8b392..c97226b 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -1,5 +1,15 @@ -from pyproject import __version__ +from pyproject.config import AppConfig +from pyproject.infrastructure import load_config +from pyproject.infrastructure.logger import LoggerConfig +from pyproject.infrastructure.logger.config import FileLoggerConfig -def test_version() -> None: - assert __version__ == "0.1.0" +def test_config() -> None: + # Arrange + path = "../../config/template.config.yaml" + + # Act + config = load_config(AppConfig, path) + + # Assert + assert config.logger != LoggerConfig(FileLoggerConfig()) diff --git a/uv.lock b/uv.lock index bd80e7e..4834b16 100644 --- a/uv.lock +++ b/uv.lock @@ -1,7 +1,16 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" +[[package]] +name = "adaptix" +version = "3.0.0b11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ea/f55d4de521cb237b1dd9c4a21c6ae3885d21b4f90130d15fd88020d6d1ca/adaptix-3.0.0b11.tar.gz", hash = "sha256:3d3d660d97d9e1a85d133b181fdac8a200ff3185422fdf686601bc9ed0017162", size = 134021, upload-time = "2025-05-09T14:51:09.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/70/b087e09db584c2718cfff4a354c98dfac1844e7e7d0c75dc38685823eec1/adaptix-3.0.0b11-py3-none-any.whl", hash = "sha256:5afa7197d1a084fc93da852bf58194de4a1c506caa3f5889c9953a27ce515bda", size = 179291, upload-time = "2025-05-09T14:51:08.288Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -203,6 +212,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "orjson" +version = "3.11.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/43/61a77040ce59f1569edf38f0b9faadc90c8cf7e9bec2e0df51d0132c6bb7/orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629", size = 245271, upload-time = "2025-12-06T15:54:40.878Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/0f79be617388227866d50edd2fd320cb8fb94dc1501184bb1620981a0aba/orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3", size = 129422, upload-time = "2025-12-06T15:54:42.403Z" }, + { url = "https://files.pythonhosted.org/packages/77/42/f1bf1549b432d4a78bfa95735b79b5dac75b65b5bb815bba86ad406ead0a/orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39", size = 132060, upload-time = "2025-12-06T15:54:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/25/49/825aa6b929f1a6ed244c78acd7b22c1481fd7e5fda047dc8bf4c1a807eb6/orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f", size = 130391, upload-time = "2025-12-06T15:54:45.059Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/de55391858b49e16e1aa8f0bbbb7e5997b7345d8e984a2dec3746d13065b/orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51", size = 135964, upload-time = "2025-12-06T15:54:46.576Z" }, + { url = "https://files.pythonhosted.org/packages/1c/40/820bc63121d2d28818556a2d0a09384a9f0262407cf9fa305e091a8048df/orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8", size = 139817, upload-time = "2025-12-06T15:54:48.084Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/3a445ca9a84a0d59d26365fd8898ff52bdfcdcb825bcc6519830371d2364/orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706", size = 137336, upload-time = "2025-12-06T15:54:49.426Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b3/dc0d3771f2e5d1f13368f56b339c6782f955c6a20b50465a91acb79fe961/orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f", size = 138993, upload-time = "2025-12-06T15:54:50.939Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a2/65267e959de6abe23444659b6e19c888f242bf7725ff927e2292776f6b89/orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863", size = 141070, upload-time = "2025-12-06T15:54:52.414Z" }, + { url = "https://files.pythonhosted.org/packages/63/c9/da44a321b288727a322c6ab17e1754195708786a04f4f9d2220a5076a649/orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228", size = 413505, upload-time = "2025-12-06T15:54:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/7f/17/68dc14fa7000eefb3d4d6d7326a190c99bb65e319f02747ef3ebf2452f12/orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2", size = 151342, upload-time = "2025-12-06T15:54:55.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c5/ccee774b67225bed630a57478529fc026eda33d94fe4c0eac8fe58d4aa52/orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05", size = 141823, upload-time = "2025-12-06T15:54:56.331Z" }, + { url = "https://files.pythonhosted.org/packages/67/80/5d00e4155d0cd7390ae2087130637671da713959bb558db9bac5e6f6b042/orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef", size = 135236, upload-time = "2025-12-06T15:54:57.507Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/792cc06a84808dbdc20ac6eab6811c53091b42f8e51ecebf14b540e9cfe4/orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583", size = 133167, upload-time = "2025-12-06T15:54:58.71Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/d158bd8b50e3b1cfdcf406a7e463f6ffe3f0d167b99634717acdaf5e299f/orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287", size = 126712, upload-time = "2025-12-06T15:54:59.892Z" }, + { url = "https://files.pythonhosted.org/packages/c2/60/77d7b839e317ead7bb225d55bb50f7ea75f47afc489c81199befc5435b50/orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0", size = 245252, upload-time = "2025-12-06T15:55:01.127Z" }, + { url = "https://files.pythonhosted.org/packages/f1/aa/d4639163b400f8044cef0fb9aa51b0337be0da3a27187a20d1166e742370/orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81", size = 129419, upload-time = "2025-12-06T15:55:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/94/9eabf94f2e11c671111139edf5ec410d2f21e6feee717804f7e8872d883f/orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f", size = 132050, upload-time = "2025-12-06T15:55:03.918Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c8/ca10f5c5322f341ea9a9f1097e140be17a88f88d1cfdd29df522970d9744/orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e", size = 130370, upload-time = "2025-12-06T15:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/25/d4/e96824476d361ee2edd5c6290ceb8d7edf88d81148a6ce172fc00278ca7f/orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7", size = 136012, upload-time = "2025-12-06T15:55:06.402Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/9bc3423308c425c588903f2d103cfcfe2539e07a25d6522900645a6f257f/orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb", size = 139809, upload-time = "2025-12-06T15:55:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/b404e94e0b02a232b957c54643ce68d0268dacb67ac33ffdee24008c8b27/orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4", size = 137332, upload-time = "2025-12-06T15:55:08.961Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/cc2d69d5ce0ad9b84811cdf4a0cd5362ac27205a921da524ff42f26d65e0/orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad", size = 138983, upload-time = "2025-12-06T15:55:10.595Z" }, + { url = "https://files.pythonhosted.org/packages/0e/87/de3223944a3e297d4707d2fe3b1ffb71437550e165eaf0ca8bbe43ccbcb1/orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829", size = 141069, upload-time = "2025-12-06T15:55:11.832Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/81d5087ae74be33bcae3ff2d80f5ccaa4a8fedc6d39bf65a427a95b8977f/orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac", size = 413491, upload-time = "2025-12-06T15:55:13.314Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6f/f6058c21e2fc1efaf918986dbc2da5cd38044f1a2d4b7b91ad17c4acf786/orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d", size = 151375, upload-time = "2025-12-06T15:55:14.715Z" }, + { url = "https://files.pythonhosted.org/packages/54/92/c6921f17d45e110892899a7a563a925b2273d929959ce2ad89e2525b885b/orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439", size = 141850, upload-time = "2025-12-06T15:55:15.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/86/cdecb0140a05e1a477b81f24739da93b25070ee01ce7f7242f44a6437594/orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499", size = 135278, upload-time = "2025-12-06T15:55:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/e4/97/b638d69b1e947d24f6109216997e38922d54dcdcdb1b11c18d7efd2d3c59/orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310", size = 133170, upload-time = "2025-12-06T15:55:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -268,6 +315,12 @@ wheels = [ name = "pyproject" version = "0.1.0" source = { virtual = "." } +dependencies = [ + { name = "adaptix" }, + { name = "orjson" }, + { name = "pyyaml" }, + { name = "structlog" }, +] [package.dev-dependencies] lint = [ @@ -282,6 +335,12 @@ test = [ ] [package.metadata] +requires-dist = [ + { name = "adaptix", specifier = ">=3.0.0b11" }, + { name = "orjson", specifier = ">=3.11.5" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "structlog", specifier = ">=25.5.0" }, +] [package.metadata.requires-dev] lint = [ @@ -399,6 +458,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 0d44fb141632405cde617aee79743409524efb43 Mon Sep 17 00:00:00 2001 From: treaditup Date: Sun, 21 Dec 2025 04:51:10 +0300 Subject: [PATCH 2/4] feat: refactor logger --- src/pyproject/infrastructure/__init__.py | 3 - .../infrastructure/config/__init__.py | 3 + .../{config_loader.py => config/loader.py} | 0 src/pyproject/infrastructure/logger/setup.py | 76 +++++++++++-------- src/pyproject/main.py | 2 +- .../unit/{test_version.py => test_config.py} | 4 +- 6 files changed, 50 insertions(+), 38 deletions(-) create mode 100644 src/pyproject/infrastructure/config/__init__.py rename src/pyproject/infrastructure/{config_loader.py => config/loader.py} (100%) rename tests/unit/{test_version.py => test_config.py} (77%) diff --git a/src/pyproject/infrastructure/__init__.py b/src/pyproject/infrastructure/__init__.py index 8a9ca9e..e69de29 100644 --- a/src/pyproject/infrastructure/__init__.py +++ b/src/pyproject/infrastructure/__init__.py @@ -1,3 +0,0 @@ -from .config_loader import load_config - -__all__ = ["load_config"] diff --git a/src/pyproject/infrastructure/config/__init__.py b/src/pyproject/infrastructure/config/__init__.py new file mode 100644 index 0000000..0cfe1d9 --- /dev/null +++ b/src/pyproject/infrastructure/config/__init__.py @@ -0,0 +1,3 @@ +from .loader import load_config + +__all__ = ["load_config"] diff --git a/src/pyproject/infrastructure/config_loader.py b/src/pyproject/infrastructure/config/loader.py similarity index 100% rename from src/pyproject/infrastructure/config_loader.py rename to src/pyproject/infrastructure/config/loader.py diff --git a/src/pyproject/infrastructure/logger/setup.py b/src/pyproject/infrastructure/logger/setup.py index 853f685..efc5013 100644 --- a/src/pyproject/infrastructure/logger/setup.py +++ b/src/pyproject/infrastructure/logger/setup.py @@ -2,6 +2,7 @@ import sys import uuid from logging.handlers import RotatingFileHandler +from pathlib import Path from typing import Any import orjson @@ -23,8 +24,8 @@ def _setup_structlog(config: LoggerConfig) -> None: *_build_default_processors(config), structlog.processors.StackInfoRenderer(), structlog.stdlib.PositionalArgumentsFormatter(), - structlog.processors.UnicodeDecoder(), # convert bytes to str - structlog.stdlib.ProcessorFormatter.wrap_for_formatter, # for integration with default logging + structlog.processors.UnicodeDecoder(), + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, ] structlog.configure_once( @@ -36,57 +37,58 @@ def _setup_structlog(config: LoggerConfig) -> None: def _setup_logging(config: LoggerConfig) -> None: - renderer_processor = JSONRenderer(_serialize_to_json) if config.json else ConsoleRenderer() default_processors = _build_default_processors(config) - - logging_processors = [ + stream_processors = [ structlog.stdlib.ProcessorFormatter.remove_processors_meta, - renderer_processor, + _get_render_processor(json=config.json, colors=True), ] - - formatter = structlog.stdlib.ProcessorFormatter( + stream_formatter = structlog.stdlib.ProcessorFormatter( foreign_pre_chain=default_processors, - processors=logging_processors, + processors=stream_processors, ) - handler = logging.StreamHandler(stream=sys.stdout) - handler.set_name("default") - handler.setLevel(config.level) - handler.setFormatter(formatter) - handlers: list[logging.Handler] = [handler] - - if config.file: + stream_handler = logging.StreamHandler(stream=sys.stdout) + stream_handler.set_name("default") + stream_handler.setLevel(config.level) + stream_handler.setFormatter(stream_formatter) + handlers: list[logging.Handler] = [stream_handler] + + if config.file.enabled: + Path(config.file.path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True) + + file_processors = [ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + _get_render_processor(json=config.json, colors=False), + ] + file_formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=default_processors, + processors=file_processors, + ) file_handler = RotatingFileHandler( filename=config.file.path, maxBytes=config.file.max_size_bytes, backupCount=config.file.backup_count, encoding="utf-8", ) + file_handler.set_name("file") file_handler.setLevel(config.level) - file_handler.setFormatter(formatter) + file_handler.setFormatter(file_formatter) handlers.append(file_handler) - logging.basicConfig(handlers=handlers, level=config.level) - - -def additional_serialize(_logger: WrappedLogger, _name: str, event_dict: EventDict) -> EventDict: - for key, value in event_dict.items(): - if isinstance(value, uuid.UUID): - event_dict[key] = str(value) - - return event_dict + logging.basicConfig(handlers=handlers, level=config.level, force=True) def _build_default_processors(config: LoggerConfig) -> list[Any]: - pr = [ + processors: list[Any] = [ structlog.stdlib.add_log_level, structlog.stdlib.add_logger_name, structlog.contextvars.merge_contextvars, structlog.stdlib.ExtraAdder(), - additional_serialize, + _additional_serialize, structlog.dev.set_exc_info, - structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S.%f", utc=True), + structlog.processors.EventRenamer("msg"), + structlog.processors.TimeStamper(fmt="iso", utc=True), structlog.processors.dict_tracebacks, structlog.processors.CallsiteParameterAdder( { @@ -101,11 +103,21 @@ def _build_default_processors(config: LoggerConfig) -> list[Any]: }, ), ] + if config.json: - pr.insert(0, structlog.processors.format_exc_info) + processors.insert(0, structlog.processors.format_exc_info) + + return processors - return pr +def _additional_serialize(_logger: WrappedLogger, _name: str, event_dict: EventDict) -> EventDict: + return {k: (str(v) if isinstance(v, uuid.UUID) else v) for k, v in event_dict.items()} -def _serialize_to_json(data: Any, default: Any) -> str: + +def _serialize_to_json(data: Any, **kwargs: Any) -> str: + default = kwargs.get("default") return orjson.dumps(data, default=default).decode("utf-8") # type: ignore[no-any-return] + + +def _get_render_processor(*, json: bool, colors: bool) -> JSONRenderer | ConsoleRenderer: + return JSONRenderer(_serialize_to_json) if json else ConsoleRenderer(colors=colors) diff --git a/src/pyproject/main.py b/src/pyproject/main.py index 9b7044a..ddc28b1 100644 --- a/src/pyproject/main.py +++ b/src/pyproject/main.py @@ -1,7 +1,7 @@ import logging from pyproject.config import AppConfig -from pyproject.infrastructure import load_config +from pyproject.infrastructure.config import load_config from pyproject.infrastructure.logger import setup_logger logger = logging.getLogger(__name__) diff --git a/tests/unit/test_version.py b/tests/unit/test_config.py similarity index 77% rename from tests/unit/test_version.py rename to tests/unit/test_config.py index c97226b..ed87688 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_config.py @@ -1,12 +1,12 @@ from pyproject.config import AppConfig -from pyproject.infrastructure import load_config +from pyproject.infrastructure.config import load_config from pyproject.infrastructure.logger import LoggerConfig from pyproject.infrastructure.logger.config import FileLoggerConfig def test_config() -> None: # Arrange - path = "../../config/template.config.yaml" + path = "./config/template.config.yaml" # Act config = load_config(AppConfig, path) From c426125650f1cb1ac38cbeb053428ab9499acc06 Mon Sep 17 00:00:00 2001 From: treaditup Date: Sun, 21 Dec 2025 04:56:37 +0300 Subject: [PATCH 3/4] fix: lint --- pyproject.toml | 1 + uv.lock | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 61df7ab..8b8fdf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ lint = [ "ruff>=0.14.10", "mypy>=1.19.1", "pre-commit>=4.5.1", + "types-pyyaml>=6.0.12.20250915", ] test = [ "pytest>=9.0.1", diff --git a/uv.lock b/uv.lock index 4834b16..d8b1b30 100644 --- a/uv.lock +++ b/uv.lock @@ -327,6 +327,7 @@ lint = [ { name = "mypy" }, { name = "pre-commit" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] test = [ { name = "pytest" }, @@ -347,6 +348,7 @@ lint = [ { name = "mypy", specifier = ">=1.19.1" }, { name = "pre-commit", specifier = ">=4.5.1" }, { name = "ruff", specifier = ">=0.14.10" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, ] test = [ { name = "pytest", specifier = ">=9.0.1" }, @@ -467,6 +469,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, ] +[[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" From f8faa5420bc6592d1b837f369d7992de8d10bc63 Mon Sep 17 00:00:00 2001 From: treaditup Date: Sun, 21 Dec 2025 04:58:50 +0300 Subject: [PATCH 4/4] fix: lint --- src/pyproject/infrastructure/config/loader.py | 2 +- src/pyproject/infrastructure/logger/setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pyproject/infrastructure/config/loader.py b/src/pyproject/infrastructure/config/loader.py index e92ac6c..948935b 100644 --- a/src/pyproject/infrastructure/config/loader.py +++ b/src/pyproject/infrastructure/config/loader.py @@ -15,4 +15,4 @@ def load_config[T](config_type: type[T], path: str | Path | None = None) -> T: data = yaml.safe_load(f) retort = Retort(strict_coercion=False) - return retort.load(data, config_type) # type: ignore[no-any-return] + return retort.load(data, config_type) diff --git a/src/pyproject/infrastructure/logger/setup.py b/src/pyproject/infrastructure/logger/setup.py index efc5013..cb6f7a1 100644 --- a/src/pyproject/infrastructure/logger/setup.py +++ b/src/pyproject/infrastructure/logger/setup.py @@ -116,7 +116,7 @@ def _additional_serialize(_logger: WrappedLogger, _name: str, event_dict: EventD def _serialize_to_json(data: Any, **kwargs: Any) -> str: default = kwargs.get("default") - return orjson.dumps(data, default=default).decode("utf-8") # type: ignore[no-any-return] + return orjson.dumps(data, default=default).decode("utf-8") def _get_render_processor(*, json: bool, colors: bool) -> JSONRenderer | ConsoleRenderer: