From bcb7ea1c23aefa02a48daac16dc162c2f96198d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 12:57:29 +0000 Subject: [PATCH 1/5] Add pytest plugin for caplog integration - Create pytest plugin that forwards loguru logs to caplog fixture - Register plugin as pytest11 entry point so it loads automatically - Bypass standard logging to avoid recursion with InterceptHandler - Support caplog level filtering via at_level() context manager - Add comprehensive tests for caplog integration Closes the issue where logs output via logprise don't show up in caplog. --- logprise/pytest_plugin.py | 111 +++++++++++++++++++++++++++++++ pyproject.toml | 3 + tests/test_caplog_integration.py | 108 ++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 logprise/pytest_plugin.py create mode 100644 tests/test_caplog_integration.py diff --git a/logprise/pytest_plugin.py b/logprise/pytest_plugin.py new file mode 100644 index 0000000..a466305 --- /dev/null +++ b/logprise/pytest_plugin.py @@ -0,0 +1,111 @@ +"""Pytest plugin for logprise/caplog integration. + +This plugin ensures that logs emitted via logprise (which uses loguru internally) +are captured by pytest's caplog fixture. + +The plugin is automatically loaded by pytest when logprise is installed, +thanks to the pytest11 entry point defined in pyproject.toml. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import pytest +from loguru import logger + + +if TYPE_CHECKING: + from collections.abc import Generator + + from _pytest.logging import LogCaptureFixture + + +class _CaplogState: + """Container for caplog fixture state to avoid module-level globals.""" + + fixture: LogCaptureFixture | None = None + + +_state = _CaplogState() + + +def _create_log_record(record: dict) -> logging.LogRecord: + """Create a standard logging.LogRecord from a loguru record dict.""" + # Get the level info + level_no = record["level"].no + level_name = record["level"].name + + # Get module/function info + module = record.get("module", "") + func_name = record.get("function", "") + line_no = record.get("line", 0) + file_path = record.get("file", None) + pathname = str(file_path) if file_path else "" + + # Create a LogRecord directly + log_record = logging.LogRecord( + name=record.get("name") or module or "logprise", + level=level_no, + pathname=pathname, + lineno=line_no, + msg=record["message"], + args=(), + exc_info=record["exception"], + func=func_name, + ) + + # Set the level name explicitly + log_record.levelname = level_name + + return log_record + + +def _loguru_to_caplog(message: object) -> None: + """Sink function that forwards loguru messages directly to caplog's handler. + + This function bypasses the standard logging system entirely to avoid + the recursion caused by logprise's InterceptHandler. + """ + if _state.fixture is None: + return + + # Get the record dict from the message + record = message.record # type: ignore[union-attr] + + # Create a standard LogRecord + log_record = _create_log_record(record) + + # Check if the record's level meets the caplog's handler level threshold + # This respects caplog.at_level() context manager + if log_record.levelno >= _state.fixture.handler.level: + _state.fixture.handler.emit(log_record) + + +@pytest.fixture +def caplog(caplog: LogCaptureFixture) -> Generator[LogCaptureFixture, None, None]: + """Enhanced caplog fixture that captures logprise/loguru logs. + + This fixture wraps pytest's built-in caplog fixture and adds a loguru + sink that forwards logs directly to caplog's handler, bypassing + the standard logging system to avoid recursion with logprise's + InterceptHandler. + """ + # Store reference to caplog fixture + _state.fixture = caplog + + # Add a loguru sink that writes directly to caplog + handler_id = logger.add( + _loguru_to_caplog, + format="{message}", + level=0, # Capture all levels, let caplog filter + catch=False, + ) + + try: + yield caplog + finally: + # Remove the sink and clear the fixture reference + logger.remove(handler_id) + _state.fixture = None diff --git a/pyproject.toml b/pyproject.toml index bbdb1cc..e8ecedc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,9 @@ pytest-mock = "*" ruff = "*" mypy = "*" +[tool.poetry.plugins."pytest11"] +logprise = "logprise.pytest_plugin" + [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/tests/test_caplog_integration.py b/tests/test_caplog_integration.py new file mode 100644 index 0000000..437e21d --- /dev/null +++ b/tests/test_caplog_integration.py @@ -0,0 +1,108 @@ +"""TDD tests for caplog integration. + +These tests verify that logprise logs show up in pytest's caplog fixture. +""" + +from __future__ import annotations + +import logging + + +def test_loguru_logger_captured_in_caplog(caplog): + """Test that logs from loguru's logger show up in caplog.""" + from logprise import logger + + with caplog.at_level(logging.INFO): + logger.info("Test message from loguru") + + assert len(caplog.records) >= 1 + assert any("Test message from loguru" in record.message for record in caplog.records) + + +def test_standard_logging_captured_in_caplog(caplog): + """Test that standard logging (intercepted by logprise) shows up in caplog.""" + with caplog.at_level(logging.WARNING): + logging.warning("Test warning from standard logging") + + assert len(caplog.records) >= 1 + assert any("Test warning from standard logging" in record.message for record in caplog.records) + + +def test_loguru_different_levels_captured(caplog): + """Test that different log levels are captured correctly.""" + from logprise import logger + + with caplog.at_level(logging.DEBUG): + logger.debug("Debug message") + logger.info("Info message") + logger.warning("Warning message") + logger.error("Error message") + + messages = [record.message for record in caplog.records] + assert any("Debug message" in msg for msg in messages) + assert any("Info message" in msg for msg in messages) + assert any("Warning message" in msg for msg in messages) + assert any("Error message" in msg for msg in messages) + + +def test_caplog_level_filtering(caplog): + """Test that caplog level filtering works with logprise.""" + from logprise import logger + + with caplog.at_level(logging.WARNING): + logger.info("Should not appear") + logger.warning("Should appear") + + messages = [record.message for record in caplog.records] + assert not any("Should not appear" in msg for msg in messages) + assert any("Should appear" in msg for msg in messages) + + +def test_caplog_records_have_correct_level(caplog): + """Test that captured records have the correct log level.""" + from logprise import logger + + with caplog.at_level(logging.DEBUG): + logger.warning("Warning test") + logger.error("Error test") + + warning_records = [r for r in caplog.records if "Warning test" in r.message] + error_records = [r for r in caplog.records if "Error test" in r.message] + + assert len(warning_records) >= 1 + assert len(error_records) >= 1 + assert warning_records[0].levelno == logging.WARNING + assert error_records[0].levelno == logging.ERROR + + +def test_caplog_clear_works(caplog): + """Test that caplog.clear() works properly.""" + from logprise import logger + + with caplog.at_level(logging.INFO): + logger.info("First message") + assert len(caplog.records) >= 1 + + caplog.clear() + assert len(caplog.records) == 0 + + logger.info("Second message") + assert len(caplog.records) >= 1 + messages = [r.message for r in caplog.records] + assert not any("First message" in msg for msg in messages) + assert any("Second message" in msg for msg in messages) + + +def test_multiple_loggers_captured(caplog): + """Test that logs from multiple loggers are captured.""" + from logprise import logger + + named_logger = logging.getLogger("test.named.logger") + + with caplog.at_level(logging.INFO): + logger.info("From loguru") + named_logger.info("From named logger") + + messages = [record.message for record in caplog.records] + assert any("From loguru" in msg for msg in messages) + assert any("From named logger" in msg for msg in messages) From 206183d1e5daa10ced5f72964773ff10bb3d4a64 Mon Sep 17 00:00:00 2001 From: Steven Van Ingelgem Date: Wed, 27 May 2026 05:35:51 +0200 Subject: [PATCH 2/5] fix: correct type annotations in pytest plugin to satisfy stubtest Type the loguru sink parameter as loguru.Message and the record helper as loguru.Record instead of object/dict. This removes the incorrect 'type: ignore[union-attr]' comment (mypy reported attr-defined) that was failing the stubtest lint step in CI, and cast the loguru RecordException to the exc_info type expected by logging.LogRecord. --- logprise/pytest_plugin.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/logprise/pytest_plugin.py b/logprise/pytest_plugin.py index a466305..f6e224e 100644 --- a/logprise/pytest_plugin.py +++ b/logprise/pytest_plugin.py @@ -10,7 +10,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import pytest from loguru import logger @@ -18,8 +18,10 @@ if TYPE_CHECKING: from collections.abc import Generator + from logging import _SysExcInfoType - from _pytest.logging import LogCaptureFixture + import loguru + from pytest import LogCaptureFixture class _CaplogState: @@ -31,7 +33,7 @@ class _CaplogState: _state = _CaplogState() -def _create_log_record(record: dict) -> logging.LogRecord: +def _create_log_record(record: loguru.Record) -> logging.LogRecord: """Create a standard logging.LogRecord from a loguru record dict.""" # Get the level info level_no = record["level"].no @@ -52,7 +54,7 @@ def _create_log_record(record: dict) -> logging.LogRecord: lineno=line_no, msg=record["message"], args=(), - exc_info=record["exception"], + exc_info=cast("_SysExcInfoType | None", record["exception"]), func=func_name, ) @@ -62,7 +64,7 @@ def _create_log_record(record: dict) -> logging.LogRecord: return log_record -def _loguru_to_caplog(message: object) -> None: +def _loguru_to_caplog(message: loguru.Message) -> None: """Sink function that forwards loguru messages directly to caplog's handler. This function bypasses the standard logging system entirely to avoid @@ -72,7 +74,7 @@ def _loguru_to_caplog(message: object) -> None: return # Get the record dict from the message - record = message.record # type: ignore[union-attr] + record = message.record # Create a standard LogRecord log_record = _create_log_record(record) From 1486850ce6cdd1ee87521a7c25243be33d3a4d84 Mon Sep 17 00:00:00 2001 From: Steven Van Ingelgem Date: Wed, 27 May 2026 05:43:52 +0200 Subject: [PATCH 3/5] style: hoist logprise import to module top in caplog tests The logprise pytest plugin (pytest11 entry point) already forces logprise to load at pytest startup, so the per-test inline imports provided no lazy-loading benefit and only triggered ruff PLC0415. Move the single import to the module top level. --- tests/test_caplog_integration.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/tests/test_caplog_integration.py b/tests/test_caplog_integration.py index 437e21d..71bd1f0 100644 --- a/tests/test_caplog_integration.py +++ b/tests/test_caplog_integration.py @@ -7,11 +7,11 @@ import logging +from logprise import logger + def test_loguru_logger_captured_in_caplog(caplog): """Test that logs from loguru's logger show up in caplog.""" - from logprise import logger - with caplog.at_level(logging.INFO): logger.info("Test message from loguru") @@ -30,8 +30,6 @@ def test_standard_logging_captured_in_caplog(caplog): def test_loguru_different_levels_captured(caplog): """Test that different log levels are captured correctly.""" - from logprise import logger - with caplog.at_level(logging.DEBUG): logger.debug("Debug message") logger.info("Info message") @@ -47,8 +45,6 @@ def test_loguru_different_levels_captured(caplog): def test_caplog_level_filtering(caplog): """Test that caplog level filtering works with logprise.""" - from logprise import logger - with caplog.at_level(logging.WARNING): logger.info("Should not appear") logger.warning("Should appear") @@ -60,8 +56,6 @@ def test_caplog_level_filtering(caplog): def test_caplog_records_have_correct_level(caplog): """Test that captured records have the correct log level.""" - from logprise import logger - with caplog.at_level(logging.DEBUG): logger.warning("Warning test") logger.error("Error test") @@ -77,8 +71,6 @@ def test_caplog_records_have_correct_level(caplog): def test_caplog_clear_works(caplog): """Test that caplog.clear() works properly.""" - from logprise import logger - with caplog.at_level(logging.INFO): logger.info("First message") assert len(caplog.records) >= 1 @@ -95,8 +87,6 @@ def test_caplog_clear_works(caplog): def test_multiple_loggers_captured(caplog): """Test that logs from multiple loggers are captured.""" - from logprise import logger - named_logger = logging.getLogger("test.named.logger") with caplog.at_level(logging.INFO): From 6ca3cc5564aa5dad1c76c04056b5e715b92e79cb Mon Sep 17 00:00:00 2001 From: Steven Van Ingelgem Date: Wed, 27 May 2026 05:53:47 +0200 Subject: [PATCH 4/5] test: bring pytest plugin to 100% coverage and require it via codecov - Add a direct test for the sink's no-fixture early-return branch. - Reload the plugin module once via a session-scoped autouse fixture so its import-time definitions (loaded at pytest startup, before coverage begins) are measured. - Add codecov.yml requiring 100% patch coverage on every change, with project coverage held at no-regression. --- codecov.yml | 15 +++++++++++++++ tests/conftest.py | 17 ++++++++++++++++- tests/test_pytest_plugin.py | 16 ++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 codecov.yml create mode 100644 tests/test_pytest_plugin.py diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..dbb6707 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,15 @@ +# Codecov configuration. +# +# Every change must fully cover the lines it touches: the patch status fails +# unless 100% of the lines added or modified in a pull request are covered. +# Overall project coverage is held at "no regression" so it can only improve +# over time. +coverage: + status: + project: + default: + target: auto + threshold: 0% + patch: + default: + target: 100% diff --git a/tests/conftest.py b/tests/conftest.py index df29e2b..4eefa6d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import importlib import sys import threading from collections.abc import Generator @@ -6,7 +7,21 @@ import pytest from apprise import NotifyBase, NotifyType -from logprise import Appriser, logger +from logprise import Appriser, logger, pytest_plugin + + +@pytest.fixture(scope="session", autouse=True) +def _measure_plugin_module_definitions() -> None: + """Re-import the logprise pytest plugin so its module-level code is measured. + + Pytest loads the plugin (via its pytest11 entry point) at startup, before + pytest-cov begins measuring, so the imports, class and function definitions + would otherwise never register as executed. Reloading once at session start + runs them again while coverage is active. Reload refreshes the existing + module namespace in place, so the already-registered caplog fixture keeps + working. + """ + importlib.reload(pytest_plugin) class NoOpNotifier(NotifyBase): diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py new file mode 100644 index 0000000..efcdeee --- /dev/null +++ b/tests/test_pytest_plugin.py @@ -0,0 +1,16 @@ +"""Direct unit tests for the logprise pytest plugin internals. + +The caplog-integration behaviour is covered in test_caplog_integration.py; +this exercises the sink helper in isolation to reach the branch the +fixture-driven path does not. +""" + +from __future__ import annotations + +from logprise import pytest_plugin + + +def test_sink_returns_early_when_no_fixture(): + """The sink is a no-op (and never touches message.record) without a fixture.""" + pytest_plugin._state.fixture = None + assert pytest_plugin._loguru_to_caplog("ignored message") is None From 4d2986ac21c0c086846fbb2e90f4a1ce01e0687f Mon Sep 17 00:00:00 2001 From: Steven Van Ingelgem Date: Wed, 27 May 2026 05:55:48 +0200 Subject: [PATCH 5/5] docs: document caplog integration and add status badges - Add a 'Testing with caplog' section explaining the auto-loaded pytest plugin that surfaces loguru/logprise logs in pytest's caplog fixture. - Add PyPI version, Python versions, build, codecov coverage and license badges to the README header. --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index a79b25a..97b635e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Logprise +[![PyPI version](https://img.shields.io/pypi/v/logprise.svg)](https://pypi.org/project/logprise/) +[![Python versions](https://img.shields.io/pypi/pyversions/logprise.svg)](https://pypi.org/project/logprise/) +[![Build and Test](https://github.com/svaningelgem/logprise/actions/workflows/build.yml/badge.svg)](https://github.com/svaningelgem/logprise/actions/workflows/build.yml) +[![codecov](https://img.shields.io/codecov/c/github/svaningelgem/logprise?logo=codecov)](https://codecov.io/gh/svaningelgem/logprise) +[![License: MIT](https://img.shields.io/pypi/l/logprise.svg)](https://github.com/svaningelgem/logprise/blob/master/LICENSE) + Logprise provides a one-stop logger for your Python application by integrating [loguru](https://github.com/Delgan/loguru/) and [apprise](https://github.com/caronc/apprise). It intercepts all standard `logging` calls and routes them through a unified interface. Above a configurable threshold, errors are automatically sent as alerts via Slack, Discord, email, or 100+ other services - no code changes needed. ## Why Logprise? @@ -195,6 +201,27 @@ appriser.send_notification( ) ``` +## Testing with `caplog` + +Loguru logs don't normally show up in pytest's built-in `caplog` fixture, because loguru bypasses the standard `logging` machinery that `caplog` hooks into. Logprise ships a pytest plugin that bridges this gap automatically - it's enabled the moment logprise is installed, with no configuration or `conftest.py` changes required. + +Just use `caplog` as you always would: + +```python +import logging +from logprise import logger + + +def test_payment_failure_is_logged(caplog): + with caplog.at_level(logging.ERROR): + logger.error("Payment processing failed") + + assert "Payment processing failed" in caplog.text + assert caplog.records[0].levelno == logging.ERROR +``` + +This works for logs from `logprise.logger`, from the standard `logging` module, and from third-party libraries - anything logprise intercepts. The `caplog.at_level()` context manager and level filtering behave exactly as they do with standard logging. + ## Contributing ```bash