Skip to content
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions pytest_in_docker/_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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)
Expand All @@ -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,
)
38 changes: 34 additions & 4 deletions pytest_in_docker/_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 (
Expand All @@ -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])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium

This helper only looks at positional marker args and coerces them to int, which diverges from pytest-timeout’s documented behaviour:

  • @pytest.mark.timeout(timeout=10) (keyword form) is ignored and you fall back to the ini/default.
  • Fractional values that pytest-timeout accepts (e.g., 0.5) get truncated toward zero, so short floating timeouts or timeout=0 (the official way to disable) become a zero-second RPC limit.
  • Pytest-timeout’s default is 0 meaning “no limit”, but when neither a marker nor ini entry is present you now force a 30‑second timeout.

If the goal is to align with pytest, this needs to honour keyword arguments, keep the float precision, and treat 0/None as “no timeout” instead of enforcing an immediate or 30‑second cut-off.

Fix in Cursor • Fix in Claude

Prompt for Agent
Task: Address review feedback left on GitHub.
Repository: mesa-dot-dev/pytest-in-docker#4
File: pytest_in_docker/_plugin.py#L107
Action: Open this file location in your editor, inspect the highlighted code, and resolve the issue described below.

Feedback:
This helper only looks at positional marker args and coerces them to `int`, which diverges from pytest-timeout’s documented behaviour:

* `@pytest.mark.timeout(timeout=10)` (keyword form) is ignored and you fall back to the ini/default.
* Fractional values that pytest-timeout accepts (e.g., `0.5`) get truncated toward zero, so short floating timeouts or `timeout=0` (the official way to disable) become a zero-second RPC limit.
* Pytest-timeout’s default is `0` meaning “no limit”, but when neither a marker nor ini entry is present you now force a 30‑second timeout.

If the goal is to align with pytest, this needs to honour keyword arguments, keep the float precision, and treat `0`/`None` as “no timeout” instead of enforcing an immediate or 30‑second cut-off.

try:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium

pyfuncitem.config.getini("timeout") only works if the pytest-timeout plugin (or some other plugin/register) declared that ini option. In a plain pytest run without pytest-timeout installed, calling getini("timeout") raises ValueError during collection, so every @pytest.mark.in_container test now crashes even though the user never asked for a timeout. Please guard the lookup (e.g., wrap in try/except ValueError or check config.inicfg first) and fall back to your default only when the option actually exists.

Fix in Cursor • Fix in Claude

Prompt for Agent
Task: Address review feedback left on GitHub.
Repository: mesa-dot-dev/pytest-in-docker#4
File: pytest_in_docker/_plugin.py#L108
Action: Open this file location in your editor, inspect the highlighted code, and resolve the issue described below.

Feedback:
`pyfuncitem.config.getini("timeout")` only works if the pytest-timeout plugin (or some other plugin/register) declared that ini option. In a plain pytest run without pytest-timeout installed, calling `getini("timeout")` raises `ValueError` during collection, so every `@pytest.mark.in_container` test now crashes even though the user never asked for a timeout. Please guard the lookup (e.g., wrap in `try/except ValueError` or check `config.inicfg` first) and fall back to your default only when the option actually exists.

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")
Expand All @@ -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
Loading