Repository: CS-SI/eodag (python)

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


Pull request description:
Download callback
Closes #121.

- For `download_all`: callback is done inside the `download_all` method of `eodag.plugins.download.base.Download`, because its children  `download_all` methods delegates to `super()`. Every class inheriting `Download` will get this callback behavior when calling `super().download_all(...)`.
- For `download`: the callback is done after the download is finished, inside the `download` method of `EOProduct`. i wanted to use it too inside of the `Download` class but its children don't call its `download` method. I could add the callback inside every children `download` method, but some of them have a lot or `return`, it is not really efficient.

As of now, callback is inside of `Download` for `download_all`, and in `EOProduct` for `download`.
While it is working, it's not clean. These are the changes I see to unify the logic behind the callback location :

- Just like for `download_all`, by making the class inheriting `Download` call their parent `download` I could add this behavior

OR

- Make the callback inside of `download` and `download_all` of `EODataAccessGateway`. Here, the callback logic is high level and is not present in any of the `eodag.plugins.apis.base.Api`  and `eodag.plugins.download.base.Download` classes.


Patch:
diff --git a/docs/api_reference/utils.rst b/docs/api_reference/utils.rst
index dec68622..d2934e81 100644
--- a/docs/api_reference/utils.rst
+++ b/docs/api_reference/utils.rst
@@ -10,9 +10,11 @@ Logging
 .. automodule:: eodag.utils.logging
    :members:
 
-Progress callback
+Callbacks
 -----------------
 
+.. autofunction:: eodag.utils.DownloadedCallback
+.. autofunction:: eodag.utils.AllDownloadedCallback
 .. autofunction:: eodag.utils.ProgressCallback
 
 Notebook
