Repository: CS-SI/eodag

Issue:
download_all callback
**[Original report](https://bitbucket.org/geostorm/eodag/issue/121) by me.**

----------------------------------------

When using download_all, provide the availability to launch a callback when each individual download in finished


Discussion:

Patch:
diff --git a/docs/api_reference/utils.rst b/docs/api_reference/utils.rst
index dec68622..315bf26f 100644
--- a/docs/api_reference/utils.rst
+++ b/docs/api_reference/utils.rst
@@ -10,9 +10,10 @@ Logging
 .. automodule:: eodag.utils.logging
    :members:
 
-Progress callback
+Callbacks
 -----------------
 
+.. autofunction:: eodag.utils.DownloadedCallback
 .. autofunction:: eodag.utils.ProgressCallback
 
 Notebook
diff --git a/eodag/api/core.py b/eodag/api/core.py
index 7d5d039b..e4f03232 100644
--- a/eodag/api/core.py
+++ b/eodag/api/core.py
@@ -1172,6 +1172,7 @@ class EODataAccessGateway(object):
     def download_all(
         self,
         search_result,
+        downloaded_callback=None,
         progress_callback=None,
         wait=DEFAULT_DOWNLOAD_WAIT,
         timeout=DEFAULT_DOWNLOAD_TIMEOUT,
@@ -1181,6 +1182,13 @@ class EODataAccessGateway(object):
 
         :param search_result: A collection of EO products resulting from a search
         :type search_result: :class:`~eodag.api.search_result.SearchResult`
+        :param downloaded_callback: (optional) A method or a callable object which takes
+                                    as parameter the ``product``. You can use the base class
+                                    :class:`~eodag.utils.DownloadedCallback` and override
+                                    its ``__call__`` method. Will be called each time a product
+                                    finishes downloading
+        :type downloaded_callback: Callable[[:class:`~eodag.api.product._product.EOProduct`], None]
+                                   or None
         :param progress_callback: (optional) A method or a callable object
                                   which takes a current size and a maximum
                                   size as inputs and handle progress bar
@@ -1211,6 +1219,7 @@ class EODataAccessGateway(object):
             )
             paths = download_plugin.download_all(
                 search_result,
+                downloaded_callback=downloaded_callback,
                 progress_callback=progress_callback,
                 wait=wait,
                 timeout=timeout,
diff --git a/eodag/plugins/apis/base.py b/eodag/plugins/apis/base.py
index 051a26d7..d49661e2 100644
--- a/eodag/plugins/apis/base.py
+++ b/eodag/plugins/apis/base.py
@@ -78,6 +78,8 @@ class Api(PluginTopic):
 
         :param product: The EO product to download
         :type product: :class:`~eodag.api.product._product.EOProduct`
+        :param auth: (optional) The configuration of a plugin of type Authentication
+        :type auth: :class:`~eodag.config.PluginConfig`
         :param progress_callback: (optional) A progress callback
         :type progress_callback: :class:`~eodag.utils.ProgressCallback`
         :param wait: (optional) If download fails, wait time in minutes between two download tries
@@ -103,6 +105,7 @@ class Api(PluginTopic):
         self,
         products,
         auth=None,
+        downloaded_callback=None,
         progress_callback=None,
         wait=DEFAULT_DOWNLOAD_WAIT,
         timeout=DEFAULT_DOWNLOAD_TIMEOUT,
@@ -113,6 +116,15 @@ class Api(PluginTopic):
 
         :param products: Products to download
         :type products: :class:`~eodag.api.search_result.SearchResult`
+        :param auth: (optional) The configuration of a plugin of type Authentication
+        :type auth: :class:`~eodag.config.PluginConfig`
+        :param downloaded_callback: (optional) A method or a callable object which takes
+                                    as parameter the ``product``. You can use the base class
+                                    :class:`~eodag.utils.DownloadedCallback` and override
+                                    its ``__call__`` method. Will be called each time a product
+                                    finishes downloading
+        :type downloaded_callback: Callable[[:class:`~eodag.api.product._product.EOProduct`], None]
+                                   or None
         :param progress_callback: (optional) A progress callback
         :type progress_callback: :class:`~eodag.utils.ProgressCallback`
         :param wait: (optional) If download fails, wait time in minutes between two download tries
diff --git a/eodag/plugins/apis/usgs.py b/eodag/plugins/apis/usgs.py
index bcc06c64..39804a91 100644
--- a/eodag/plugins/apis/usgs.py
+++ b/eodag/plugins/apis/usgs.py
@@ -276,6 +276,7 @@ class UsgsApi(Download, Api):
         self,
         products,
         auth=None,
+        downloaded_callback=None,
         progress_callback=None,
         wait=DEFAULT_DOWNLOAD_WAIT,
         timeout=DEFAULT_DOWNLOAD_TIMEOUT,
@@ -287,6 +288,7 @@ class UsgsApi(Download, Api):
         return super(UsgsApi, self).download_all(
             products,
             auth=auth,
+            downloaded_callback=downloaded_callback,
             progress_callback=progress_callback,
             wait=wait,
             timeout=timeout,
diff --git a/eodag/plugins/download/aws.py b/eodag/plugins/download/aws.py
index 0a8e2632..3202c76a 100644
--- a/eodag/plugins/download/aws.py
+++ b/eodag/plugins/download/aws.py
@@ -862,10 +862,21 @@ class AwsDownload(Download):
         logger.debug("Downloading %s to %s" % (chunk.key, product_path))
         return product_path
 
-    def download_all(self, products, auth=None, progress_callback=None, **kwargs):
+    def download_all(
+        self,
+        products,
+        auth=None,
+        downloaded_callback=None,
+        progress_callback=None,
+        **kwargs
+    ):
         """
         download_all using parent (base plugin) method
         """
         return super(AwsDownload, self).download_all(
-            products, auth=auth, progress_callback=progress_callback, **kwargs
+            products,
+            auth=auth,
+            downloaded_callback=downloaded_callback,
+            progress_callback=progress_callback,
+            **kwargs
         )
diff --git a/eodag/plugins/download/base.py b/eodag/plugins/download/base.py
index 16b181f3..99fef16c 100644
--- a/eodag/plugins/download/base.py
+++ b/eodag/plugins/download/base.py
@@ -370,6 +370,7 @@ class Download(PluginTopic):
         self,
         products,
         auth=None,
+        downloaded_callback=None,
         progress_callback=None,
         wait=DEFAULT_DOWNLOAD_WAIT,
         timeout=DEFAULT_DOWNLOAD_TIMEOUT,
@@ -383,6 +384,15 @@ class Download(PluginTopic):
 
         :param products: Products to download
         :type products: :class:`~eodag.api.search_result.SearchResult`
+        :param auth: (optional) The configuration of a plugin of type Authentication
+        :type auth: :class:`~eodag.config.PluginConfig`
+        :param downloaded_callback: (optional) A method or a callable object which takes
+                                    as parameter the ``product``. You can use the base class
+                                    :class:`~eodag.utils.DownloadedCallback` and override
+                                    its ``__call__`` method. Will be called each time a product
+                                    finishes downloading
+        :type downloaded_callback: Callable[[:class:`~eodag.api.product._product.EOProduct`], None]
+                                   or None
         :param progress_callback: (optional) A progress callback
         :type progress_callback: :class:`~eodag.utils.ProgressCallback`
         :param wait: (optional) If download fails, wait time in minutes between two download tries
@@ -448,6 +458,9 @@ class Download(PluginTopic):
                                 )
                             )
 
+                            if downloaded_callback:
+                                downloaded_callback(product)
+
                             # product downloaded, to not retry it
                             products.remove(product)
                             bar(1)
diff --git a/eodag/plugins/download/http.py b/eodag/plugins/download/http.py
index c73fcb3c..0fdcd732 100644
--- a/eodag/plugins/download/http.py
+++ b/eodag/plugins/download/http.py
@@ -416,6 +416,7 @@ class HTTPDownload(Download):
         self,
         products,
         auth=None,
+        downloaded_callback=None,
         progress_callback=None,
         wait=DEFAULT_DOWNLOAD_WAIT,
         timeout=DEFAULT_DOWNLOAD_TIMEOUT,
@@ -427,6 +428,7 @@ class HTTPDownload(Download):
         return super(HTTPDownload, self).download_all(
             products,
             auth=auth,
+            downloaded_callback=downloaded_callback,
             progress_callback=progress_callback,
             wait=wait,
             timeout=timeout,
diff --git a/eodag/utils/__init__.py b/eodag/utils/__init__.py
index 90ccf43c..03ac665d 100644
--- a/eodag/utils/__init__.py
+++ b/eodag/utils/__init__.py
@@ -400,6 +400,18 @@ def get_timestamp(date_time):
     return dt.timestamp()
 
 
+class DownloadedCallback:
+    """Example class for callback after each download in :meth:`~eodag.api.core.EODataAccessGateway.download_all`"""
+
+    def __call__(self, product):
+        """Callback
+
+        :param product: The downloaded EO product
+        :type product: :class:`~eodag.api.product._product.EOProduct`
+        """
+        logger.debug("Download finished for the product %s", product)
+
+
 class ProgressCallback(tqdm):
     """A callable used to render progress to users for long running processes.
 


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index e2afd291..5cf15b0d 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -16,7 +16,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import io
 import os
+import pathlib
 import random
 import shutil
 import tempfile
@@ -29,7 +31,10 @@ from owslib.etree import etree
 from owslib.ows import ExceptionReport
 from shapely import wkt
 
+from eodag import config
+from eodag.api.product import EOProduct
 from eodag.api.product.metadata_mapping import DEFAULT_METADATA_MAPPING
+from eodag.plugins.download.http import HTTPDownload
 
 jp = os.path.join
 dirn = os.path.dirname
@@ -278,3 +283,80 @@ class EODagTestCase(unittest.TestCase):
             }
         )
         return mock.DEFAULT
+
+    def _dummy_product(
+        self, provider=None, properties=None, productType=None, **kwargs
+    ):
+        return EOProduct(
+            self.provider if provider is None else provider,
+            self.eoproduct_props if properties is None else properties,
+            productType=self.product_type if productType is None else productType,
+            **kwargs,
+        )
+
+    def _dummy_downloadable_product(
+        self,
+        product=None,
+        base_uri=None,
+        outputs_prefix=None,
+        extract=None,
+        delete_archive=None,
+    ):
+        self._set_download_simulation()
+        dl_config = config.PluginConfig.from_mapping(
+            {
+                "base_uri": "fake_base_uri" if base_uri is None else base_uri,
+                "outputs_prefix": tempfile.gettempdir()
+                if outputs_prefix is None
+                else outputs_prefix,
+                "extract": True if extract is None else extract,
+                "delete_archive": False if delete_archive is None else delete_archive,
+            }
+        )
+        downloader = HTTPDownload(provider=self.provider, config=dl_config)
+        if product is None:
+            product = self._dummy_product()
+        product.register_downloader(downloader, None)
+        return product
+
+    def _clean_product(self, product_path):
+        product_path = pathlib.Path(product_path)
+        download_records_dir = product_path.parent / ".downloaded"
+        product_zip = product_path.parent / (product_path.name + ".zip")
+        shutil.rmtree(product_path)
+        shutil.rmtree(download_records_dir)
+        if os.path.exists(product_zip):
+            os.remove(product_zip)
+
+    def _set_download_simulation(self):
+        self.requests_http_get.return_value = self._download_response_archive()
+
+    def _download_response_archive(self):
+        class Response(object):
+            """Emulation of a response to requests.get method for a zipped product"""
+
+            def __init__(response):
+                # Using a zipped product file
+                with open(self.local_product_as_archive_path, "rb") as fh:
+                    response.__zip_buffer = io.BytesIO(fh.read())
+                cl = response.__zip_buffer.getbuffer().nbytes
+                response.headers = {"content-length": cl}
+
+            def __enter__(response):
+                return response
+
+            def __exit__(response, *args):
+                pass
+
+            def iter_content(response, **kwargs):
+                with response.__zip_buffer as fh:
+                    while True:
+                        chunk = fh.read(kwargs["chunk_size"])
+                        if not chunk:
+                            break
+                        yield chunk
+
+            def raise_for_status(response):
+                pass
+
+        return Response()
diff --git a/tests/context.py b/tests/context.py
index 96aca67b..1a31b578 100644
--- a/tests/context.py
+++ b/tests/context.py
@@ -53,6 +53,7 @@ from eodag.utils import (
     path_to_uri,
     ProgressCallback,
     uri_to_path,
+    DownloadedCallback,
 )
 from eodag.utils.exceptions import (
     AddressNotFound,
diff --git a/tests/integration/test_core_search_results.py b/tests/integration/test_core_search_results.py
index db49dea3..e14f7e25 100644
--- a/tests/integration/test_core_search_results.py
+++ b/tests/integration/test_core_search_results.py
@@ -20,16 +20,16 @@ import json
 import os
 import shutil
 import tempfile
-import unittest
 
 from shapely import geometry
 
-from tests import TEST_RESOURCES_PATH
+from tests import TEST_RESOURCES_PATH, EODagTestCase
 from tests.context import EODataAccessGateway, EOProduct, SearchResult
 
 
-class TestCoreSearchResults(unittest.TestCase):
+class TestCoreSearchResults(EODagTestCase):
     def setUp(self):
+        super(TestCoreSearchResults, self).setUp()
         self.dag = EODataAccessGateway()
         self.maxDiff = None
         self.geojson_repr = {
@@ -203,3 +203,27 @@ class TestCoreSearchResults(unittest.TestCase):
         self.assertIn(1, ss_len)
         self.assertIn(2, ss_len)
         self.assertIn(3, ss_len)
+
+    def test_empty_search_result_return_empty_list(self):
+        products_paths = self.dag.download_all(None)
+        self.assertFalse(products_paths)
+
+    def test_download_all_callback(self):
+        product = self._dummy_downloadable_product()
+        search_result = SearchResult([product])
+
+        def downloaded_callback_func(product):
+            self.assertTrue(product in search_result)
+            downloaded_callback_func.times_called += 1
+
+        downloaded_callback_func.times_called = 0
+
+        try:
+            self.assertEqual(downloaded_callback_func.times_called, 0)
+            products_paths = self.dag.download_all(
+                search_result, downloaded_callback=downloaded_callback_func
+            )
+            self.assertEqual(downloaded_callback_func.times_called, len(search_result))
+        finally:
+            for product_path in products_paths:
+                self._clean_product(product_path)
diff --git a/tests/units/test_eoproduct.py b/tests/units/test_eoproduct.py
index a72428a9..72d6ff7d 100644
--- a/tests/units/test_eoproduct.py
+++ b/tests/units/test_eoproduct.py
@@ -40,9 +40,7 @@ class TestEOProduct(EODagTestCase):
 
     def test_eoproduct_search_intersection_geom(self):
         """EOProduct search_intersection attr must be it's geom when no bbox_or_intersect param given"""  # noqa
-        product = EOProduct(
-            self.provider, self.eoproduct_props, productType=self.product_type
-        )
+        product = self._dummy_product()
         self.assertEqual(product.geometry, product.search_intersection)
 
     def test_eoproduct_search_intersection_none(self):
@@ -60,10 +58,7 @@ class TestEOProduct(EODagTestCase):
                 ]
             ],
         }
