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
131 changes: 26 additions & 105 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,11 @@ Decorate any test function. It runs inside a Docker container:

```python
from pytest_in_docker import in_container
import platform

@in_container("python:alpine")
def test_runs_on_alpine():
import platform

info = platform.freedesktop_os_release()
info = platform.freedesktop_os_release() # platform is available in the container.
assert info["ID"] == "alpine"
```

Expand All @@ -52,39 +51,21 @@ Then run pytest as usual:
pytest
```

The function is executed in a fresh `python:alpine` container and the result is
reported back to your terminal.
The function is serialized with [cloudpickle](https://github.com/cloudpickle/cloudpickle), sent to a fresh `python:alpine` container, and the result is reported back to your terminal.

## Usage

### Marker API

If you prefer pytest markers over decorators, the plugin auto-registers `@pytest.mark.in_container`:

```python
import pytest

@pytest.mark.in_container("python:alpine")
def test_mark_basic():
import platform

info = platform.freedesktop_os_release()
assert info["ID"].lower() == "alpine"
```

The marker API integrates with all standard pytest features — fixtures, parametrize, and reporting work as expected.

### Build from a Dockerfile

Point to a directory containing a `Dockerfile` and provide a tag. The image is built before the test runs:

```python
from pytest_in_docker import in_container
import subprocess
Copy link

Choose a reason for hiding this comment

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

Low

This sample now uses the marker form (@pytest.mark.in_container) but the snippet no longer imports pytest, so copying it verbatim raises NameError. Either add import pytest to this block or revert the example back to the decorator version to keep it self‑contained.

Fix in Cursor • Fix in Claude

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

Feedback:
This sample now uses the marker form (`@pytest.mark.in_container`) but the snippet no longer imports `pytest`, so copying it verbatim raises `NameError`. Either add `import pytest` to this block or revert the example back to the decorator version to keep it self‑contained.


@in_container(path="./docker", tag="my-test-image:latest")
@pytest.mark.in_container(path="./docker", tag="my-test-image:latest")
def test_custom_image():
import subprocess

result = subprocess.run(["cat", "/etc/os-release"], capture_output=True, text=True)
assert "alpine" in result.stdout.lower()
```
Expand All @@ -99,10 +80,13 @@ def test_custom_image_with_marker():

### Test Across Multiple Images

Combine `@pytest.mark.parametrize` with the marker to run the same test across different containers. Use `image` as the parameter name — the plugin picks it up automatically:
Combine `@pytest.mark.parametrize` with the marker to run the same test across
different containers. Use `image` as the parameter name — the plugin picks it up
automatically:

```python
import pytest
import platform

@pytest.mark.parametrize(
("image", "expected_id"),
Expand All @@ -113,20 +97,22 @@ import pytest
)
@pytest.mark.in_container()
def test_across_distros(image: str, expected_id: str):
import platform

info = platform.freedesktop_os_release()
assert info["ID"].lower() == expected_id
```

When `@pytest.mark.in_container()` is called with no arguments, it reads the `image` parameter from `@pytest.mark.parametrize`. This lets you build a compatibility matrix with zero boilerplate.
When `@pytest.mark.in_container()` is called with no arguments, it reads the `image`
parameter from `@pytest.mark.parametrize`. This lets you build a compatibility matrix
with zero boilerplate.

### Custom Container Factory

When you need to customise the container beyond what the other modes offer — environment variables, volumes, extra ports — pass a factory:
When you need to customise the container beyond what the other modes offer —
environment variables, volumes, extra ports — pass a factory:

```python
from contextlib import contextmanager
import os
from typing import Iterator

from testcontainers.core.container import DockerContainer
Expand All @@ -146,14 +132,15 @@ def my_container(port: int) -> Iterator[DockerContainer]:
yield container


@in_container(factory=my_container)
@pytest.mark.in_container(factory=my_container)
def test_env_is_set():
import os

assert os.environ["APP_ENV"] == "test"
```

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`.
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

