Skip to content

Commit c4d248b

Browse files
committed
Make test infra optional
1 parent 4a7314e commit c4d248b

File tree

13 files changed

+182
-131
lines changed

13 files changed

+182
-131
lines changed

README.rst

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Find the latest documentation on `dcermak.github.io/pytest_container
2121
<https://dcermak.github.io/pytest_container/>`_.
2222

2323
``pytest_container`` is a `pytest <https://pytest.org>`_ plugin
24-
to test container images via pytest fixtures and `testinfra
24+
to test container images via pytest fixtures and optionally `testinfra
2525
<https://testinfra.readthedocs.io/en/latest/>`_. It takes care of all the boring
2626
tasks, like spinning up containers, finding free ports and cleaning up after
2727
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
5555
The fixture automatically pulls and spins up the container, stops it and removes
5656
it after the test is completed. Your test function receives an instance of
5757
``ContainerData`` with the ``ContainerData.connection`` attribute. The
58-
``ContainerData.connection`` attribute is a `testinfra
59-
<https://testinfra.readthedocs.io/en/latest/>`_ connection object. It can be
58+
``ContainerData.connection`` attribute is a shell connection object. It can be
6059
used to run basic tests inside the container itself. For example, you can check
6160
whether files are present, packages are installed, etc.
6261

pytest_container/container.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import operator
1212
import os
1313
import socket
14+
import subprocess
1415
import sys
1516
import tempfile
1617
import time
@@ -43,10 +44,10 @@
4344
import _pytest.mark
4445
import deprecation
4546
import pytest
46-
import testinfra
4747
from filelock import BaseFileLock
4848
from filelock import FileLock
4949

