Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,6 @@ dmypy.json

# Logs
*.log

# Config
config.yaml
8 changes: 8 additions & 0 deletions config/template.config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
logger:
level: INFO
json: true
file:
enabled: true
path: app.log
max_size_mb: 10
backup_count: 5
2 changes: 2 additions & 0 deletions config/template.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# App config
CONFIG_PATH=/etc/pyproject/config.yaml
44 changes: 44 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions docker/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -22,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",
Expand All @@ -42,6 +48,7 @@ lint.select = [
lint.ignore = [
"ANN401", # allow any-type
"D", # ignore docstring requirements
"PTH123" # allow use open()
]

[tool.ruff.lint.per-file-ignores]
Expand Down
1 change: 0 additions & 1 deletion src/pyproject/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
__version__ = "0.1.0"
8 changes: 8 additions & 0 deletions src/pyproject/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from dataclasses import dataclass

from pyproject.infrastructure.logger import LoggerConfig


@dataclass(frozen=True, slots=True)
class AppConfig:
logger: LoggerConfig
Empty file.
3 changes: 3 additions & 0 deletions src/pyproject/infrastructure/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .loader import load_config

__all__ = ["load_config"]
18 changes: 18 additions & 0 deletions src/pyproject/infrastructure/config/loader.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions src/pyproject/infrastructure/logger/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .config import LoggerConfig
from .setup import setup_logger

__all__ = ["LoggerConfig", "setup_logger"]
27 changes: 27 additions & 0 deletions src/pyproject/infrastructure/logger/config.py
Original file line number Diff line number Diff line change
@@ -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
123 changes: 123 additions & 0 deletions src/pyproject/infrastructure/logger/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import logging
import sys
import uuid
from logging.handlers import RotatingFileHandler
from pathlib import Path
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(),
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
]

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:
default_processors = _build_default_processors(config)
stream_processors = [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
_get_render_processor(json=config.json, colors=True),
]
stream_formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=default_processors,
processors=stream_processors,
)

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(file_formatter)
handlers.append(file_handler)

logging.basicConfig(handlers=handlers, level=config.level, force=True)


def _build_default_processors(config: LoggerConfig) -> list[Any]:
processors: list[Any] = [
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.EventRenamer("msg"),
structlog.processors.TimeStamper(fmt="iso", 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:
processors.insert(0, structlog.processors.format_exc_info)

return processors


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, **kwargs: Any) -> str:
default = kwargs.get("default")
return orjson.dumps(data, default=default).decode("utf-8")


def _get_render_processor(*, json: bool, colors: bool) -> JSONRenderer | ConsoleRenderer:
return JSONRenderer(_serialize_to_json) if json else ConsoleRenderer(colors=colors)
Loading