Skip to content

Commit

Permalink
v0.7 (#28)
Browse files Browse the repository at this point in the history
* release v0.7

* blacken

* try pyright-action

* pyright --verifytypes

* deferred evaluation of annotations

* is that really needed?

* fill out dostrings
  • Loading branch information
wimglenn authored Feb 4, 2024
1 parent a52c50c commit 56e80e8
Show file tree
Hide file tree
Showing 10 changed files with 186 additions and 116 deletions.
42 changes: 25 additions & 17 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,45 @@ on:

jobs:
tests:
name: "Python ${{ matrix.python-version }}"
runs-on: "ubuntu-22.04"
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-22.04

strategy:
matrix:
python-version: ["3.7", "3.11"]
python-version:
- "3.7"
- "3.8"
- "3.9"
- "3.10"
- "3.11"
- "3.12"

steps:
- uses: "actions/checkout@v3"
- uses: "actions/setup-python@v4"
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "${{ matrix.python-version }}"
- name: "Install"
python-version: ${{ matrix.python-version }}
- name: Install
run: |
set -xe
python -m pip install --editable .
- name: "Run tests for ${{ matrix.python-version }}"
- name: Run tests for ${{ matrix.python-version }}
run: python -m pytest
- name: "Run mypy checks for ${{ matrix.python-version }}"

- name: Run mypy checks for ${{ matrix.python-version }}
run: |
python -m pip install mypy==1.4.1
python -m pip install mypy
python -m mypy pytest_structlog
- name: "Run mypy install checks for ${{ matrix.python-version }}"
- name: Run mypy install checks for ${{ matrix.python-version }}
run: |
# This checks that things like the py.typed bits work
cd tests
python -m pip install ..
python -m mypy .
- name: "Run pyright testing ${{ matrix.python-version }}"
run: |
# Pyright and mypy don't always agree, so check with both
cd tests
python -m pip install pyright==1.1.349
python -m pyright .
- uses: jakebailey/pyright-action@v2
with:
ignore-external: true
verify-types: pytest_structlog
File renamed without changes.
41 changes: 41 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
[build-system]
requires = ["setuptools>=61.2"]
build-backend = "setuptools.build_meta"

[project]
name = "pytest-structlog"
dynamic = ["version"]
description = "Structured logging assertions"
classifiers = [
"Framework :: Pytest",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
]
requires-python = ">=3.7"
dependencies = [
"pytest",
"structlog>=22.2.0",
]

[[project.authors]]
name = "Wim Glenn"
email = "[email protected]"

[project.license]
text = "MIT"

[project.readme]
file = "README.rst"
content-type = "text/x-rst; charset=UTF-8"

[project.urls]
Homepage = "https://github.com/wimglenn/pytest-structlog"

[project.entry-points.pytest11]
pytest-structlog = "pytest_structlog"

[tool.setuptools]
include-package-data = true

[tool.setuptools.dynamic]
version = {attr = "pytest_structlog.__version__"}
92 changes: 55 additions & 37 deletions pytest_structlog/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
from __future__ import annotations

import logging
import os
from typing import Any, Generator, List, Sequence, Union, cast
from typing import Any
from typing import cast
from typing import Generator
from typing import List
from typing import Sequence
from typing import Union

import pytest
import structlog
from structlog.typing import EventDict, WrappedLogger, Processor

try:
from structlog.contextvars import merge_contextvars
from structlog.contextvars import clear_contextvars
except ImportError:
# structlog < 20.1.0
# use a "missing" sentinel to avoid a NameError later on
merge_contextvars = lambda *a, **kw: {} # noqa
clear_contextvars = lambda *a, **kw: None # noqa
from structlog.contextvars import clear_contextvars
from structlog.contextvars import merge_contextvars
from structlog.typing import EventDict
from structlog.typing import Processor
from structlog.typing import WrappedLogger


__version__ = "0.6"
__version__ = "0.7"


class EventList(List[EventDict]):
Expand All @@ -40,7 +42,7 @@ def __lt__(self, other: Sequence[EventDict]) -> bool:
return len(self) < len(other) and is_subseq(self, other)


absent = object()
_absent = object()


def level_to_name(level: Union[str, int]) -> str:
Expand All @@ -51,60 +53,75 @@ def level_to_name(level: Union[str, int]) -> str:


def is_submap(d1: EventDict, d2: EventDict) -> bool:
"""is every pair from d1 also in d2? (unique and order insensitive)"""
return all(d2.get(k, absent) == v for k, v in d1.items())
"""Is every pair from d1 also in d2? (unique and order insensitive)"""
return all(d2.get(k, _absent) == v for k, v in d1.items())


def is_subseq(l1: Sequence, l2: Sequence) -> bool:
"""is every element of l1 also in l2? (non-unique and order sensitive)"""
def is_subseq(l1: Sequence[Any], l2: Sequence[Any]) -> bool:
"""Is every element of l1 also in l2? (non-unique and order sensitive)"""
it = iter(l2)
return all(d in it for d in l1)


class StructuredLogCapture(object):
class StructuredLogCapture:
"""Processor which accumulates log events during testing. The log fixture
provided by pytest_structlog is an instance of this class."""

def __init__(self) -> None:
self.events = EventList()
self.events: EventList = EventList()

def process(self, logger: WrappedLogger, method_name: str, event_dict: EventDict) -> EventDict:
def process(
self, logger: WrappedLogger, method_name: str, event_dict: EventDict
) -> EventDict:
"""Captures a logging event, appending it as a dict in the event list."""
event_dict["level"] = method_name
self.events.append(event_dict)
raise structlog.DropEvent

def has(self, message: str, **context: Any) -> bool:
"""Returns whether the event message has been logged, with optional
subcontext. Usage in test code would be with an assertion, e.g.:
assert log.has("foo")
assert log.has("bar", k1="v1", k2="v2")
"""
context["event"] = message
return any(is_submap(context, e) for e in self.events)

def log(self, level: Union[int, str], event: str, **kw: Any) -> dict:
"""Create log event to assert against"""
def log(self, level: Union[int, str], event: str, **kw: Any) -> dict[str, Any]:
"""Create log event to assert against."""
return dict(level=level_to_name(level), event=event, **kw)

def debug(self, event: str, **kw: Any) -> dict:
"""Create debug-level log event to assert against"""
def debug(self, event: str, **kw: Any) -> dict[str, Any]:
"""Create debug-level log event to assert against."""
return self.log(logging.DEBUG, event, **kw)

def info(self, event: str, **kw: Any) -> dict:
"""Create info-level log event to assert against"""
def info(self, event: str, **kw: Any) -> dict[str, Any]:
"""Create info-level log event to assert against."""
return self.log(logging.INFO, event, **kw)

def warning(self, event: str, **kw: Any) -> dict:
"""Create warning-level log event to assert against"""
def warning(self, event: str, **kw: Any) -> dict[str, Any]:
"""Create warning-level log event to assert against."""
return self.log(logging.WARNING, event, **kw)

def error(self, event: str, **kw: Any) -> dict:
"""Create error-level log event to assert against"""
def error(self, event: str, **kw: Any) -> dict[str, Any]:
"""Create error-level log event to assert against."""
return self.log(logging.ERROR, event, **kw)

def critical(self, event: str, **kw: Any) -> dict:
"""Create critical-level log event to assert against"""
def critical(self, event: str, **kw: Any) -> dict[str, Any]:
"""Create critical-level log event to assert against."""
return self.log(logging.CRITICAL, event, **kw)


def no_op(*args: Any, **kwargs: Any) -> None:
"""Function used to stub out the original structlog.configure method."""
pass


@pytest.fixture
def log(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> Generator[StructuredLogCapture, None, None]:
def log(
monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest
) -> Generator[StructuredLogCapture, None, None]:
"""Fixture providing access to captured structlog events. Interesting attributes:
``log.events`` a list of dicts, contains any events logged during the test
Expand All @@ -120,7 +137,7 @@ def log(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> Gene
new_processors: List[Processor] = []
for processor in original_processors:
if isinstance(processor, structlog.stdlib.PositionalArgumentsFormatter):
# if there was a positional argument formatter in there, keep it there
# if there was a positional argument formatter in there, keep it
# see https://github.com/wimglenn/pytest-structlog/issues/18
new_processors.append(processor)
elif processor is merge_contextvars:
Expand All @@ -129,8 +146,8 @@ def log(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> Gene
new_processors.append(processor)
new_processors.append(cap.process)
structlog.configure(processors=new_processors, cache_logger_on_first_use=False)
cap.original_configure = configure = structlog.configure # type:ignore[attr-defined]
cap.configure_once = structlog.configure_once # type:ignore[attr-defined]
cap.original_configure = cfg = structlog.configure # type:ignore[attr-defined]
cap.configure_once = structlog.configure_once # type:ignore[attr-defined]
monkeypatch.setattr("structlog.configure", no_op)
monkeypatch.setattr("structlog.configure_once", no_op)
request.node.structlog_events = cap.events
Expand All @@ -139,11 +156,12 @@ def log(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> Gene
clear_contextvars()

# back to original behavior
configure(processors=original_processors)
cfg(processors=original_processors)


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]:
"""Prints out a section of captured structlog events on test failures."""
yield
events = getattr(item, "structlog_events", [])
content = os.linesep.join([str(e) for e in events])
Expand Down
22 changes: 0 additions & 22 deletions setup.py

This file was deleted.

5 changes: 3 additions & 2 deletions tests/test_issue14.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import structlog
import pytest_structlog

from pytest_structlog import StructuredLogCapture


logger = structlog.get_logger("some logger")
Expand All @@ -19,7 +20,7 @@ def test_first():
logger.warning("test")


def test_second(log: pytest_structlog.StructuredLogCapture):
def test_second(log: StructuredLogCapture):
logger.warning("test")
assert log.has("test")

Expand Down
5 changes: 2 additions & 3 deletions tests/test_issue18.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import pytest

import structlog

import pytest_structlog
from pytest_structlog import StructuredLogCapture


logger = structlog.get_logger(__name__)
Expand All @@ -26,7 +25,7 @@ def stdlib_configure():
)


def test_positional_formatting(stdlib_configure, log: pytest_structlog.StructuredLogCapture):
def test_positional_formatting(stdlib_configure, log: StructuredLogCapture):
items_count = 2
dt = 0.02
logger.info("Processed %d CC items in total in %.2f seconds", items_count, dt)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_issue20.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest
import structlog

import pytest_structlog
from pytest_structlog import StructuredLogCapture


logger = structlog.get_logger()
Expand All @@ -24,7 +24,7 @@ def issue20_setup():
structlog.contextvars.clear_contextvars()


def test_contextvar(issue20_setup, log: pytest_structlog.StructuredLogCapture):
def test_contextvar(issue20_setup, log: StructuredLogCapture):
structlog.contextvars.clear_contextvars()
logger.info("log1", log1var="value")
structlog.contextvars.bind_contextvars(contextvar="cv")
Expand Down
7 changes: 4 additions & 3 deletions tests/test_issue24.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pytest
import structlog

import pytest_structlog
from pytest_structlog import StructuredLogCapture

logger = structlog.get_logger()

Expand All @@ -25,12 +25,13 @@ def issue24_setup():


@pytest.mark.parametrize("n", list(range(RUN_COUNT)))
def test_contextvar_isolation_in_events(issue24_setup, log: pytest_structlog.StructuredLogCapture, n):
def test_contextvar_isolation_in_events(issue24_setup, log: StructuredLogCapture, n):
logger.info("without_context")
structlog.contextvars.bind_contextvars(ctx=n)
logger.info("with_context")
assert log.events == [
{"event": "without_context", "level": "info"}, # in issue 24 this has "ctx" from previous run
# in issue 24 the first event has "ctx" from previous run
{"event": "without_context", "level": "info"},
{"event": "with_context", "level": "info", "ctx": n},
]
assert structlog.contextvars.get_contextvars() == {"ctx": n}
Loading

0 comments on commit 56e80e8

Please sign in to comment.