diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 73c174a..5a297f7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,8 +11,24 @@ Breaking changes: the runtime is not functional (`gh#238 `_) +- Remove intermediate class ``container.ContainerBaseABC`` + +- :py:class:`~pytest_container.container.Container` and + :py:class:`~pytest_container.container.DerivedContainer` are no longer + dataclasses. + The biggest change due to that is that ``__dict___`` can no longer be used to + pass it directly into the constructor. To remedy this use case, we now provide + :py:meth:`~pytest_container.container.ContainerBase.dict` and + :py:meth:`~pytest_container.container.DerivedContainer.dict`. + Improvements and new features: +- :py:class:`~pytest_container.container.Container` and + :py:class:`~pytest_container.container.DerivedContainer` now inherit from + ``pytest``'s ``ParameterSet`` and can thus be directly used for test + parametrization including + marks. :py:class:`~pytest_container.container.DerivedContainer` also inherits + all marks from the base containers. Documentation: @@ -97,7 +113,8 @@ Internal changes: Breaking changes: - add the parameter ``container_runtime`` to - :py:func:`~pytest_container.container.ContainerBaseABC.prepare_container` and + :py:func:`~pytest_container.container.ContainerBase.prepare_container` (was + ``ContainerBaseABC.prepare_container``) and :py:func:`~pytest_container.build.MultiStageBuild.prepare_build`. - deprecate the function ``pytest_container.container_from_pytest_param``, @@ -105,8 +122,9 @@ Breaking changes: :py:func:`~pytest_container.container.container_and_marks_from_pytest_param` instead. -- :py:func:`~pytest_container.container.ContainerBaseABC.get_base` no longer - returns the recursive base but the immediate base. +- :py:func:`~pytest_container.container.ContainerBase.get_base` (was + ``ContainerBaseABC.get_base``) no longer returns the recursive base but the + immediate base. Improvements and new features: @@ -153,9 +171,9 @@ Breaking changes: Improvements and new features: -- Add :py:attr:`~pytest_container.container.ContainerBaseABC.baseurl` property - to get the registry url of the container on which any currently existing - container is based on. +- Add :py:attr:`~pytest_container.container.ContainerBase.baseurl` (was + ``ContainerBaseABC.baseurl``) property to get the registry url of the + container on which any currently existing container is based on. Documentation: diff --git a/pylintrc b/pylintrc index 516f835..7cd94d6 100644 --- a/pylintrc +++ b/pylintrc @@ -39,7 +39,7 @@ extension-pkg-whitelist= fail-on=missing-function-docstring,missing-class-docstring # Specify a score threshold under which the program will exit with error. -fail-under=9.3 +fail-under=9.8 # Interpret the stdin as a python script, whose filename needs to be passed as # the module_or_package argument. diff --git a/pyproject.toml b/pyproject.toml index 53e7591..3cb88b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,3 +58,12 @@ ignore = [ [tool.ruff.lint.isort] force-single-line = true case-sensitive = true + +[tool.pytest.ini_options] +xfail_strict = true +addopts = "--strict-markers" +markers = [ + 'secretleapmark', + 'othersecretmark', + 'secretpodmark', +] diff --git a/pytest_container/container.py b/pytest_container/container.py index 961fdd7..6123421 100644 --- a/pytest_container/container.py +++ b/pytest_container/container.py @@ -36,6 +36,7 @@ from typing import Optional from typing import Tuple from typing import Type +from typing import TypeVar from typing import Union from typing import overload from uuid import uuid4 @@ -46,6 +47,8 @@ import testinfra from filelock import BaseFileLock from filelock import FileLock +from pytest import Mark +from pytest import MarkDecorator from pytest_container.helpers import get_always_pull_option from pytest_container.helpers import get_extra_build_args @@ -58,12 +61,23 @@ from pytest_container.runtime import OciRuntimeBase from pytest_container.runtime import get_selected_runtime +try: + from typing import TypedDict +except ImportError: + from typing_extensions import TypedDict +try: + from typing import Unpack +except ImportError: + from typing_extensions import Unpack +try: + from typing import Self +except ImportError: + from typing_extensions import Self + if sys.version_info >= (3, 8): from importlib import metadata - from typing import Literal else: import importlib_metadata as metadata - from typing_extensions import Literal @enum.unique @@ -431,84 +445,190 @@ class EntrypointSelection(enum.Enum): IMAGE = enum.auto() -@dataclass -class ContainerBase: +T = TypeVar("T", bound="ContainerBase") + + +class _ContainerBaseKwargs(TypedDict): + url: str + container_id: str + entry_point: EntrypointSelection + custom_entry_point: Optional[str] + extra_launch_args: Optional[List[str]] + extra_entrypoint_args: Optional[List[str]] + healthcheck_timeout: Optional[timedelta] + extra_environment_variables: Optional[Dict[str, str]] + singleton: bool + forwarded_ports: Optional[List[PortForwarding]] + volume_mounts: Optional[List[Union[ContainerVolume, BindMount]]] + marks: Optional[Collection[Union[MarkDecorator, Mark]]] + + +class ContainerBase(ABC, _pytest.mark.ParameterSet): """Base class for defining containers to be tested. Not to be used directly, instead use :py:class:`Container` or :py:class:`DerivedContainer`. """ - #: Full url to this container via which it can be pulled - #: - #: If your container image is not available via a registry and only locally, - #: then you can use the following syntax: ``containers-storage:$local_name`` - url: str = "" - - #: id of the container if it is not available via a registry URL - container_id: str = "" - - #: Defines which entrypoint of the container is used. - #: By default either :py:attr:`custom_entry_point` will be used (if defined) - #: or the container's entrypoint or cmd. If neither of the two is set, then - #: :file:`/bin/bash` will be used. - entry_point: EntrypointSelection = EntrypointSelection.AUTO - - #: custom entry point for this container (i.e. neither its default, nor - #: :file:`/bin/bash`) - custom_entry_point: Optional[str] = None - - #: List of additional flags that will be inserted after - #: `docker/podman run -d` and before the image name (i.e. these arguments - #: are not passed to the entrypoint or ``CMD``). The list must be properly - #: escaped, e.g. as created by ``shlex.split``. - extra_launch_args: List[str] = field(default_factory=list) - - #: List of additional arguments that are passed to the ``CMD`` or - #: entrypoint. These arguments are inserted after the :command:`docker/podman - #: run -d $image` on launching the image. - #: The list must be properly escaped, e.g. by passing the string through - #: ``shlex.split``. - #: The arguments must not cause the container to exit early. It must remain - #: active in the background, otherwise this library will not function - #: properly. - extra_entrypoint_args: List[str] = field(default_factory=list) - - #: Time for the container to become healthy (the timeout is ignored - #: when the container image defines no ``HEALTHCHECK`` or when the timeout - #: is below zero). - #: When the value is ``None``, then the timeout will be inferred from the - #: container image's ``HEALTHCHECK`` directive. - healthcheck_timeout: Optional[timedelta] = None - - #: additional environment variables that should be injected into the - #: container - extra_environment_variables: Optional[Dict[str, str]] = None - - #: Indicate whether there must never be more than one running container of - #: this type at all times (e.g. because it opens a shared port). - singleton: bool = False - - #: forwarded ports of this container - forwarded_ports: List[PortForwarding] = field(default_factory=list) - - #: optional list of volumes that should be mounted in this container - volume_mounts: List[Union[ContainerVolume, BindMount]] = field( - default_factory=list - ) + #: Prefix of images from the local storage + _LOCAL_PREFIX = "containers-storage:" - _is_local: bool = False + def __new__(cls: Type[T], *args: Any, **kwargs: Any) -> T: + # Filter out all fields of ParameterSet and invoke object.__new__ only for the + # fields that it supports + parameter_set_fields = _pytest.mark.ParameterSet._fields + filtered_kwargs = {} + for f in parameter_set_fields: + filtered_kwargs[f] = kwargs.get(f, None) - def __post_init__(self) -> None: - local_prefix = "containers-storage:" - if self.url.startswith(local_prefix): - self._is_local = True - # returns before_separator, separator, after_separator - before, sep, self.url = self.url.partition(local_prefix) - assert before == "" and sep == local_prefix + return super().__new__(cls, *args, **filtered_kwargs) + + def __init__( + self, + url: str = "", + container_id: str = "", + entry_point: EntrypointSelection = EntrypointSelection.AUTO, + custom_entry_point: Optional[str] = None, + extra_launch_args: Optional[List[str]] = None, + extra_entrypoint_args: Optional[List[str]] = None, + healthcheck_timeout: Optional[timedelta] = None, + extra_environment_variables: Optional[Dict[str, str]] = None, + singleton: bool = False, + forwarded_ports: Optional[List[PortForwarding]] = None, + volume_mounts: Optional[ + List[Union[ContainerVolume, BindMount]] + ] = None, + marks: Optional[Collection[Union[MarkDecorator, Mark]]] = None, + ) -> None: + #: + #: + self.url = url + + #: id of the container if it is not available via a registry URL + self.container_id = container_id + + #: Defines which entrypoint of the container is used. + #: By default either :py:attr:`custom_entry_point` will be used (if defined) + #: or the container's entrypoint or cmd. If neither of the two is set, then + #: :file:`/bin/bash` will be used. + self.entry_point = entry_point + + #: custom entry point for this container (i.e. neither its default, nor + #: :file:`/bin/bash`) + self.custom_entry_point = custom_entry_point + + #: List of additional flags that will be inserted after + #: `docker/podman run -d` and before the image name (i.e. these arguments + #: are not passed to the entrypoint or ``CMD``). The list must be properly + #: escaped, e.g. as created by ``shlex.split``. + self.extra_launch_args = extra_launch_args or [] + + #: List of additional arguments that are passed to the ``CMD`` or + #: entrypoint. These arguments are inserted after the :command:`docker/podman + #: run -d $image` on launching the image. + #: The list must be properly escaped, e.g. by passing the string through + #: ``shlex.split``. + #: The arguments must not cause the container to exit early. It must remain + #: active in the background, otherwise this library will not function + #: properly. + self.extra_entrypoint_args = extra_entrypoint_args or [] + + #: Time for the container to become healthy (the timeout is ignored + #: when the container image defines no ``HEALTHCHECK`` or when the timeout + #: is below zero). + #: When the value is ``None``, then the timeout will be inferred from the + #: container image's ``HEALTHCHECK`` directive. + self.healthcheck_timeout = healthcheck_timeout + + #: additional environment variables that should be injected into the + #: container + self.extra_environment_variables = extra_environment_variables + + #: Indicate whether there must never be more than one running container of + #: this type at all times (e.g. because it opens a shared port). + self.singleton = singleton + + #: forwarded ports of this container + self.forwarded_ports = forwarded_ports or [] + + #: optional list of volumes that should be mounted in this container + self.volume_mounts = volume_mounts or [] + + #: optional list of marks applied to this container image under test + self._marks = marks or [] + + def __eq__(self, value: object) -> bool: + if not isinstance(value, ContainerBase): + return False + + if set(self.__dict__.keys()) != set(value.__dict__.keys()): + return False + + for k, v in self.__dict__.items(): + if v != value.__dict__[k]: + return False + + return True + + def __ne__(self, value: object) -> bool: + return not self.__eq__(value) def __str__(self) -> str: return self.url or self.container_id + def __bool__(self) -> bool: + return True + + @property + def url(self) -> str: + """Full url to this container via which it can be pulled. + + If your container image is not available via a registry and only + locally, then you can use the following syntax: + ``containers-storage:$local_name`` + + Note that the prefix ``containers-storage`` is stripped from the + url. Whether the image is only local can be inferred via the property + :py:attr:`~pytest_container.ContainerBase.local_image`. + + """ + return self._url + + @url.setter + def url(self, new_url: str) -> None: + if new_url.startswith(self._LOCAL_PREFIX): + self._is_local = True + # returns before_separator, separator, after_separator + before, sep, self._url = new_url.partition(self._LOCAL_PREFIX) + assert before == "" and sep == self._LOCAL_PREFIX + else: + self._is_local = False + self._url = new_url + + def dict(self) -> Dict[str, Any]: + """Returns all attributes as a dictionary so that they can be passed + into the constructor to obtain the same object. + + """ + res = {} + for attr in ( + "container_id", + "custom_entry_point", + "entry_point", + "extra_entrypoint_args", + "extra_environment_variables", + "extra_launch_args", + "forwarded_ports", + "healthcheck_timeout", + "marks", + "singleton", + "volume_mounts", + ): + res[attr] = getattr(self, attr) + + res["url"] = (self._LOCAL_PREFIX if self._is_local else "") + self.url + + return res + @property def _build_tag(self) -> str: """Internal build tag assigned to each immage, either the image url or @@ -525,6 +645,28 @@ def local_image(self) -> bool: """ return self._is_local + @property + def marks(self) -> Collection[Union[MarkDecorator, Mark]]: + """Marks added to this container.""" + return self._marks + + @property + def values(self) -> Tuple[Self, ...]: + """Returns a tuple with itself as the only element. This property is for + compatibility with pytest's ``ParameterSet``. + + """ + return (self,) + + @property + def id(self) -> str: + """Textual representation of this container for tests. + + **NOT** the container id! + + """ + return str(self) + def get_launch_cmd( self, container_runtime: OciRuntimeBase, @@ -630,13 +772,6 @@ def filelock_filename(self) -> str: # that is not available on old python versions that we still support return f"{sha3_256((''.join(all_elements)).encode()).hexdigest()}.lock" - -class ContainerBaseABC(ABC): - """Abstract base class defining the methods that must be implemented by the - classes fed to the ``*container*`` fixtures. - - """ - @abstractmethod def prepare_container( self, @@ -662,8 +797,7 @@ def baseurl(self) -> Optional[str]: """ -@dataclass(unsafe_hash=True) -class Container(ContainerBase, ContainerBaseABC): +class Container(ContainerBase): """This class stores information about the Container Image under test.""" def pull_container(self, container_runtime: OciRuntimeBase) -> None: @@ -701,8 +835,30 @@ def baseurl(self) -> Optional[str]: return self.url -@dataclass(unsafe_hash=True) -class DerivedContainer(ContainerBase, ContainerBaseABC): +_ContainerBaseArgs = Tuple[ + str, + str, + EntrypointSelection, + Optional[str], + Optional[List[str]], + Optional[List[str]], + Optional[timedelta], + Optional[Dict[str, str]], + bool, + Optional[List[PortForwarding]], + Optional[List[Union[ContainerVolume, BindMount]]], + Optional[Collection[Union[MarkDecorator, Mark]]], +] + + +class _DerivedContainerKwargs(_ContainerBaseKwargs): + base: Union[Container, "DerivedContainer", str] + containerfile: str + image_format: Optional[ImageFormat] + add_build_tags: Optional[List[str]] + + +class DerivedContainer(ContainerBase): """Class for storing information about the Container Image under test, that is build from a :file:`Containerfile`/:file:`Dockerfile` from a different image (can be any image from a registry or an instance of @@ -710,29 +866,67 @@ class DerivedContainer(ContainerBase, ContainerBaseABC): """ - base: Union[Container, "DerivedContainer", str] = "" + def __init__( + self, + base: Union[Container, "DerivedContainer", str], + containerfile: str = "", + image_format: Optional[ImageFormat] = None, + add_build_tags: Optional[List[str]] = None, + *args: Unpack[_ContainerBaseArgs], + **kwargs: Unpack[_ContainerBaseKwargs], + ) -> None: + super().__init__(*args, **kwargs) - #: The :file:`Containerfile` that is used to build this container derived - #: from :py:attr:`base`. - containerfile: str = "" + #: The base container of this container image + self.base = base - #: An optional image format when building images with :command:`buildah`. It - #: is ignored when the container runtime is :command:`docker`. - #: The ``oci`` image format is used by default. If the image format is - #: ``None`` and the base image has a ``HEALTHCHECK`` defined, then the - #: ``docker`` image format will be used instead. - #: Specifying an image format disables the auto-detection and uses the - #: supplied value. - image_format: Optional[ImageFormat] = None + #: The :file:`Containerfile` that is used to build this container derived + #: from :py:attr:`base`. + self.containerfile = containerfile - #: Additional build tags/names that should be added to the container once it - #: has been built - add_build_tags: List[str] = field(default_factory=list) + #: An optional image format when building images with :command:`buildah`. It + #: is ignored when the container runtime is :command:`docker`. + #: The ``oci`` image format is used by default. If the image format is + #: ``None`` and the base image has a ``HEALTHCHECK`` defined, then the + #: ``docker`` image format will be used instead. + #: Specifying an image format disables the auto-detection and uses the + #: supplied value. + self.image_format = image_format - def __post_init__(self) -> None: - super().__post_init__() - if not self.base: - raise ValueError("A base container must be provided") + #: Additional build tags/names that should be added to the container once it + #: has been built + self.add_build_tags = add_build_tags or [] + + def dict(self) -> Dict[str, Any]: + """Returns all attributes as a dictionary so that they can be passed + into the constructor to obtain the same object. + + """ + + return { + "base": self.base, + "containerfile": self.containerfile, + "image_format": self.image_format, + "add_build_tags": self.add_build_tags, + **super().dict(), + } + + @staticmethod + def _get_recursive_marks( + ctr: Union[Container, "DerivedContainer", str], + ) -> Collection[Union[MarkDecorator, Mark]]: + if isinstance(ctr, str): + return [] + if isinstance(ctr, Container): + return ctr._marks + + return tuple(ctr._marks) + tuple( + DerivedContainer._get_recursive_marks(ctr.base) + ) + + @property + def marks(self) -> Collection[Union[MarkDecorator, Mark]]: + return DerivedContainer._get_recursive_marks(self) @property def baseurl(self) -> Optional[str]: @@ -783,7 +977,9 @@ def prepare_container( with tempfile.TemporaryDirectory() as tmpdirname: containerfile_path = join(tmpdirname, "Dockerfile") iidfile = join(tmpdirname, str(uuid4())) - with open(containerfile_path, "w") as containerfile: + with open( + containerfile_path, "w", encoding="utf8" + ) as containerfile: from_id = ( self.base if isinstance(self.base, str) @@ -933,27 +1129,6 @@ def container_to_pytest_param( return pytest.param(container, marks=marks or [], id=str(container)) -@overload -def container_and_marks_from_pytest_param( - ctr_or_param: Container, -) -> Tuple[Container, Literal[None]]: ... - - -@overload -def container_and_marks_from_pytest_param( - ctr_or_param: DerivedContainer, -) -> Tuple[DerivedContainer, Literal[None]]: ... - - -@overload -def container_and_marks_from_pytest_param( - ctr_or_param: _pytest.mark.ParameterSet, -) -> Tuple[ - Union[Container, DerivedContainer], - Optional[Collection[Union[_pytest.mark.MarkDecorator, _pytest.mark.Mark]]], -]: ... - - def container_and_marks_from_pytest_param( ctr_or_param: Union[ _pytest.mark.ParameterSet, Container, DerivedContainer @@ -974,7 +1149,7 @@ def container_and_marks_from_pytest_param( """ if isinstance(ctr_or_param, (Container, DerivedContainer)): - return ctr_or_param, None + return ctr_or_param, ctr_or_param.marks if len(ctr_or_param.values) > 0 and isinstance( ctr_or_param.values[0], (Container, DerivedContainer) diff --git a/pytest_container/inspect.py b/pytest_container/inspect.py index 13f2315..2d1a2e5 100644 --- a/pytest_container/inspect.py +++ b/pytest_container/inspect.py @@ -186,6 +186,7 @@ def from_container_inspect( @dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes class ContainerState: """State of the container, it is populated from the ``State`` attribute in the inspect of a container. @@ -211,6 +212,7 @@ class ContainerState: @dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes class Config: """Container configuration obtained from the ``Config`` attribute in the inspect of a container. It features the most useful attributes and those @@ -292,6 +294,7 @@ class VolumeMount(Mount): @dataclass(frozen=True) +# pylint: disable-next=too-many-instance-attributes class ContainerInspect: """Common subset of the information exposed via :command:`podman inspect` and :command:`docker inspect`. diff --git a/pytest_container/plugin.py b/pytest_container/plugin.py index c232fb9..119c443 100644 --- a/pytest_container/plugin.py +++ b/pytest_container/plugin.py @@ -8,10 +8,12 @@ from subprocess import run from typing import Callable from typing import Generator +from typing import Union +from pytest_container.container import Container from pytest_container.container import ContainerData from pytest_container.container import ContainerLauncher -from pytest_container.container import container_and_marks_from_pytest_param +from pytest_container.container import DerivedContainer from pytest_container.logging import _logger from pytest_container.pod import PodData from pytest_container.pod import PodLauncher @@ -75,13 +77,12 @@ def fixture_funct( pytest_generate_tests. """ - try: - container, _ = container_and_marks_from_pytest_param(request.param) - except AttributeError as attr_err: - raise RuntimeError( - "This fixture was not parametrized correctly, " - "did you forget to call `auto_container_parametrize` in `pytest_generate_tests`?" - ) from attr_err + container: Union[DerivedContainer, Container] = ( + request.param + if isinstance(request.param, (DerivedContainer, Container)) + else request.param[0] + ) + assert isinstance(container, (DerivedContainer, Container)) _logger.debug("Requesting the container %s", str(container)) if scope == "session" and container.singleton: diff --git a/pytest_container/pod.py b/pytest_container/pod.py index 2758ce1..0660ed0 100644 --- a/pytest_container/pod.py +++ b/pytest_container/pod.py @@ -7,13 +7,24 @@ from pathlib import Path from subprocess import check_output from types import TracebackType +from typing import Any +from typing import Collection from typing import List from typing import Optional +from typing import TypeVar + +try: + from typing import Self +except ImportError: + from typing_extensions import Self +from typing import Tuple from typing import Type from typing import Union from _pytest.mark import ParameterSet from pytest import Config +from pytest import Mark +from pytest import MarkDecorator from pytest_container.container import Container from pytest_container.container import ContainerData @@ -29,9 +40,10 @@ from pytest_container.runtime import PodmanRuntime from pytest_container.runtime import get_selected_runtime +T = TypeVar("T", bound="Pod") -@dataclass -class Pod: + +class Pod(ParameterSet): """A pod is a collection of containers that share the same network and port forwards. Currently only :command:`podman` supports creating pods. @@ -40,11 +52,63 @@ class Pod: """ - #: containers belonging to the pod - containers: List[Union[DerivedContainer, Container]] + def __new__(cls: Type[T], *args: Any, **kwargs: Any) -> T: + # Filter out all fields of ParameterSet and invoke object.__new__ only for the + # fields that it supports + parameter_set_fields = ParameterSet._fields + filtered_kwargs = {} + for f in parameter_set_fields: + filtered_kwargs[f] = kwargs.get(f, None) + + return super().__new__(cls, *args, **filtered_kwargs) + + def __init__( + self, + containers: List[Union[DerivedContainer, Container]], + forwarded_ports: Optional[List[PortForwarding]] = None, + marks: Optional[Collection[Union[MarkDecorator, Mark]]] = None, + ) -> None: + #: containers belonging to the pod + self.containers = containers + + #: ports exposed by the pod + self.forwarded_ports = forwarded_ports or [] + + self._marks = marks or [] + + @property + def values(self) -> Tuple[Self]: + """Returns a tuple with itself as the only element. This property is for + compatibility with pytest's ``ParameterSet``. + + """ + return (self,) + + @property + def marks(self) -> Collection[Union[MarkDecorator, Mark]]: + """Returns all marks of this pod concatenated with all marks of all + containers. + + """ + marks = tuple(self._marks) + for ctr in self.containers: + marks += tuple(ctr.marks) + return marks + + @property + def id(self) -> str: + """Textual representation of this pod for tests. + + **NOT** the pod's id! + + """ + + return "Pod with containers: " + ",".join( + str(c) for c in self.containers + ) - #: ports exposed by the pod - forwarded_ports: List[PortForwarding] = field(default_factory=list) + def __bool__(self) -> bool: + return True @dataclass(frozen=True) diff --git a/source/conf.py b/source/conf.py index 13b59da..b5da400 100644 --- a/source/conf.py +++ b/source/conf.py @@ -58,7 +58,10 @@ intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} nitpicky = True -nitpick_ignore = [("py:class", "py._path.local.LocalPath")] +nitpick_ignore = [ + ("py:class", "py._path.local.LocalPath"), + ("py:class", "pathlib._local.Path"), +] nitpick_ignore_regex = [ ("py:class", "_pytest.*"), ("py:class", ".*BaseFileLock.*"), diff --git a/source/usage.rst b/source/usage.rst index 651b919..ea4f39c 100644 --- a/source/usage.rst +++ b/source/usage.rst @@ -8,7 +8,7 @@ Sometimes it is necessary to customize the build, run or pod create parameters of the container runtime globally, e.g. to use the host's network with docker via ``--network=host``. -The :py:meth:`~pytest_container.container.ContainerBaseABC.prepare_container` +The :py:meth:`~pytest_container.container.ContainerBase.prepare_container` and :py:meth:`~pytest_container.container.ContainerBase.get_launch_cmd` methods support passing such additional arguments/flags, but this is rather cumbersome to use in practice. The ``*container*`` and ``pod*`` fixtures will therefore diff --git a/tests/images.py b/tests/images.py index 06ad662..0c7347d 100644 --- a/tests/images.py +++ b/tests/images.py @@ -1,5 +1,7 @@ """Module that defines all commonly used container images for testing.""" +import pytest + from pytest_container.container import Container from pytest_container.container import DerivedContainer from pytest_container.container import ImageFormat @@ -57,3 +59,5 @@ LEAP_WITH_MAN_AND_LUA = DerivedContainer( base=LEAP_WITH_MAN, containerfile="RUN zypper -n in lua" ) + +LEAP_WITH_MARK = Container(url=LEAP_URL, marks=[pytest.mark.secretleapmark]) diff --git a/tests/test_container.py b/tests/test_container.py index 5eae9b1..a48ddad 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -1,6 +1,7 @@ # pylint: disable=missing-function-docstring,missing-module-docstring from pathlib import Path from tempfile import gettempdir +from typing import Any from typing import Optional from typing import Union @@ -8,24 +9,15 @@ from pytest_container import Container from pytest_container import DerivedContainer +from pytest_container.container import ContainerBase from pytest_container.container import ContainerLauncher +from pytest_container.container import EntrypointSelection from pytest_container.container import ImageFormat from pytest_container.runtime import OciRuntimeBase from . import images -def test_derived_container_fails_without_base() -> None: - """Ensure that a DerivedContainer cannot be instantiated without providing - the base parameter. - - """ - with pytest.raises(ValueError) as val_err_ctx: - DerivedContainer() - - assert str(val_err_ctx.value) == "A base container must be provided" - - def test_get_base_of_derived_container() -> None: """Ensure that :py:meth:`~pytest_container.DerivedContainer.get_base` returns a :py:class:`Container` with the correct url. @@ -35,12 +27,86 @@ def test_get_base_of_derived_container() -> None: assert DerivedContainer(base=url).get_base() == Container(url=url) +@pytest.mark.parametrize( + "ctr1, ctr2", + [ + (images.LEAP, images.LEAP), + (Container(url=images.LEAP_URL), images.LEAP), + ], +) +def test_equality(ctr1: ContainerBase, ctr2: ContainerBase) -> None: + assert ctr1 == ctr2 + assert not ctr1 != ctr2 + + +@pytest.mark.parametrize( + "ctr1, ctr2", + [ + (images.LEAP, images.LEAP_WITH_MAN), + (Container(url=images.LEAP_URL), "foobar"), + ( + images.LEAP, + Container( + url=images.LEAP_URL, entry_point=EntrypointSelection.BASH + ), + ), + ], +) +def test_ctr_inequality(ctr1: ContainerBase, ctr2: Any) -> None: + assert ctr1 != ctr2 + assert not ctr1 == ctr2 + + def test_image_format() -> None: """Check that the string representation of the ImageFormat enum is correct.""" assert str(ImageFormat.DOCKER) == "docker" assert str(ImageFormat.OCIv1) == "oci" +@pytest.mark.parametrize( + "ctr", + [ + images.LEAP, + images.LEAP_WITH_MAN, + images.LEAP_WITH_MAN, + images.BUSYBOX, + images.LEAP_WITH_MAN_AND_LUA, + Container(url="containers-storage:foo.com/baz:latest"), + images.LEAP_WITH_MARK, + ], +) +def test_dict_can_be_passed_into_constructor(ctr: ContainerBase) -> None: + assert type(ctr)(**ctr.dict()) == ctr + + +@pytest.mark.parametrize( + "ctr, new_url, expected_url, expected_local_image", + ( + ( + Container(url="foobar.com:latest"), + "baz.com:latest", + "baz.com:latest", + False, + ), + ( + Container(url="foobar.com:latest"), + "containers-storage:baz.com:latest", + "baz.com:latest", + True, + ), + ), +) +def test_url_setting( + ctr: ContainerBase, + new_url: str, + expected_url: str, + expected_local_image: bool, +) -> None: + ctr.url = new_url + assert ctr.url == expected_url + assert ctr.local_image == expected_local_image + + def test_local_image_url(container_runtime: OciRuntimeBase) -> None: url = "docker.io/library/iDontExistHopefully/bazbarf/something" cont = Container(url=f"containers-storage:{url}") diff --git a/tests/test_marks.py b/tests/test_marks.py new file mode 100644 index 0000000..849efd9 --- /dev/null +++ b/tests/test_marks.py @@ -0,0 +1,98 @@ +# pylint: disable=missing-function-docstring,missing-module-docstring +import pytest +from _pytest.mark import ParameterSet + +from pytest_container.container import Container +from pytest_container.container import ContainerBase +from pytest_container.container import DerivedContainer +from pytest_container.pod import Pod +from tests.images import LEAP_URL +from tests.images import LEAP_WITH_MARK + +DERIVED_ON_LEAP_WITH_MARK = DerivedContainer(base=LEAP_WITH_MARK) + +SECOND_DERIVED_ON_LEAP = DerivedContainer( + base=DERIVED_ON_LEAP_WITH_MARK, marks=[pytest.mark.othersecretmark] +) + +INDEPENDENT_OTHER_LEAP = Container( + url=LEAP_URL, marks=[pytest.mark.othersecretmark] +) + +UNMARKED_POD = Pod(containers=[LEAP_WITH_MARK, INDEPENDENT_OTHER_LEAP]) + +MARKED_POD = Pod( + containers=[LEAP_WITH_MARK, INDEPENDENT_OTHER_LEAP], + marks=[pytest.mark.secretpodmark], +) + + +def test_marks() -> None: + assert list(LEAP_WITH_MARK.marks) == [pytest.mark.secretleapmark] + assert list(DERIVED_ON_LEAP_WITH_MARK.marks) == [ + pytest.mark.secretleapmark + ] + assert list(SECOND_DERIVED_ON_LEAP.marks) == [ + pytest.mark.othersecretmark, + pytest.mark.secretleapmark, + ] + assert not DerivedContainer( + base=LEAP_URL, containerfile="ENV HOME=/root" + ).marks + + pod_marks = UNMARKED_POD.marks + assert ( + len(pod_marks) == 2 + and pytest.mark.othersecretmark in pod_marks + and pytest.mark.secretleapmark in pod_marks + ) + + pod_marks = MARKED_POD.marks + assert ( + len(pod_marks) == 3 + and pytest.mark.othersecretmark in pod_marks + and pytest.mark.secretleapmark in pod_marks + and pytest.mark.secretpodmark in pod_marks + ) + + +@pytest.mark.parametrize( + "ctr", + [ + LEAP_WITH_MARK, + DERIVED_ON_LEAP_WITH_MARK, + SECOND_DERIVED_ON_LEAP, + INDEPENDENT_OTHER_LEAP, + ], +) +def test_container_is_pytest_param(ctr) -> None: + assert isinstance(ctr, ParameterSet) + assert isinstance(ctr, (Container, DerivedContainer)) + + +@pytest.mark.parametrize( + "ctr", + [ + LEAP_WITH_MARK, + DERIVED_ON_LEAP_WITH_MARK, + SECOND_DERIVED_ON_LEAP, + INDEPENDENT_OTHER_LEAP, + ], +) +def test_container_is_truthy(ctr: ContainerBase) -> None: + """Regression test that we don't accidentally inherit __bool__ from tuple + and the container is False by default. + + """ + assert ctr + + +@pytest.mark.parametrize("pd", [MARKED_POD, UNMARKED_POD]) +def test_pod_is_pytest_param(pd: Pod) -> None: + assert isinstance(pd, ParameterSet) + assert isinstance(pd, Pod) + + +@pytest.mark.parametrize("pd", [MARKED_POD, UNMARKED_POD]) +def test_pod_is_truthy(pd: Pod) -> None: + assert pd diff --git a/tests/test_port_forwarding.py b/tests/test_port_forwarding.py index e37fbef..e697073 100644 --- a/tests/test_port_forwarding.py +++ b/tests/test_port_forwarding.py @@ -131,7 +131,7 @@ def test_port_forward_set_up(auto_container: ContainerData, host): assert ( host.check_output( - f"{_CURL} localhost:{auto_container.forwarded_ports[0].host_port}", + f"{_CURL} 0.0.0.0:{auto_container.forwarded_ports[0].host_port}", ).strip() == "Hello Green World!" ) @@ -163,15 +163,16 @@ def test_multiple_open_ports(container: ContainerData, number: int, host): and container.forwarded_ports[0].container_port == 80 ) assert f"Test page {number}" in host.check_output( - f"{_CURL} localhost:{container.forwarded_ports[0].host_port}" + f"{_CURL} 0.0.0.0:{container.forwarded_ports[0].host_port}" ) assert ( container.forwarded_ports[1].protocol == NetworkProtocol.TCP and container.forwarded_ports[1].container_port == 443 ) + assert f"Test page {number}" in host.check_output( - f"curl --insecure https://localhost:{container.forwarded_ports[1].host_port}", + f"curl --insecure https://0.0.0.0:{container.forwarded_ports[1].host_port}", ) @@ -243,7 +244,7 @@ def test_container_bind_to_host_port( assert launcher.container_data.forwarded_ports[0].host_port == PORT assert ( - host.check_output(f"{_CURL} http://localhost:{PORT}").strip() + host.check_output(f"{_CURL} http://0.0.0.0:{PORT}").strip() == "Hello Green World!" ) @@ -273,6 +274,6 @@ def test_pod_bind_to_host_port( assert launcher.pod_data.forwarded_ports[0].host_port == PORT assert ( - host.check_output(f"{_CURL} http://localhost:{PORT}").strip() + host.check_output(f"{_CURL} http://0.0.0.0:{PORT}").strip() == "Hello Green World!" ) diff --git a/tests/test_pytest_param.py b/tests/test_pytest_param.py index b1a360d..3745fd9 100644 --- a/tests/test_pytest_param.py +++ b/tests/test_pytest_param.py @@ -131,7 +131,7 @@ def test_container_and_marks_from_pytest_param() -> None: ) assert cont == LEAP and not marks - assert container_and_marks_from_pytest_param(LEAP) == (LEAP, None) + assert container_and_marks_from_pytest_param(LEAP) == (LEAP, LEAP.marks) derived = DerivedContainer(base=LEAP, containerfile="ENV foo=bar") cont, marks = container_and_marks_from_pytest_param( @@ -139,7 +139,10 @@ def test_container_and_marks_from_pytest_param() -> None: ) assert cont == derived and not marks - assert container_and_marks_from_pytest_param(derived) == (derived, None) + assert container_and_marks_from_pytest_param(derived) == ( + derived, + derived.marks, + ) with pytest.raises(ValueError) as val_err_ctx: container_and_marks_from_pytest_param(pytest.param(16, 45)) diff --git a/tox.ini b/tox.ini index fe5d766..8660c39 100644 --- a/tox.ini +++ b/tox.ini @@ -47,6 +47,7 @@ commands = ruff check [testenv:format] +package = skip allowlist_externals = ./format.sh deps = ruff