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
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
name: ci

on:
push:
branches: [main]
pull_request:
branches: [main]

workflow_dispatch:
permissions:
contents: read

Expand Down
18 changes: 16 additions & 2 deletions src/release_kit/core/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@
import structlog


def _stderr_logger(*_args: Any) -> structlog.PrintLogger:
"""
A PrintLogger on whatever ``sys.stderr`` is when a message is logged.

``PrintLoggerFactory(file=sys.stderr)`` captured the stream that was current when
``configure()`` ran, and ``cache_logger_on_first_use`` kept it. Once that stream was
replaced and closed (a test harness capturing output, a host redirecting stderr),
every later log call raised "I/O operation on closed file". Resolving the stream here,
with caching off, follows stderr wherever it currently points.
"""
return structlog.PrintLogger(file=sys.stderr)


def configure(level: str = "INFO", *, json: bool = False) -> None:
"""
Configure structlog + stdlib logging for the process.
Expand Down Expand Up @@ -53,8 +66,9 @@ def configure(level: str = "INFO", *, json: bool = False) -> None:
structlog.configure(
processors=processors,
wrapper_class=structlog.make_filtering_bound_logger(log_level),
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
cache_logger_on_first_use=True,
logger_factory=_stderr_logger,
# Off on purpose: a cached logger keeps the stderr it was created with.
cache_logger_on_first_use=False,
)


Expand Down
27 changes: 27 additions & 0 deletions tests/core/test_logging_stream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Logging must follow sys.stderr, not keep the stream that was current at configure time."""

from __future__ import annotations

import io
import sys

import pytest

from release_kit.core.logging import configure, get_logger


def test_logging_survives_the_configured_stream_being_closed(monkeypatch: pytest.MonkeyPatch) -> None:
# A harness swaps stderr, the CLI configures logging, the harness closes its stream.
captured = io.StringIO()
monkeypatch.setattr(sys, "stderr", captured)
configure("INFO")
get_logger("first").info("into the temporary stream")
captured.close()
monkeypatch.undo()

# The next log call must reach the current stderr, not the closed one.
replacement = io.StringIO()
monkeypatch.setattr(sys, "stderr", replacement)
get_logger("second").info("after the swap")

assert "after the swap" in replacement.getvalue()
Loading