-        product = EOProduct(
-            self.provider,
-            self.eoproduct_props,
-            productType=self.product_type,
+        product = self._dummy_product(
             geometry=geometry.Polygon(
                 (
                     (10.469970703124998, 3.9957805129630373),
@@ -77,18 +72,12 @@ class TestEOProduct(EODagTestCase):
 
     def test_eoproduct_default_driver_unsupported_product_type(self):
         """EOProduct driver attr must be NoDriver if its product type is not associated with a eodag dataset driver"""  # noqa
-        product = EOProduct(
-            self.provider,
-            self.eoproduct_props,
-            productType=self.NOT_ASSOCIATED_PRODUCT_TYPE,
-        )
+        product = self._dummy_product(productType=self.NOT_ASSOCIATED_PRODUCT_TYPE)
         self.assertIsInstance(product.driver, NoDriver)
 
     def test_eoproduct_geointerface(self):
         """EOProduct must provide a geo-interface with a set of specific properties"""
-        product = EOProduct(
-            self.provider, self.eoproduct_props, productType=self.product_type
-        )
+        product = self._dummy_product()
         geo_interface = geojson.loads(geojson.dumps(product))
         self.assertEqual(geo_interface["type"], "Feature")
         self.assertEqual(
@@ -105,9 +94,7 @@ class TestEOProduct(EODagTestCase):
 
     def test_eoproduct_from_geointerface(self):
         """EOProduct must be build-able from its geo-interface"""
-        product = EOProduct(
-            self.provider, self.eoproduct_props, productType=self.product_type
-        )
+        product = self._dummy_product()
         same_product = EOProduct.from_geojson(geojson.loads(geojson.dumps(product)))
         self.assertSequenceEqual(
             [
@@ -138,9 +125,7 @@ class TestEOProduct(EODagTestCase):
 
     def test_eoproduct_get_quicklook_no_quicklook_url(self):
         """EOProduct.get_quicklook must return an empty string if no quicklook property"""  # noqa
-        product = EOProduct(
-            self.provider, self.eoproduct_props, productType=self.product_type
-        )
+        product = self._dummy_product()
         product.properties["quicklook"] = None
 
         quicklook_file_path = product.get_quicklook()
@@ -148,9 +133,7 @@ class TestEOProduct(EODagTestCase):
 
     def test_eoproduct_get_quicklook_http_error(self):
         """EOProduct.get_quicklook must return an empty string if there was an error during retrieval"""  # noqa
-        product = EOProduct(
-            self.provider, self.eoproduct_props, productType=self.product_type
-        )
+        product = self._dummy_product()
         product.properties["quicklook"] = "https://fake.url.to/quicklook"
 
         self.requests_http_get.return_value.__enter__.return_value.raise_for_status.side_effect = (  # noqa
@@ -172,9 +155,7 @@ class TestEOProduct(EODagTestCase):
 
     def test_eoproduct_get_quicklook_ok(self):
         """EOProduct.get_quicklook must return the path to the successfully downloaded quicklook"""  # noqa
-        product = EOProduct(
-            self.provider, self.eoproduct_props, productType=self.product_type
-        )
+        product = self._dummy_product()
         product.properties["quicklook"] = "https://fake.url.to/quicklook"
 
         self.requests_http_get.return_value = self._quicklook_response()
@@ -224,9 +205,7 @@ class TestEOProduct(EODagTestCase):
             os.mkdir(quicklook_dir)
         with open(existing_quicklook_file_path, "wb") as fh:
             fh.write(b"content")
-        product = EOProduct(
-            self.provider, self.eoproduct_props, productType=self.product_type
-        )
+        product = self._dummy_product()
         product.properties["quicklook"] = "https://fake.url.to/quicklook"
         mock_downloader = mock.MagicMock(
             spec_set=Download(provider=self.provider, config=None)
@@ -273,20 +252,7 @@ class TestEOProduct(EODagTestCase):
     def test_eoproduct_download_http_default(self):
         """eoproduct.download must save the product at outputs_prefix and create a .downloaded dir"""  # noqa
         # Setup
-        product = EOProduct(
-            self.provider, self.eoproduct_props, productType=self.product_type
-        )
-        self.requests_http_get.return_value = self._download_response_archive()
-        dl_config = config.PluginConfig.from_mapping(
-            {
-                "base_uri": "fake_base_uri",
-                "outputs_prefix": tempfile.gettempdir(),
-                "delete_archive": False,
-            }
-        )
-        downloader = HTTPDownload(provider=self.provider, config=dl_config)
-        product.register_downloader(downloader, None)
-
+        product = self._dummy_downloadable_product()
         try:
             # Download
             product_dir_path = product.download()
@@ -313,65 +279,33 @@ class TestEOProduct(EODagTestCase):
             self.assertTrue(zipfile.is_zipfile(product_zip))
         finally:
             # Teardown
-            os.remove(product_zip)
-            shutil.rmtree(product_dir_path)
-            os.remove(str(records_file))
-            os.rmdir(str(download_records_dir))
+            self._clean_product(product_dir_path)
 
     def test_eoproduct_download_http_delete_archive(self):
         """eoproduct.download must delete the downloaded archive"""  # noqa
         # Setup
-        product = EOProduct(
-            self.provider, self.eoproduct_props, productType=self.product_type
-        )
-        self.requests_http_get.return_value = self._download_response_archive()
-        dl_config = config.PluginConfig.from_mapping(
-            {
-                "base_uri": "fake_base_uri",
-                "outputs_prefix": tempfile.gettempdir(),
-                "delete_archive": True,
-            }
-        )
-        downloader = HTTPDownload(provider=self.provider, config=dl_config)
-        product.register_downloader(downloader, None)
-
+        product = self._dummy_downloadable_product(delete_archive=True)
         try:
             # Download
             product_dir_path = product.download()
-            product_dir_path = pathlib.Path(product_dir_path)
             # Check that the mocked request was properly called.
             self.requests_http_get.assert_called_with(
                 self.download_url, stream=True, auth=None, params={}
             )
-            download_records_dir = product_dir_path.parent / ".downloaded"
             # Check that the product's directory exists.
             self.assertTrue(os.path.isdir(product_dir_path))
             # Check that the ZIP file was deleted there
-            product_zip = product_dir_path.parent / (product_dir_path.name + ".zip")
+            _product_dir_path = pathlib.Path(product_dir_path)
+            product_zip = _product_dir_path.parent / (_product_dir_path.name + ".zip")
             self.assertFalse(os.path.exists(product_zip))
         finally:
             # Teardown
-            shutil.rmtree(product_dir_path)
-            shutil.rmtree(download_records_dir)
+            self._clean_product(product_dir_path)
 
     def test_eoproduct_download_http_extract(self):
         """eoproduct.download over must be able to extract a product"""
         # Setup
-        product = EOProduct(
-            self.provider, self.eoproduct_props, productType=self.product_type
-        )
-        self.requests_http_get.return_value = self._download_response_archive()
-        dl_config = config.PluginConfig.from_mapping(
-            {
-                "base_uri": "fake_base_uri",
-                "outputs_prefix": tempfile.gettempdir(),
-                "extract": True,
-                "delete_archive": False,
-            }
-        )
-        downloader = HTTPDownload(provider=self.provider, config=dl_config)
-        product.register_downloader(downloader, None)
-
+        product = self._dummy_downloadable_product(extract=True)
         try:
             # Download
             product_dir_path = product.download()
@@ -384,21 +318,15 @@ class TestEOProduct(EODagTestCase):
             # The zip file should is around
             product_zip_file = product_dir_path.with_suffix(".SAFE.zip")
             self.assertTrue(product_zip_file.is_file)
-
-            download_records_dir = pathlib.Path(product_dir_path).parent / ".downloaded"
         finally:
             # Teardown
-            shutil.rmtree(str(product_dir_path))
-            os.remove(str(product_zip_file))
-            shutil.rmtree(str(download_records_dir))
+            self._clean_product(product_dir_path)
 
     def test_eoproduct_download_http_dynamic_options(self):
         """eoproduct.download must accept the download options to be set automatically"""
         # Setup
-        product = EOProduct(
-            self.provider, self.eoproduct_props, productType=self.product_type
-        )
-        self.requests_http_get.return_value = self._download_response_archive()
+        product = self._dummy_product()
+        self._set_download_simulation()
         dl_config = config.PluginConfig.from_mapping(
             {"base_uri": "fake_base_uri", "outputs_prefix": "will_be_overriden"}
         )
@@ -409,7 +337,7 @@ class TestEOProduct(EODagTestCase):
         output_dir = pathlib.Path(tempfile.gettempdir()) / output_dir_name
         try:
             if output_dir.is_dir():
-                shutil.rmtree(str(output_dir))
+                shutil.rmtree(output_dir)
             output_dir.mkdir()
 
             # Download
@@ -435,34 +363,4 @@ class TestEOProduct(EODagTestCase):
             self.assertTrue(product_zip_file.is_file)
         finally:
             # Teardown (all the created files are within outputs_prefix)
-            shutil.rmtree(str(output_dir))
-
-    def _download_response_archive(self):
-        class Response(object):
-            """Emulation of a response to requests.get method for a zipped product"""
-
-            def __init__(response):
-                # Using a zipped product file
-                with open(self.local_product_as_archive_path, "rb") as fh:
-                    response.__zip_buffer = io.BytesIO(fh.read())
-                cl = response.__zip_buffer.getbuffer().nbytes
-                response.headers = {"content-length": cl}
-
-            def __enter__(response):
-                return response
-
-            def __exit__(response, *args):
-                pass
-
-            def iter_content(response, **kwargs):
-                with response.__zip_buffer as fh:
-                    while True:
-                        chunk = fh.read(kwargs["chunk_size"])
-                        if not chunk:
-                            break
-                        yield chunk
-
-            def raise_for_status(response):
-                pass
-
-        return Response()
+            shutil.rmtree(output_dir)
diff --git a/tests/units/test_utils.py b/tests/units/test_utils.py
index 62a5b713..d7757876 100644
--- a/tests/units/test_utils.py
+++ b/tests/units/test_utils.py
@@ -23,6 +23,7 @@ from datetime import datetime
 from io import StringIO
 
 from tests.context import (
+    DownloadedCallback,
     ProgressCallback,
     get_timestamp,
     merge_mappings,
@@ -74,6 +75,15 @@ class TestUtils(unittest.TestCase):
         else:
             self.assertEqual(path_to_uri("/tmp/file.txt"), "file:///tmp/file.txt")
 
+    def test_downloaded_callback(self):
+        """DownloadedCallback instance is callable with product as parameter"""
+        downloaded_callback = DownloadedCallback()
+        self.assertTrue(callable(downloaded_callback))
+        try:
+            downloaded_callback(product=None)
+        except TypeError as e:
+            self.fail(f"DownloadedCallback got an error when called: {e}")
+
     def test_progresscallback_init(self):
         """ProgressCallback can be instantiated using defaults values"""
         with ProgressCallback() as bar: