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
6 changes: 3 additions & 3 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Setup Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: 3.13

- name: Install uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version-file: ./pyproject.toml

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Setup Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: 3.13

- name: Install uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version-file: ./pyproject.toml

Expand All @@ -41,7 +41,7 @@ jobs:
uv run pytest tests --cov=pyproject --junitxml=junitxml.xml --cov-report "xml:coverage.xml"; exit ${PIPESTATUS[0]}

- name: Test coverage comment
uses: MishaKav/pytest-coverage-comment@ae0e8a539a3f310aefb3bfb6a2209778a21fa42b # v1.2.0
uses: MishaKav/pytest-coverage-comment@287292879eaaff04116f36d3eb1a670f6e5df1a4 # v1.7.1
with:
pytest-xml-coverage-path: ./coverage.xml
junitxml-path: ./junitxml.xml
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/zizmor.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Run zizmor
uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
with:
advanced-security: false
2 changes: 1 addition & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM docker.io/library/python:3.13-slim@sha256:baf66684c5fcafbda38a54b227ee30ec41e40af1e4073edee3a7110a417756ba AS base
FROM docker.io/library/python:3.13-slim@sha256:56ab277ddf459858f94052252565945c34617c841818faf8f34f6896de06cffe AS base

ENV PATH="/venv/bin:$PATH" \
VIRTUAL_ENV="/venv" \
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[build-system]
requires = ["uv_build>=0.9.0,<0.10.0"]
requires = ["uv_build>=0.9.0,<0.11.0"]
build-backend = "uv_build"

[project]
Expand Down
4 changes: 2 additions & 2 deletions src/pyproject/infrastructure/logger/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .config import LoggerConfig
from .config import FileLoggerConfig, LoggerConfig
from .setup import setup_logger

__all__ = ["LoggerConfig", "setup_logger"]
__all__ = ["FileLoggerConfig", "LoggerConfig", "setup_logger"]
42 changes: 18 additions & 24 deletions src/pyproject/infrastructure/logger/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,20 @@

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_structlog()
_setup_logging(config)


