diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f66aa5..0254c81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,10 @@ name: ci on: - push: - branches: [main] pull_request: branches: [main] + workflow_dispatch: permissions: contents: read diff --git a/src/release_kit/core/logging.py b/src/release_kit/core/logging.py index 7145a0c..106de90 100644 --- a/src/release_kit/core/logging.py +++ b/src/release_kit/core/logging.py @@ -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. @@ -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, ) diff --git a/tests/core/test_logging_stream.py b/tests/core/test_logging_stream.py new file mode 100644 index 0000000..fc8586d --- /dev/null +++ b/tests/core/test_logging_stream.py @@ -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()