diff --git a/README.rst b/README.rst
index 531f44f0..6ea7446b 100644
--- a/README.rst
+++ b/README.rst
@@ -21,7 +21,7 @@ Find the latest documentation on `dcermak.github.io/pytest_container
`_.
``pytest_container`` is a `pytest `_ plugin
-to test container images via pytest fixtures and `testinfra
+to test container images via pytest fixtures and optionally `testinfra
`_. It takes care of all the boring
tasks, like spinning up containers, finding free ports and cleaning up after
tests, and allows you to focus on implementing the actual tests.
@@ -55,8 +55,7 @@ instantiating a ``Container`` and parametrizing a test function with the
The fixture automatically pulls and spins up the container, stops it and removes
it after the test is completed. Your test function receives an instance of
``ContainerData`` with the ``ContainerData.connection`` attribute. The
-``ContainerData.connection`` attribute is a `testinfra
-`_ connection object. It can be
+``ContainerData.connection`` attribute is a shell connection object. It can be
used to run basic tests inside the container itself. For example, you can check
whether files are present, packages are installed, etc.
diff --git a/pytest_container/container.py b/pytest_container/container.py
index 961fdd74..8bac9ae8 100644
--- a/pytest_container/container.py
+++ b/pytest_container/container.py
@@ -43,10 +43,10 @@
import _pytest.mark
import deprecation
import pytest
-import testinfra
from filelock import BaseFileLock
from filelock import FileLock
+from pytest_container import helpers
from pytest_container.helpers import get_always_pull_option
from pytest_container.helpers import get_extra_build_args
from pytest_container.helpers import get_extra_run_args
@@ -877,7 +877,7 @@ def prepare_container(
@dataclass(frozen=True)
class ContainerData:
"""Class returned by the ``*container*`` fixtures to the test function. It
- contains information about the launched container and the testinfra
+ contains information about the launched container and its shell connection
:py:attr:`connection` to the running container.
"""
@@ -887,7 +887,7 @@ class ContainerData:
image_url_or_id: str
#: ID of the started container
container_id: str
- #: the testinfra connection to the running container
+ #: the shell connection to the running container
connection: Any
#: the container data class that has been used in this test
container: Union[Container, DerivedContainer]
@@ -1013,6 +1013,28 @@ def container_from_pytest_param(
raise ValueError(f"Invalid pytest.param values: {param.values}")
+@dataclass(frozen=True)
+class ContainerRemoteEndpoint:
+ _container_id: str
+ _runtime: OciRuntimeBase
+
+ def __post_init__(self) -> None:
+ assert self._container_id, "Container ID must not be empty"
+
+ def check_output(self, cmd: str, strip: bool = True) -> str:
+ """Run a command in the container and return its output."""
+ return helpers.run_command(
+ [
+ self._runtime.runner_binary,
+ "exec",
+ self._container_id,
+ "/bin/sh",
+ "-c",
+ cmd,
+ ],
+ )[1]
+
+
@dataclass
class ContainerLauncher:
"""Helper context manager to setup, start and teardown a container including
@@ -1174,12 +1196,22 @@ def container_data(self) -> ContainerData:
"""
if not self._container_id:
raise RuntimeError(f"Container {self.container} has not started")
+ connection: Any = None
+ try:
+ import testinfra
+
+ connection = testinfra.get_host(
+ f"{self.container_runtime.runner_binary}://{self._container_id}"
+ )
+ except ImportError:
+ connection = ContainerRemoteEndpoint(
+ self._container_id, self.container_runtime
+ )
+
return ContainerData(
image_url_or_id=self.container.url or self.container.container_id,
container_id=self._container_id,
- connection=testinfra.get_host(
- f"{self.container_runtime.runner_binary}://{self._container_id}"
- ),
+ connection=connection,
container=self.container,
forwarded_ports=self._new_port_forwards,
_container_runtime=self.container_runtime,
diff --git a/pytest_container/helpers.py b/pytest_container/helpers.py
index 073549f7..f1e47ab2 100644
--- a/pytest_container/helpers.py
+++ b/pytest_container/helpers.py
@@ -6,12 +6,15 @@
import logging
import os
+import subprocess
from typing import List
+from typing import Tuple
from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.python import Metafunc
+from pytest_container.logging import _logger
from pytest_container.logging import set_internal_logging_level
@@ -157,3 +160,26 @@ def get_always_pull_option() -> bool:
"""
return bool(int(os.getenv("PULL_ALWAYS", "1")))
+
+
+def run_command(
+ cmd: List[str],
+ ignore_errors=True,
+) -> Tuple[int, str, str]:
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ check=not ignore_errors,
+ )
+
+ _logger.debug(
+ f"RUN CMD: {cmd} RC: {result.returncode} STDOUT: {result.stdout} STDERR:{result.stderr}"
+ )
+ return result.returncode, result.stdout, result.stderr
+ except subprocess.CalledProcessError as exc:
+ _logger.debug(
+ f"RUN(Failed) CMD: {cmd} RC: {exc.returncode} STDOUT:{exc.stdout} STDERR:{exc.stderr}"
+ )
+ raise exc
diff --git a/pytest_container/runtime.py b/pytest_container/runtime.py
index 6c72333b..798478fd 100644
--- a/pytest_container/runtime.py
+++ b/pytest_container/runtime.py
@@ -6,6 +6,7 @@
import json
import re
+import shutil
import sys
from abc import ABC
from abc import abstractmethod
@@ -20,10 +21,10 @@
from typing import Optional
from typing import Union
-import testinfra
from _pytest.mark.structures import ParameterSet
from pytest import param
+from pytest_container import helpers
from pytest_container.inspect import BindMount
from pytest_container.inspect import Config
from pytest_container.inspect import ContainerHealth
@@ -411,9 +412,6 @@ def __str__(self) -> str:
return self.__class__.__name__
-LOCALHOST = testinfra.host.get_host("local://")
-
-
def _get_podman_version(version_stdout: str) -> Version:
if version_stdout[:15] != "podman version ":
raise RuntimeError(
@@ -424,7 +422,7 @@ def _get_podman_version(version_stdout: str) -> Version:
def _get_buildah_version() -> Version:
- version_stdout = LOCALHOST.check_output("buildah --version")
+ version_stdout = helpers.run_command(["buildah", "--version"])[1]
build_version_begin = "buildah version "
if not version_stdout.startswith(build_version_begin):
raise RuntimeError(
@@ -443,11 +441,11 @@ class PodmanRuntime(OciRuntimeBase):
"""
def __init__(self) -> None:
- podman_ps = LOCALHOST.run("podman ps")
- if not podman_ps.succeeded:
- raise RuntimeError(f"`podman ps` failed with {podman_ps.stderr}")
+ podman_ps = helpers.run_command(["podman", "ps"])
+ if not podman_ps[0] == 0:
+ raise RuntimeError(f"`podman ps` failed with {podman_ps[2]}")
- self._buildah_functional = LOCALHOST.run("buildah").succeeded
+ self._buildah_functional = helpers.run_command(["buildah"])[0] == 0
super().__init__(
build_command=(
["buildah", "bud", "--layers", "--force-rm"]
@@ -461,8 +459,11 @@ def __init__(self) -> None:
@cached_property
def version(self) -> Version:
"""Returns the version of podman installed on the system"""
+
return _get_podman_version(
- LOCALHOST.run_expect([0], "podman --version").stdout
+ helpers.run_command(["podman", "--version"], ignore_errors=False)[
+ 1
+ ]
)
@cached_property
@@ -539,9 +540,9 @@ class DockerRuntime(OciRuntimeBase):
containers."""
def __init__(self) -> None:
- docker_ps = LOCALHOST.run("docker ps")
- if not docker_ps.succeeded:
- raise RuntimeError(f"`docker ps` failed with {docker_ps.stderr}")
+ docker_ps = helpers.run_command(["docker", "ps"])
+ if not docker_ps[0] == 0:
+ raise RuntimeError(f"`docker ps` failed with {docker_ps[2]}")
super().__init__(
build_command=["docker", "build", "--force-rm"],
@@ -552,7 +553,9 @@ def __init__(self) -> None:
def version(self) -> Version:
"""Returns the version of docker installed on this system"""
return _get_docker_version(
- LOCALHOST.run_expect([0], "docker --version").stdout
+ helpers.run_command(["docker", "--version"], ignore_errors=False)[
+ 1
+ ]
)
@property
@@ -613,8 +616,8 @@ def get_selected_runtime() -> OciRuntimeBase:
If neither docker nor podman are available, then a ValueError is raised.
"""
- podman_exists = LOCALHOST.exists("podman")
- docker_exists = LOCALHOST.exists("docker")
+ podman_exists = shutil.which("podman")
+ docker_exists = shutil.which("docker")
runtime_choice = getenv("CONTAINER_RUNTIME", "podman").lower()
if runtime_choice not in ("podman", "docker"):
diff --git a/test-requirements.txt b/test-requirements.txt
index 323c791d..c5a01c15 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -3,4 +3,5 @@ pytest-xdist
coverage
pytest-rerunfailures
typeguard
+ifaddr
.
diff --git a/tests/test_container_build.py b/tests/test_container_build.py
index 155ee7af..66f13451 100644
--- a/tests/test_container_build.py
+++ b/tests/test_container_build.py
@@ -13,8 +13,8 @@
from pytest_container.container import ContainerData
from pytest_container.container import ContainerLauncher
from pytest_container.container import EntrypointSelection
+from pytest_container.helpers import run_command
from pytest_container.inspect import PortForwarding
-from pytest_container.runtime import LOCALHOST
from pytest_container.runtime import OciRuntimeBase
from .images import LEAP
@@ -285,9 +285,9 @@ def test_multistage_build_target(
extra_build_args=get_extra_build_args(pytestconfig),
)
assert (
- LOCALHOST.check_output(
- f"{container_runtime.runner_binary} run --rm {first_target}",
- ).strip()
+ run_command(
+ [container_runtime.runner_binary, "run", "--rm", first_target]
+ )[1].strip()
== "foobar"
)
@@ -301,9 +301,15 @@ def test_multistage_build_target(
assert first_target != second_target
assert (
- LOCALHOST.check_output(
- f"{container_runtime.runner_binary} run --rm {second_target} /bin/test.sh",
- ).strip()
+ run_command(
+ [
+ container_runtime.runner_binary,
+ "run",
+ "--rm",
+ second_target,
+ "/bin/test.sh",
+ ]
+ )[1].strip()
== "foobar"
)
@@ -313,10 +319,17 @@ def test_multistage_build_target(
):
assert (
distro
- in LOCALHOST.check_output(
- f"{container_runtime.runner_binary} run --rm --entrypoint= {target} "
- "cat /etc/os-release",
- ).strip()
+ in run_command(
+ [
+ container_runtime.runner_binary,
+ "run",
+ "--rm",
+ "--entrypoint=",
+ target,
+ "cat",
+ "/etc/os-release",
+ ]
+ )[1].strip()
)
diff --git a/tests/test_launcher.py b/tests/test_launcher.py
index 3814cd4d..cb5aedc3 100644
--- a/tests/test_launcher.py
+++ b/tests/test_launcher.py
@@ -19,7 +19,7 @@
from pytest_container.container import ContainerVolume
from pytest_container.container import DerivedContainer
from pytest_container.container import EntrypointSelection
-from pytest_container.runtime import LOCALHOST
+from pytest_container.helpers import run_command
from pytest_container.runtime import OciRuntimeBase
from .images import CMDLINE_APP_CONTAINER
@@ -101,9 +101,17 @@ def test_launcher_creates_and_cleanes_up_volumes(
assert vol.host_path and os.path.exists(vol.host_path)
elif isinstance(vol, ContainerVolume):
assert vol.volume_id
- assert LOCALHOST.run_expect(
- [0],
- f"{container_runtime.runner_binary} volume inspect {vol.volume_id}",
+ assert (
+ run_command(
+ [
+ container_runtime.runner_binary,
+ "volume",
+ "inspect",
+ vol.volume_id,
+ ],
+ ignore_errors=False,
+ )[0]
+ == 0
)
else:
assert False, f"invalid volume type {type(vol)}"
diff --git a/tests/test_port_forwarding.py b/tests/test_port_forwarding.py
index e37fbef9..4756abce 100644
--- a/tests/test_port_forwarding.py
+++ b/tests/test_port_forwarding.py
@@ -2,10 +2,11 @@
:py:attr:`~pytest_container.container.ContainerBase.forwarded_ports`."""
# pylint: disable=missing-function-docstring
-import itertools
+import re
import socket
from typing import List
+import ifaddr
import pytest
from pytest_container.container import ContainerData
@@ -13,10 +14,10 @@
from pytest_container.container import DerivedContainer
from pytest_container.container import PortForwarding
from pytest_container.container import lock_host_port_search
+from pytest_container.helpers import run_command
from pytest_container.inspect import NetworkProtocol
from pytest_container.pod import Pod
from pytest_container.pod import PodLauncher
-from pytest_container.runtime import LOCALHOST
from pytest_container.runtime import OciRuntimeBase
from pytest_container.runtime import Version
@@ -65,8 +66,12 @@ def _create_nginx_container(number: int) -> DerivedContainer:
CONTAINER_IMAGES = [WEB_SERVER]
+curl_version_string = run_command(["curl", "--version"])[1]
+pattern = r"curl\s+(\d+(?:\.\d+)+)"
+match = re.search(pattern, curl_version_string)
+assert match is not None, "Curl version not found"
+_curl_version = Version.parse(match.group(1))
-_curl_version = Version.parse(LOCALHOST.package("curl").version)
#: curl cli with additional retries as a single curl sometimes fails with docker
#: with ``curl: (56) Recv failure: Connection reset by peer`` for reasons…
@@ -175,18 +180,18 @@ def test_multiple_open_ports(container: ContainerData, number: int, host):
)
-_INTERFACES = [
- name
- for name in LOCALHOST.interface.names()
- if name[:2] in ("en", "et", "wl")
-]
-_ADDRESSES = [
- addr
- for addr in itertools.chain.from_iterable(
- LOCALHOST.interface(interface).addresses for interface in _INTERFACES
- )
- if not addr.startswith("169.254.") and not addr.startswith("fe80:")
-]
+def _find_all_usable_ips():
+ for adapter in ifaddr.get_adapters():
+ if not adapter.name.startswith(("en", "et", "wl")):
+ continue
+ for addr in adapter.ips:
+ if addr.is_IPv4 and not addr.ip.startswith("169.254."):
+ yield str(addr.ip)
+ elif addr.is_IPv6 and not addr.ip[0].startswith("fe80:"):
+ yield addr.ip[0]
+
+
+_ADDRESSES = list(_find_all_usable_ips())
@pytest.mark.parametrize(
diff --git a/tests/test_runtime.py b/tests/test_runtime.py
index b9955f4f..4d15c622 100644
--- a/tests/test_runtime.py
+++ b/tests/test_runtime.py
@@ -1,5 +1,6 @@
# pylint: disable=missing-function-docstring,missing-module-docstring
import os
+import shutil
from pathlib import Path
from typing import Callable
from typing import Type
@@ -8,7 +9,7 @@
import pytest
-from pytest_container.runtime import LOCALHOST
+from pytest_container import helpers
from pytest_container.runtime import DockerRuntime
from pytest_container.runtime import OciRuntimeBase
from pytest_container.runtime import PodmanRuntime
@@ -27,45 +28,6 @@ def container_runtime_envvar(request):
yield
-# pylint: disable-next=unused-argument
-def _mock_run_success(*args, **kwargs):
- class Succeeded:
- """Class that mocks the returned object of `testinfra`'s `run`."""
-
- @property
- def succeeded(self) -> bool:
- return True
-
- @property
- def rc(self) -> int:
- return 0
-
- return Succeeded()
-
-
-def generate_mock_fail(*, rc: int = 1, stderr: str = "failure!!"):
- # pylint: disable-next=unused-argument
- def mock_run_fail(cmd: str):
- class Failure:
- """Class that mocks the returned object of `testinfra`'s `run`."""
-
- @property
- def succeeded(self) -> bool:
- return False
-
- @property
- def rc(self) -> int:
- return rc
-
- @property
- def stderr(self) -> str:
- return stderr
-
- return Failure()
-
- return mock_run_fail
-
-
def _create_mock_exists(
podman_should_exist: bool, docker_should_exist: bool
) -> Callable[[str], bool]:
@@ -96,8 +58,7 @@ def test_runtime_selection(
runtime: OciRuntimeBase,
monkeypatch: pytest.MonkeyPatch,
):
- monkeypatch.setattr(LOCALHOST, "run", _mock_run_success)
- monkeypatch.setattr(LOCALHOST, "exists", _create_mock_exists(True, True))
+ monkeypatch.setattr(shutil, "which", _create_mock_exists(True, True))
assert get_selected_runtime() == runtime
@@ -107,7 +68,7 @@ def test_value_err_when_docker_and_podman_missing(
runtime: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CONTAINER_RUNTIME", runtime)
- monkeypatch.setattr(LOCALHOST, "exists", _create_mock_exists(False, False))
+ monkeypatch.setattr(shutil, "which", _create_mock_exists(False, False))
with pytest.raises(ValueError) as val_err_ctx:
get_selected_runtime()
@@ -125,7 +86,11 @@ def test_runtime_construction_fails_if_ps_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
stderr = "container runtime failed"
- monkeypatch.setattr(LOCALHOST, "run", generate_mock_fail(stderr=stderr))
+ monkeypatch.setattr(
+ helpers,
+ "run_command",
+ lambda _: (1, "", stderr),
+ )
with pytest.raises(RuntimeError) as rt_err_ctx:
cls()
@@ -145,7 +110,9 @@ def test_buildah_version_parsing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
- LOCALHOST, "check_output", lambda _: f"buildah version {version_str}"
+ helpers,
+ "run_command",
+ lambda _: (0, f"buildah version {version_str}", ""),
)
assert _get_buildah_version() == expected_version
@@ -154,7 +121,7 @@ def test_buildah_version_parsing(
def test_get_buildah_version_fails_on_unexpected_stdout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- monkeypatch.setattr(LOCALHOST, "check_output", lambda _: "foobar")
+ monkeypatch.setattr(helpers, "run_command", lambda _: (0, "foobar", ""))
with pytest.raises(RuntimeError) as rt_err_ctx:
_get_buildah_version()
diff --git a/tests/test_volumes.py b/tests/test_volumes.py
index a4360b63..675ec0a5 100644
--- a/tests/test_volumes.py
+++ b/tests/test_volumes.py
@@ -2,6 +2,7 @@
import os
from os.path import abspath
from os.path import join
+from pathlib import Path
from typing import List
import pytest
@@ -14,7 +15,6 @@
from pytest_container.container import DerivedContainer
from pytest_container.container import VolumeFlag
from pytest_container.container import get_volume_creator
-from pytest_container.runtime import LOCALHOST
from pytest_container.runtime import OciRuntimeBase
from .images import LEAP_URL
@@ -118,8 +118,8 @@ def test_container_host_volumes(container_per_test: ContainerData):
for vol in container_per_test.container.volume_mounts:
assert isinstance(vol, BindMount)
assert vol.host_path
- dir_on_host = LOCALHOST.file(vol.host_path)
- assert dir_on_host.exists and dir_on_host.is_directory
+ dir_on_host = Path(vol.host_path)
+ assert dir_on_host.is_dir()
dir_in_container = container_per_test.connection.file(
vol.container_path
@@ -142,8 +142,8 @@ def test_container_volume_host_writing(container_per_test: ContainerData):
assert isinstance(vol, BindMount)
assert vol.host_path
- host_dir = LOCALHOST.file(vol.host_path)
- assert not host_dir.listdir()
+ host_dir = Path(vol.host_path)
+ assert not list(host_dir.iterdir())
container_dir = container_per_test.connection.file(vol.container_path)
assert not container_dir.listdir()
@@ -265,9 +265,14 @@ def test_concurrent_container_volumes(container_per_test: ContainerData):
def test_bind_mount_cwd(container: ContainerData):
vol = container.container.volume_mounts[0]
assert isinstance(vol, BindMount)
+ assert vol.host_path is not None
+
assert container.connection.file("/src/").exists and sorted(
container.connection.file("/src/").listdir()
- ) == sorted(LOCALHOST.file(vol.host_path).listdir())
+ ) == sorted(
+ str(p.relative_to(vol.host_path))
+ for p in Path(vol.host_path).iterdir()
+ )
def test_bind_mount_fails_when_host_path_not_present() -> None: