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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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?
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -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%
113 changes: 113 additions & 0 deletions logprise/pytest_plugin.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 16 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib
import sys
import threading
from collections.abc import Generator
Expand All @@ -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):
Expand Down
98 changes: 98 additions & 0 deletions tests/test_caplog_integration.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 16 additions & 0 deletions tests/test_pytest_plugin.py
Original file line number Diff line number Diff line change
@@ -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