diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e961f08..7e4b7b3b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,8 @@ jobs: - python-version: "3.10" - python-version: "3.11" - python-version: "3.12" + - lib-versions: "structlog~=23.3.0" + - lib-versions: "structlog~=24.1.0" - lib-versions: "loguru~=0.6.0" - lib-versions: "loguru~=0.7.0" - lib-versions: "pytest~=7.0" diff --git a/README.md b/README.md index 6a687c52..a01e78c0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ `logot` makes it easy to test whether your code is logging correctly: -``` python +```python from logot import Logot, logged def test_something(logot: Logot) -> None: @@ -17,24 +17,20 @@ def test_something(logot: Logot) -> None: logot.assert_logged(logged.info("Something was done")) ``` -`logot` integrates with popular testing (e.g. [`pytest`](https://logot.readthedocs.io/latest/using-pytest.html), [`unittest`](https://logot.readthedocs.io/latest/using-unittest.html)), asynchronous (e.g. [`asyncio`](https://logot.readthedocs.io/latest/index.html#index-testing-threaded), [`trio`](https://logot.readthedocs.io/latest/integrations/trio.html)) and logging frameworks (e.g. [`logging`](https://logot.readthedocs.io/latest/log-capturing.html), [`loguru`](https://logot.readthedocs.io/latest/integrations/loguru.html)). It can be extended to support many others. 💪 - +`logot` integrates with popular testing (e.g. [`pytest`](https://logot.readthedocs.io/latest/using-pytest.html), [`unittest`](https://logot.readthedocs.io/latest/using-unittest.html)), asynchronous (e.g. [`asyncio`](https://logot.readthedocs.io/latest/index.html#index-testing-threaded), [`trio`](https://logot.readthedocs.io/latest/integrations/trio.html)) and logging frameworks (e.g. [`logging`](https://logot.readthedocs.io/latest/log-capturing.html), [`loguru`](https://logot.readthedocs.io/latest/integrations/loguru.html), [`structlog`](https://logot.readthedocs.io/latest/integrations/structlog.html)). It can be extended to support many others. 💪 ## Documentation 📖 Full documentation is published on [Read the Docs](https://logot.readthedocs.io). - ## Bugs / feedback 🐛 Issue tracking is hosted on [GitHub](https://github.com/etianen/logot/issues). - ## Changelog 🏗️ Release notes are published on [GitHub](https://github.com/etianen/logot/releases). - ## License ⚖️ `logot` is published as open-source software under the [MIT license](https://github.com/etianen/logot/blob/main/LICENSE). diff --git a/docs/api/logot.structlog.rst b/docs/api/logot.structlog.rst new file mode 100644 index 00000000..e32d2210 --- /dev/null +++ b/docs/api/logot.structlog.rst @@ -0,0 +1,10 @@ +:mod:`logot.structlog` +======================= + +.. automodule:: logot.structlog + + +API reference +------------- + +.. autoclass:: StructlogCapturer diff --git a/docs/conf.py b/docs/conf.py index 489d5e4d..e0cc52b9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,6 +27,7 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3/", None), "loguru": ("https://loguru.readthedocs.io/en/latest/", None), + "structlog": ("https://www.structlog.org/en/stable/", None), "pytest": ("https://docs.pytest.org/en/latest/", None), "trio": ("https://trio.readthedocs.io/en/latest/", None), } diff --git a/docs/index.rst b/docs/index.rst index 3d210838..854ba79c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,7 +18,8 @@ Log-based testing 🪵 :mod:`logot` integrates with popular testing (e.g. :doc:`pytest `, :doc:`unittest `), asynchronous (e.g. :ref:`asyncio `, :doc:`trio `) and logging frameworks (e.g. :doc:`logging `, - :doc:`loguru `). It can be extended to support many others. 💪 + :doc:`loguru `, :doc:`structlog `). It can be extended + to support many others. 💪 Why test logging? 🤔 diff --git a/docs/integrations/index.rst b/docs/integrations/index.rst index 2e27d61b..58b94d55 100644 --- a/docs/integrations/index.rst +++ b/docs/integrations/index.rst @@ -47,6 +47,7 @@ Supported frameworks: :maxdepth: 1 loguru + structlog .. seealso:: diff --git a/docs/integrations/structlog.rst b/docs/integrations/structlog.rst new file mode 100644 index 00000000..4feb1d5c --- /dev/null +++ b/docs/integrations/structlog.rst @@ -0,0 +1,95 @@ +Using with :mod:`structlog` +============================ + +.. currentmodule:: logot + +:mod:`logot` makes it easy to capture logs from :mod:`structlog`: + +.. code:: python + + from logot.structlog import StructlogCapturer + + with Logot(capturer=StructlogCapturer).capturing() as logot: + do_something() + logot.assert_logged(logged.info("App started")) + +:mod:`logot` preserves the preconfigured :mod:`structlog` processor chain. Events are captured at the end of the chain, +but before the final processor, as it is responsible for emitting the log event to the underlying logging system. For +more information, see the +`structlog documentation `_. + + +Installing +---------- + +Ensure :mod:`logot` is installed alongside a compatible :mod:`structlog` version by adding the ``structlog`` extra: + +.. code:: bash + + pip install 'logot[structlog]' + +.. seealso:: + + See :ref:`installing-extras` usage guide. + + +Enabling for :mod:`pytest` +-------------------------- + +Enable :mod:`structlog` support in your :external+pytest:doc:`pytest configuration `: + +.. code:: ini + + # pytest.ini or .pytest.ini + [pytest] + logot_capturer = logot.structlog.StructlogCapturer + +.. code:: toml + + # pyproject.toml + [tool.pytest.ini_options] + logot_capturer = "logot.structlog.StructlogCapturer" + +.. seealso:: + + See :doc:`/using-pytest` usage guide. + + +Enabling for :mod:`unittest` +---------------------------- + +Enable :mod:`structlog` support in your :class:`logot.unittest.LogotTestCase`: + +.. code:: python + + from logot.structlog import StructlogCapturer + + class MyAppTest(LogotTestCase): + logot_capturer = StructlogCapturer + +.. seealso:: + + See :doc:`/using-unittest` usage guide. + + +Enabling manually +----------------- + +Enable :mod:`structlog` support for your :class:`Logot` instance: + +.. code:: python + + from logot.structlog import StructlogCapturer + + logot = Logot(capturer=StructlogCapturer) + +Enable :mod:`structlog` support for a single :meth:`Logot.capturing` call: + +.. code:: python + + with Logot().capturing(capturer=StructlogCapturer) as logot: + do_something() + +.. seealso:: + + See :class:`Logot` and :meth:`Logot.capturing` API reference. diff --git a/docs/log-capturing.rst b/docs/log-capturing.rst index 411539b5..e7e330e5 100644 --- a/docs/log-capturing.rst +++ b/docs/log-capturing.rst @@ -13,7 +13,7 @@ Log capturing .. seealso:: - See :ref:`integrations-logging` for other supported logging frameworks (e.g. :doc:`loguru `). + See :ref:`integrations-logging` for other supported logging frameworks (e.g. :doc:`loguru `, :doc:`structlog `). Test framework integrations diff --git a/docs/using-pytest.rst b/docs/using-pytest.rst index c0aeef1d..20bc6e46 100644 --- a/docs/using-pytest.rst +++ b/docs/using-pytest.rst @@ -37,7 +37,8 @@ using |caplog|_ as: - Support for :doc:`log message matching ` using ``%``-style placeholders. - Support for :doc:`log pattern matching ` using *log pattern operators*. - Support for testing :ref:`threaded ` and :ref:`async ` code. -- Support for :ref:`3rd-party logging frameworks ` (e.g. :doc:`loguru `). +- Support for :ref:`3rd-party logging frameworks ` (e.g. :doc:`loguru `, + :doc:`structlog `). - A cleaner, clearer syntax. diff --git a/docs/using-unittest.rst b/docs/using-unittest.rst index 8eebe5d0..8e349c02 100644 --- a/docs/using-unittest.rst +++ b/docs/using-unittest.rst @@ -43,7 +43,8 @@ testing. The above example can be rewritten using :meth:`assertLogs() ` using ``%``-style placeholders. - Support for :doc:`log pattern matching ` using *log pattern operators*. - Support for testing :ref:`threaded ` and :ref:`async ` code. -- Support for :ref:`3rd-party logging frameworks ` (e.g. :doc:`loguru `). +- Support for :ref:`3rd-party logging frameworks ` (e.g. :doc:`loguru `, + :doc:`structlog `). - A cleaner, clearer syntax. diff --git a/logot/_structlog.py b/logot/_structlog.py new file mode 100644 index 00000000..f1dd4415 --- /dev/null +++ b/logot/_structlog.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from functools import partial + +import structlog +from structlog.processors import NAME_TO_LEVEL +from structlog.types import EventDict, WrappedLogger + +from logot._capture import Captured +from logot._logot import Capturer, Logot +from logot._typing import Level, Name + + +class StructlogCapturer(Capturer): + """ + A :class:`logot.Capturer` implementation for :mod:`structlog`. + """ + + __slots__ = ("_old_processors",) + + def start_capturing(self, logot: Logot, /, *, level: Level, name: Name) -> None: + config = structlog.get_config() + processors = config["processors"] + self._old_processors = processors + + if isinstance(level, str): + levelno = NAME_TO_LEVEL[level.lower()] + else: + levelno = level + + # We need to insert our processor before the last processor, as this is the processor that transforms the + # `event_dict` into the final log message. As this depends on the wrapped logger's formatting requirements, + # it can interfere with our capturing. + # See https://www.structlog.org/en/stable/processors.html#adapting-and-rendering + structlog.configure( + processors=[*processors[:-1], partial(_processor, logot=logot, name=name, levelno=levelno), processors[-1]] + ) + + def stop_capturing(self) -> None: + structlog.configure(processors=self._old_processors) + + +def _processor( + logger: WrappedLogger, method_name: str, event_dict: EventDict, *, logot: Logot, name: Name, levelno: int +) -> EventDict: + msg = event_dict["event"] + level = method_name.upper() + event_levelno = NAME_TO_LEVEL[method_name] + + if getattr(logger, "name", None) == name and event_levelno >= levelno: + logot.capture(Captured(level, msg, levelno=event_levelno)) + + return event_dict diff --git a/logot/structlog.py b/logot/structlog.py new file mode 100644 index 00000000..0a1b460b --- /dev/null +++ b/logot/structlog.py @@ -0,0 +1,10 @@ +""" +Integration API for :mod:`structlog`. + +.. seealso:: + + See :doc:`/integrations/structlog` usage guide. +""" +from __future__ import annotations + +from logot._structlog import StructlogCapturer as StructlogCapturer diff --git a/poetry.lock b/poetry.lock index bf5baac1..38d098f3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -369,13 +369,13 @@ sphinx-basic-ng = "*" [[package]] name = "hypothesis" -version = "6.98.4" +version = "6.98.5" description = "A library for property-based testing" optional = false python-versions = ">=3.8" files = [ - {file = "hypothesis-6.98.4-py3-none-any.whl", hash = "sha256:8417d1df13e7ba0eb6cba0917e0aa6c8b0b6b35a4e7fb78db6ab84dfbeb8c8fe"}, - {file = "hypothesis-6.98.4.tar.gz", hash = "sha256:785f47ddac183c7ffef9463b5ab7f2e4433ca9b2b1171e52eeb3f8c5b1f09fa2"}, + {file = "hypothesis-6.98.5-py3-none-any.whl", hash = "sha256:9449b9878116133269da4941b6a20e83003ef95503a2106365d4756ef3adc2b7"}, + {file = "hypothesis-6.98.5.tar.gz", hash = "sha256:cfe4c2320580f97dd0d11cd3ee954a347764aec42aa0c95b7a0285c2b02447ab"}, ] [package.dependencies] @@ -384,7 +384,7 @@ exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""} sortedcontainers = ">=2.1.0,<3.0.0" [package.extras] -all = ["backports.zoneinfo (>=0.2.1)", "black (>=19.10b0)", "click (>=7.0)", "django (>=3.2)", "dpcontracts (>=0.4)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.17.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2023.4)"] +all = ["backports.zoneinfo (>=0.2.1)", "black (>=19.10b0)", "click (>=7.0)", "django (>=3.2)", "dpcontracts (>=0.4)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.17.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2024.1)"] cli = ["black (>=19.10b0)", "click (>=7.0)", "rich (>=9.0.0)"] codemods = ["libcst (>=0.3.16)"] dateutil = ["python-dateutil (>=1.4)"] @@ -397,7 +397,7 @@ pandas = ["pandas (>=1.1)"] pytest = ["pytest (>=4.6)"] pytz = ["pytz (>=2014.1)"] redis = ["redis (>=3.0.0)"] -zoneinfo = ["backports.zoneinfo (>=0.2.1)", "tzdata (>=2023.4)"] +zoneinfo = ["backports.zoneinfo (>=0.2.1)", "tzdata (>=2024.1)"] [[package]] name = "idna" @@ -982,6 +982,23 @@ lint = ["docutils-stubs", "flake8", "mypy"] standalone = ["Sphinx (>=5)"] test = ["pytest"] +[[package]] +name = "structlog" +version = "24.1.0" +description = "Structured Logging for Python" +optional = true +python-versions = ">=3.8" +files = [ + {file = "structlog-24.1.0-py3-none-any.whl", hash = "sha256:3f6efe7d25fab6e86f277713c218044669906537bb717c1807a09d46bca0714d"}, + {file = "structlog-24.1.0.tar.gz", hash = "sha256:41a09886e4d55df25bdcb9b5c9674bccfab723ff43e0a86a1b7b236be8e57b16"}, +] + +[package.extras] +dev = ["structlog[tests,typing]"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "sphinxext-opengraph", "twisted"] +tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"] +typing = ["mypy (>=1.4)", "rich", "twisted"] + [[package]] name = "tomli" version = "2.0.1" @@ -1115,9 +1132,10 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [extras] loguru = ["loguru"] pytest = ["pytest"] +structlog = ["structlog"] trio = ["trio"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "07cb8de1b3a73f101b1eaf92087c255c52163e6611d4def2da1830b41756c208" +content-hash = "63fcb3851814034bf59fd4e861abc8727b75025b58cc16d26c21b8bc9a90fd01" diff --git a/pyproject.toml b/pyproject.toml index 861e08f6..669e095e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,12 +25,14 @@ packages = [{ include = "logot" }] [tool.poetry.dependencies] python = "^3.8" loguru = { version = ">=0.6,<0.8", optional = true } +structlog = { version = ">=23.3,<25", optional = true } pytest = { version = ">=7,<9", optional = true } trio = { version = ">=0.22,<0.25", optional = true } typing-extensions = { version = ">=4.9", python = "<3.10" } [tool.poetry.extras] loguru = ["loguru"] +structlog = ["structlog"] pytest = ["pytest"] trio = ["trio"] @@ -75,6 +77,8 @@ addopts = "--tb=native --import-mode=importlib" [tool.ruff] include = ["docs/**/*.py", "logot/**/*.py", "tests/**/*.py"] line-length = 120 + +[tool.ruff.lint] select = ["E", "F", "W", "I", "UP"] [build-system] diff --git a/tests/test_structlog.py b/tests/test_structlog.py new file mode 100644 index 00000000..bb98f0b0 --- /dev/null +++ b/tests/test_structlog.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Callable, Iterator + +import pytest +import structlog +from structlog.stdlib import LoggerFactory + +from logot import Logot, logged +from logot.structlog import StructlogCapturer + +logger = structlog.get_logger() + + +@pytest.fixture +def stdlib_logger() -> Iterator[None]: + structlog.configure(logger_factory=LoggerFactory()) + yield + structlog.reset_defaults() + + +@pytest.fixture(scope="session") +def logot_capturer() -> Callable[[], StructlogCapturer]: + return StructlogCapturer + + +def test_capturing() -> None: + with Logot(capturer=StructlogCapturer).capturing() as logot: + # Ensure log capturing is enabled. + logger.info("foo bar") + logot.assert_logged(logged.info("foo bar")) + # Ensure log capturing is disabled. + logger.info("foo bar") + logot.assert_not_logged(logged.info("foo bar")) + + +def test_multiple_capturing() -> None: + with Logot(capturer=StructlogCapturer).capturing() as logot_1: + with Logot(capturer=StructlogCapturer).capturing() as logot_2: + # Ensure log capturing is enabled. + logger.info("foo bar") + logot_1.assert_logged(logged.info("foo bar")) + logot_2.assert_logged(logged.info("foo bar")) + # Ensure log capturing is disabled. + logger.info("foo bar") + logot_1.assert_not_logged(logged.info("foo bar")) + logot_2.assert_not_logged(logged.info("foo bar")) + + +def test_capturing_level_pass() -> None: + with Logot(capturer=StructlogCapturer).capturing(level="INFO") as logot: + logger.info("foo bar") + logot.assert_logged(logged.info("foo bar")) + + +def test_capturing_level_fail() -> None: + with Logot(capturer=StructlogCapturer).capturing(level="INFO") as logot: + logger.debug("foo bar") + logot.assert_not_logged(logged.debug("foo bar")) + + +def test_capturing_level_as_int_pass() -> None: + with Logot(capturer=StructlogCapturer).capturing(level=20) as logot: + logger.info("foo bar") + logot.assert_logged(logged.info("foo bar")) + + +def test_capturing_level_as_int_fail() -> None: + with Logot(capturer=StructlogCapturer).capturing(level=20) as logot: + logger.debug("foo bar") + logot.assert_not_logged(logged.debug("foo bar")) + + +def test_capturing_name_pass(stdlib_logger: None) -> None: + logger = structlog.get_logger("tests") + with Logot(capturer=StructlogCapturer).capturing(name="tests") as logot: + logger.info("foo bar") + logot.assert_logged(logged.info("foo bar")) + + +def test_capturing_name_fail(stdlib_logger: None) -> None: + logger = structlog.get_logger("tests") + with Logot(capturer=StructlogCapturer).capturing(name="boom") as logot: + logger.info("foo bar") + logot.assert_not_logged(logged.info("foo bar")) + + +def test_capture(logot: Logot) -> None: + logger.info("foo bar") + logot.assert_logged(logged.info("foo bar")) + + +def test_capture_levelno(logot: Logot) -> None: + logger.log(20, "foo bar") + logot.assert_logged(logged.log(20, "foo bar"))