diff --git a/README.md b/README.md index f76ed09..8790846 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,19 @@ def test_env_is_set(): A factory is a callable that accepts a `port: int` argument and returns a context manager yielding an already-started `DockerContainer`. The framework passes the communication port automatically — the factory just needs to expose it and run `sleep infinity`. +### Timeouts + +Tests running inside containers default to a 30-second timeout. If [pytest-timeout](https://pypi.org/project/pytest-timeout/) is installed, its `timeout` ini setting and `@pytest.mark.timeout` marker are respected automatically: + +```python +import pytest + +@pytest.mark.timeout(60) +@pytest.mark.in_container("python:alpine") +def test_slow_operation(): + ... +``` + ## How It Works When a decorated test runs: diff --git a/pytest_in_docker/_container.py b/pytest_in_docker/_container.py index 61e22c9..228d86c 100644 --- a/pytest_in_docker/_container.py +++ b/pytest_in_docker/_container.py @@ -120,12 +120,15 @@ def _install_deps(container: DockerContainer, python: pathlib.Path) -> pathlib.P return python -def _connect_with_retries(host: str, port: int) -> Any: # noqa: ANN401 +def _connect_with_retries( + host: str, port: int, *, sync_request_timeout: int = 30 +) -> Any: # noqa: ANN401 """Connect to the rpyc server, retrying until it's ready.""" last_err: Exception | None = None for _ in range(_CONNECT_RETRIES): try: conn = rpyc.classic.connect(host, port) + conn._config["sync_request_timeout"] = sync_request_timeout # noqa: SLF001 lo = conn.teleport(_loopback) if lo("hello") != "hello": msg = "Failed to communicate with rpyc server on the container." @@ -143,7 +146,9 @@ def _connect_with_retries(host: str, port: int) -> Any: # noqa: ANN401 raise ContainerPrepareError(msg) -def bootstrap_container(container: DockerContainer) -> Any: # noqa: ANN401 +def bootstrap_container( + container: DockerContainer, *, sync_request_timeout: int = 30 +) -> Any: # noqa: ANN401 """Install dependencies, start rpyc server, and return a verified connection.""" python = _find_one_of(container, ["python3", "python"]) _check_python_version(container, python) @@ -163,4 +168,5 @@ def bootstrap_container(container: DockerContainer) -> Any: # noqa: ANN401 return _connect_with_retries( container.get_container_host_ip(), container.get_exposed_port(RPYC_PORT), + sync_request_timeout=sync_request_timeout, ) diff --git a/pytest_in_docker/_plugin.py b/pytest_in_docker/_plugin.py index 1de7b04..9386d71 100644 --- a/pytest_in_docker/_plugin.py +++ b/pytest_in_docker/_plugin.py @@ -57,6 +57,8 @@ def _run_test_in_container( func: Any, # noqa: ANN401 container_spec: ContainerSpec, test_kwargs: dict[str, Any], + *, + sync_request_timeout: int = 30, ) -> None: """Run a test function inside a Docker container.""" clean = _get_clean_func(func) @@ -68,7 +70,10 @@ def _run_test_in_container( .with_exposed_ports(RPYC_PORT) as container ): started = container.start() - remote_func = bootstrap_container(started).teleport(clean) + conn = bootstrap_container( + started, sync_request_timeout=sync_request_timeout + ) + remote_func = conn.teleport(clean) remote_func(**test_kwargs) elif isinstance(container_spec, BuildSpec): with ( @@ -78,17 +83,37 @@ def _run_test_in_container( .with_exposed_ports(RPYC_PORT) as container, ): started = container.start() - remote_func = bootstrap_container(started).teleport(clean) + conn = bootstrap_container( + started, sync_request_timeout=sync_request_timeout + ) + remote_func = conn.teleport(clean) remote_func(**test_kwargs) elif isinstance(container_spec, FactorySpec): with container_spec.factory(RPYC_PORT) as container: - remote_func = bootstrap_container(container).teleport(clean) + conn = bootstrap_container( + container, sync_request_timeout=sync_request_timeout + ) + remote_func = conn.teleport(clean) remote_func(**test_kwargs) else: msg = "Invalid container specification." raise InvalidContainerSpecError(msg) +def _get_timeout(pyfuncitem: Function) -> int: + """Read the pytest timeout marker, falling back to the ini default or 30s.""" + timeout_marker = pyfuncitem.get_closest_marker("timeout") + if timeout_marker and timeout_marker.args: + return int(timeout_marker.args[0]) + try: + ini_val = pyfuncitem.config.getini("timeout") + except ValueError: + return 30 + if ini_val: + return int(ini_val) + return 30 + + def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: """Intercept test execution for in_container-marked tests.""" marker = pyfuncitem.get_closest_marker("in_container") @@ -100,5 +125,10 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: argnames = pyfuncitem._fixtureinfo.argnames # noqa: SLF001 test_kwargs = {arg: funcargs[arg] for arg in argnames} container_spec = _resolve_container_spec(marker, funcargs) - _run_test_in_container(testfunction, container_spec, test_kwargs) + _run_test_in_container( + testfunction, + container_spec, + test_kwargs, + sync_request_timeout=_get_timeout(pyfuncitem), + ) return True