def _setup_structlog(config: LoggerConfig) -> None:
def _setup_structlog() -> None:
processors = [
*_build_default_processors(config),
*_build_default_processors(),
structlog.processors.StackInfoRenderer(),
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.UnicodeDecoder(),
Expand All @@ -37,14 +36,10 @@ def _setup_structlog(config: LoggerConfig) -> None:


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

stream_handler = logging.StreamHandler(stream=sys.stdout)
Expand All @@ -56,13 +51,9 @@ def _setup_logging(config: LoggerConfig) -> None:
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,
processors=_build_render_processors(json=config.json, colors=False),
)
file_handler = RotatingFileHandler(
filename=config.file.path,
Expand All @@ -79,15 +70,15 @@ def _setup_logging(config: LoggerConfig) -> None:
logging.basicConfig(handlers=handlers, level=config.level, force=True)


def _build_default_processors(config: LoggerConfig) -> list[Any]:
def _build_default_processors() -> 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.format_exc_info,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.CallsiteParameterAdder(
{
Expand All @@ -103,10 +94,17 @@ def _build_default_processors(config: LoggerConfig) -> list[Any]:
),
]

if config.json:
processors.append(structlog.processors.dict_tracebacks)
return processors


def _build_render_processors(*, json: bool, colors: bool) -> list[Any]:
processors: list[Any] = [structlog.stdlib.ProcessorFormatter.remove_processors_meta]

if json:
processors.append(structlog.processors.EventRenamer("msg"))
processors.append(JSONRenderer(_serialize_to_json))
else:
processors.insert(0, structlog.processors.format_exc_info)
processors.append(structlog.dev.ConsoleRenderer(colors=colors))

return processors

Expand All @@ -118,7 +116,3 @@ 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")


def _get_render_processor(*, json: bool, colors: bool) -> JSONRenderer | ConsoleRenderer:
return JSONRenderer(_serialize_to_json) if json else ConsoleRenderer(colors=colors)
136 changes: 136 additions & 0 deletions tests/unit/test_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import json
import logging
import uuid
from collections.abc import Iterator
from pathlib import Path

import pytest
import structlog

from pyproject.infrastructure.logger import FileLoggerConfig, LoggerConfig, setup_logger


@pytest.fixture(autouse=True)
def reset_logging_state() -> Iterator[None]:
structlog.reset_defaults()
logging.basicConfig(handlers=[], force=True)

yield

structlog.reset_defaults()
logging.basicConfig(handlers=[], force=True)


def raise_value_error() -> None:
msg = "boom"
raise ValueError(msg)


def flush_handlers() -> None:
for handler in logging.getLogger().handlers:
handler.flush()


def test_plain_structlog_logger_writes_formatted_message_to_stdout(capsys: pytest.CaptureFixture[str]) -> None:
# Arrange
config = LoggerConfig(file=FileLoggerConfig(), json=False)
setup_logger(config)

# Act
structlog.get_logger("demo").info("hello %s", "world")

# Assert
captured = capsys.readouterr()
assert "hello world" in captured.out


def test_plain_stdlib_logger_formats_traceback(capsys: pytest.CaptureFixture[str]) -> None:
# Arrange
config = LoggerConfig(file=FileLoggerConfig(), json=False)
setup_logger(config)

# Act
try:
raise_value_error()
except ValueError:
logging.getLogger("demo").exception("plain failure")

# Assert
captured = capsys.readouterr()
assert "plain failure" in captured.out
assert "ValueError: boom" in captured.out


def test_json_structlog_logger_writes_serialized_event_to_stdout(capsys: pytest.CaptureFixture[str]) -> None:
# Arrange
request_id = uuid.UUID("12345678-1234-5678-1234-567812345678")
config = LoggerConfig(file=FileLoggerConfig(), json=True)
setup_logger(config)

# Act
try:
raise_value_error()
except ValueError:
structlog.get_logger("demo").exception("json %s", "failure", request_id=request_id)

# Assert
captured = capsys.readouterr()
event = json.loads(captured.out)
assert event["msg"] == "json failure"
assert event["request_id"] == str(request_id)
assert isinstance(event["exception"], str)
assert "ValueError: boom" in event["exception"]


def test_json_stdlib_logger_writes_extra_fields_to_stdout(capsys: pytest.CaptureFixture[str]) -> None:
# Arrange
request_id = uuid.UUID("87654321-4321-8765-4321-876543218765")
config = LoggerConfig(file=FileLoggerConfig(), json=True)
setup_logger(config)

# Act
logging.getLogger("demo").info("stdlib json message", extra={"request_id": request_id})

# Assert
captured = capsys.readouterr()
event = json.loads(captured.out)
assert event["msg"] == "stdlib json message"
assert event["request_id"] == str(request_id)
assert event["level"] == "info"


def test_file_logger_writes_plain_message_to_stdout_and_file(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
# Arrange
log_path = tmp_path / "logs" / "app.log"
file_config = FileLoggerConfig(enabled=True, path=str(log_path), max_size_mb=1, backup_count=1)
config = LoggerConfig(file=file_config, json=False)
setup_logger(config)

# Act
logging.getLogger("demo").warning("file message")
flush_handlers()

# Assert
captured = capsys.readouterr()
assert "file message" in captured.out
assert "file message" in log_path.read_text()


def test_file_logger_writes_json_message_to_file(tmp_path: Path) -> None:
# Arrange
log_path = tmp_path / "logs" / "app.log"
file_config = FileLoggerConfig(enabled=True, path=str(log_path), max_size_mb=1, backup_count=1)
config = LoggerConfig(file=file_config, json=True)
setup_logger(config)

# Act
structlog.get_logger("demo").info("json file message", category="audit")
flush_handlers()

# Assert
event = json.loads(log_path.read_text())
assert event["msg"] == "json file message"
assert event["category"] == "audit"
Loading
Loading