diff --git a/docs/api.rst b/docs/api.rst index eaa9a3de..d887474a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -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` ------------------- diff --git a/docs/captured.rst b/docs/captured.rst index 7f86568a..187bf232 100644 --- a/docs/captured.rst +++ b/docs/captured.rst @@ -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) diff --git a/logot/_captured.py b/logot/_captured.py index 21eeecd3..da2aef2c 100644 --- a/logot/_captured.py +++ b/logot/_captured.py @@ -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: @@ -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) diff --git a/logot/_format.py b/logot/_format.py index d0ec82cb..6564284d 100644 --- a/logot/_format.py +++ b/logot/_format.py @@ -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}" diff --git a/logot/_logged.py b/logot/_logged.py index 6c11678b..6400537f 100644 --- a/logot/_logged.py +++ b/logot/_logged.py @@ -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): @@ -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 ` 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 `. """ - return _RecordLogged(validate_levelno(level), msg) + return _RecordLogged(validate_level(level), msg) def debug(msg: str) -> Logged: @@ -74,7 +73,7 @@ def debug(msg: str) -> Logged: :param msg: A log :doc:`message pattern `. """ - return _RecordLogged(logging.DEBUG, msg) + return _RecordLogged("DEBUG", msg) def info(msg: str) -> Logged: @@ -83,7 +82,7 @@ def info(msg: str) -> Logged: :param msg: A log :doc:`message pattern `. """ - return _RecordLogged(logging.INFO, msg) + return _RecordLogged("INFO", msg) def warning(msg: str) -> Logged: @@ -92,7 +91,7 @@ def warning(msg: str) -> Logged: :param msg: A log :doc:`message pattern `. """ - return _RecordLogged(logging.WARNING, msg) + return _RecordLogged("WARNING", msg) def error(msg: str) -> Logged: @@ -101,7 +100,7 @@ def error(msg: str) -> Logged: :param msg: A log :doc:`message pattern `. """ - return _RecordLogged(logging.ERROR, msg) + return _RecordLogged("ERROR", msg) def critical(msg: str) -> Logged: @@ -110,30 +109,39 @@ def critical(msg: str) -> Logged: :param msg: A log :doc:`message pattern `. """ - 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): diff --git a/logot/_logot.py b/logot/_logot.py index c0441d3d..c1b802be 100644 --- a/logot/_logot.py +++ b/logot/_logot.py @@ -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) @@ -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 @@ -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__( @@ -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]: """ @@ -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: """ @@ -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) diff --git a/logot/_validate.py b/logot/_validate.py index ecf28c3e..6b47a573 100644 --- a/logot/_validate.py +++ b/logot/_validate.py @@ -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}") diff --git a/pyproject.toml b/pyproject.toml index 78858450..75b875c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "logot" -version = "0.2.0" +version = "0.3.0" description = "Log-based testing" authors = ["Dave Hall "] license = "MIT" diff --git a/tests/__init__.py b/tests/__init__.py index 9c2582e5..09e75a6d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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) diff --git a/tests/test_captured.py b/tests/test_captured.py index 019b4d13..149314f3 100644 --- a/tests/test_captured.py +++ b/tests/test_captured.py @@ -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" diff --git a/tests/test_format.py b/tests/test_format.py index 7ca8458c..384ca20a 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -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" diff --git a/tests/test_logged.py b/tests/test_logged.py index e23632e8..40581770 100644 --- a/tests/test_logged.py +++ b/tests/test_logged.py @@ -3,7 +3,7 @@ import logging from logot import Captured, Logged, logged -from tests import lines +from tests import lines, log def assert_reduce(logged: Logged | None, *captured_items: Captured) -> None: @@ -27,7 +27,8 @@ def test_record_logged_eq_fail() -> None: def test_record_logged_repr() -> None: - assert repr(logged.log(logging.DEBUG, "foo bar")) == "log('DEBUG', 'foo bar')" + assert repr(logged.log(10, "foo bar")) == "log(10, 'foo bar')" + assert repr(logged.log("DEBUG", "foo bar")) == "log('DEBUG', 'foo bar')" assert repr(logged.debug("foo bar")) == "log('DEBUG', 'foo bar')" assert repr(logged.info("foo bar")) == "log('INFO', 'foo bar')" assert repr(logged.warning("foo bar")) == "log('WARNING', 'foo bar')" @@ -37,6 +38,7 @@ def test_record_logged_repr() -> None: def test_record_logged_str() -> None: assert str(logged.log(logging.DEBUG, "foo bar")) == "[DEBUG] foo bar" + assert str(logged.log("DEBUG", "foo bar")) == "[DEBUG] foo bar" assert str(logged.debug("foo bar")) == "[DEBUG] foo bar" assert str(logged.info("foo bar")) == "[INFO] foo bar" assert str(logged.warning("foo bar")) == "[WARNING] foo bar" @@ -45,11 +47,19 @@ def test_record_logged_str() -> None: def test_record_logged_reduce() -> None: + # Test `str` level. assert_reduce( - logged.info("foo bar"), - Captured(logging.INFO, "boom!"), # Non-matching. - Captured(logging.DEBUG, "foo bar"), # Non-matching. - Captured(logging.INFO, "foo bar"), # Matching. + logged.log("INFO", "foo bar"), + log("INFO", "boom!"), # Non-matching. + log("DEBUG", "foo bar"), # Non-matching. + log("INFO", "foo bar"), # Matching. + ) + # Test `int` level. + assert_reduce( + logged.log(logging.INFO, "foo bar"), + log("INFO", "boom!"), # Non-matching. + log("DEBUG", "foo bar"), # Non-matching. + log("INFO", "foo bar"), # Matching. ) @@ -113,23 +123,23 @@ def test_ordered_all_logged_str() -> None: def test_ordered_all_logged_reduce() -> None: assert_reduce( logged.info("foo") >> logged.info("bar") >> logged.info("baz"), - Captured(logging.INFO, "boom!"), # Non-matching. - Captured(logging.INFO, "baz"), # Non-matching. - Captured(logging.INFO, "bar"), # Non-matching. - Captured(logging.INFO, "foo"), # Matching. - Captured(logging.INFO, "foo"), # Non-matching. - Captured(logging.INFO, "bar"), # Matching. - Captured(logging.INFO, "baz"), # Matching. + log("INFO", "boom!"), # Non-matching. + log("INFO", "baz"), # Non-matching. + log("INFO", "bar"), # Non-matching. + log("INFO", "foo"), # Matching. + log("INFO", "foo"), # Non-matching. + log("INFO", "bar"), # Matching. + log("INFO", "baz"), # Matching. ) assert_reduce( (logged.info("foo1") & logged.info("foo2")) >> (logged.info("bar1") & logged.info("bar2")), - Captured(logging.INFO, "boom!"), # Non-matching. - Captured(logging.INFO, "bar2"), # Non-matching. - Captured(logging.INFO, "foo2"), # Matching. - Captured(logging.INFO, "bar1"), # Non-matching. - Captured(logging.INFO, "foo1"), # Matching. - Captured(logging.INFO, "bar2"), # Matching. - Captured(logging.INFO, "bar1"), # Matching. + log("INFO", "boom!"), # Non-matching. + log("INFO", "bar2"), # Non-matching. + log("INFO", "foo2"), # Matching. + log("INFO", "bar1"), # Non-matching. + log("INFO", "foo1"), # Matching. + log("INFO", "bar2"), # Matching. + log("INFO", "bar1"), # Matching. ) @@ -193,21 +203,21 @@ def test_unordered_all_logged_str() -> None: def test_unordered_all_logged_reduce() -> None: assert_reduce( logged.info("foo") & logged.info("bar") & logged.info("baz"), - Captured(logging.INFO, "boom!"), # Non-matching. - Captured(logging.INFO, "baz"), # Matching. - Captured(logging.INFO, "baz"), # Non-matching. - Captured(logging.INFO, "bar"), # Matching. - Captured(logging.INFO, "foo"), # Matching. + log("INFO", "boom!"), # Non-matching. + log("INFO", "baz"), # Matching. + log("INFO", "baz"), # Non-matching. + log("INFO", "bar"), # Matching. + log("INFO", "foo"), # Matching. ) assert_reduce( (logged.info("foo1") >> logged.info("foo2")) & (logged.info("bar1") >> logged.info("bar2")), - Captured(logging.INFO, "boom!"), # Non-matching. - Captured(logging.INFO, "bar2"), # Non-matching. - Captured(logging.INFO, "foo2"), # Non-matching. - Captured(logging.INFO, "bar1"), # Matching. - Captured(logging.INFO, "foo1"), # Matching. - Captured(logging.INFO, "foo2"), # Matching. - Captured(logging.INFO, "bar2"), # Matching. + log("INFO", "boom!"), # Non-matching. + log("INFO", "bar2"), # Non-matching. + log("INFO", "foo2"), # Non-matching. + log("INFO", "bar1"), # Matching. + log("INFO", "foo1"), # Matching. + log("INFO", "foo2"), # Matching. + log("INFO", "bar2"), # Matching. ) @@ -271,15 +281,15 @@ def test_any_logged_str() -> None: def test_any_logged_reduce() -> None: assert_reduce( logged.info("foo") | logged.info("bar") | logged.info("baz"), - Captured(logging.INFO, "boom!"), # Non-matching. - Captured(logging.INFO, "bar"), # Matching. + log("INFO", "boom!"), # Non-matching. + log("INFO", "bar"), # Matching. ) assert_reduce( (logged.info("foo1") >> logged.info("foo2")) | (logged.info("bar1") >> logged.info("bar2")), - Captured(logging.INFO, "boom!"), # Non-matching. - Captured(logging.INFO, "bar2"), # Non-matching. - Captured(logging.INFO, "foo2"), # Non-matching. - Captured(logging.INFO, "bar1"), # Matching. - Captured(logging.INFO, "foo1"), # Matching. - Captured(logging.INFO, "foo2"), # Matching. + log("INFO", "boom!"), # Non-matching. + log("INFO", "bar2"), # Non-matching. + log("INFO", "foo2"), # Non-matching. + log("INFO", "bar1"), # Matching. + log("INFO", "foo1"), # Matching. + log("INFO", "foo2"), # Matching. ) diff --git a/tests/test_validate.py b/tests/test_validate.py index a58cd6dc..104ba430 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -5,27 +5,21 @@ import pytest -from logot._validate import validate_levelno, validate_logger, validate_timeout +from logot._validate import validate_level, validate_logger, validate_timeout from tests import logger -def test_validate_levelno_int_pass() -> None: - assert validate_levelno(logging.INFO) == logging.INFO +def test_validate_level_str_pass() -> None: + assert validate_level("INFO") == "INFO" -def test_validate_levelno_str_pass() -> None: - assert validate_levelno("INFO") == logging.INFO +def test_validate_level_int_pass() -> None: + assert validate_level(20) == 20 -def test_validate_levelno_str_fail() -> None: - with pytest.raises(ValueError) as ex: - validate_levelno("BOOM") - assert str(ex.value) == "Unknown level: 'BOOM'" - - -def test_validate_levelno_type_fail() -> None: +def test_validate_level_type_fail() -> None: with pytest.raises(TypeError) as ex: - validate_levelno(cast(int, 1.5)) + validate_level(cast(int, 1.5)) assert str(ex.value) == "Invalid level: 1.5"