diff --git a/eodag/api/core.py b/eodag/api/core.py
index 9746a67e..318d124e 100644
--- a/eodag/api/core.py
+++ b/eodag/api/core.py
@@ -1136,6 +1136,7 @@ class EODataAccessGateway(object):
     def download_all(
         self,
         search_result,
+        alldownloaded_callback=None,
         progress_callback=None,
         wait=DEFAULT_DOWNLOAD_WAIT,
         timeout=DEFAULT_DOWNLOAD_TIMEOUT,
@@ -1145,6 +1146,12 @@ 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 alldownloaded_callback: (optional) A method or a callable object which takes
+                                       as parameter the `search_result`. You can use the base class
+                                       :class:`~eodag.utils.AllDownloadedCallback` and override
+                                       its `__call__`.
+        :type alldownloaded_callback: Callable[[:class:`~eodag.api.search_result.SearchResult`], 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
@@ -1180,6 +1187,8 @@ class EODataAccessGateway(object):
                 timeout=timeout,
                 **kwargs,
             )
+            if alldownloaded_callback:
+                alldownloaded_callback(search_result)
         else:
             logger.info("Empty search result, nothing to be downloaded !")
         return paths
@@ -1312,6 +1321,7 @@ class EODataAccessGateway(object):
     def download(
         self,
         product,
+        downloaded_callback=None,
         progress_callback=None,
         wait=DEFAULT_DOWNLOAD_WAIT,
         timeout=DEFAULT_DOWNLOAD_TIMEOUT,
@@ -1339,6 +1349,12 @@ class EODataAccessGateway(object):
 
         :param product: The EO product to download
         :type product: :class:`~eodag.api.product._product.EOProduct`
+        :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__`.
+        :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
@@ -1372,7 +1388,11 @@ class EODataAccessGateway(object):
                 self._plugins_manager.get_download_plugin(product), auth
             )
         path = product.download(
-            progress_callback=progress_callback, wait=wait, timeout=timeout, **kwargs
+            downloaded_callback=downloaded_callback,
+            progress_callback=progress_callback,
+            wait=wait,
+            timeout=timeout,
+            **kwargs,
         )
 
         return path
diff --git a/eodag/api/product/_product.py b/eodag/api/product/_product.py
index 51a789cb..3011e80d 100644
--- a/eodag/api/product/_product.py
+++ b/eodag/api/product/_product.py
@@ -223,6 +223,7 @@ class EOProduct(object):
 
     def download(
         self,
+        downloaded_callback=None,
         progress_callback=None,
         wait=DEFAULT_DOWNLOAD_WAIT,
         timeout=DEFAULT_DOWNLOAD_TIMEOUT,
@@ -235,6 +236,12 @@ class EOProduct(object):
         method. A side effect of this method is that it changes the ``location``
         attribute of an EOProduct, from its remote address to the local address.
 
+        :param downloaded_callback: (optional) A method or a callable object which takes
+                                    a product as parameter. You can use the base class
+                                    :class:`~eodag.utils.DownloadedCallback` and override
+                                    its `__call__`.
+        :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
@@ -298,6 +305,9 @@ class EOProduct(object):
         if close_progress_callback:
             progress_callback.close()
 
+        if downloaded_callback:
+            downloaded_callback(self)
+
         if fs_path is None:
             raise DownloadError("Missing file location returned by download process")
         logger.debug(
diff --git a/eodag/utils/__init__.py b/eodag/utils/__init__.py
index 846e6fff..bbfa0ccc 100644
--- a/eodag/utils/__init__.py
+++ b/eodag/utils/__init__.py
@@ -399,6 +399,30 @@ def get_timestamp(date_time):
     return dt.timestamp()
 
 
+class DownloadedCallback:
+    """Example class for :meth:`~eodag.api.core.EODataAccessGateway.download` callback"""
+
+    def __call__(self, product):
+        """Callback
+
+        :param product: The EO product downloaded
+        :type product: :class:`~eodag.api.product._product.EOProduct`
+        """
+        logger.debug("Download finished for the product %s", product)
+
+
+class AllDownloadedCallback:
+    """Example class for :meth:`~eodag.api.core.EODataAccessGateway.download_all` callback"""
+
+    def __call__(self, search_result):
+        """Callback
+
+        :param search_result: A collection of EO products resulting from a search
+        :type search_result: :class:`~eodag.api.search_result.SearchResult`
+        """
+        logger.debug("Download finished for the search result %s", search_result)
+
+
 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/integration/test_core_search_results.py b/tests/integration/test_core_search_results.py
index db49dea3..34735787 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,46 @@ 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 alldownloaded_callback_func(downloaded_search_result):
+            self.assertEqual(search_result, downloaded_search_result)
+            alldownloaded_callback_func.called = True
+
+        alldownloaded_callback_func.called = False
+
+        try:
+            self.assertFalse(alldownloaded_callback_func.called)
+            products_paths = self.dag.download_all(
+                search_result, alldownloaded_callback=alldownloaded_callback_func
+            )
+            self.assertTrue(alldownloaded_callback_func.called)
+        finally:
+            for product_path in products_paths:
+                self._clean_product(product_path)
+
+    def test_download_callback(self):
+        product = self._dummy_downloadable_product()
+
+        def downloaded_callback_func(downloaded_product):
+            self.assertEqual(product, downloaded_product)
+            downloaded_callback_func.called = True
+
+        downloaded_callback_func.called = False
+
+        try:
+            self.assertFalse(downloaded_callback_func.called)
+            product_path = self.dag.download(
+                product, downloaded_callback=downloaded_callback_func
+            )
+            self.assertTrue(downloaded_callback_func.called)
+        finally:
+            # Teardown
+            self._clean_product(product_path)
diff --git a/tests/units/test_eoproduct.py b/tests/units/test_eoproduct.py
index a72428a9..89758417 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,55 @@ 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_downloaded_callback(self):
+        """eoproduct.download must save the product at outputs_prefix and create a .downloaded dir"""  # noqa
+        # Setup
+        product = self._dummy_downloadable_product()
+
+        def downloaded_callback_func(product):
+            self.assertEqual(product, product)
+            downloaded_callback_func.called = True
+
+        downloaded_callback_func.called = False
+
+        try:
+            # Download
+            product_dir_path = product.download(
+                downloaded_callback=downloaded_callback_func
+            )
+            # Check that callback function was called
+            self.assertTrue(downloaded_callback_func.called)
+        finally:
+            # Teardown
+            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 +340,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 +359,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 +385,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..d8779f21 100644
--- a/tests/units/test_utils.py
+++ b/tests/units/test_utils.py
@@ -22,6 +22,7 @@ from contextlib import closing
 from datetime import datetime
 from io import StringIO
 
+from eodag.utils import AllDownloadedCallback, DownloadedCallback
 from tests.context import (
     ProgressCallback,
     get_timestamp,
@@ -74,6 +75,24 @@ 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_alldownloaded_callback(self):
+        """AllDownloadedCallback instance is callable with search_result as parameter"""
+        alldownloaded_callback = AllDownloadedCallback()
+        self.assertTrue(callable(alldownloaded_callback))
+        try:
+            alldownloaded_callback(search_result=None)
+        except TypeError as e:
+            self.fail(f"AllDownloadedCallback got an error when called: {e}")
+
     def test_progresscallback_init(self):
         """ProgressCallback can be instantiated using defaults values"""
         with ProgressCallback() as bar: