Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Log level matching refactor #45

Merged
merged 17 commits into from
Jan 29, 2024
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
4 changes: 3 additions & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ Import the :mod:`logot` API in your tests:

.. autoclass:: logot.Captured

.. autoattribute:: levelno
.. autoattribute:: levelname

.. autoattribute:: msg

.. autoattribute:: levelno


:mod:`logot.logged`
-------------------
Expand Down
2 changes: 1 addition & 1 deletion docs/captured.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Any 3rd-party logging library can be integrated with :mod:`logot` by sending :cl
.. code:: python

def on_foo_log(logot: Logot, record: FooRecord) -> None:
logot.capture(Captured(record.levelno, record.msg))
logot.capture(Captured(record.levelname, record.msg, levelno=record.levelno))

foo_logger.add_handler(on_foo_log)

Expand Down
36 changes: 22 additions & 14 deletions logot/_captured.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
from __future__ import annotations

import logging
from typing import Final

from logot._format import format_log
from logot._validate import validate_levelno


class Captured:
Expand All @@ -23,31 +19,43 @@ class Captured:

See :ref:`captured-3rd-party` usage guide.

:param level: The log level (e.g. :data:`logging.DEBUG`) or string name (e.g. ``"DEBUG"``).
:param levelname: The log level name (e.g. ``"DEBUG"``).
:param msg: The log message.
:param levelno: The log level number (e.g. :data:`logging.DEBUG`).
"""

__slots__ = ("levelno", "msg")
__slots__ = ("levelname", "msg", "levelno")

levelno: Final[int]
levelname: str
"""
The integer log level (e.g. :data:`logging.DEBUG`).
The log level name (e.g. ``"DEBUG"``).
"""

msg: Final[str]
msg: str
"""
The log message.
"""

def __init__(self, level: int | str, msg: str) -> None:
self.levelno = validate_levelno(level)
levelno: int
"""
The log level number (e.g. :data:`logging.DEBUG`).
"""

def __init__(self, levelname: str, msg: str, *, levelno: int) -> None:
self.levelname = levelname
self.msg = msg
self.levelno = levelno

def __eq__(self, other: object) -> bool:
return isinstance(other, Captured) and other.levelno == self.levelno and other.msg == self.msg
return (
isinstance(other, Captured)
and other.levelname == self.levelname
and other.msg == self.msg
and other.levelno == self.levelno
)

def __repr__(self) -> str:
return f"Captured({logging.getLevelName(self.levelno)!r}, {self.msg!r})"
return f"Captured({self.levelname!r}, {self.msg!r}, levelno={self.levelno!r})"

def __str__(self) -> str:
return format_log(self.levelno, self.msg)
return format_log(self.levelname, self.msg)
13 changes: 11 additions & 2 deletions logot/_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,14 @@
import logging


def format_log(levelno: int, msg: str) -> str:
return f"[{logging.getLevelName(levelno)}] {msg}"
def format_level(level: str | int) -> str:
# Format `str` level.
if isinstance(level, str):
return level
# Format `int` level.
level: str = logging.getLevelName(level)
return level


def format_log(levelname: str, msg: str) -> str:
return f"[{levelname}] {msg}"
48 changes: 28 additions & 20 deletions logot/_logged.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from __future__ import annotations

import logging
from abc import ABC, abstractmethod

from logot._captured import Captured
from logot._format import format_log
from logot._format import format_level, format_log
from logot._match import compile_matcher
from logot._validate import validate_levelno
from logot._validate import validate_level


class Logged(ABC):
Expand Down Expand Up @@ -58,14 +57,14 @@ def _str(self, *, indent: str) -> str:
raise NotImplementedError


def log(level: int | str, msg: str) -> Logged:
def log(level: str | int, msg: str) -> Logged:
"""
Creates a :doc:`log pattern <logged>` representing a log record at the given ``level`` with the given ``msg``.

:param level: A log level (e.g. :data:`logging.DEBUG`) or string name (e.g. ``"DEBUG"``).
:param level: A log level name (e.g. ``"DEBUG"``) or numeric constant (e.g. :data:`logging.DEBUG`).
:param msg: A log :doc:`message pattern <match>`.
"""
return _RecordLogged(validate_levelno(level), msg)
return _RecordLogged(validate_level(level), msg)


def debug(msg: str) -> Logged:
Expand All @@ -74,7 +73,7 @@ def debug(msg: str) -> Logged:

:param msg: A log :doc:`message pattern <match>`.
"""
return _RecordLogged(logging.DEBUG, msg)
return _RecordLogged("DEBUG", msg)


def info(msg: str) -> Logged:
Expand All @@ -83,7 +82,7 @@ def info(msg: str) -> Logged:

:param msg: A log :doc:`message pattern <match>`.
"""
return _RecordLogged(logging.INFO, msg)
return _RecordLogged("INFO", msg)


def warning(msg: str) -> Logged:
Expand All @@ -92,7 +91,7 @@ def warning(msg: str) -> Logged:

:param msg: A log :doc:`message pattern <match>`.
"""
return _RecordLogged(logging.WARNING, msg)
return _RecordLogged("WARNING", msg)


def error(msg: str) -> Logged:
Expand All @@ -101,7 +100,7 @@ def error(msg: str) -> Logged:

:param msg: A log :doc:`message pattern <match>`.
"""
return _RecordLogged(logging.ERROR, msg)
return _RecordLogged("ERROR", msg)


def critical(msg: str) -> Logged:
Expand All @@ -110,30 +109,39 @@ def critical(msg: str) -> Logged:

:param msg: A log :doc:`message pattern <match>`.
"""
return _RecordLogged(logging.CRITICAL, msg)
return _RecordLogged("CRITICAL", msg)


class _RecordLogged(Logged):
__slots__ = ("_levelno", "_msg", "_matcher")
__slots__ = ("_level", "_msg", "_matcher")

def __init__(self, levelno: int, msg: str) -> None:
self._levelno = levelno
def __init__(self, level: str | int, msg: str) -> None:
self._level = level
self._msg = msg
self._matcher = compile_matcher(msg)

def __eq__(self, other: object) -> bool:
return isinstance(other, _RecordLogged) and other._levelno == self._levelno and other._msg == self._msg
return isinstance(other, _RecordLogged) and other._level == self._level and other._msg == self._msg

def __repr__(self) -> str:
return f"log({logging.getLevelName(self._levelno)!r}, {self._msg!r})"
return f"log({self._level!r}, {self._msg!r})"

def _reduce(self, captured: Captured) -> Logged | None:
if self._levelno == captured.levelno and self._matcher(captured.msg):
return None
return self
# Match `str` level.
if isinstance(self._level, str):
if self._level != captured.levelname:
return self
# Match `int` level.
elif self._level != captured.levelno:
return self
# Match message.
if not self._matcher(captured.msg):
return self
# We matched!
return None

def _str(self, *, indent: str) -> str:
return format_log(self._levelno, self._msg)
return format_log(format_level(self._level), self._msg)


class _ComposedLogged(Logged):
Expand Down
28 changes: 12 additions & 16 deletions logot/_logot.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from logot._captured import Captured
from logot._logged import Logged
from logot._validate import validate_levelno, validate_logger, validate_timeout
from logot._validate import validate_level, validate_logger, validate_timeout
from logot._waiter import AsyncWaiter, SyncWaiter, Waiter

W = TypeVar("W", bound=Waiter)
Expand All @@ -29,11 +29,9 @@ class Logot:

__slots__ = ("_timeout", "_lock", "_queue", "_waiter")

DEFAULT_LEVEL: ClassVar[int | str] = logging.NOTSET
DEFAULT_LEVEL: ClassVar[str | int] = "DEBUG"
"""
The default ``level`` used by :meth:`capturing`.

This is :data:`logging.NOTSET`, specifying that all logs are captured.
"""

DEFAULT_LOGGER: ClassVar[logging.Logger | str | None] = None
Expand All @@ -45,9 +43,7 @@ class Logot:

DEFAULT_TIMEOUT: ClassVar[float] = 3.0
"""
The default ``timeout`` used by :meth:`wait_for` and :meth:`await_for`.

This is 3 seconds.
The default ``timeout`` (in seconds) used by :meth:`wait_for` and :meth:`await_for`.
"""

