Skip to content
Draft
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
30 changes: 30 additions & 0 deletions docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,34 @@ Records from the library's own threads do not carry this ID. Group them by
`log_id` instead: the records of one mock server share its ID, and the records
of one provider verification share `verify:<provider name>`.

### Pytest

The pytest plugin in [`pact.testing.pytest`][pact.testing.pytest] is enabled
automatically. Its autouse fixture
[`pact_test_run_id`][pact.testing.pytest.pact_test_run_id] sets each test's
node ID as the run ID. Pytest's own log capture then shows Pact records in the
"Captured log call" section of a failing test. Disable the plugin with `-p
no:pact`.

### Unittest

Use [`PactTestMixin`][pact.testing.unittest.PactTestMixin] on test cases which
use Pact. It sets the test ID as the run ID and, when a test fails or errors,
writes the Pact records collected during the test to stderr. While the test
runs, the `pact` logger is lowered to the forwarding level
([`forwarding_level`][pact.logging.forwarding_level]) if it has no explicit
level of its own, so records below the root logger's level are collected too:

```python
import unittest

from pact.testing.unittest import PactTestMixin


class ConsumerTest(PactTestMixin, unittest.TestCase):
def test_something(self) -> None: ...
```

## Migrating from `pact_ffi.log_to_stderr`

Earlier releases left the library's logging unconfigured, and test suites
Expand Down Expand Up @@ -122,4 +150,6 @@ Under pytest, `--log-cli-level=INFO` shows the records as each test runs.
## Further Information

- [`pact.logging`][pact.logging]
- [`pact.testing.pytest`][pact.testing.pytest]
- [`pact.testing.unittest`][pact.testing.unittest]
- [`pact_ffi.LevelFilter`][pact_ffi.LevelFilter]
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ dependencies = [
issues = "https://github.com/pact-foundation/pact-python/issues"
source = "https://github.com/pact-foundation/pact-python"

[project.entry-points.pytest11]
pact = "pact.testing.pytest"

[dependency-groups]
# Linting and formatting tools use a more narrow specification to ensure
# developper consistency. All other dependencies are as above.
Expand Down
2 changes: 2 additions & 0 deletions src/pact/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
from __future__ import annotations

from pact import logging as logging
from pact import testing as testing
from pact import xml as xml
from pact.__version__ import __version__, __version_tuple__
from pact.pact import Pact
Expand All @@ -127,5 +128,6 @@
"__version__",
"__version_tuple__",
"logging",
"testing",
"xml",
]
4 changes: 3 additions & 1 deletion src/pact/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
library's `TRACE` level is exposed as [`TRACE`][TRACE], below `logging.DEBUG`.

Log records can be attributed to the test which caused them by setting a test
run ID with [`set_test_run_id`][set_test_run_id].
run ID with [`set_test_run_id`][set_test_run_id]. The pytest plugin in
[`pact.testing.pytest`][testing.pytest] and the `unittest` mixin in
[`pact.testing.unittest`][testing.unittest] do this automatically.

Importing `pact` replaces `pact_ffi.init`, `pact_ffi.init_with_log_level`,
`pact_ffi.log_to_stderr`, `pact_ffi.log_to_stdout` and `pact_ffi.log_to_file`
Expand Down
8 changes: 8 additions & 0 deletions src/pact/testing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""
Test runner integrations.

- [`pact.testing.pytest`][testing.pytest] is a pytest plugin, enabled
automatically when `pact-python` is installed.
- [`pact.testing.unittest`][testing.unittest] provides a mixin for
`unittest.TestCase` subclasses.
"""
42 changes: 42 additions & 0 deletions src/pact/testing/pytest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""
Pytest plugin scoping the Pact test run ID to each test.

The plugin is registered through the `pytest11` entry point and is active
whenever `pact-python` is installed. It provides one autouse fixture,
[`pact_test_run_id`][testing.pytest.pact_test_run_id], which sets the
test's node ID as the Pact test run ID for the test's duration. The Pact
library's records from the test's thread, and the log entries a plugin sends
back for the test's requests, then carry the test which caused them.

Disable the plugin with `-p no:pact`.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from collections.abc import Generator


@pytest.fixture(autouse=True)
def pact_test_run_id(request: pytest.FixtureRequest) -> Generator[str, None, None]:
"""
Set the test's node ID as the Pact test run ID.

Args:
request:
The pytest request for the running test.