Expand All @@ -176,88 +163,22 @@ When a decorated test runs:
Host (pytest) Docker Container
───────────── ────────────────
1. Spin up container ──────> python:alpine starts
2. Install rpyc + pytest ──────> pip install rpyc pytest
2. Install deps ──────> pip install cloudpickle rpyc pytest
3. Start RPyC server ──────> listening on port 51337
4. Teleport test function ──────> function executes here
4. Serialize test (cloudpickle)
5. Send bytes over RPyC ──────> deserialize + execute
<────── result (pass/fail/exception) ──────
5. Container stops
6. Container stops
```

**The teleportation trick:** [RPyC](https://rpyc.readthedocs.io/) can serialize a Python function and execute it on a remote interpreter. But pytest rewrites test function bytecode for better assertion messages, which breaks serialization. So before teleporting, `pytest-in-docker` recompiles your test from its original source code, producing a clean function that RPyC can transport.
**How serialization works:** [cloudpickle](https://github.com/cloudpickle/cloudpickle) serializes your test function — including closures, lambdas, and locally-defined helpers — into bytes on the host. Those bytes are sent to the container over [RPyC](https://rpyc.readthedocs.io/), deserialized with the standard `pickle` module, and executed natively.

This means:
- Your test code runs **natively** inside the container — not through `docker exec` or shell commands
- Full Python semantics: imports, exceptions, and return values all work naturally
- **Closures and lambdas** serialize correctly — use helper functions, captured variables, and dynamic code freely
- pytest assertion introspection still works on the host side for reporting

## Requirements

| Requirement | Version |
|---|---|
| Python | >= 3.14 |
| Docker | Running on the host |
| pytest | >= 9 |

> Container images must have `python` and `pip` available. The official `python:*` images work out of the box.

## API Reference

### `in_container(image)`

Decorator. Runs the test inside a container pulled from `image`.

```python
@in_container("python:3.14-slim")
def test_something():
...
```

### `in_container(path, tag)`

Decorator. Builds an image from the Dockerfile at `path`, tags it as `tag`, then runs the test inside it.

```python
@in_container(path="./docker", tag="my-app:test")
def test_something():
...
```

### `in_container(factory)`

Decorator. Runs the test inside a container created by `factory`, a `ContainerFactory` — a callable that accepts a `port: int` and returns a context manager yielding a started `DockerContainer`.

```python
@in_container(factory=my_container)
def test_something():
...
```

### `@pytest.mark.in_container(...)`

Marker. Same arguments as the decorator. When called with no arguments, reads `image` from `@pytest.mark.parametrize` funcargs.

## Contributing

```bash
git clone https://github.com/mesa-dot-dev/pytest-in-docker.git
cd pytest-in-docker
uv sync
```

Run the linter and type checker:

```bash
uv run ruff check
uv run ruff format --check
uv run pyright
```

Run the tests (requires Docker):

```bash
uv run pytest
```

## License

[MIT](LICENSE.txt)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"cloudpickle>=3.1.1",
"pytest>=9.0.2",
"rpyc>=6.0.2",
"testcontainers>=4.14.1",
Expand Down
94 changes: 90 additions & 4 deletions pytest_in_docker/_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from pytest_in_docker._types import ContainerPrepareError

if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Callable, Iterable

from testcontainers.core.container import DockerContainer

Expand Down Expand Up @@ -96,8 +96,9 @@ def _check_python_version(container: DockerContainer, python: pathlib.Path) -> N
local_ver = f"{sys.version_info.major}.{sys.version_info.minor}"
if remote_ver != local_ver:
msg = (
f"Python version mismatch: host has {local_ver} but container has "
f"{remote_ver}. rpyc teleport requires matching major.minor versions."
f"Python version mismatch: host has {local_ver} but "
f"container has {remote_ver}. Matching major.minor "
f"versions are required for pickle compatibility."
)
raise ContainerPrepareError(msg)

Expand All @@ -113,7 +114,7 @@ def _install_deps(container: DockerContainer, python: pathlib.Path) -> pathlib.P
if venv_ok:
python = pathlib.Path(f"{_VENV_DIR}/bin/python")

install_cmd = [str(python), "-m", "pip", "install", "rpyc", "pytest"]
install_cmd = [str(python), "-m", "pip", "install", "cloudpickle", "rpyc", "pytest"]
if not venv_ok:
install_cmd.insert(4, "--break-system-packages")
_run_or_fail(container, install_cmd, "Failed to install container deps.")
Expand Down Expand Up @@ -170,3 +171,88 @@ def bootstrap_container(
container.get_exposed_port(RPYC_PORT),
sync_request_timeout=sync_request_timeout,
)


def _make_picklable[T: Callable[..., Any]](func: T) -> T:
"""Return a copy of *func* that cloudpickle will serialise by value.

Two things prevent a test function from being naively pickled into a
remote container:

1. cloudpickle pickles importable functions *by reference*
(module + qualname), but the test module does not exist in the
container.
2. pytest's assertion rewriter injects ``@pytest_ar`` into the
function's ``__globals__``, and that object drags in the test
module itself.

We fix both by creating a **new** function object whose
``__module__`` is ``"__mp_main__"`` (forces pickle-by-value) and
whose ``__globals__`` are a *shared* clean dict stripped of the
assertion-rewriter helper. All sibling callables (same module) are
cloned into the same ``clean_globals`` dict so transitive calls
between helpers resolve to the patched versions.
"""
import types # noqa: PLC0415

original_module = func.__module__

# First pass: build clean_globals with non-callable entries,
# collect names of same-module callables to patch.
clean_globals: dict[str, Any] = {}
to_patch: list[str] = []
for k, v in func.__globals__.items():
if k == "@pytest_ar":
continue
if (
isinstance(v, types.FunctionType)
and getattr(v, "__module__", None) == original_module
):
to_patch.append(k)
else:
clean_globals[k] = v

# Second pass: clone callables so they all share clean_globals.
for k in to_patch:
orig = func.__globals__[k]
clone = types.FunctionType(
orig.__code__,
clean_globals,
orig.__name__,
orig.__defaults__,
orig.__closure__,
)
clone.__module__ = "__mp_main__"
clone.__qualname__ = orig.__qualname__
clone.__annotations__ = orig.__annotations__
clone.__kwdefaults__ = orig.__kwdefaults__
clean_globals[k] = clone

# Clone the test function itself into the same shared dict.
clone = types.FunctionType(
func.__code__,
clean_globals,
func.__name__,
Copy link

Choose a reason for hiding this comment

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

High

Callable is now imported only under TYPE_CHECKING, but this module doesn’t use from __future__ import annotations. The annotations in _make_picklable, _force_by_value, and run_pickled are therefore evaluated at import time, and Callable is undefined. Importing pytest_in_docker._container will immediately raise NameError: name 'Callable' is not defined, so none of the container helpers can be used. Please import Callable unconditionally (or enable postponed evaluation of annotations) before referencing it in runtime annotations.

Fix in Cursor • Fix in Claude

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

Feedback:
`Callable` is now imported only under `TYPE_CHECKING`, but this module doesn’t use `from __future__ import annotations`. The annotations in `_make_picklable`, `_force_by_value`, and `run_pickled` are therefore evaluated at import time, and `Callable` is undefined. Importing `pytest_in_docker._container` will immediately raise `NameError: name 'Callable' is not defined`, so none of the container helpers can be used. Please import `Callable` unconditionally (or enable postponed evaluation of annotations) before referencing it in runtime annotations.

func.__defaults__,
func.__closure__,
)
clone.__annotations__ = func.__annotations__
clone.__kwdefaults__ = func.__kwdefaults__
clone.__module__ = "__mp_main__"
clone.__qualname__ = func.__qualname__
return clone # type: ignore[return-value]


def run_pickled[T](
conn: Any, # noqa: ANN401
func: Callable[..., T],
*args: Any, # noqa: ANN401
**kwargs: Any, # noqa: ANN401
) -> T:
"""Serialize *func* with cloudpickle, send to container, execute there."""
import cloudpickle # noqa: PLC0415

payload = cloudpickle.dumps(_make_picklable(func))
rpickle = conn.modules["pickle"]
remote_func = rpickle.loads(payload)
return remote_func(*args, **kwargs)
Loading
Loading