Skip to content

Commit 3734b4a

Browse files
committed
Add MultiStageContainer class
1 parent 303dc4f commit 3734b4a

File tree

9 files changed

+395
-120
lines changed

9 files changed

+395
-120
lines changed

CHANGELOG.rst

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ Breaking changes:
99

1010
Improvements and new features:
1111

12+
- Add the class :py:class:`~pytest_container.container.MultiStageContainer` as a
13+
replacement of :py:class:`~pytest_container.build.MultiStageBuild` to handle
14+
container images built from a :file:`Containerfile` with multiple stages
15+
1216
- Add the function
1317
:py:func:`~pytest_container.container.ContainerData.read_container_logs` to
1418
get access to the logs of the running container
@@ -71,8 +75,9 @@ Internal changes:
7175
Breaking changes:
7276

7377
- add the parameter ``container_runtime`` to
74-
:py:func:`~pytest_container.container.ContainerBaseABC.prepare_container` and
75-
:py:func:`~pytest_container.build.MultiStageBuild.prepare_build`.
78+
``ContainerBaseABC.prepare_container`` (now called
79+
:py:func:`~pytest_container.container.ContainerPrepareABC.prepare_container`)
80+
and :py:func:`~pytest_container.build.MultiStageBuild.prepare_build`.
7681

7782
- deprecate the function ``pytest_container.container_from_pytest_param``,
7883
please use
@@ -225,7 +230,8 @@ Improvements and new features:
225230
parametrize this test run.
226231

227232
- Add support to add tags to container images via
228-
:py:attr:`~pytest_container.container.DerivedContainer.add_build_tags`.
233+
``DerivedContainer.add_build_tags`` (is now called
234+
:py:attr:`~pytest_container.container._ContainerForBuild.add_build_tags`)
229235

230236
- Lock container preparation so that only a single process is pulling & building
231237
a container image.

pytest_container/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"container_from_pytest_param",
1111
"container_to_pytest_param",
1212
"DerivedContainer",
13+
"MultiStageContainer",
1314
"add_extra_run_and_build_args_options",
1415
"add_logging_level_options",
1516
"auto_container_parametrize",
@@ -31,6 +32,7 @@
3132
from .container import container_from_pytest_param
3233
from .container import container_to_pytest_param
3334
from .container import DerivedContainer
35+
from .container import MultiStageContainer
3436
from .helpers import add_extra_run_and_build_args_options
3537
from .helpers import add_logging_level_options
3638
from .helpers import auto_container_parametrize

pytest_container/container.py

Lines changed: 134 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from os.path import isabs
2626
from os.path import join
2727
from pathlib import Path
28+
from string import Template
2829
from subprocess import call
2930
from subprocess import check_output
3031
from types import TracebackType
@@ -616,7 +617,7 @@ def filelock_filename(self) -> str:
616617
if isinstance(value, list):
617618
all_elements.append("".join([str(elem) for elem in value]))
618619
elif isinstance(value, dict):
619-
all_elements.append("".join(value.values()))
620+
all_elements.append("".join(str(v) for v in value.values()))
620621
else:
621622
all_elements.append(str(value))
622623

@@ -627,7 +628,7 @@ def filelock_filename(self) -> str:
627628
return f"{sha3_256((''.join(all_elements)).encode()).hexdigest()}.lock"
628629

629630

