Skip to content

Commit 0d7a6ed

Browse files
committed
WIP: Add MultiStageContainer class
1 parent 3e006b5 commit 0d7a6ed

File tree

6 files changed

+122
-26
lines changed

6 files changed

+122
-26
lines changed

CHANGELOG.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ Internal changes:
6868
Breaking changes:
6969

7070
- add the parameter ``container_runtime`` to
71-
:py:func:`~pytest_container.container.ContainerBaseABC.prepare_container` and
72-
:py:func:`~pytest_container.build.MultiStageBuild.prepare_build`.
71+
``ContainerBaseABC.prepare_container`` (now called
72+
:py:func:`~pytest_container.container.ContainerPrepareABC.prepare_container`)
73+
and :py:func:`~pytest_container.build.MultiStageBuild.prepare_build`.
7374

7475
- deprecate the function ``pytest_container.container_from_pytest_param``,
7576
please use
@@ -222,7 +223,8 @@ Improvements and new features:
222223
parametrize this test run.
223224

224225
- Add support to add tags to container images via
225-
:py:attr:`~pytest_container.container.DerivedContainer.add_build_tags`.
226+
``DerivedContainer.add_build_tags`` (is now called
227+
:py:attr:`~pytest_container.container._ContainerForBuild.add_build_tags`)
226228

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

pytest_container/container.py

Lines changed: 85 additions & 20 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
@@ -613,7 +614,7 @@ def filelock_filename(self) -> str:
613614
if isinstance(value, list):
614615
all_elements.append("".join([str(elem) for elem in value]))
615616
elif isinstance(value, dict):
616-
all_elements.append("".join(value.values()))
617+
all_elements.append("".join(str(v) for v in value.values()))
617618
else:
618619
all_elements.append(str(value))
619620

@@ -624,7 +625,7 @@ def filelock_filename(self) -> str:
624625
return f"{sha3_256((''.join(all_elements)).encode()).hexdigest()}.lock"
625626

626627

