Repository: gabrieldemarmiesse/python-on-whales (language: python)

Issue:
Implementing --pull flag for commands 'run' and 'create' and functions 'copy_from' and 'copy_to' (#369)
Hi everyone :) 

I found the open issue [#369](https://github.com/gabrieldemarmiesse/python-on-whales/issues/369) and decided to implement the flag _--pull_.

With the proposed changes the following now happens (assume that I don't yet have the desired image stored locally:

```python
>>> from python_on_whales import docker
>>> docker.container.run('alpine:latest', pull='never')  # assumes that image exists locally
docker: Error response from daemon: No such image: alpine:latest.
```

Let's now pull the desired image:

```python

>>> docker.container.run('alpine:latest', pull='missing')  # pulls the image only if required
Unable to find image 'alpine:latest' locally
latest: Pulling from library/alpine
213ec9aee27d: Already exists
Digest: sha256:bc41182d7ef5ffc53a40b044e725193bc10142a1243f395ee852a8d9730fc2ad
Status: Downloaded newer image for alpine:latest
''
```

If now I rerun both the previous commands the containers will start automatically as I already have the desired image stored locally: 

```python

>>> docker.container.run('alpine:latest', pull='never')
''
>>> docker.container.run('alpine:latest', pull='missing')
''
```
If required I can force the image to be pulled every time:

```python

>>> docker.container.run('alpine:latest', pull='always')  # always pulls the image
latest: Pulling from library/alpine
Digest: sha256:bc41182d7ef5ffc53a40b044e725193bc10142a1243f395ee852a8d9730fc2ad
Status: Image is up to date for alpine:latest
''
>>> docker.container.run('alpine:latest', pull='always')
latest: Pulling from library/alpine
Digest: sha256:bc41182d7ef5ffc53a40b044e725193bc10142a1243f395ee852a8d9730fc2ad
Status: Image is up to date for alpine:latest
''
```

By default, the value _missing_ is assumed, identically to the Docker CLI.
In this case, everything works exactly the same as before without any required changes.

```python
>>> docker.container.run('alpine:latest')  # same as docker.container.run('alpine:latest', pull='missing')
```

The same exact mechanism applies to the _create_ command.

```python
>>> docker.container.create('alpine:latest')  # same as  docker.container.create('alpine:latest', pull='missing')
python_on_whales.Container(id='ce4add4deb27', name='confident_davinci')

# The following command takes a bit more time due to pulling the image
>>> docker.container.create('alpine:latest', pull='always')
python_on_whales.Container(id='12584d2d79bb', name='beautiful_bassi')
```

Finally, I think it could be helpful also to control when to pull an image whilst using the functions ```docker.image.copy_from``` and ```docker.image.copy_to```.  Therefore the exact same mechanism was then implemented for these functions.

Suggested changes include unit tests.


Discussion / hints:

Fix patch:
diff --git a/python_on_whales/command_line_entrypoint.py b/python_on_whales/command_line_entrypoint.py
index 8a7ee934..b1964ec9 100644
--- a/python_on_whales/command_line_entrypoint.py
+++ b/python_on_whales/command_line_entrypoint.py
@@ -27,9 +27,8 @@ def copy(source: str, destination: str):
 
 
 @image_app.command()
-def copy_from(docker_image: str, source: str, destination: str):
-    image = docker.image._pull_if_necessary(docker_image)
-    docker.image.copy_from(image, source, destination)
+def copy_from(docker_image: str, source: str, destination: str, pull: str = "missing"):
+    docker.image.copy_from(docker_image, source, destination, pull)
 
 
 @image_app.command()
@@ -39,9 +38,9 @@ def copy_to(
     destination: str,
     new_tag: Optional[str] = None,
     push: bool = False,
+    pull: str = "missing",
 ):
-    image = docker.image._pull_if_necessary(docker_image)
-    docker.image.copy_to(image, source, destination, new_tag)
+    docker.image.copy_to(docker_image, source, destination, new_tag, pull)
     if push:
         docker.image.push(new_tag)
 
diff --git a/python_on_whales/components/container/cli_wrapper.py b/python_on_whales/components/container/cli_wrapper.py
index 595fce3b..1753ca5c 100644
--- a/python_on_whales/components/container/cli_wrapper.py
+++ b/python_on_whales/components/container/cli_wrapper.py
@@ -557,6 +557,7 @@ def create(
         privileged: bool = False,
         publish: List[ValidPortMapping] = [],
         publish_all: bool = False,
+        pull: str = "missing",
         read_only: bool = False,
         restart: Optional[str] = None,
         remove: bool = False,
@@ -595,9 +596,15 @@ def create(
 
         The arguments are the same as [`docker.run`](#run).
         """
-        python_on_whales.components.image.cli_wrapper.ImageCLI(
+
+        image_cli = python_on_whales.components.image.cli_wrapper.ImageCLI(
             self.client_config
-        )._pull_if_necessary(image)
+        )
+        if pull == "missing":
+            image_cli._pull_if_necessary(image)
+        elif pull == "always":
+            image_cli.pull(image)
+
         full_cmd = self.docker_cmd + ["create"]
 
         add_hosts = [f"{host}:{ip}" for host, ip in add_hosts]
@@ -704,6 +711,9 @@ def create(
         self._add_publish_to_command(full_cmd, publish)
         full_cmd.add_flag("--publish-all", publish_all)
 
+        if pull == "never":
+            full_cmd.add_simple_arg("--pull", "never")
+
         full_cmd.add_flag("--read-only", read_only)
         full_cmd.add_simple_arg("--restart", restart)
         full_cmd.add_flag("--rm", remove)
@@ -1236,6 +1246,7 @@ def run(
         privileged: bool = False,
         publish: List[ValidPortMapping] = [],
         publish_all: bool = False,
+        pull: str = "missing",
         read_only: bool = False,
         restart: Optional[str] = None,
         remove: bool = False,
@@ -1393,6 +1404,7 @@ def run(
                 the tuple to signify that you want a random free port on the host. For example:
                 `publish=[(80,)]`.
             publish_all: Publish all exposed ports to random ports.
+            pull: Pull image before running ("always"|"missing"|"never") (default "missing").
             read_only: Mount the container's root filesystem as read only.
             restart: Restart policy to apply when a container exits (default "no")
             remove: Automatically remove the container when it exits.
@@ -1417,9 +1429,13 @@ def run(
             and a `python_on_whales.Container` if detach is `True`.
         """
 
-        python_on_whales.components.image.cli_wrapper.ImageCLI(
+        image_cli = python_on_whales.components.image.cli_wrapper.ImageCLI(
             self.client_config
-        )._pull_if_necessary(image)
+        )
+        if pull == "missing":
+            image_cli._pull_if_necessary(image)
+        elif pull == "always":
+            image_cli.pull(image)
 
         full_cmd = self.docker_cmd + ["container", "run"]
 
@@ -1530,6 +1546,9 @@ def run(
         self._add_publish_to_command(full_cmd, publish)
         full_cmd.add_flag("--publish-all", publish_all)
 
+        if pull == "never":
+            full_cmd.add_simple_arg("--pull", "never")
+
         full_cmd.add_flag("--read-only", read_only)
         full_cmd.add_simple_arg("--restart", restart)
         full_cmd.add_flag("--rm", remove)
diff --git a/python_on_whales/components/image/cli_wrapper.py b/python_on_whales/components/image/cli_wrapper.py
index fb8805d2..cb73338f 100644
--- a/python_on_whales/components/image/cli_wrapper.py
+++ b/python_on_whales/components/image/cli_wrapper.py
@@ -8,12 +8,15 @@
 from typing import Any, Dict, Iterator, List, Optional, Union, overload
 
 import python_on_whales.components.buildx.cli_wrapper
-import python_on_whales.components.container.cli_wrapper
 from python_on_whales.client_config import (
     ClientConfig,
     DockerCLICaller,
     ReloadableObjectFromJson,
 )
+from python_on_whales.components.container.cli_wrapper import (
+    ContainerCLI,
+    ContainerConfig,
+)
 from python_on_whales.components.image.models import (
     ImageGraphDriver,
     ImageInspectResult,
@@ -82,7 +85,7 @@ def container(self) -> str:
     @property
     def container_config(
         self,
-    ) -> python_on_whales.components.container.cli_wrapper.ContainerConfig:
+    ) -> ContainerConfig:
         return self._get_inspect_result().container_config
 
     @property
@@ -96,7 +99,7 @@ def author(self) -> str:
     @property
     def config(
         self,
-    ) -> python_on_whales.components.container.cli_wrapper.ContainerConfig:
+    ) -> ContainerConfig:
         return self._get_inspect_result().config
 
     @property
@@ -158,18 +161,23 @@ def tag(self, new_tag: str) -> None:
         """
         return ImageCLI(self.client_config).tag(self, new_tag)
 
-    def copy_from(self, path_in_image: ValidPath, destination: ValidPath):
+    def copy_from(
+        self, path_in_image: ValidPath, destination: ValidPath, pull: str = "missing"
+    ):
         """Copy a file from a docker image in the local filesystem.
 
         See the `docker.image.copy_from` command for information about the arguments.
         """
-        return ImageCLI(self.client_config).copy_from(self, path_in_image, destination)
+        return ImageCLI(self.client_config).copy_from(
+            self, path_in_image, destination, pull
+        )
 
     def copy_to(
         self,
         local_path: ValidPath,
         path_in_image: ValidPath,
         new_tag: Optional[str] = None,
+        pull: str = "missing",
     ) -> Image:
         """Copy a file from the local filesystem in a docker image to create a new
         docker image.
@@ -179,7 +187,7 @@ def copy_to(
         See the `docker.image.copy_to` command for information about the arguments.
         """
         return ImageCLI(self.client_config).copy_to(
-            self, local_path, path_in_image, new_tag
+            self, local_path, path_in_image, new_tag, pull
         )
 
     def exists(self) -> bool:
@@ -678,11 +686,13 @@ def _pull_if_necessary(self, image: ValidImage) -> Image:
             return self.pull(image)
 
     def copy_from(
-        self, image: ValidImage, path_in_image: ValidPath, destination: ValidPath
+        self,
+        image: ValidImage,
+        path_in_image: ValidPath,
+        destination: ValidPath,
+        pull: str = "missing",
     ):
-        with python_on_whales.components.container.cli_wrapper.ContainerCLI(
-            self.client_config
-        ).create(image) as tmp_container:
+        with ContainerCLI(self.client_config).create(image, pull=pull) as tmp_container:
             tmp_container.copy_from(path_in_image, destination)
 
     def copy_to(
@@ -691,9 +701,10 @@ def copy_to(
         local_path: ValidPath,
         path_in_image: ValidPath,
         new_tag: Optional[str] = None,
+        pull: str = "missing",
     ) -> Image:
-        with python_on_whales.components.container.cli_wrapper.ContainerCLI(
-            self.client_config
-        ).create(base_image) as tmp_container:
+        with ContainerCLI(self.client_config).create(
+            base_image, pull=pull
+        ) as tmp_container:
             tmp_container.copy_to(local_path, path_in_image)
             return tmp_container.commit(tag=new_tag)


Test patch:
diff --git a/tests/python_on_whales/components/test_container.py b/tests/python_on_whales/components/test_container.py
index c6641ef4..69dc48fc 100644
--- a/tests/python_on_whales/components/test_container.py
+++ b/tests/python_on_whales/components/test_container.py
@@ -702,3 +702,320 @@ def test_attach_sig_proxy_argument(run_mock: Mock, inspect_mock: Mock) -> None:
     run_mock.assert_called_once_with(
         docker.client_config.docker_cmd + ["attach", test_container_name], tty=True
     )
+
+
+@patch("python_on_whales.components.container.cli_wrapper.run")
+@patch("python_on_whales.components.container.cli_wrapper.Container")
+@patch("python_on_whales.components.image.cli_wrapper.ImageCLI")
+def test_container_create_default_pull(
+    image_mock: Mock, _: Mock, run_mock: Mock
+) -> None:
+
+    image_cli_mock = Mock()
+    image_mock.return_value = image_cli_mock
+
+    test_image_name = "test_dummy_image"
+
+    docker.create(test_image_name)
+
+    image_cli_mock._pull_if_necessary.assert_called_once_with(test_image_name)
+    image_cli_mock.pull.assert_not_called()
+    run_mock.assert_called_once_with(
+        docker.client_config.docker_cmd + ["create", test_image_name]
+    )
+
+
+@patch("python_on_whales.components.container.cli_wrapper.run")
+@patch("python_on_whales.components.container.cli_wrapper.Container")
+@patch("python_on_whales.components.image.cli_wrapper.ImageCLI")
+def test_container_create_missing_pull(
+    image_mock: Mock, _: Mock, run_mock: Mock
+) -> None:
+
+    image_cli_mock = Mock()
+    image_mock.return_value = image_cli_mock
+
+    test_image_name = "test_dummy_image"
+
+    docker.create(test_image_name, pull="missing")
+
+    image_cli_mock._pull_if_necessary.assert_called_once_with(test_image_name)
+    image_cli_mock.pull.assert_not_called()
+    run_mock.assert_called_once_with(
+        docker.client_config.docker_cmd + ["create", test_image_name]
+    )
+
+
+@patch("python_on_whales.components.container.cli_wrapper.run")
+@patch("python_on_whales.components.container.cli_wrapper.Container")
+@patch("python_on_whales.components.image.cli_wrapper.ImageCLI")
+def test_container_create_always_pull(
+    image_mock: Mock, _: Mock, run_mock: Mock
+) -> None:
+
+    image_cli_mock = Mock()
+    image_mock.return_value = image_cli_mock
+
+    test_image_name = "test_dummy_image"
+
+    docker.create(test_image_name, pull="always")
+
+    image_cli_mock._pull_if_necessary.assert_not_called()
+    image_cli_mock.pull.assert_called_once_with(test_image_name)
+    run_mock.assert_called_once_with(
+        docker.client_config.docker_cmd + ["create", test_image_name]
+    )
+
+
+@patch("python_on_whales.components.container.cli_wrapper.run")
+@patch("python_on_whales.components.container.cli_wrapper.Container")
+@patch("python_on_whales.components.image.cli_wrapper.ImageCLI")
+def test_container_create_never_pull(image_mock: Mock, _: Mock, run_mock: Mock) -> None:
+
+    image_cli_mock = Mock()
+    image_mock.return_value = image_cli_mock
+
+    test_image_name = "test_dummy_image"
+
+    docker.create(test_image_name, pull="never")
+
+    image_cli_mock._pull_if_necessary.assert_not_called()
+    image_cli_mock.pull.assert_not_called()
+    run_mock.assert_called_once_with(
+        docker.client_config.docker_cmd + ["create", "--pull", "never", test_image_name]
+    )
+
+
+@patch("python_on_whales.components.container.cli_wrapper.run")
+@patch("python_on_whales.components.container.cli_wrapper.Container")
+@patch("python_on_whales.components.image.cli_wrapper.ImageCLI")
+def test_container_run_default_pull(image_mock: Mock, _: Mock, run_mock: Mock) -> None:
+
+    image_cli_mock = Mock()
+    image_mock.return_value = image_cli_mock
+
+    test_image_name = "test_dummy_image"
+
+    docker.run(test_image_name)
+
+    image_cli_mock._pull_if_necessary.assert_called_once_with(test_image_name)
+    image_cli_mock.pull.assert_not_called()
+    run_mock.assert_called_once_with(
+        docker.client_config.docker_cmd + ["container", "run", test_image_name],
+        tty=False,
+        capture_stderr=False,
+    )
+
+
+@patch("python_on_whales.components.container.cli_wrapper.run")
+@patch("python_on_whales.components.container.cli_wrapper.Container")
+@patch("python_on_whales.components.image.cli_wrapper.ImageCLI")
+def test_container_run_missing_pull(image_mock: Mock, _: Mock, run_mock: Mock) -> None:
+
+    image_cli_mock = Mock()
+    image_mock.return_value = image_cli_mock
+
+    test_image_name = "test_dummy_image"
+
+    docker.run(test_image_name, pull="missing")
+
+    image_cli_mock._pull_if_necessary.assert_called_once_with(test_image_name)
+    image_cli_mock.pull.assert_not_called()
+    run_mock.assert_called_once_with(
+        docker.client_config.docker_cmd + ["container", "run", test_image_name],
+        tty=False,
+        capture_stderr=False,
+    )
+
+
+@patch("python_on_whales.components.container.cli_wrapper.run")
+@patch("python_on_whales.components.container.cli_wrapper.Container")
+@patch("python_on_whales.components.image.cli_wrapper.ImageCLI")
+def test_container_run_always_pull(image_mock: Mock, _: Mock, run_mock: Mock) -> None:
+
+    image_cli_mock = Mock()
+    image_mock.return_value = image_cli_mock
+
+    test_image_name = "test_dummy_image"
+
+    docker.run(test_image_name, pull="always")
+
+    image_cli_mock._pull_if_necessary.assert_not_called()
+    image_cli_mock.pull.assert_called_once_with(test_image_name)
+    run_mock.assert_called_once_with(
+        docker.client_config.docker_cmd + ["container", "run", test_image_name],
+        tty=False,
+        capture_stderr=False,
+    )
+
+
+@patch("python_on_whales.components.container.cli_wrapper.run")
+@patch("python_on_whales.components.container.cli_wrapper.Container")
+@patch("python_on_whales.components.image.cli_wrapper.ImageCLI")
+def test_container_run_never_pull(image_mock: Mock, _: Mock, run_mock: Mock) -> None:
+
+    image_cli_mock = Mock()
+    image_mock.return_value = image_cli_mock
+
+    test_image_name = "test_dummy_image"
+
+    docker.run(test_image_name, pull="never")
+
+    image_cli_mock._pull_if_necessary.assert_not_called()
+    image_cli_mock.pull.assert_not_called()
+    run_mock.assert_called_once_with(
+        docker.client_config.docker_cmd
+        + ["container", "run", "--pull", "never", test_image_name],
+        tty=False,
+        capture_stderr=False,
+    )
+
+
+def test_container_call_create_never_pull_error() -> None:
+
+    test_image = "alpine:latest"
+
+    if docker.image.exists(test_image):
+        docker.image.remove(test_image, force=True)
+
+    with pytest.raises(DockerException):
+        docker.container.create(test_image, pull="never")
+
+
+def test_container_call_run_never_pull_error() -> None:
+
+    test_image = "alpine:latest"
+
+    if docker.image.exists(test_image):
+        docker.image.remove(test_image, force=True)
+
+    with pytest.raises(DockerException):
+        docker.container.run(test_image, pull="never")
+
+
+def test_container_call_create_missing_pull_unexistent() -> None:
+
+    base_image_name = "alpine:latest"
+
+    if docker.image.exists(base_image_name):
+        docker.image.remove(base_image_name, force=True)
+    assert not docker.image.exists(base_image_name)
+    docker.container.create(base_image_name, pull="missing")
+    assert docker.image.exists(base_image_name)
+
+
+def test_container_call_create_missing_pull_existent(tmp_path, docker_registry) -> None:
+
+    base_image_name = "alpine:latest"
+    test_image_name = f"{docker_registry}/{base_image_name}"
+
+    base_image = docker.image.pull(base_image_name)
+    base_image.tag(test_image_name)
+    docker.push(test_image_name)
+    remote_id = base_image.id
+
+    (tmp_path / "dodo.txt").write_text("Hello world!")
+    updated_image = base_image.copy_to(
+        tmp_path / "dodo.txt", "/dada.txt", new_tag=test_image_name
+    )
+    local_id = updated_image.id
+
+    assert remote_id != local_id
+    docker.container.create(test_image_name, pull="missing")
+    assert docker.image.inspect(test_image_name).id == local_id
+
+
+def test_container_call_run_missing_pull_unexistent() -> None:
+
+    base_image_name = "alpine:latest"
+
+    if docker.image.exists(base_image_name):
+        docker.image.remove(base_image_name, force=True)
+    assert not docker.image.exists(base_image_name)
+    docker.container.run(base_image_name, pull="missing")
+    assert docker.image.exists(base_image_name)
+
+
+def test_container_call_run_missing_pull_existent(tmp_path, docker_registry) -> None:
+
+    base_image_name = "alpine:latest"
+    test_image_name = f"{docker_registry}/{base_image_name}"
+
+    base_image = docker.image.pull(base_image_name)
+    base_image.tag(test_image_name)
+    docker.push(test_image_name)
+    remote_id = base_image.id
+
+    (tmp_path / "dodo.txt").write_text("Hello world!")
+    updated_image = base_image.copy_to(
+        tmp_path / "dodo.txt", "/dada.txt", new_tag=test_image_name
+    )
+    local_id = updated_image.id
+
+    assert remote_id != local_id
+    docker.container.run(test_image_name, pull="missing")
+    assert docker.image.inspect(test_image_name).id == local_id
+
+
+def test_container_call_create_always_pull_unexistent() -> None:
+
+    base_image_name = "alpine:latest"
+
+    if docker.image.exists(base_image_name):
+        docker.image.remove(base_image_name, force=True)
+    assert not docker.image.exists(base_image_name)
+    docker.container.create(base_image_name, pull="always")
+    assert docker.image.exists(base_image_name)
+
+
+def test_container_call_create_always_pull_existent(tmp_path, docker_registry) -> None:
+
+    base_image_name = "alpine:latest"
+    test_image_name = f"{docker_registry}/{base_image_name}"
+
+    base_image = docker.image.pull(base_image_name)
+    base_image.tag(test_image_name)
+    docker.push(test_image_name)
+    remote_id = base_image.id
+
+    (tmp_path / "dodo.txt").write_text("Hello world!")
+    updated_image = base_image.copy_to(
+        tmp_path / "dodo.txt", "/dada.txt", new_tag=test_image_name
+    )
+    local_id = updated_image.id
+
+    assert remote_id != local_id
+    docker.container.create(test_image_name, pull="always")
+    assert docker.image.inspect(test_image_name).id == remote_id
+
+
+def test_container_call_run_always_pull_unexistent() -> None:
+
+    base_image_name = "alpine:latest"
+
+    if docker.image.exists(base_image_name):
+        docker.image.remove(base_image_name, force=True)
+    assert not docker.image.exists(base_image_name)
+    docker.container.run(base_image_name, pull="always")
+    assert docker.image.exists(base_image_name)
+
+
+def test_container_call_run_always_pull_existent(tmp_path, docker_registry) -> None:
+
+    base_image_name = "alpine:latest"
+    test_image_name = f"{docker_registry}/{base_image_name}"
+
+    base_image = docker.image.pull(base_image_name)
+    base_image.tag(test_image_name)
+    docker.push(test_image_name)
+    remote_id = base_image.id
+
+    (tmp_path / "dodo.txt").write_text("Hello world!")
+    updated_image = base_image.copy_to(
+        tmp_path / "dodo.txt", "/dada.txt", new_tag=test_image_name
+    )
+    local_id = updated_image.id
+
+    assert remote_id != local_id
+    docker.container.run(test_image_name, pull="always")
+    assert docker.image.inspect(test_image_name).id == remote_id
diff --git a/tests/python_on_whales/components/test_image.py b/tests/python_on_whales/components/test_image.py
index f8a8c7af..950ff043 100644
--- a/tests/python_on_whales/components/test_image.py
+++ b/tests/python_on_whales/components/test_image.py
@@ -1,3 +1,5 @@
+from unittest.mock import MagicMock, Mock, patch
+
 import pytest
 
 from python_on_whales import docker
@@ -252,3 +254,71 @@ def test_exists():
     assert docker.image.exists("busybox")
 
     assert not docker.image.exists("dudurghurozgiozpfezjigfoeioengizeonig")
+
+
+@patch("python_on_whales.components.image.cli_wrapper.ContainerCLI")
+def test_copy_from_default_pull(container_mock: Mock) -> None:
+
+    container_cli_mock = MagicMock()
+    container_mock.return_value = container_cli_mock
+
+    test_image_name = "test_dummy_image"
+
+    docker.image.copy_from(
+        test_image_name, "test_path_in_image", "test_local_destination"
+    )
+
+    container_cli_mock.create.assert_called_with(test_image_name, pull="missing")
+
+
+@patch("python_on_whales.components.image.cli_wrapper.ContainerCLI")
+def test_copy_from_pull(container_mock: Mock) -> None:
+
+    container_cli_mock = MagicMock()
+    container_mock.return_value = container_cli_mock
+
+    test_image_name = "test_dummy_image"
+    test_pull_flag = "test_pull_flag_value"
+
+    docker.image.copy_from(
+        test_image_name,
+        "test_path_in_image",
+        "test_local_destination",
+        pull=test_pull_flag,
+    )
+
+    container_cli_mock.create.assert_called_with(test_image_name, pull=test_pull_flag)
+
+
+@patch("python_on_whales.components.image.cli_wrapper.ContainerCLI")
+def test_copy_to_default_pull(container_mock: Mock) -> None:
+
+    container_cli_mock = MagicMock()
+    container_mock.return_value = container_cli_mock
+
+    test_image_name = "test_dummy_image"
+
+    docker.image.copy_to(
+        test_image_name, "test_local_destination", "test_path_in_image"
+    )
+
+    container_cli_mock.create.assert_called_with(test_image_name, pull="missing")
+
+
+@patch("python_on_whales.components.image.cli_wrapper.ContainerCLI")
+def test_copy_to_pull(container_mock: Mock) -> None:
+
+    container_cli_mock = MagicMock()
+    container_mock.return_value = container_cli_mock
+
+    test_image_name = "test_dummy_image"
+    test_pull_flag = "test_pull_flag_value"
+
+    docker.image.copy_to(
+        test_image_name,
+        "test_local_destination",
+        "test_path_in_image",
+        pull=test_pull_flag,
+    )
+
+    container_cli_mock.create.assert_called_with(test_image_name, pull=test_pull_flag)