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

conn: add support for timeout #97

Merged
merged 1 commit into from
Feb 20, 2025
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/articles/running-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ SSH connection to the host for different user).
running-commands/configuration
running-commands/blocking-calls
running-commands/non-blocking-calls
running-commands/timeouts
64 changes: 64 additions & 0 deletions docs/articles/running-commands/timeouts.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
Timeouts
########

By default, all command timeout in 300 seconds, which should be more then
sufficient for most use cases. This timeout ensures that code that runs
unexpectedly long time (for example an unexpected user input is requested) is
terminated gracefully. A command that hits the timeout raises
:class:`~pytest_mh.conn.ProcessTimeoutError`.

The default timeout can be overridden on multiple places:

* Per host, in a configuration file, by setting ``timeout`` field in the
``host.conn`` section
* In a blocking calls using the ``timeout`` parameter, see
:meth:`~pytest_mh.conn.Connection.run` and
:meth:`~pytest_mh.conn.Connection.exec`
* In a non-blocking calls using the ``timeout`` parameter on the
:meth:`~pytest_mh.conn.Process.wait` method of a running process creating by
:meth:`~pytest_mh.conn.Connection.async_run` or
:meth:`~pytest_mh.conn.Connection.async_exec`

.. code-block:: yaml
:caption: Example: Overriding default timeout for the host
hosts:
- hostname: client1.test
role: client
conn:
type: ssh
host: 192.168.0.10
user: root
password: Secret123
timeout: 600 # setting default timeout to 10 minutes
.. code-block:: python
:caption: Example: Setting specific timeout for single command
@pytest.mark.topology(KnownTopology.Client)
def test_timeout(client: Client):
result = client.host.conn.run(
"""
echo 'stdout before';
>&2 echo 'stderr before';
sleep 15;
echo 'stdout after';
>&2 echo 'stderr after'
""",
timeout=5,
)
@pytest.mark.topology(KnownTopology.Client)
def test_timeout_async(client: Client):
process = client.host.conn.async_run(
"""
echo 'stdout before';
>&2 echo 'stderr before';
sleep 15;
echo 'stdout after';
>&2 echo 'stderr after'
"""
)
process.wait(timeout=5)
49 changes: 47 additions & 2 deletions pytest_mh/_private/misc.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,63 @@
from __future__ import annotations

import signal
from collections.abc import Mapping
from copy import deepcopy
from functools import partial
from functools import partial, wraps
from inspect import getfullargspec
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from typing import TYPE_CHECKING, Any, Callable, ParamSpec, TypeVar

from .types import MultihostOutcome

if TYPE_CHECKING:
from .artifacts import MultihostArtifactsMode


Param = ParamSpec("Param")
RetType = TypeVar("RetType")


def timeout(
seconds: int, message: str = "Operation timed out"
) -> Callable[[Callable[Param, RetType]], Callable[Param, RetType]]:
"""
Raise TimeoutError if function takes longer then ``seconds`` to finish.
:param seconds: Number of seconds to wait.
:type seconds: int
:param message: Exception message, defaults to "Operation timed out"
:type message: str, optional
:raises ValueError: If ``seconds`` is less or equal to zero.
:raises TimeoutError: If timeout occurrs.
:return: Decorator.
:rtype: Callable[[Callable[Param, RetType]], Callable[Param, RetType]]
"""
if seconds < 0:
raise ValueError(f"Invalid timeout value: {seconds}")

def _timeout_handler(signum, frame):
raise TimeoutError(seconds, message)

def decorator(func: Callable[Param, RetType]) -> Callable[Param, RetType]:
if seconds == 0:
return func

@wraps(func)
def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> RetType:
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
old_timer = signal.setitimer(signal.ITIMER_REAL, seconds)
try:
return func(*args, **kwargs)
finally:
signal.setitimer(signal.ITIMER_REAL, *old_timer)
signal.signal(signal.SIGALRM, old_handler)

return wrapper

return decorator


def validate_configuration(
required_keys: list[str], confdict: dict[str, Any], error_fmt: str = '"{key}" property is missing'
) -> None:
Expand Down
18 changes: 14 additions & 4 deletions pytest_mh/_private/multihost.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,17 @@
import pytest

from ..cli import CLIBuilder
from ..conn import Bash, Connection, Powershell, Process, ProcessError, ProcessInputBuffer, ProcessResult, Shell
from ..conn import (
Bash,
Connection,
Powershell,
Process,
ProcessError,
ProcessInputBuffer,
ProcessResult,
ProcessTimeoutError,
Shell,
)
from ..conn.container import ContainerClient
from ..conn.ssh import SSHClient
from .artifacts import (
Expand Down Expand Up @@ -526,9 +536,9 @@ def __init__(self, domain: DomainType, confdict: dict[str, Any]):
raise ValueError(f"Unknown operating system os_family: {self.os_family}")

# Connection to the host
self.conn: Connection[Process[ProcessResult, ProcessInputBuffer], ProcessResult[ProcessError]] = (
self.get_connection()
)
self.conn: Connection[
Process[ProcessResult, ProcessInputBuffer, ProcessTimeoutError], ProcessResult[ProcessError]
] = self.get_connection()
"""Connection to the host."""

# CLI Builder instance
Expand Down
Loading