Skip to content

Commit

Permalink
Added Logot.reduce() and Logged.reduce() (#110)
Browse files Browse the repository at this point in the history
These were previously protected methods, but are useful for building
more complex high-level log assertions.
  • Loading branch information
etianen authored Feb 15, 2024
1 parent 18cf2b5 commit 4f2e4f0
Show file tree
Hide file tree
Showing 4 changed files with 64 additions and 38 deletions.
1 change: 1 addition & 0 deletions docs/api/logot.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ API reference
:members:

.. autoclass:: Logged
:members:

.. autoclass:: AsyncWaiter
:members:
29 changes: 21 additions & 8 deletions logot/_logged.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,20 @@ def __repr__(self) -> str:
raise NotImplementedError

@abstractmethod
def _reduce(self, captured: Captured) -> Logged | None:
def reduce(self, captured: Captured) -> Logged | None:
"""
Reduces this :doc:`log pattern </log-pattern-matching>` using the given :class:`Captured` log.
- No match - The same :doc:`log pattern </log-pattern-matching>` is returned.
- Partial match - A smaller :doc:`log pattern </log-pattern-matching>` is returned.
- Full match - :data:`None` is returned.
.. note::
This method is for building high-level log assertions. It is not generally used when writing tests.
:param captured: The :class:`Captured` log.
"""
raise NotImplementedError

@abstractmethod
Expand Down Expand Up @@ -132,7 +145,7 @@ def __eq__(self, other: object) -> bool:
def __repr__(self) -> str:
return f"log({self._level!r}, {self._msg!r})"

def _reduce(self, captured: Captured) -> Logged | None:
def reduce(self, captured: Captured) -> Logged | None:
# Match `str` level.
if isinstance(self._level, str):
if self._level != captured.levelname:
Expand Down Expand Up @@ -191,9 +204,9 @@ class _OrderedAllLogged(_ComposedLogged):
def __repr__(self) -> str:
return f"({' >> '.join(map(repr, self._logged_items))})"

def _reduce(self, captured: Captured) -> Logged | None:
def reduce(self, captured: Captured) -> Logged | None:
logged = self._logged_items[0]
reduced = logged._reduce(captured)
reduced = logged.reduce(captured)
# Handle full reduction.
if reduced is None:
return _OrderedAllLogged.from_reduce(self._logged_items[1:])
Expand All @@ -213,9 +226,9 @@ class _UnorderedAllLogged(_ComposedLogged):
def __repr__(self) -> str:
return f"({' & '.join(map(repr, self._logged_items))})"

def _reduce(self, captured: Captured) -> Logged | None:
def reduce(self, captured: Captured) -> Logged | None:
for n, logged in enumerate(self._logged_items):
reduced = logged._reduce(captured)
reduced = logged.reduce(captured)
# Handle full reduction.
if reduced is None:
return _UnorderedAllLogged.from_reduce((*self._logged_items[:n], *self._logged_items[n + 1 :]))
Expand All @@ -237,9 +250,9 @@ class _AnyLogged(_ComposedLogged):
def __repr__(self) -> str:
return f"({' | '.join(map(repr, self._logged_items))})"

def _reduce(self, captured: Captured) -> Logged | None:
def reduce(self, captured: Captured) -> Logged | None:
for n, logged in enumerate(self._logged_items):
reduced = logged._reduce(captured)
reduced = logged.reduce(captured)
# Handle full reduction.
if reduced is None:
return None
Expand Down
70 changes: 41 additions & 29 deletions logot/_logot.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def capture(self, captured: Captured) -> None:
with self._lock:
# If there is a waiter that has not been fully reduced, attempt to reduce it.
if self._wait is not None and self._wait.logged is not None:
self._wait.logged = self._wait.logged._reduce(captured)
self._wait.logged = self._wait.logged.reduce(captured)
# If the waiter has fully reduced, release the blocked caller.
if self._wait.logged is None:
self._wait.waiter_obj.release()
Expand All @@ -158,23 +158,23 @@ def capture(self, captured: Captured) -> None:

def assert_logged(self, logged: Logged) -> None:
"""
Fails *immediately* if the expected ``log`` pattern has not arrived.
Fails *immediately* if the expected log pattern has not arrived.
:param logged: The expected :doc:`log pattern </log-pattern-matching>`.
:raises AssertionError: If the expected ``log`` pattern has not arrived.
:raises AssertionError: If the expected log pattern has not arrived.
"""
reduced = self._reduce(logged)
reduced = self.reduce(logged)
if reduced is not None:
raise AssertionError(f"Not logged:\n\n{reduced}")

def assert_not_logged(self, logged: Logged) -> None:
"""
Fails *immediately* if the expected ``log`` pattern **has** arrived.
Fails *immediately* if the expected log pattern **has** arrived.
:param logged: The expected :doc:`log pattern </log-pattern-matching>`.
:raises AssertionError: If the expected ``log`` pattern **has** arrived.
:raises AssertionError: If the expected log pattern **has** arrived.
"""
reduced = self._reduce(logged)
reduced = self.reduce(logged)
if reduced is None:
raise AssertionError(f"Logged:\n\n{logged}")

Expand All @@ -185,11 +185,11 @@ def wait_for(
timeout: float | None = None,
) -> None:
"""
Waits for the expected ``log`` pattern to arrive or the ``timeout`` to expire.
Waits for the expected log pattern to arrive or the ``timeout`` to expire.
:param logged: The expected :doc:`log pattern </log-pattern-matching>`.
:param timeout: How long to wait (in seconds) before failing the test. Defaults to :attr:`Logot.timeout`.
:raises AssertionError: If the expected ``log`` pattern does not arrive within ``timeout`` seconds.
:raises AssertionError: If the expected log pattern does not arrive within ``timeout`` seconds.
"""
wait = self._start_waiting(logged, create_threading_waiter, timeout=timeout)
if wait is None:
Expand All @@ -207,13 +207,13 @@ async def await_for(
async_waiter: Callable[[], AsyncWaiter] | None = None,
) -> None:
"""
Waits *asynchronously* for the expected ``log`` pattern to arrive or the ``timeout`` to expire.
Waits *asynchronously* for the expected log pattern to arrive or the ``timeout`` to expire.
:param logged: The expected :doc:`log pattern </log-pattern-matching>`.
:param timeout: How long to wait (in seconds) before failing the test. Defaults to :attr:`Logot.timeout`.
:param async_waiter: Protocol used to pause tests until expected logs arrive. This is for integration with
:ref:`3rd-party asynchronous frameworks <integrations-async>`. Defaults to :attr:`Logot.async_waiter`.
:raises AssertionError: If the expected ``log`` pattern does not arrive within ``timeout`` seconds.
:raises AssertionError: If the expected log pattern does not arrive within ``timeout`` seconds.
"""
if async_waiter is None:
async_waiter = self.async_waiter
Expand All @@ -225,15 +225,39 @@ async def await_for(
finally:
self._stop_waiting(wait)

def reduce(self, logged: Logged) -> Logged | None:
"""
Reduces the expected log pattern using captured logs.
- No match - The same :doc:`log pattern </log-pattern-matching>` is returned.
- Partial match - A smaller :doc:`log pattern </log-pattern-matching>` is returned.
- Full match - :data:`None` is returned.
.. note::
This method is for building high-level log assertions. It is not generally used when writing tests.
:param logged: The expected :doc:`log pattern </log-pattern-matching>`.
"""
reduced: Logged | None = logged
# Drain the queue until the log is fully reduced.
# This does not need a lock, since `deque.popleft()` is thread-safe.
while reduced is not None:
try:
captured = self._queue.popleft()
except IndexError:
break
reduced = reduced.reduce(captured)
# All done!
return reduced

def clear(self) -> None:
"""
Clears any captured logs.
"""
self._queue.clear()

def _start_waiting(
self, logged: Logged | None, waiter: Callable[[], W], *, timeout: float | None
) -> _Wait[W] | None:
def _start_waiting(self, logged: Logged, waiter: Callable[[], W], *, timeout: float | None) -> _Wait[W] | None:
with self._lock:
# If no timeout is provided, use the default timeout.
# Otherwise, validate and use the provided timeout.
Expand All @@ -245,12 +269,12 @@ def _start_waiting(
if self._wait is not None: # pragma: no cover
raise RuntimeError("Multiple concurrent waiters are not supported")
# Apply an immediate reduction.
logged = self._reduce(logged)
if logged is None:
reduced = self.reduce(logged)
if reduced is None:
return None
# All done!
waiter_obj = waiter()
wait = self._wait = _Wait(logged=logged, timeout=timeout, waiter_obj=waiter_obj)
wait = self._wait = _Wait(logged=reduced, timeout=timeout, waiter_obj=waiter_obj)
return wait

def _stop_waiting(self, wait: _Wait[Any]) -> None:
Expand All @@ -261,18 +285,6 @@ def _stop_waiting(self, wait: _Wait[Any]) -> None:
if wait.logged is not None:
raise AssertionError(f"Not logged:\n\n{wait.logged}")

def _reduce(self, logged: Logged | None) -> Logged | None:
# Drain the queue until the log is fully reduced.
# This does not need a lock, since `deque.popleft()` is thread-safe.
while logged is not None:
try:
captured = self._queue.popleft()
except IndexError:
break
logged = logged._reduce(captured)
# All done!
return logged

def __repr__(self) -> str:
return f"Logot(capturer={self.capturer!r}, timeout={self.timeout!r}, async_waiter={self.async_waiter!r})"

Expand Down
2 changes: 1 addition & 1 deletion tests/test_logged.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def assert_reduce(logged: Logged | None, *captured_items: Captured) -> None:
for captured in captured_items:
# The `Logged` should not have been fully reduced.
assert logged is not None
logged = logged._reduce(captured)
logged = logged.reduce(captured)
# Once captured items are consumed, the `Logged` should have been fully-reduced.
assert logged is None

Expand Down

0 comments on commit 4f2e4f0

Please sign in to comment.