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
@@ -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
9741033def 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
9831042def 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
0 commit comments