Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ $ uvx --from 'libtmux' --prerelease allow python
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Fixes

- {func}`~libtmux.test.retry.retry_until` accepts an `args` tuple forwarded to
the predicate, so waiting on a loop variable no longer needs a closure that
ruff's `B023` rejects and mypy cannot infer (#726)

### Documentation

#### Cleaner `from_env` examples (#719)
Expand Down
48 changes: 46 additions & 2 deletions src/libtmux/test/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,32 @@
pass


@t.overload
def retry_until(
fun: Callable[[], bool],
seconds: float = ...,
*,
interval: float = ...,
raises: bool | None = ...,
) -> bool: ...


@t.overload
def retry_until(
fun: Callable[..., bool],
seconds: float = ...,
*,
args: tuple[t.Any, ...],
interval: float = ...,
raises: bool | None = ...,
) -> bool: ...


def retry_until(
fun: Callable[..., bool],
seconds: float = RETRY_TIMEOUT_SECONDS,
*,
args: tuple[t.Any, ...] = (),
interval: float = RETRY_INTERVAL_SECONDS,
raises: bool | None = True,
) -> bool:
Expand All @@ -33,10 +55,15 @@ def retry_until(
----------
fun : callable
A function that will be called repeatedly until it returns ``True`` or
the specified time passes.
the specified time passes. Called with ``args`` unpacked, so it accepts
no arguments unless ``args`` is given.
seconds : float
Seconds to retry. Defaults to ``8``, which is configurable via
``RETRY_TIMEOUT_SECONDS`` environment variables.
args : tuple
Positional arguments passed to ``fun`` on every call. Pass the subject
being waited on here instead of closing over it, which keeps predicates
written inside a loop free of a loop-variable binding.
interval : float
Time in seconds to wait between calls. Defaults to ``0.05`` and is
configurable via ``RETRY_INTERVAL_SECONDS`` environment variable.
Expand All @@ -55,10 +82,27 @@ def retry_until(
In pytest:

>>> assert retry_until(fn, raises=False)

Waiting on something from a loop takes ``args`` rather than a closure:

>>> window = session.new_window(window_name='retry_until_args')
>>> panes = [window.active_pane, window.split()]
>>> for pane in panes:
... pane.send_keys('echo ready')

Compare whole lines. ``capture_pane`` returns the command tmux echoed onto
the pane, so ``'ready' in line`` matches that echo and is already true
before the shell has run anything:

>>> for pane in panes:
... assert retry_until(
... lambda p: any(line.strip() == 'ready' for line in p.capture_pane()),
... args=(pane,),
... )
"""
ini = time.time()

while not fun():
while not fun(*args):
end = time.time()
if end - ini >= seconds:
if raises:
Expand Down
37 changes: 37 additions & 0 deletions tests/test/test_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

from __future__ import annotations

import typing as t
from time import sleep, time

import pytest

from libtmux import exc
from libtmux.test.retry import retry_until

if t.TYPE_CHECKING:
from libtmux.session import Session


def test_retry_three_times() -> None:
"""Test retry_until()."""
Expand Down Expand Up @@ -103,3 +107,36 @@ def call_me_three_times() -> bool:

end = time()
assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations


def test_retry_until_forwards_args_from_loop(session: Session) -> None:
"""Waiting on a loop variable needs no closure, so no B023 suppression."""
window = session.new_window(window_name="retry_until_args")
active_pane = window.active_pane
assert active_pane is not None
panes = [active_pane, window.split()]

for pane in panes:
pane.send_keys("echo ready")

for pane in panes:
# Whole lines, not a substring: capture_pane returns the command tmux
# echoed onto the pane, so ``"ready" in ...`` is true before the shell
# has run and the wait would prove nothing.
assert retry_until(
lambda p: any(line.strip() == "ready" for line in p.capture_pane()),
2,
args=(pane,),
)


def test_retry_until_args_reach_predicate() -> None:
"""Every retry passes the same ``args`` to the predicate."""
seen: list[tuple[str, int]] = []

def ready(name: str, threshold: int) -> bool:
seen.append((name, threshold))
return len(seen) >= threshold

assert retry_until(ready, 1, args=("pane", 3), interval=0)
assert seen == [("pane", 3)] * 3
Loading