diff --git a/README.md b/README.md index 8790846..26891b8 100644 --- a/README.md +++ b/README.md @@ -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" ``` @@ -52,26 +51,10 @@ 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 @@ -79,12 +62,10 @@ The marker API integrates with all standard pytest features — fixtures, parame 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 -@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() ``` @@ -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"), @@ -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 @@ -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 @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 36c9a88..70e148d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "cloudpickle>=3.1.1", "pytest>=9.0.2", "rpyc>=6.0.2", "testcontainers>=4.14.1", diff --git a/pytest_in_docker/_container.py b/pytest_in_docker/_container.py index 228d86c..0f5342f 100644 --- a/pytest_in_docker/_container.py +++ b/pytest_in_docker/_container.py @@ -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 @@ -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) @@ -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.") @@ -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__, + 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) diff --git a/pytest_in_docker/_decorator.py b/pytest_in_docker/_decorator.py index 5c05d64..97f08f0 100644 --- a/pytest_in_docker/_decorator.py +++ b/pytest_in_docker/_decorator.py @@ -1,14 +1,14 @@ """The in_container decorator for running tests inside Docker containers.""" -import inspect -import textwrap -from collections.abc import Callable -from typing import Any, ParamSpec, TypeVar, overload +from typing import TYPE_CHECKING, ParamSpec, TypeVar, overload + +if TYPE_CHECKING: + from collections.abc import Callable from testcontainers.core.container import DockerContainer from testcontainers.core.image import DockerImage -from pytest_in_docker._container import RPYC_PORT, bootstrap_container +from pytest_in_docker._container import RPYC_PORT, bootstrap_container, run_pickled from pytest_in_docker._types import ( BuildSpec, ContainerFactory, @@ -21,20 +21,6 @@ T = TypeVar("T") -def _get_clean_func[T: Callable[..., Any]](func: T) -> T: - """Recompile a function from source to strip pytest's assertion rewriting.""" - source = textwrap.dedent(inspect.getsource(func)) - lines = source.splitlines() - for i, line in enumerate(lines): - if line.lstrip().startswith("def "): - source = "\n".join(lines[i:]) - break - code = compile(source, inspect.getfile(func), "exec") - ns: dict[str, Any] = {} - exec(code, ns) # noqa: S102 - return ns[func.__name__] - - @overload def in_container(image: str) -> Callable[[Callable[P, T]], Callable[P, T]]: ... @@ -60,8 +46,9 @@ def in_container( ) -> Callable[[Callable[P, T]], Callable[P, T]]: r"""Run a pytest test function inside a Docker container. - The decorated test is serialised, sent to the container over RPyC, executed - there, and the result (or exception) is returned to the host. + The decorated test is serialised with cloudpickle, sent to the container + over RPyC, deserialised there, and the result (or exception) is returned + to the host. Three mutually exclusive ways to specify the container are supported: @@ -127,9 +114,8 @@ def my_container_factory(port: int) -> Iterator[DockerContainer]: def decorator(func: Callable[P, T]) -> Callable[P, T]: def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: def _run_in_container(c: DockerContainer) -> T: - clean = _get_clean_func(func) - test = bootstrap_container(c).teleport(clean) - return test(*args, **kwargs) + conn = bootstrap_container(c) + return run_pickled(conn, func, *args, **kwargs) def _run_image_spec(image: ImageSpec) -> T: with ( diff --git a/pytest_in_docker/_plugin.py b/pytest_in_docker/_plugin.py index 9386d71..26661c5 100644 --- a/pytest_in_docker/_plugin.py +++ b/pytest_in_docker/_plugin.py @@ -7,8 +7,7 @@ from testcontainers.core.container import DockerContainer from testcontainers.core.image import DockerImage -from pytest_in_docker._container import RPYC_PORT, bootstrap_container -from pytest_in_docker._decorator import _get_clean_func +from pytest_in_docker._container import RPYC_PORT, bootstrap_container, run_pickled from pytest_in_docker._types import ( BuildSpec, ContainerSpec, @@ -61,8 +60,6 @@ def _run_test_in_container( sync_request_timeout: int = 30, ) -> None: """Run a test function inside a Docker container.""" - clean = _get_clean_func(func) - if isinstance(container_spec, ImageSpec): with ( DockerContainer(container_spec.image) @@ -73,8 +70,7 @@ def _run_test_in_container( conn = bootstrap_container( started, sync_request_timeout=sync_request_timeout ) - remote_func = conn.teleport(clean) - remote_func(**test_kwargs) + run_pickled(conn, func, **test_kwargs) elif isinstance(container_spec, BuildSpec): with ( DockerImage(path=container_spec.path, tag=container_spec.tag) as image, @@ -86,15 +82,11 @@ def _run_test_in_container( conn = bootstrap_container( started, sync_request_timeout=sync_request_timeout ) - remote_func = conn.teleport(clean) - remote_func(**test_kwargs) + run_pickled(conn, func, **test_kwargs) elif isinstance(container_spec, FactorySpec): with container_spec.factory(RPYC_PORT) as container: - conn = bootstrap_container( - container, sync_request_timeout=sync_request_timeout - ) - remote_func = conn.teleport(clean) - remote_func(**test_kwargs) + conn = bootstrap_container(container) + run_pickled(conn, func, **test_kwargs) else: msg = "Invalid container specification." raise InvalidContainerSpecError(msg) diff --git a/tests/test_cloudpickle.py b/tests/test_cloudpickle.py new file mode 100644 index 0000000..a595191 --- /dev/null +++ b/tests/test_cloudpickle.py @@ -0,0 +1,48 @@ +"""Tests proving cloudpickle serialization works with module-level references. + +The whole point of cloudpickle (vs rpyc teleport) is that test functions can +reference module-level imports, constants, and helpers without needing to +re-import everything inside the function body. +""" + +import platform + +from pytest_in_docker import in_container + +EXPECTED_ID = "alpine" + + +def get_os_id() -> str: + """Module-level helper that reads the OS identifier.""" + return platform.freedesktop_os_release()["ID"] + + +@in_container("python:alpine") +def test_module_level_import() -> None: + """Test function uses a module-level import (platform).""" + rel_info = platform.freedesktop_os_release() + assert rel_info["ID"] == "alpine" + + +@in_container("python:alpine") +def test_module_level_constant() -> None: + """Test function references a module-level constant.""" + rel_info = platform.freedesktop_os_release() + assert rel_info["ID"] == EXPECTED_ID + + +@in_container("python:alpine") +def test_module_level_helper() -> None: + """Test function calls a module-level helper.""" + assert get_os_id() == "alpine" + + +def is_alpine() -> bool: + """Module-level helper that calls another module-level helper.""" + return get_os_id() == "alpine" + + +@in_container("python:alpine") +def test_transitive_module_level_helpers() -> None: + """Helpers that call other helpers serialize transitively.""" + assert is_alpine() diff --git a/uv.lock b/uv.lock index 8a71e6c..3bbd672 100644 --- a/uv.lock +++ b/uv.lock @@ -36,6 +36,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -159,6 +168,7 @@ name = "pytest-in-docker" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "cloudpickle" }, { name = "pytest" }, { name = "rpyc" }, { name = "testcontainers" }, @@ -172,6 +182,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cloudpickle", specifier = ">=3.1.1" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "rpyc", specifier = ">=6.0.2" }, { name = "testcontainers", specifier = ">=4.14.1" },