def __init__(
Expand All @@ -63,7 +59,7 @@ def __init__(
def capturing(
self,
*,
level: int | str = DEFAULT_LEVEL,
level: str | int = DEFAULT_LEVEL,
logger: logging.Logger | str | None = DEFAULT_LOGGER,
) -> AbstractContextManager[Logot]:
"""
Expand All @@ -76,13 +72,13 @@ def capturing(

See :doc:`captured` usage guide.

:param level: A log level (e.g. :data:`logging.DEBUG`) or string name (e.g. ``"DEBUG"``). Defaults to
:data:`logging.NOTSET`, specifying that all logs are captured.
:param logger: A logger or logger name to capture logs from. Defaults to the root logger.
:param level: A log level name (e.g. ``"DEBUG"``) or numeric constant (e.g. :data:`logging.DEBUG`). Defaults to
:attr:`DEFAULT_LEVEL`.
:param logger: A logger or logger name to capture logs from. Defaults to :attr:`DEFAULT_LOGGER`.
"""
levelno = validate_levelno(level)
level = validate_level(level)
logger = validate_logger(logger)
return _Capturing(self, _Handler(self, levelno=levelno), logger=logger)
return _Capturing(self, _Handler(level, self), logger=logger)

def capture(self, captured: Captured) -> None:
"""
Expand Down Expand Up @@ -245,10 +241,10 @@ def __exit__(
class _Handler(logging.Handler):
__slots__ = ("_logot",)

def __init__(self, logot: Logot, *, levelno: int) -> None:
super().__init__(levelno)
def __init__(self, level: str | int, logot: Logot) -> None:
super().__init__(level)
self._logot = logot

def emit(self, record: logging.LogRecord) -> None:
captured = Captured(record.levelno, record.getMessage())
captured = Captured(record.levelname, record.getMessage(), levelno=record.levelno)
self._logot.capture(captured)
12 changes: 3 additions & 9 deletions logot/_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,10 @@
import logging


def validate_levelno(level: int | str) -> int:
# Handle `int` level.
if isinstance(level, int):
def validate_level(level: str | int) -> str | int:
# Handle `str` or `int` level.
if isinstance(level, (str, int)):
return level
# Handle `str` level.
if isinstance(level, str):
levelno = logging.getLevelName(level)
if not isinstance(levelno, int):
raise ValueError(f"Unknown level: {level!r}")
return levelno
# Handle invalid level.
raise TypeError(f"Invalid level: {level!r}")

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "logot"
version = "0.2.0"
version = "0.3.0"
description = "Log-based testing"
authors = ["Dave Hall <dave@etianen.com>"]
license = "MIT"
Expand Down
11 changes: 11 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@
from contextlib import contextmanager
from time import sleep

from logot import Captured

logger = logging.getLogger("logot")


def log(levelname: str, msg: str, *, levelno: int | None = None) -> Captured:
# Look up default `levelno` from `logging`.
if levelno is None:
levelno = logging.getLevelName(levelname)
assert isinstance(levelno, int)
# All done!
return Captured(levelname, msg, levelno=levelno)


def lines(*lines: str) -> str:
return "\n".join(lines)

Expand Down
14 changes: 8 additions & 6 deletions tests/test_captured.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@


def test_eq_pass() -> None:
assert Captured(logging.INFO, "foo bar") == Captured(logging.INFO, "foo bar")
assert Captured("INFO", "foo bar", levelno=logging.INFO) == Captured("INFO", "foo bar", levelno=logging.INFO)


def test_eq_fail() -> None:
# Different levels are not equal.
assert Captured(logging.INFO, "foo bar") != Captured(logging.DEBUG, "foo bar")
# Different levelnames are not equal.
assert Captured("INFO", "foo bar", levelno=logging.INFO) != Captured("DEBUG", "foo bar", levelno=logging.INFO)
# Different messages are not equal.
assert Captured(logging.INFO, "foo bar") != Captured(logging.INFO, "foo")
assert Captured("INFO", "foo bar", levelno=logging.INFO) != Captured("INFO", "foo", levelno=logging.INFO)
# Different levelnos are not equal.
assert Captured("INFO", "foo bar", levelno=logging.INFO) != Captured("INFO", "foo bar", levelno=logging.DEBUG)


def test_repr() -> None:
assert repr(Captured(logging.INFO, "foo bar")) == "Captured('INFO', 'foo bar')"
assert repr(Captured("INFO", "foo bar", levelno=99)) == "Captured('INFO', 'foo bar', levelno=99)"


def test_str() -> None:
assert str(Captured(logging.INFO, "foo bar")) == "[INFO] foo bar"
assert str(Captured("INFO", "foo bar", levelno=logging.INFO)) == "[INFO] foo bar"
12 changes: 10 additions & 2 deletions tests/test_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@

import logging

from logot._format import format_log
from logot._format import format_level, format_log


def test_format_level_str() -> None:
assert format_level("INFO") == "INFO"


def test_format_level_int() -> None:
assert format_level(logging.INFO) == "INFO"


def test_format_log() -> None:
assert format_log(logging.INFO, "foo bar") == "[INFO] foo bar"
assert format_log("INFO", "foo bar") == "[INFO] foo bar"
Loading