diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 47640ab..bddd14d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,11 +10,11 @@ on: jobs: tests: name: "Python ${{ matrix.python-version }}" - runs-on: "ubuntu-latest" + runs-on: "ubuntu-22.04" strategy: matrix: - python-version: ["2.7", "3.11"] + python-version: ["3.7", "3.11"] steps: - uses: "actions/checkout@v3" @@ -27,3 +27,19 @@ jobs: python -m pip install --editable . - name: "Run tests for ${{ matrix.python-version }}" run: python -m pytest + - name: "Run mypy checks for ${{ matrix.python-version }}" + run: | + python -m pip install mypy==1.4.1 + python -m mypy pytest_structlog + - 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 . diff --git a/MANIFEST.in b/MANIFEST.in index 065cc87..a7651af 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include LICENSE recursive-include tests *.py +include pytest_structlog/py.typed \ No newline at end of file diff --git a/README.rst b/README.rst index d81089c..ce946ab 100644 --- a/README.rst +++ b/README.rst @@ -55,8 +55,9 @@ Then your test suite might use assertions such as shown below: # test_your_lib.py from your_lib import spline_reticulator + import pytest_structlog - def test_spline_reticulator(log): + def test_spline_reticulator(log: pytest_structlog.StructuredLogCapture): assert len(log.events) == 0 spline_reticulator() assert len(log.events) == 5 diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..1554ac0 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,10 @@ +[mypy] +show_error_codes = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = true +warn_unreachable = true +strict_equality = true \ No newline at end of file diff --git a/pytest_structlog.py b/pytest_structlog/__init__.py similarity index 71% rename from pytest_structlog.py rename to pytest_structlog/__init__.py index fd7a014..8b0127b 100644 --- a/pytest_structlog.py +++ b/pytest_structlog/__init__.py @@ -1,8 +1,10 @@ import logging import os +from typing import Any, Generator, List, Sequence, Union, cast import pytest import structlog +from structlog.typing import EventDict, WrappedLogger, Processor try: from structlog.contextvars import merge_contextvars @@ -10,14 +12,14 @@ except ImportError: # structlog < 20.1.0 # use a "missing" sentinel to avoid a NameError later on - merge_contextvars = object() + merge_contextvars = lambda *a, **kw: {} # noqa clear_contextvars = lambda *a, **kw: None # noqa __version__ = "0.6" -class EventList(list): +class EventList(List[EventDict]): """A list subclass that overrides ordering operations. Instead of A <= B being a lexicographical comparison, now it means every element of A is contained within B, @@ -25,84 +27,84 @@ class EventList(list): interspersed throughout (i.e. A is a subsequence of B) """ - def __ge__(self, other): + def __ge__(self, other: Sequence[EventDict]) -> bool: return is_subseq(other, self) - def __gt__(self, other): + def __gt__(self, other: Sequence[EventDict]) -> bool: return len(self) > len(other) and is_subseq(other, self) - def __le__(self, other): + def __le__(self, other: Sequence[EventDict]) -> bool: return is_subseq(self, other) - def __lt__(self, other): + def __lt__(self, other: Sequence[EventDict]) -> bool: return len(self) < len(other) and is_subseq(self, other) absent = object() -def level_to_name(level): +def level_to_name(level: Union[str, int]) -> str: """Given the name or number for a log-level, return the lower-case level name.""" if isinstance(level, str): return level.lower() - return logging.getLevelName(level).lower() + return cast(str, logging.getLevelName(level)).lower() -def is_submap(d1, d2): +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()) -def is_subseq(l1, l2): +def is_subseq(l1: Sequence, l2: Sequence) -> 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): - def __init__(self): + def __init__(self) -> None: self.events = EventList() - def process(self, logger, method_name, event_dict): + def process(self, logger: WrappedLogger, method_name: str, event_dict: EventDict) -> EventDict: event_dict["level"] = method_name self.events.append(event_dict) raise structlog.DropEvent - def has(self, message, **context): + def has(self, message: str, **context: Any) -> bool: context["event"] = message return any(is_submap(context, e) for e in self.events) - def log(self, level, event, **kw): + def log(self, level: Union[int, str], event: str, **kw: Any) -> dict: """Create log event to assert against""" return dict(level=level_to_name(level), event=event, **kw) - def debug(self, event, **kw): + def debug(self, event: str, **kw: Any) -> dict: """Create debug-level log event to assert against""" return self.log(logging.DEBUG, event, **kw) - def info(self, event, **kw): + def info(self, event: str, **kw: Any) -> dict: """Create info-level log event to assert against""" return self.log(logging.INFO, event, **kw) - def warning(self, event, **kw): + def warning(self, event: str, **kw: Any) -> dict: """Create warning-level log event to assert against""" return self.log(logging.WARNING, event, **kw) - def error(self, event, **kw): + def error(self, event: str, **kw: Any) -> dict: """Create error-level log event to assert against""" return self.log(logging.ERROR, event, **kw) - def critical(self, event, **kw): + def critical(self, event: str, **kw: Any) -> dict: """Create critical-level log event to assert against""" return self.log(logging.CRITICAL, event, **kw) -def no_op(*args, **kwargs): +def no_op(*args: Any, **kwargs: Any) -> None: pass @pytest.fixture -def log(monkeypatch, request): +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 @@ -115,7 +117,7 @@ def log(monkeypatch, request): # redirect logging to log capture cap = StructuredLogCapture() - new_processors = [] + 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 @@ -127,8 +129,8 @@ def log(monkeypatch, request): 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 - cap.configure_once = structlog.configure_once + cap.original_configure = configure = 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 @@ -141,7 +143,7 @@ def log(monkeypatch, request): @pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_call(item): +def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]: yield events = getattr(item, "structlog_events", []) content = os.linesep.join([str(e) for e in events]) diff --git a/pytest_structlog/py.typed b/pytest_structlog/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py index 5e927d2..3475e57 100644 --- a/setup.py +++ b/setup.py @@ -10,14 +10,13 @@ author="Wim Glenn", author_email="hey@wimglenn.com", license="MIT", - install_requires=["pytest", "structlog"], - py_modules=["pytest_structlog"], + install_requires=["pytest", "structlog>=22.2.0"], entry_points={"pytest11": ["pytest-structlog=pytest_structlog"]}, + python_requires=">=3.7", classifiers=[ "Framework :: Pytest", "Programming Language :: Python", - "Programming Language :: Python :: 2", "Programming Language :: Python :: 3", ], - options={"bdist_wheel": {"universal": "1"}}, + include_package_data=True, ) diff --git a/tests/test_issue14.py b/tests/test_issue14.py index ee71093..8d04d11 100644 --- a/tests/test_issue14.py +++ b/tests/test_issue14.py @@ -1,4 +1,5 @@ import structlog +import pytest_structlog logger = structlog.get_logger("some logger") @@ -18,7 +19,7 @@ def test_first(): logger.warning("test") -def test_second(log): +def test_second(log: pytest_structlog.StructuredLogCapture): logger.warning("test") assert log.has("test") diff --git a/tests/test_issue18.py b/tests/test_issue18.py index 40b2cfc..28c73e6 100644 --- a/tests/test_issue18.py +++ b/tests/test_issue18.py @@ -2,6 +2,8 @@ import structlog +import pytest_structlog + logger = structlog.get_logger(__name__) @@ -24,7 +26,7 @@ def stdlib_configure(): ) -def test_positional_formatting(stdlib_configure, log): +def test_positional_formatting(stdlib_configure, log: pytest_structlog.StructuredLogCapture): items_count = 2 dt = 0.02 logger.info("Processed %d CC items in total in %.2f seconds", items_count, dt) diff --git a/tests/test_issue20.py b/tests/test_issue20.py index 81c5e61..3124993 100644 --- a/tests/test_issue20.py +++ b/tests/test_issue20.py @@ -1,6 +1,8 @@ import pytest import structlog +import pytest_structlog + logger = structlog.get_logger() @@ -22,7 +24,7 @@ def issue20_setup(): structlog.contextvars.clear_contextvars() -def test_contextvar(issue20_setup, log): +def test_contextvar(issue20_setup, log: pytest_structlog.StructuredLogCapture): structlog.contextvars.clear_contextvars() logger.info("log1", log1var="value") structlog.contextvars.bind_contextvars(contextvar="cv") diff --git a/tests/test_issue24.py b/tests/test_issue24.py index 7467554..761818a 100644 --- a/tests/test_issue24.py +++ b/tests/test_issue24.py @@ -3,6 +3,8 @@ import pytest import structlog +import pytest_structlog + logger = structlog.get_logger() @@ -23,7 +25,7 @@ def issue24_setup(): @pytest.mark.parametrize("n", list(range(RUN_COUNT))) -def test_contextvar_isolation_in_events(issue24_setup, log, n): +def test_contextvar_isolation_in_events(issue24_setup, log: pytest_structlog.StructuredLogCapture, n): logger.info("without_context") structlog.contextvars.bind_contextvars(ctx=n) logger.info("with_context") diff --git a/tests/test_log.py b/tests/test_log.py index ec618d5..e493f45 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -1,6 +1,7 @@ import logging import pytest +import pytest_structlog import structlog @@ -25,49 +26,49 @@ def binding(): log.warning("uh-oh") -def test_capture_creates_items(log): +def test_capture_creates_items(log: pytest_structlog.StructuredLogCapture): assert not log.events spline_reticulator() assert log.events -def test_assert_without_context(log): +def test_assert_without_context(log: pytest_structlog.StructuredLogCapture): spline_reticulator() assert log.has("reticulating splines") -def test_assert_with_subcontext(log): +def test_assert_with_subcontext(log: pytest_structlog.StructuredLogCapture): spline_reticulator() assert log.has("reticulating splines", n_splines=123) -def test_assert_with_bogus_context(log): +def test_assert_with_bogus_context(log: pytest_structlog.StructuredLogCapture): spline_reticulator() assert not log.has("reticulating splines", n_splines=0) -def test_assert_with_all_context(log): +def test_assert_with_all_context(log: pytest_structlog.StructuredLogCapture): spline_reticulator() assert log.has("reticulating splines", n_splines=123, level="info") -def test_assert_with_super_context(log): +def test_assert_with_super_context(log: pytest_structlog.StructuredLogCapture): spline_reticulator() assert not log.has("reticulating splines", n_splines=123, level="info", k="v") -def test_configurator(log): +def test_configurator(log: pytest_structlog.StructuredLogCapture): main_ish() assert log.has("yo", level="debug") -def test_multiple_events(log): +def test_multiple_events(log: pytest_structlog.StructuredLogCapture): binding() assert log.has("dbg", k="v", level="debug") assert log.has("inf", k="v", kk="more context", level="info") -def test_length(log): +def test_length(log: pytest_structlog.StructuredLogCapture): binding() assert len(log.events) == 3 @@ -79,54 +80,54 @@ def test_length(log): ] -def test_membership(log): +def test_membership(log: pytest_structlog.StructuredLogCapture): binding() assert d0 in log.events -def test_superset_single(log): +def test_superset_single(log: pytest_structlog.StructuredLogCapture): binding() assert log.events >= [d0] -def test_superset_multi(log): +def test_superset_multi(log: pytest_structlog.StructuredLogCapture): binding() assert log.events >= [d0, d2] -def test_superset_respects_ordering(log): +def test_superset_respects_ordering(log: pytest_structlog.StructuredLogCapture): binding() assert not log.events >= [d2, d0] -def test_superset_multi_all(log): +def test_superset_multi_all(log: pytest_structlog.StructuredLogCapture): binding() assert log.events >= [d0, d1, d2] -def test_superset_multi_strict(log): +def test_superset_multi_strict(log: pytest_structlog.StructuredLogCapture): binding() assert not log.events > [d0, d1, d2] -def test_equality(log): +def test_equality(log: pytest_structlog.StructuredLogCapture): binding() assert log.events == [d0, d1, d2] -def test_inequality(log): +def test_inequality(log: pytest_structlog.StructuredLogCapture): binding() assert log.events != [d0, {}, d1, d2] -def test_total_ordering(log): +def test_total_ordering(log: pytest_structlog.StructuredLogCapture): binding() assert log.events <= [d0, d1, d2, {}] assert log.events < [d0, d1, d2, {}] assert log.events > [d0, d1] -def test_dupes(log): +def test_dupes(log: pytest_structlog.StructuredLogCapture): logger.info("a") logger.info("a") logger.info("b") @@ -145,7 +146,7 @@ def test_dupes(log): ] -def test_event_factories(log): +def test_event_factories(log: pytest_structlog.StructuredLogCapture): assert log.debug("debug-level", extra=True) == {"event": "debug-level", "level": "debug", "extra": True} assert log.info("info-level", more="yes") == {"event": "info-level", "level": "info", "more": "yes"} assert log.warning("warning-level", another=42) == {"event": "warning-level", "level": "warning", "another": 42} @@ -171,5 +172,5 @@ def test_dynamic_event_factory(log, level, name): assert log.log(name.upper(), "dynamic-level", other=42) == expected -def test_event_factory__bad_level_number(log): +def test_event_factory__bad_level_number(log: pytest_structlog.StructuredLogCapture): assert log.log(1234, "text") == {'event': 'text', 'level': 'level 1234'}