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 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/logprise/pytest_plugin.py b/logprise/pytest_plugin.py new file mode 100644 index 0000000..f6e224e --- /dev/null +++ b/logprise/pytest_plugin.py @@ -0,0 +1,113 @@ +"""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, cast + +import pytest +from loguru import logger + + +if TYPE_CHECKING: + from collections.abc import Generator + from logging import _SysExcInfoType + + import loguru + from pytest 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: loguru.Record) -> 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=cast("_SysExcInfoType | None", record["exception"]), + func=func_name, + ) + + # Set the level name explicitly + log_record.levelname = level_name + + return log_record + + +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 + the recursion caused by logprise's InterceptHandler. + """ + if _state.fixture is None: + return + + # Get the record dict from the message + record = message.record + + # 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 dda3022..cf96872 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.3.0,<3.0.0"] build-backend = "poetry.core.masonry.api" 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_caplog_integration.py b/tests/test_caplog_integration.py new file mode 100644 index 0000000..71bd1f0 --- /dev/null +++ b/tests/test_caplog_integration.py @@ -0,0 +1,98 @@ +"""TDD tests for caplog integration. + +These tests verify that logprise logs show up in pytest's caplog fixture. +""" + +from __future__ import annotations + +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.""" + 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.""" + 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.""" + 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.""" + 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.""" + 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.""" + 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) 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