627-
class ContainerBaseABC(ABC):
628+
class ContainerPrepareABC(ABC):
628629
"""Abstract base class defining the methods that must be implemented by the
629630
classes fed to the ``*container*`` fixtures.
630631
@@ -639,6 +640,8 @@ def prepare_container(
639640
) -> None:
640641
"""Prepares the container so that it can be launched."""
641642

643+
644+
class ContainerBaseABC(ContainerPrepareABC):
642645
@abstractmethod
643646
def get_base(self) -> "Union[Container, DerivedContainer]":
644647
"""Returns the Base of this Container Image. If the container has no
@@ -795,20 +798,12 @@ def _run_container_build(
795798

796799

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

806-
base: Union[Container, "DerivedContainer", str] = ""
807-
808-
#: The :file:`Containerfile` that is used to build this container derived
809-
#: from :py:attr:`base`.
810-
containerfile: str = ""
811-
812807
#: An optional image format when building images with :command:`buildah`. It
813808
#: is ignored when the container runtime is :command:`docker`.
814809
#: The ``oci`` image format is used by default. If the image format is
@@ -822,6 +817,22 @@ class DerivedContainer(ContainerBase, ContainerBaseABC):
822817
#: has been built
823818
add_build_tags: List[str] = field(default_factory=list)
824819

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

895906

907+
@dataclass
908+
class MultiStageContainer(_ContainerForBuild, ContainerPrepareABC):
909+
containerfile: str = ""
910+
911+
containers: Dict[str, Union[Container, DerivedContainer, str]] = field(
912+
default_factory=dict
913+
)
914+
915+
def prepare_container(
916+
self,
917+
container_runtime: OciRuntimeBase,
918+
rootdir: Path,
919+
extra_build_args: Optional[List[str]],
920+
) -> None:
921+
"""Prepares the container so that it can be launched."""
922+
923+
template_kwargs: Dict[str, str] = {}
896924

925+
for name, ctr in self.containers.items():
926+
if isinstance(ctr, str):
927+
c = Container(url=ctr)
928+
c.prepare_container(
929+
container_runtime, rootdir, extra_build_args
930+
)
931+
template_kwargs[name] = c._build_tag
932+
else:
933+
ctr.prepare_container(
934+
container_runtime, rootdir, extra_build_args
935+
)
936+
template_kwargs[name] = ctr._build_tag
937+
938+
ctrfile = Template(self.containerfile).substitute(**template_kwargs)
897939

940+
self.container_id, internal_tag = _run_container_build(
941+
container_runtime,
942+
rootdir,
943+
ctrfile,
944+
None,
945+
extra_build_args,
946+
self.image_format,
947+
self.add_build_tags,
948+
)
949+
assert self._build_tag == internal_tag
898950

899951

900952
@dataclass(frozen=True)
@@ -913,7 +965,7 @@ class ContainerData:
913965
#: the testinfra connection to the running container
914966
connection: Any
915967
#: the container data class that has been used in this test
916-
container: Union[Container, DerivedContainer]
968+
container: Union[Container, DerivedContainer, MultiStageContainer]
917969
#: any ports that are exposed by this container
918970
forwarded_ports: List[PortForwarding]
919971

@@ -970,22 +1022,32 @@ def container_and_marks_from_pytest_param(
9701022
...
9711023

9721024

1025+
@overload
1026+
def container_and_marks_from_pytest_param(
1027+
ctr_or_param: MultiStageContainer,
1028+
) -> Tuple[MultiStageContainer, Literal[None]]:
1029+
...
1030+
1031+
9731032
@overload
9741033
def container_and_marks_from_pytest_param(
9751034
ctr_or_param: _pytest.mark.ParameterSet,
9761035
) -> Tuple[
977-
Union[Container, DerivedContainer],
1036+
Union[Container, DerivedContainer, MultiStageContainer],
9781037
Optional[Collection[Union[_pytest.mark.MarkDecorator, _pytest.mark.Mark]]],
9791038
]:
9801039
...
9811040

9821041

9831042
def container_and_marks_from_pytest_param(
9841043
ctr_or_param: Union[
985-
_pytest.mark.ParameterSet, Container, DerivedContainer
1044+
_pytest.mark.ParameterSet,
1045+
Container,
1046+
DerivedContainer,
1047+
MultiStageContainer,
9861048
],
9871049
) -> Tuple[
988-
Union[Container, DerivedContainer],
1050+
Union[Container, DerivedContainer, MultiStageContainer],
9891051
Optional[Collection[Union[_pytest.mark.MarkDecorator, _pytest.mark.Mark]]],
9901052
]:
9911053
"""Extracts the :py:class:`~pytest_container.container.Container` or
@@ -999,11 +1061,14 @@ def container_and_marks_from_pytest_param(
9991061
returned directly and the second return value is ``None``.
10001062
10011063
"""
1002-
if isinstance(ctr_or_param, (Container, DerivedContainer)):
1064+
if isinstance(
1065+
ctr_or_param, (Container, DerivedContainer, MultiStageContainer)
1066+
):
10031067
return ctr_or_param, None
10041068

10051069
if len(ctr_or_param.values) > 0 and isinstance(
1006-
ctr_or_param.values[0], (Container, DerivedContainer)
1070+
ctr_or_param.values[0],
1071+
(Container, DerivedContainer, MultiStageContainer),
10071072
):
10081073
return ctr_or_param.values[0], ctr_or_param.marks
10091074

@@ -1047,7 +1112,7 @@ class ContainerLauncher:
10471112
"""
10481113

10491114
#: The container that will be launched
1050-
container: Union[Container, DerivedContainer]
1115+
container: Union[Container, DerivedContainer, MultiStageContainer]
10511116

10521117
#: The container runtime via which the container will be launched
10531118
container_runtime: OciRuntimeBase

pytest_container/pod.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from pytest_container.container import create_host_port_port_forward
1919
from pytest_container.container import DerivedContainer
2020
from pytest_container.container import lock_host_port_search
21+
from pytest_container.container import MultiStageContainer
2122
from pytest_container.inspect import PortForwarding
2223
from pytest_container.logging import _logger
2324
from pytest_container.runtime import get_selected_runtime
@@ -35,7 +36,7 @@ class Pod:
3536
"""
3637

3738
#: containers belonging to the pod
38-
containers: List[Union[DerivedContainer, Container]]
39+
containers: List[Union[MultiStageContainer, DerivedContainer, Container]]
3940

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

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

source/usage.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Sometimes it is necessary to customize the build, run or pod create parameters
88
of the container runtime globally, e.g. to use the host's network with docker
99
via ``--network=host``.
1010

11-
The :py:meth:`~pytest_container.container.ContainerBaseABC.prepare_container`
11+
The :py:meth:`~pytest_container.container.ContainerPrepareABC.prepare_container`
1212
and :py:meth:`~pytest_container.container.ContainerBase.get_launch_cmd` methods
1313
support passing such additional arguments/flags, but this is rather cumbersome
1414
to use in practice. The ``*container*`` and ``pod*`` fixtures will therefore

tests/test_container_build.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pytest_container.container import ContainerData
1212
from pytest_container.container import ContainerLauncher
1313
from pytest_container.container import EntrypointSelection
14+
from pytest_container.container import MultiStageContainer
1415
from pytest_container.inspect import PortForwarding
1516
from pytest_container.runtime import LOCALHOST
1617
from pytest_container.runtime import OciRuntimeBase
@@ -85,6 +86,28 @@
8586
""",
8687
)
8788

89+
MULTI_STAGE_CTR = MultiStageContainer(
90+
containers={
91+
"builder": LEAP_WITH_MAN,
92+
"runner1": LEAP,
93+
"runner2": "docker.io/alpine",
94+
},
95+
containerfile=r"""FROM $builder as builder
96+
WORKDIR /src
97+
RUN echo $$'#!/bin/sh \n\
98+
echo "foobar"' > test.sh && chmod +x test.sh
99+
100+
FROM $runner1 as runner1
101+
WORKDIR /bin
102+
COPY --from=builder /src/test.sh .
103+
ENTRYPOINT ["/bin/test.sh"]
104+
105+
FROM $runner2 as runner2
106+
WORKDIR /bin
107+
COPY --from=builder /src/test.sh .
108+
""",
109+
)
110+
88111
# This container would just stop if we would launch it with -d and use the
89112
# default entrypoint. If we set the entrypoint to bash, then it should stay up.
90113
CONTAINER_THAT_STOPS = DerivedContainer(
@@ -269,6 +292,11 @@ def test_multistage_build(
269292
)
270293

271294

295+
@pytest.mark.parametrize("container", [MULTI_STAGE_CTR], indirect=True)
296+
def test_multistage_container(container: ContainerData) -> None:
297+
assert container.connection.file("/bin/test.sh").exists
298+
299+
272300
def test_multistage_build_target(
273301
tmp_path: Path, pytestconfig: Config, container_runtime: OciRuntimeBase
274302
):

0 commit comments

Comments
 (0)