630-
class ContainerBaseABC(ABC):
631+
class ContainerPrepareABC(ABC):
631632
"""Abstract base class defining the methods that must be implemented by the
632633
classes fed to the ``*container*`` fixtures.
633634
@@ -642,6 +643,8 @@ def prepare_container(
642643
) -> None:
643644
"""Prepares the container so that it can be launched."""
644645

646+
647+
class ContainerBaseABC(ContainerPrepareABC):
645648
@abstractmethod
646649
def get_base(self) -> "Union[Container, DerivedContainer]":
647650
"""Returns the Base of this Container Image. If the container has no
@@ -798,20 +801,12 @@ def _run_container_build(
798801

799802

800803
@dataclass(unsafe_hash=True)
801-
class DerivedContainer(ContainerBase, ContainerBaseABC):
802-
"""Class for storing information about the Container Image under test, that
803-
is build from a :file:`Containerfile`/:file:`Dockerfile` from a different
804-
image (can be any image from a registry or an instance of
805-
:py:class:`Container` or :py:class:`DerivedContainer`).
804+
class _ContainerForBuild(ContainerBase):
805+
"""Intermediate class for adding properties to :py:class:`DerivedContainer`
806+
and :py:class:`MultiStageContainer`.
806807
807808
"""
808809

809-
base: Union[Container, "DerivedContainer", str] = ""
810-
811-
#: The :file:`Containerfile` that is used to build this container derived
812-
#: from :py:attr:`base`.
813-
containerfile: str = ""
814-
815810
#: An optional image format when building images with :command:`buildah`. It
816811
#: is ignored when the container runtime is :command:`docker`.
817812
#: The ``oci`` image format is used by default. If the image format is
@@ -825,6 +820,22 @@ class DerivedContainer(ContainerBase, ContainerBaseABC):
825820
#: has been built
826821
add_build_tags: List[str] = field(default_factory=list)
827822

823+
824+
@dataclass(unsafe_hash=True)
825+
class DerivedContainer(_ContainerForBuild, ContainerBaseABC):
826+
"""Class for storing information about the Container Image under test, that
827+
is build from a :file:`Containerfile`/:file:`Dockerfile` from a different
828+
image (can be any image from a registry or an instance of
829+
:py:class:`Container` or :py:class:`DerivedContainer`).
830+
831+
"""
832+
833+
base: Union[Container, "DerivedContainer", str] = ""
834+
835+
#: The :file:`Containerfile` that is used to build this container derived
836+
#: from :py:attr:`base`.
837+
containerfile: str = ""
838+
828839
def __post_init__(self) -> None:
829840
super().__post_init__()
830841
if not self.base:
@@ -898,8 +909,88 @@ def prepare_container(
898909
assert self._build_tag == internal_build_tag
899910

900911

912+
@dataclass
913+
class MultiStageContainer(_ContainerForBuild, ContainerPrepareABC):
914+
"""Class representing a container built from a :file:`Containerfile`
915+
containing multiple stages. The :py:attr:`MultiStageContainer.containerfile`
916+
is templated using the builtin :py:class:`string.Template`, where container
917+
image IDs are inserted from the containers in
918+
:py:attr:`MultiStageContainer.containers` after these have been
919+
built/pulled.
920+
921+
"""
901922

923+
#: :file:`Containerfile` to built the container. If any stages require
924+
#: images that are defined using a :py:class:`DerivedContainer` or a
925+
#: :py:class:`Container`, then insert their ids as a template name and
926+
#: provide that name and the class instance as the key & value into
927+
#: :py:attr:`containers`.
928+
containerfile: str = ""
902929

930+
#: Dictionary of container stages that are used to build the final
931+
#: image. The keys are the template names used in :py:attr:`containerfile`
932+
#: and will be replaced with the container image ids of the respective values.
933+
containers: Dict[str, Union[Container, DerivedContainer, str]] = field(
934+
default_factory=dict
935+
)
936+
937+
#: Optional stage of the multistage container build that should be built.
938+
#: The last stage is built by default.
939+
target_stage: str = ""
940+
941+
def prepare_container(
942+
self,
943+
container_runtime: OciRuntimeBase,
944+
rootdir: Path,
945+
extra_build_args: Optional[List[str]],
946+
) -> None:
947+
"""Builds all intermediate containers and then builds the final
948+
container up to :py:attr:`target_stage` or to the last stage.
949+
950+
"""
951+
952+
template_kwargs: Dict[str, str] = {}
953+
954+
for name, ctr in self.containers.items():
955+
if isinstance(ctr, str):
956+
warnings.warn(
957+
UserWarning(
958+
"Putting container URLs or scratch into the containers "
959+
"dictionary is not required, just enter them into the "
960+
"containerfile directly."
961+
)
962+
)
963+
964+
if ctr == "scratch":
965+
template_kwargs[name] = ctr
966+
else:
967+
c = Container(url=ctr)
968+
c.prepare_container(
969+
container_runtime, rootdir, extra_build_args
970+
)
971+
template_kwargs[name] = c._build_tag
972+
else:
973+
ctr.prepare_container(
974+
container_runtime, rootdir, extra_build_args
975+
)
976+
template_kwargs[name] = ctr._build_tag
977+
978+
ctrfile = Template(self.containerfile).substitute(**template_kwargs)
979+
980+
build_args = tuple(*extra_build_args) if extra_build_args else ()
981+
if self.target_stage:
982+
build_args += ("--target", self.target_stage)
983+
984+
self.container_id, internal_tag = _run_container_build(
985+
container_runtime,
986+
rootdir,
987+
ctrfile,
988+
None,
989+
build_args,
990+
self.image_format,
991+
self.add_build_tags,
992+
)
993+
assert self._build_tag == internal_tag
903994

904995

905996
@dataclass(frozen=True)
@@ -918,7 +1009,7 @@ class ContainerData:
9181009
#: the testinfra connection to the running container
9191010
connection: Any
9201011
#: the container data class that has been used in this test
921-
container: Union[Container, DerivedContainer]
1012+
container: Union[Container, DerivedContainer, MultiStageContainer]
9221013
#: any ports that are exposed by this container
9231014
forwarded_ports: List[PortForwarding]
9241015

@@ -965,50 +1056,65 @@ def container_to_pytest_param(
9651056
def container_and_marks_from_pytest_param(
9661057
ctr_or_param: Container,
9671058
) -> Tuple[Container, Literal[None]]:
968-
...
1059+
... # pragma: no cover
9691060

9701061

9711062
@overload
9721063
def container_and_marks_from_pytest_param(
9731064
ctr_or_param: DerivedContainer,
9741065
) -> Tuple[DerivedContainer, Literal[None]]:
975-
...
1066+
... # pragma: no cover
1067+
1068+
1069+
@overload
1070+
def container_and_marks_from_pytest_param(
1071+
ctr_or_param: MultiStageContainer,
1072+
) -> Tuple[MultiStageContainer, Literal[None]]:
1073+
... # pragma: no cover
9761074

9771075

9781076
@overload
9791077
def container_and_marks_from_pytest_param(
9801078
ctr_or_param: _pytest.mark.ParameterSet,
9811079
) -> Tuple[
982-
Union[Container, DerivedContainer],
1080+
Union[Container, DerivedContainer, MultiStageContainer],
9831081
Optional[Collection[Union[_pytest.mark.MarkDecorator, _pytest.mark.Mark]]],
9841082
]:
985-
...
1083+
... # pragma: no cover
9861084

9871085

9881086
def container_and_marks_from_pytest_param(
9891087
ctr_or_param: Union[
990-
_pytest.mark.ParameterSet, Container, DerivedContainer
1088+
_pytest.mark.ParameterSet,
1089+
Container,
1090+
DerivedContainer,
1091+
MultiStageContainer,
9911092
],
9921093
) -> Tuple[
993-
Union[Container, DerivedContainer],
1094+
Union[Container, DerivedContainer, MultiStageContainer],
9941095
Optional[Collection[Union[_pytest.mark.MarkDecorator, _pytest.mark.Mark]]],
9951096
]:
996-
"""Extracts the :py:class:`~pytest_container.container.Container` or
997-
:py:class:`~pytest_container.container.DerivedContainer` and the
1097+
"""Extracts the :py:class:`~pytest_container.container.Container`,
1098+
:py:class:`~pytest_container.container.DerivedContainer` or
1099+
:py:class:`~pytest_container.container.MultiStageContainer` and the
9981100
corresponding marks from a `pytest.param
9991101
<https://docs.pytest.org/en/stable/reference.html?#pytest.param>`_ and
10001102
returns both.
10011103
10021104
If ``param`` is either a :py:class:`~pytest_container.container.Container`
1003-
or a :py:class:`~pytest_container.container.DerivedContainer`, then param is
1004-
returned directly and the second return value is ``None``.
1105+
or a :py:class:`~pytest_container.container.DerivedContainer` or a
1106+
:py:class:`~pytest_container.container.MultiStageContainer`, then ``param``
1107+
is returned directly and the second return value is ``None``.
10051108
10061109
"""
1007-
if isinstance(ctr_or_param, (Container, DerivedContainer)):
1110+
if isinstance(
1111+
ctr_or_param, (Container, DerivedContainer, MultiStageContainer)
1112+
):
10081113
return ctr_or_param, None
10091114

10101115
if len(ctr_or_param.values) > 0 and isinstance(
1011-
ctr_or_param.values[0], (Container, DerivedContainer)
1116+
ctr_or_param.values[0],
1117+
(Container, DerivedContainer, MultiStageContainer),
10121118
):
10131119
return ctr_or_param.values[0], ctr_or_param.marks
10141120

@@ -1052,7 +1158,7 @@ class ContainerLauncher:
10521158
"""
10531159

10541160
#: The container that will be launched
1055-
container: Union[Container, DerivedContainer]
1161+
container: Union[Container, DerivedContainer, MultiStageContainer]
10561162

10571163
#: The container runtime via which the container will be launched
10581164
container_runtime: OciRuntimeBase
@@ -1082,7 +1188,7 @@ class ContainerLauncher:
10821188

10831189
@staticmethod
10841190
def from_pytestconfig(
1085-
container: Union[Container, DerivedContainer],
1191+
container: Union[Container, DerivedContainer, MultiStageContainer],
10861192
container_runtime: OciRuntimeBase,
10871193
pytestconfig: Config,
10881194
container_name: str = "",

pytest_container/pod.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from pytest_container.container import create_host_port_port_forward
2020
from pytest_container.container import DerivedContainer
2121
from pytest_container.container import lock_host_port_search
22+
from pytest_container.container import MultiStageContainer
2223
from pytest_container.helpers import get_extra_build_args
2324
from pytest_container.helpers import get_extra_pod_create_args
2425
from pytest_container.helpers import get_extra_run_args
@@ -39,7 +40,7 @@ class Pod:
3940
"""
4041

4142
#: containers belonging to the pod
42-
containers: List[Union[DerivedContainer, Container]]
43+
containers: List[Union[MultiStageContainer, DerivedContainer, Container]]
4344

4445
#: ports exposed by the pod
4546
forwarded_ports: List[PortForwarding] = field(default_factory=list)

source/api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ The container module
88
.. automodule:: pytest_container.container
99
:members:
1010
:undoc-members:
11+
:private-members:
12+
:show-inheritance:
1113

1214

1315
The pod module

source/fixtures.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,5 @@ directive is only supported for docker images. While this is the default with
4545
:command:`docker`, :command:`buildah` will by default build images in the
4646
``OCIv1`` format which does **not** support ``HEALTHCHECK``. To ensure that your
4747
created container includes the ``HEALTHCHECK``, set the attribute
48-
:py:attr:`~pytest_container.container.DerivedContainer.image_format` to
48+
:py:attr:`~pytest_container.container._ContainerForBuild.image_format` to
4949
:py:attr:`~pytest_container.container.ImageFormat.DOCKER`.

0 commit comments

Comments
 (0)