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

Added Logot.reduce() and Logged.reduce() #110

Merged
merged 2 commits into from
Feb 15, 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
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