50+
from pytest_container import helpers
5051
from pytest_container.helpers import get_always_pull_option
5152
from pytest_container.helpers import get_extra_build_args
5253
from pytest_container.helpers import get_extra_run_args
@@ -877,7 +878,7 @@ def prepare_container(
877878
@dataclass(frozen=True)
878879
class ContainerData:
879880
"""Class returned by the ``*container*`` fixtures to the test function. It
880-
contains information about the launched container and the testinfra
881+
contains information about the launched container and its shell connection
881882
:py:attr:`connection` to the running container.
882883
883884
"""
@@ -887,7 +888,7 @@ class ContainerData:
887888
image_url_or_id: str
888889
#: ID of the started container
889890
container_id: str
890-
#: the testinfra connection to the running container
891+
#: the shell connection to the running container
891892
connection: Any
892893
#: the container data class that has been used in this test
893894
container: Union[Container, DerivedContainer]
@@ -1013,6 +1014,27 @@ def container_from_pytest_param(
10131014
raise ValueError(f"Invalid pytest.param values: {param.values}")
10141015

10151016

1017+
@dataclass(frozen=True)
1018+
class ContainerRemoteEndpoint:
1019+
_container_id: str
1020+
_runtime: OciRuntimeBase
1021+
1022+
def __post_init__(self) -> None:
1023+
assert self._container_id, "Container ID must not be empty"
1024+
1025+
def check_output(self, cmd: str, strip: bool = True) -> str:
1026+
"""Run a command in the container and return its output."""
1027+
return helpers.run_command(
1028+
[
1029+
self._runtime.runner_binary,
1030+
"exec",
1031+
self._container_id,
1032+
"/bin/sh",
1033+
"-c",
1034+
cmd,
1035+
],
1036+
)[1]
1037+
10161038
@dataclass
10171039
class ContainerLauncher:
10181040
"""Helper context manager to setup, start and teardown a container including
@@ -1174,12 +1196,22 @@ def container_data(self) -> ContainerData:
11741196
"""
11751197
if not self._container_id:
11761198
raise RuntimeError(f"Container {self.container} has not started")
1199+
connection: Any = None
1200+
try:
1201+
import testinfra
1202+
1203+
connection = testinfra.get_host(
1204+
f"{self.container_runtime.runner_binary}://{self._container_id}"
1205+
)
1206+
except ImportError:
1207+
connection = ContainerRemoteEndpoint(
1208+
self._container_id, self.container_runtime
1209+
)
1210+
11771211
return ContainerData(
11781212
image_url_or_id=self.container.url or self.container.container_id,
11791213
container_id=self._container_id,
1180-
connection=testinfra.get_host(
1181-
f"{self.container_runtime.runner_binary}://{self._container_id}"
1182-
),
1214+
connection=connection,
11831215
container=self.container,
11841216
forwarded_ports=self._new_port_forwards,
11851217
_container_runtime=self.container_runtime,

pytest_container/helpers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66

77
import logging
88
import os
9+
import subprocess
910
from typing import List
1011

1112
from _pytest.config import Config
1213
from _pytest.config.argparsing import Parser
1314
from _pytest.python import Metafunc
1415

16+
from pytest_container.logging import _logger
1517
from pytest_container.logging import set_internal_logging_level
1618

1719

@@ -157,3 +159,26 @@ def get_always_pull_option() -> bool:
157159
158160
"""
159161
return bool(int(os.getenv("PULL_ALWAYS", "1")))
162+
163+
164+
def run_command(
165+
cmd: List[str],
166+
ignore_errors=True,
167+
):
168+
try:
169+
result = subprocess.run(
170+
cmd,
171+
capture_output=True,
172+
text=True,
173+
check=not ignore_errors,
174+
)
175+
176+
_logger.debug(
177+
f"RUN CMD: {cmd} RC: {result.returncode} STDOUT: {result.stdout} STDERR:{result.stderr}"
178+
)
179+
return result.returncode, result.stdout, result.stderr
180+
except subprocess.CalledProcessError as exc:
181+
_logger.debug(
182+
f"RUN(Failed) CMD: {cmd} RC: {exc.returncode} STDOUT:{exc.stdout} STDERR:{exc.stderr}"
183+
)
184+
raise exc

pytest_container/runtime.py

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,24 @@
66

77
import json
88
import re
9+
import shutil
910
import sys
1011
from abc import ABC
1112
from abc import abstractmethod
1213
from dataclasses import dataclass
1314
from os import getenv
1415
from pathlib import Path
15-
from subprocess import check_output
1616
from typing import TYPE_CHECKING
1717
from typing import Any
1818
from typing import Callable
1919
from typing import List
2020
from typing import Optional
2121
from typing import Union
2222

23-
import testinfra
2423
from _pytest.mark.structures import ParameterSet
2524
from pytest import param
2625

26+
from pytest_container import helpers
2727
from pytest_container.inspect import BindMount
2828
from pytest_container.inspect import Config
2929
from pytest_container.inspect import ContainerHealth
@@ -271,23 +271,24 @@ def get_image_size(
271271
else str(image_or_id_or_container)
272272
)
273273
return float(
274-
check_output(
274+
helpers.run_command(
275275
[
276276
self.runner_binary,
277277
"inspect",
278278
"-f",
279279
'"{{ .Size }}"',
280280
id_to_inspect,
281281
]
282-
)
283-
.decode()
282+
)[1]
284283
.strip()
285284
.replace('"', "")
286285
)
287286

288287
def _get_container_inspect(self, container_id: str) -> Any:
289288
inspect = json.loads(
290-
check_output([self.runner_binary, "inspect", container_id])
289+
helpers.run_command([self.runner_binary, "inspect", container_id])[
290+
1
291+
]
291292
)
292293
if len(inspect) != 1:
293294
raise RuntimeError(
@@ -305,19 +306,15 @@ def _get_image_entrypoint_cmd(
305306
defined.
306307
307308
"""
308-
entrypoint = (
309-
check_output(
310-
[
311-
self.runner_binary,
312-
"inspect",
313-
"-f",
314-
f"{{{{.Config.{query_type}}}}}",
315-
image_url_or_id,
316-
]
317-
)
318-
.decode("utf-8")
319-
.strip()
320-
)
309+
entrypoint = helpers.run_command(
310+
[
311+
self.runner_binary,
312+
"inspect",
313+
"-f",
314+
f"{{{{.Config.{query_type}}}}}",
315+
image_url_or_id,
316+
]
317+
)[1].strip()
321318
return None if entrypoint == "[]" else entrypoint
322319

323320
@staticmethod
@@ -411,9 +408,6 @@ def __str__(self) -> str:
411408
return self.__class__.__name__
412409

413410

414-
LOCALHOST = testinfra.host.get_host("local://")
415-
416-
417411
def _get_podman_version(version_stdout: str) -> Version:
418412
if version_stdout[:15] != "podman version ":
419413
raise RuntimeError(
@@ -424,7 +418,7 @@ def _get_podman_version(version_stdout: str) -> Version:
424418

425419

426420
def _get_buildah_version() -> Version:
427-
version_stdout = LOCALHOST.check_output("buildah --version")
421+
version_stdout = helpers.run_command(["buildah", "--version"])[1]
428422
build_version_begin = "buildah version "
429423
if not version_stdout.startswith(build_version_begin):
430424
raise RuntimeError(
@@ -443,11 +437,11 @@ class PodmanRuntime(OciRuntimeBase):
443437
"""
444438

445439
def __init__(self) -> None:
446-
podman_ps = LOCALHOST.run("podman ps")
447-
if not podman_ps.succeeded:
448-
raise RuntimeError(f"`podman ps` failed with {podman_ps.stderr}")
440+
podman_ps = helpers.run_command(["podman", "ps"])
441+
if not podman_ps[0] == 0:
442+
raise RuntimeError(f"`podman ps` failed with {podman_ps[2]}")
449443

450-
self._buildah_functional = LOCALHOST.run("buildah").succeeded
444+
self._buildah_functional = helpers.run_command(["buildah"])[0] == 0
451445
super().__init__(
452446
build_command=(
453447
["buildah", "bud", "--layers", "--force-rm"]
@@ -461,8 +455,11 @@ def __init__(self) -> None:
461455
@cached_property
462456
def version(self) -> Version:
463457
"""Returns the version of podman installed on the system"""
458+
464459
return _get_podman_version(
465-
LOCALHOST.run_expect([0], "podman --version").stdout
460+
helpers.run_command(["podman", "--version"], ignore_errors=False)[
461+
1
462+
]
466463
)
467464

468465
@cached_property
@@ -539,9 +536,9 @@ class DockerRuntime(OciRuntimeBase):
539536
containers."""
540537

541538
def __init__(self) -> None:
542-
docker_ps = LOCALHOST.run("docker ps")
543-
if not docker_ps.succeeded:
544-
raise RuntimeError(f"`docker ps` failed with {docker_ps.stderr}")
539+
docker_ps = helpers.run_command(["docker", "ps"])
540+
if not docker_ps[0] == 0:
541+
raise RuntimeError(f"`docker ps` failed with {docker_ps[2]}")
545542

546543
super().__init__(
547544
build_command=["docker", "build", "--force-rm"],
@@ -552,7 +549,9 @@ def __init__(self) -> None:
552549
def version(self) -> Version:
553550
"""Returns the version of docker installed on this system"""
554551
return _get_docker_version(
555-
LOCALHOST.run_expect([0], "docker --version").stdout
552+
helpers.run_command(["docker", "--version"], ignore_errors=False)[
553+
1
554+
]
556555
)
557556

558557
@property
@@ -613,8 +612,8 @@ def get_selected_runtime() -> OciRuntimeBase:
613612
614613
If neither docker nor podman are available, then a ValueError is raised.
615614
"""
616-
podman_exists = LOCALHOST.exists("podman")
617-
docker_exists = LOCALHOST.exists("docker")
615+
podman_exists = shutil.which("podman")
616+
docker_exists = shutil.which("docker")
618617

619618
runtime_choice = getenv("CONTAINER_RUNTIME", "podman").lower()
620619
if runtime_choice not in ("podman", "docker"):

source/prerequisites.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Prerequisites
22
=============
33

4-
`pytest_container` works with Python 3.6 and later and requires `pytest
4+
`pytest_container` works with Python 3.6 and later and optionally requires `pytest
55
<https://pytest.org/>`_ and `pytest-testinfra
66
<https://testinfra.readthedocs.io/>`_. Additionally, for python 3.6, you'll need
77
the `dataclasses <https://pypi.org/project/dataclasses/>`_ module.

source/tutorials.rst

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,6 @@ parametrizing each of the test manually.
110110
The test function receives a
111111
:py:class:`~pytest_container.container.ContainerData` instance, where the
112112
:py:attr:`~pytest_container.container.ContainerData.connection` attribute
113-
provides a ``testinfra`` connection. The `run_expect
114-
<https://testinfra.readthedocs.io/en/latest/modules.html#testinfra.host.Host.run_expect>`_
115-
function is used to execute the binary and check that its exit code is
116-
``0``. Afterwards, we check that a search string is in the standard output.
113+
provides a shell connection.
117114

118115
You can now execute this test via :command:`poetry run pytest`.

source/usage.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ To successfully copy files, we need to undertake the following steps:
206206
1. Request the following fixtures: any of the ``(auto)_container_per_test``,
207207
``host``, ``container_runtime``.
208208
2. Obtain the running container's hash.
209-
3. Use :command:`podman|docker cp command`, via testinfra's host fixture.
209+
3. Use :command:`podman|docker cp command`, via connection object
210210

211211
The above steps could be implemented as follows:
212212

test-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ pytest-xdist
33
coverage
44
pytest-rerunfailures
55
typeguard
6+
ifaddr
67
.

0 commit comments

Comments
 (0)