Capture loguru logs in pytest caplog via a propagate sink and structured extra: working versions (tested 2026-10-15)
Forward loguru records into stdlib logging so pytest caplog sees them, keep bound context as structured extra, and test it offline without a real log backend.
passed5 passed · 0 failed2026-10-15 13:23 UTC- Status
- passed
- Last passed
- 2026-10-15 13:23 UTC
- Tests
- 5 / 5
- Language
- Python
- Runtime
- Python 3.13.16
pinned versions2
loguru@0.7.3pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
test environment
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38741369695
The code
Exactly the files that passed. Your agent can pull them with get_recipe("loguru-pytest-caplog") (install), or see them on GitHub.
requirements.txt
loguru==0.7.3 pytest==9.1.1
logsetup.py
import logging
from loguru import logger
logging.addLevelName(25, "SUCCESS") # loguru's SUCCESS level has no stdlib name
def _to_stdlib(message):
r = message.record
logging.getLogger(r["name"]).log(
r["level"].no,
r["message"], # already formatted; no %-formatting is applied without args
# nest extras so bound keys like "message" or "name" cannot clash with LogRecord attributes
extra={"loguru_extra": dict(r["extra"])},
)
def setup_propagation(level="DEBUG") -> int:
"""Forward loguru records to stdlib logging. Returns the sink id for logger.remove()."""
return logger.add(_to_stdlib, level=level)
orders.py
from loguru import logger
def charge(order_id: str, amount_cents: int) -> bool:
log = logger.bind(order_id=order_id, amount_cents=amount_cents)
log.info("charging order")
if amount_cents <= 0:
log.warning("non-positive amount, skipped")
return False
log.success("charged")
return True
tests/test_logging.py
import logging
import pytest
from loguru import logger
from logsetup import setup_propagation
from orders import charge
@pytest.fixture
def propagate(caplog):
caplog.set_level(logging.DEBUG)
sink_id = setup_propagation()
yield
logger.remove(sink_id) # without this, sinks leak and every later test sees duplicates
def test_records_reach_caplog(propagate, caplog):
charge("o1", 500)
assert "charging order" in caplog.messages
assert "charged" in caplog.messages
def test_bound_context_travels_as_extra(propagate, caplog):
charge("o1", 500)
rec = next(r for r in caplog.records if r.getMessage() == "charging order")
assert rec.loguru_extra == {"order_id": "o1", "amount_cents": 500}
def test_success_level_has_a_stdlib_name(propagate, caplog):
charge("o1", 500)
rec = next(r for r in caplog.records if r.getMessage() == "charged")
assert rec.levelname == "SUCCESS" and rec.levelno == 25
def test_braces_and_percent_are_not_reformatted(propagate, caplog):
logger.info("100% done, {not_a_field}")
assert caplog.messages[-1] == "100% done, {not_a_field}"
def test_bind_keys_named_like_logrecord_attrs(propagate, caplog):
logger.bind(message="hi", name="x").info("ok")
rec = caplog.records[-1]
assert rec.loguru_extra["message"] == "hi" and rec.loguru_extra["name"] == "x"
pytest.ini
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.