2525from os .path import isabs
2626from os .path import join
2727from pathlib import Path
28+ from string import Template
2829from subprocess import call
2930from subprocess import check_output
3031from 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(
9651056def 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
9721063def 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
9791077def 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
9881086def 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 = "" ,
0 commit comments