Yields:
The node ID set as the run ID. It is cleared on teardown.
"""
# Imported here so that collecting the plugin does not load the FFI shared
# library in every environment where pact-python is installed.
from pact import logging as pact_logging # noqa: PLC0415

pact_logging.set_test_run_id(request.node.nodeid)
yield request.node.nodeid
pact_logging.set_test_run_id(None)
103 changes: 103 additions & 0 deletions src/pact/testing/unittest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
`unittest` integration for Pact logs.

[`PactTestMixin`][testing.unittest.PactTestMixin] scopes the Pact test run
ID to each test and prints the Pact log records collected during a test to
stderr when the test fails or errors:

```python
import unittest

from pact.testing.unittest import PactTestMixin


class ConsumerTest(PactTestMixin, unittest.TestCase):
def test_something(self) -> None: ...
```

List the mixin before `unittest.TestCase` so that its `run` wraps the test.
"""

from __future__ import annotations

import logging
import sys
import unittest

from pact import logging as pact_logging

__all__ = ["PactTestMixin"]


class _ListHandler(logging.Handler):
"""Handler appending every record to a list."""

def __init__(self) -> None:
super().__init__(logging.NOTSET)
self.records: list[logging.LogRecord] = []

def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)


class PactTestMixin:
"""
Mixin for `unittest.TestCase` subclasses using Pact.

While a test runs, its `id()` is set as the Pact test run ID and every
record on the `pact` logger is buffered. If the test fails or errors, the
buffered records are written to stderr under a banner naming the test.

A logger only hands records at or above its effective level to its
handlers, so when the `pact` logger has no explicit level it is lowered to
[`forwarding_level`][pact_logging.forwarding_level] for the duration of the
test and restored afterwards.
"""

def run(
self,
result: unittest.TestResult | None = None,
) -> unittest.TestResult | None:
"""
Run the test with the Pact run ID set and Pact logs buffered.

Args:
result:
The result to record into. When omitted, a new
`unittest.TestResult` is created, as `unittest.TestCase.run`
does.

Returns:
The result returned by `unittest.TestCase.run`.
"""
if result is None:
result = unittest.TestResult()
test_id: str = self.id() # type: ignore[attr-defined]

handler = _ListHandler()
pact_logger = logging.getLogger("pact")
failures = len(result.failures) + len(result.errors)

pact_logging.set_test_run_id(test_id)
pact_logger.addHandler(handler)
lowered = pact_logger.level == logging.NOTSET
if lowered:
pact_logger.setLevel(pact_logging.forwarding_level())
try:
outcome = super().run(result) # type: ignore[misc]
if len(result.failures) + len(result.errors) > failures:
self._dump(test_id, handler.records)
return outcome
finally:
if lowered:
pact_logger.setLevel(logging.NOTSET)
pact_logger.removeHandler(handler)
pact_logging.set_test_run_id(None)

@staticmethod
def _dump(test_id: str, records: list[logging.LogRecord]) -> None:
formatter = logging.Formatter("%(levelname)s %(name)s: %(message)s")
lines = [f"--- Pact logs for {test_id} ---"]
lines.extend(formatter.format(record) for record in records)
lines.append("--- end of Pact logs ---")
sys.stderr.write("\n".join(lines) + "\n")
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import pytest

pytest_plugins = ["pytester"]


@pytest.fixture
def temp_assets(tmp_path: Path) -> Path:
Expand Down
Empty file added tests/testing/__init__.py
Empty file.
59 changes: 59 additions & 0 deletions tests/testing/test_pytest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""
Tests for the pytest plugin in [`pact.testing.pytest`][pact.testing.pytest].
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
import pytest

CONFTEST = """
import pytest

import pact.logging

calls = []


# Session scope so the recorder is in place before the plugin's function-scoped
# autouse fixture runs.
@pytest.fixture(autouse=True, scope="session")
def _record():
original = pact.logging.set_test_run_id
pact.logging.set_test_run_id = calls.append
yield
pact.logging.set_test_run_id = original


def pytest_sessionfinish(session):
session.config.rootpath.joinpath("calls.txt").write_text(
"\\n".join(str(c) for c in calls)
)
"""

TEST_FILE = """
def test_one(pact_test_run_id, request):
assert pact_test_run_id == request.node.nodeid
"""


def test_fixture_scopes_the_run_id(pytester: pytest.Pytester) -> None:
pytester.makeconftest(CONFTEST)
pytester.makepyfile(test_example=TEST_FILE)

result = pytester.runpytest("-p", "no:cacheprovider")

result.assert_outcomes(passed=1)
calls = pytester.path.joinpath("calls.txt").read_text().splitlines()
assert calls == ["test_example.py::test_one", "None"]


def test_plugin_can_be_disabled(pytester: pytest.Pytester) -> None:
pytester.makepyfile(test_example=TEST_FILE)

result = pytester.runpytest("-p", "no:pact", "-p", "no:cacheprovider")

result.assert_outcomes(errors=1)
result.stdout.fnmatch_lines(["*fixture 'pact_test_run_id' not found*"])
Loading
Loading