Repository: CS-SI/eodag (python)

Issue:
Error while downloading a quicklook in a directory where the product has already been downloaded and extracted
It might be an edge case but I got caught by this one.

```
Traceback (most recent call last):
  File "/home/maxime/TRAVAIL/06_EODAG/01_eodag/eodag/snippet_quicklook_peps.py", line 20, in <module>
    prods[0].get_quicklook(base_dir="/tmp")
  File "/home/maxime/TRAVAIL/06_EODAG/01_eodag/eodag/eodag/api/product/_product.py", line 392, in get_quicklook
    with open(quicklook_file, "wb") as fhandle:
IsADirectoryError: [Errno 21] Is a directory: '/tmp/S2B_MSIL1C_20200815T030549_N0209_R075_T55XEB_20200815T051416'
```

I first downloaded a product in a directory, say `work`. The product was automatically extracted to `work/productID`. I then tried to download the quicklook of that product with `get_quicklook` in the same directory `work`. If not provided, a quicklook get its filename from the product's ID:
https://github.com/CS-SI/eodag/blob/fec965f791a74dbf658e0833e1507b7ac950d09d/eodag/api/product/_product.py#L344-L347

In that case then, `get_quicklook` tried to save the quicklook to `work/productID` which was an existing folder, hence the error reported above.

I assume quicklooks get their filename from the product's ID because some providers don't provide an URL, instead they provide the encoded image content (*onda*, at least).

Here I guess the simplest thing to do would be to check if the file path of the quicklook to download is a directory, and raise a nicer error in that case.

Pull request:
Clearer docstring for get_quicklook about the default filename and the output folder
Fixes #226 

Patch:
diff --git a/eodag/api/core.py b/eodag/api/core.py
index 6efceb2a..556d03c9 100644
--- a/eodag/api/core.py
+++ b/eodag/api/core.py
@@ -47,6 +47,7 @@ from eodag.utils import (
     _deprecated,
     get_geometry_from_various,
     makedirs,
+    uri_to_path,
 )
 from eodag.utils.exceptions import (
     NoMatchingProductType,
@@ -434,7 +435,7 @@ class EODataAccessGateway(object):
                 if results is None:
                     results = searcher.search(query, limit=None)
                 else:
-                    results.upgrade_and_extend(searcher.search(query))
+                    results.upgrade_and_extend(searcher.search(query, limit=None))
             guesses = [r["ID"] for r in results or []]
         if guesses:
             return guesses
@@ -844,6 +845,19 @@ class EODataAccessGateway(object):
         if product_type is None:
             try:
                 guesses = self.guess_product_type(**kwargs)
+
+                # guess_product_type raises a NoMatchingProductType error if no product
+                # is found. Here, the supported search params are removed from the
+                # kwargs if present, not to propagate them to the query itself.
+                for param in (
+                    "instrument",
+                    "platform",
+                    "platformSerialIdentifier",
+                    "processingLevel",
+                    "sensorType",
+                ):
+                    kwargs.pop(param, None)
+
                 # By now, only use the best bet
                 product_type = guesses[0]
             except NoMatchingProductType:
@@ -1281,8 +1295,10 @@ class EODataAccessGateway(object):
         This is an alias to the method of the same name on
         :class:`~eodag.api.product.EOProduct`, but it performs some additional
         checks like verifying that a downloader and authenticator are registered
-        for the product before trying to download it. If the metadata mapping for
-        `downloadLink` is set to something that can be interpreted as a link on a
+        for the product before trying to download it.
+
+        If the metadata mapping for `downloadLink` is set to something that can be
+        interpreted as a link on a
         local filesystem, the download is skipped (by now, only a link starting
         with `file://` is supported). Therefore, any user that knows how to extract
         product location from product metadata on a provider can override the
@@ -1316,10 +1332,15 @@ class EODataAccessGateway(object):
         :rtype: str
         :raises: :class:`~eodag.utils.exceptions.PluginImplementationError`
         :raises: :class:`RuntimeError`
+
+        .. versionchanged:: 2.3.0
+
+           Returns a file system path instead of a file URI ('/tmp' instead of
+           'file:///tmp').
         """
-        if product.location.startswith("file"):
+        if product.location.startswith("file://"):
             logger.info("Local product detected. Download skipped")
-            return product.location
+            return uri_to_path(product.location)
         if product.downloader is None:
             auth = product.downloader_auth
             if auth is None:
diff --git a/eodag/api/product/_product.py b/eodag/api/product/_product.py
index a0d98184..8deaeb61 100644
--- a/eodag/api/product/_product.py
+++ b/eodag/api/product/_product.py
@@ -240,6 +240,11 @@ class EOProduct(object):
         :rtype: str
         :raises: :class:`~eodag.utils.exceptions.PluginImplementationError`
         :raises: :class:`RuntimeError`
+
+        .. versionchanged:: 2.3.0
+
+           Returns a file system path instead of a file URI ('/tmp' instead of
+           'file:///tmp').
         """
         if progress_callback is None:
             progress_callback = ProgressCallback()
@@ -254,7 +259,7 @@ class EOProduct(object):
             if self.downloader_auth is not None
             else self.downloader_auth
         )
-        fs_location = self.downloader.download(
+        fs_path = self.downloader.download(
             self,
             auth=auth,
             progress_callback=progress_callback,
@@ -262,13 +267,8 @@ class EOProduct(object):
             timeout=timeout,
             **kwargs
         )
-        if fs_location is None:
-            raise DownloadError(
-                "Invalid file location returned by download process: '{}'".format(
-                    fs_location
-                )
-            )
-        self.location = "file://{}".format(fs_location)
+        if fs_path is None:
+            raise DownloadError("Missing file location returned by download process")
         logger.debug(
             "Product location updated from '%s' to '%s'",
             self.remote_location,
@@ -279,24 +279,23 @@ class EOProduct(object):
             "'remote_location' property: %s",
             self.remote_location,
         )
-        return self.location
+        return fs_path
 
     def get_quicklook(self, filename=None, base_dir=None, progress_callback=None):
-        """Download the quick look image of a given EOProduct from its provider if it
+        """Download the quicklook image of a given EOProduct from its provider if it
         exists.
 
-        :param filename: (optional) the name to give to the downloaded quicklook.
+        :param filename: (optional) the name to give to the downloaded quicklook. If not
+           given, it defaults to the product's ID (without file extension).
         :type filename: str
         :param base_dir: (optional) the absolute path of the directory where to store
-                         the quicklooks in the filesystem. If it is not given, it
-                         defaults to the `quicklooks` directory under this EO product
-                         downloader's ``outputs_prefix`` config param
+           the quicklooks in the filesystem. If not given, it defaults to the
+           `quicklooks` directory under this EO product downloader's ``outputs_prefix``
+           config param (e.g. '/tmp/quicklooks/')
         :type base_dir: str
-        :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
-                                  creation and update to give the user a
-                                  feedback on the download progress
+        :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 creation
+           and update to give the user a feedback on the download progress
         :type progress_callback: :class:`~eodag.utils.ProgressCallback` or None
         :returns: The absolute path of the downloaded quicklook
         :rtype: str
diff --git a/eodag/plugins/apis/base.py b/eodag/plugins/apis/base.py
index 6a775973..b6d385ef 100644
--- a/eodag/plugins/apis/base.py
+++ b/eodag/plugins/apis/base.py
@@ -18,12 +18,40 @@
 import logging
 
 from eodag.plugins.base import PluginTopic
+from eodag.plugins.download.base import DEFAULT_DOWNLOAD_TIMEOUT, DEFAULT_DOWNLOAD_WAIT
 
 logger = logging.getLogger("eodag.plugins.apis.base")
 
 
 class Api(PluginTopic):
-    """Plugins API Base plugin"""
+    """Plugins API Base plugin
+
+    An Api plugin has three download methods that it must implement:
+    - ``query``: search for products
+    - ``download``: download a single ``EOProduct``
+    - ``download_all``: download multiple products from a ``SearchResult``
+
+    The download methods must:
+    - download data in the ``outputs_prefix`` folder defined in the plugin's
+      configuration or passed through kwargs
+    - extract products from their archive (if relevant) if ``extract`` is set to True
+      (True by default)
+    - save a product in an archive/directory (in ``outputs_prefix``) whose name must be
+      the product's ``title`` property
+    - update the product's ``location`` attribute once its data is downloaded (and
+      eventually after it's extracted) to the product's location given as a file URI
+      (e.g. 'file:///tmp/product_folder' on Linux or
+      'file:///C:/Users/username/AppData/LOcal/Temp' on Windows)
+    - save a *record* file in the directory ``outputs_prefix/.downloaded`` whose name
+      is built on the MD5 hash of the product's ``remote_location`` attribute
+      (``hashlib.md5(remote_location.encode("utf-8")).hexdigest()``) and whose content is
+      the product's ``remote_location`` attribute itself.
+    - not try to download a product whose ``location`` attribute already points to an
+      existing file/directory
+    - not try to download a product if its *record* file exists as long as the expected
+      product's file/directory. If the *record* file only is found, it must be deleted
+      (it certainly indicates that the download didn't complete)
+    """
 
     def query(self, *args, count=True, **kwargs):
         """Implementation of how the products must be searched goes here.
@@ -32,14 +60,77 @@ class Api(PluginTopic):
         which will be processed by a Download plugin (2) and the total number of products matching
         the search criteria. If ``count`` is False, the second element returned must be ``None``.
 
-        .. versionchanged::
-            2.1
+        .. versionchanged:: 2.1
 
-                * A new optional boolean parameter ``count`` which defaults to ``True``, it
-                allows to trigger or not a count query.
+           A new optional boolean parameter ``count`` which defaults to ``True``, it
+           allows to trigger or not a count query.
         """
         raise NotImplementedError("A Api plugin must implement a method named query")
 
-    def download(self, *args, **kwargs):
-        """Implementation of how the products must be downloaded."""
-        raise NotImplementedError("A Api plugin must implement a method named download")
+    def download(
+        self,
+        product,
+        auth=None,
+        progress_callback=None,
+        wait=DEFAULT_DOWNLOAD_WAIT,
+        timeout=DEFAULT_DOWNLOAD_TIMEOUT,
+        **kwargs,
+    ):
+        r"""
+        Base download method. Not available, it must be defined for each plugin.
+
+        :param product: EO product to download
+        :type product: :class:`~eodag.api.product.EOProduct`
+        :param progress_callback: A progress callback
+        :type progress_callback: :class:`~eodag.utils.ProgressCallback`, optional
+        :param wait: If download fails, wait time in minutes between two download tries
+        :type wait: int, optional
+        :param timeout: If download fails, maximum time in minutes before stop retrying
+            to download
+        :type timeout: int, optional
+        :param dict kwargs: ``outputs_prefix` (``str``), `extract` (``bool``) and
+            ``dl_url_params`` (``dict``) can be provided as additional kwargs and will
+            override any other values defined in a configuration file or with
+            environment variables.
+        :returns: The absolute path to the downloaded product in the local filesystem
+            (e.g. '/tmp/product.zip' on Linux or
+            'C:\\Users\\username\\AppData\\Local\\Temp\\product.zip' on Windows)
+        :rtype: str
+        """
+        raise NotImplementedError(
+            "An Api plugin must implement a method named download"
+        )
+
+    def download_all(
+        self,
+        products,
+        auth=None,
+        progress_callback=None,
+        wait=DEFAULT_DOWNLOAD_WAIT,
+        timeout=DEFAULT_DOWNLOAD_TIMEOUT,
+        **kwargs,
+    ):
+        """
+        Base download_all method.
+
+        :param products: Products to download
+        :type products: :class:`~eodag.api.search_result.SearchResult`
+        :param progress_callback: A progress callback
+        :type progress_callback: :class:`~eodag.utils.ProgressCallback`, optional
+        :param wait: If download fails, wait time in minutes between two download tries
+        :type wait: int, optional
+        :param timeout: If download fails, maximum time in minutes before stop retrying
+            to download
+        :type timeout: int, optional
+        :param dict kwargs: ``outputs_prefix` (``str``), `extract` (``bool``) and
+            ``dl_url_params`` (``dict``) can be provided as additional kwargs and will
+            override any other values defined in a configuration file or with
+            environment variables.
+        :returns: List of absolute paths to the downloaded products in the local
+            filesystem (e.g. ``['/tmp/product.zip']`` on Linux or
+            ``['C:\\Users\\username\\AppData\\Local\\Temp\\product.zip']`` on Windows)
+        :rtype: list
+        """
+        raise NotImplementedError(
+            "A Api plugin must implement a method named download_all"
+        )
diff --git a/eodag/plugins/apis/usgs.py b/eodag/plugins/apis/usgs.py
index 37ce4443..768778e5 100644
--- a/eodag/plugins/apis/usgs.py
+++ b/eodag/plugins/apis/usgs.py
@@ -37,7 +37,12 @@ from eodag.plugins.download.base import (
     DEFAULT_DOWNLOAD_WAIT,
     Download,
 )
-from eodag.utils import GENERIC_PRODUCT_TYPE, format_dict_items, get_progress_callback
+from eodag.utils import (
+    GENERIC_PRODUCT_TYPE,
+    format_dict_items,
+    get_progress_callback,
+    path_to_uri,
+)
 from eodag.utils.exceptions import AuthenticationError, NotAvailableError
 
 logger = logging.getLogger("eodag.plugins.apis.usgs")
@@ -164,6 +169,8 @@ class UsgsApi(Api, Download):
             product, outputs_extension=".tar.gz", **kwargs
         )
         if not fs_path or not record_filename:
+            if fs_path:
+                product.location = path_to_uri(fs_path)
             return fs_path
 
         # progress bar init
@@ -264,8 +271,11 @@ class UsgsApi(Api, Download):
             )
             new_fs_path = fs_path[: fs_path.index(".tar.gz")]
             shutil.move(fs_path, new_fs_path)
+            product.location = path_to_uri(new_fs_path)
             return new_fs_path
-        return self._finalize(fs_path, outputs_extension=".tar.gz", **kwargs)
+        product_path = self._finalize(fs_path, outputs_extension=".tar.gz", **kwargs)
+        product.location = path_to_uri(product_path)
+        return product_path
 
     def download_all(
         self,
diff --git a/eodag/plugins/download/aws.py b/eodag/plugins/download/aws.py
index 2f0b132d..687985c6 100644
--- a/eodag/plugins/download/aws.py
+++ b/eodag/plugins/download/aws.py
@@ -34,7 +34,7 @@ from eodag.api.product.metadata_mapping import (
     properties_from_xml,
 )
 from eodag.plugins.download.base import Download
-from eodag.utils import get_progress_callback, urlparse
+from eodag.utils import get_progress_callback, path_to_uri, urlparse
 from eodag.utils.exceptions import AuthenticationError, DownloadError
 
 logger = logging.getLogger("eodag.plugins.download.aws")
@@ -185,6 +185,8 @@ class AwsDownload(Download):
         # prepare download & create dirs (before updating metadata)
         product_local_path, record_filename = self._prepare_download(product, **kwargs)
         if not product_local_path or not record_filename:
+            if product_local_path:
+                product.location = path_to_uri(product_local_path)
             return product_local_path
         product_local_path = product_local_path.replace(".zip", "")
         # remove existing incomplete file
@@ -395,6 +397,7 @@ class AwsDownload(Download):
             fh.write(product.remote_location)
         logger.debug("Download recorded in %s", record_filename)
 
+        product.location = path_to_uri(product_local_path)
         return product_local_path
 
     def get_authenticated_objects(self, bucket_name, prefix, auth_dict):
diff --git a/eodag/plugins/download/base.py b/eodag/plugins/download/base.py
index 8d6dbd3e..bfcb8ac1 100644
--- a/eodag/plugins/download/base.py
+++ b/eodag/plugins/download/base.py
@@ -25,7 +25,7 @@ from datetime import datetime, timedelta
 from time import sleep
 
 from eodag.plugins.base import PluginTopic
-from eodag.utils import get_progress_callback, sanitize
+from eodag.utils import get_progress_callback, sanitize, uri_to_path
 from eodag.utils.exceptions import (
     AuthenticationError,
     MisconfiguredError,
@@ -43,6 +43,31 @@ DEFAULT_DOWNLOAD_TIMEOUT = 20
 class Download(PluginTopic):
     """Base Download Plugin.
 
+    A Download plugin has two download methods that it must implement:
+    - ``download``: download a single ``EOProduct``
+    - ``download_all``: download multiple products from a ``SearchResult``
+
+    They must:
+    - download data in the ``outputs_prefix`` folder defined in the plugin's
+      configuration or passed through kwargs
+    - extract products from their archive (if relevant) if ``extract`` is set to True
+      (True by default)
+    - save a product in an archive/directory (in ``outputs_prefix``) whose name must be
+      the product's ``title`` property
+    - update the product's ``location`` attribute once its data is downloaded (and
+      eventually after it's extracted) to the product's location given as a file URI
+      (e.g. 'file:///tmp/product_folder' on Linux or
+      'file:///C:/Users/username/AppData/LOcal/Temp' on Windows)
+    - save a *record* file in the directory ``outputs_prefix/.downloaded`` whose name
+      is built on the MD5 hash of the product's ``remote_location`` attribute
+      (``hashlib.md5(remote_location.encode("utf-8")).hexdigest()``) and whose content is
+      the product's ``remote_location`` attribute itself.
+    - not try to download a product whose ``location`` attribute already points to an
+      existing file/directory
+    - not try to download a product if its *record* file exists as long as the expected
+      product's file/directory. If the *record* file only is found, it must be deleted
+      (it certainly indicates that the download didn't complete)
+
     :param provider: An eodag providers configuration dictionary
     :type provider: dict
     :param config: Path to the user configuration file
@@ -62,8 +87,26 @@ class Download(PluginTopic):
         timeout=DEFAULT_DOWNLOAD_TIMEOUT,
         **kwargs,
     ):
-        """
-        Base download method. Not available, should be defined for each plugin
+        r"""
+        Base download method. Not available, it must be defined for each plugin.
+
+        :param product: EO product to download
+        :type product: :class:`~eodag.api.product.EOProduct`
+        :param progress_callback: A progress callback
+        :type progress_callback: :class:`~eodag.utils.ProgressCallback`, optional
+        :param wait: If download fails, wait time in minutes between two download tries
+        :type wait: int, optional
+        :param timeout: If download fails, maximum time in minutes before stop retrying
+            to download
+        :type timeout: int, optional
+        :param dict kwargs: ``outputs_prefix` (``str``), `extract` (``bool``) and
+            ``dl_url_params`` (``dict``) can be provided as additional kwargs and will
+            override any other values defined in a configuration file or with
+            environment variables.
+        :returns: The absolute path to the downloaded product in the local filesystem
+            (e.g. '/tmp/product.zip' on Linux or
+            'C:\\Users\\username\\AppData\\Local\\Temp\\product.zip' on Windows)
+        :rtype: str
         """
         raise NotImplementedError(
             "A Download plugin must implement a method named download"
@@ -78,8 +121,7 @@ class Download(PluginTopic):
         :rtype: tuple
         """
         if product.location != product.remote_location:
-            scheme_prefix_len = len("file://")
-            fs_path = product.location[scheme_prefix_len:]
+            fs_path = uri_to_path(product.location)
             # The fs path of a product is either a file (if 'extract' config is False) or a directory
             if os.path.isfile(fs_path) or os.path.isdir(fs_path):
                 logger.info(
@@ -249,9 +291,32 @@ class Download(PluginTopic):
         **kwargs,
     ):
         """
-        A sequential download_all implementation
-        using download method for every products
+        Base download_all method.
+
+        This specific implementation uses the ``download`` method implemented by
+        the plugin to **sequentially** attempt to download products.
+
+        :param products: Products to download
+        :type products: :class:`~eodag.api.search_result.SearchResult`
+        :param progress_callback: A progress callback
+        :type progress_callback: :class:`~eodag.utils.ProgressCallback`, optional
+        :param wait: If download fails, wait time in minutes between two download tries
+        :type wait: int, optional
+        :param timeout: If download fails, maximum time in minutes before stop retrying
+            to download
+        :type timeout: int, optional
+        :param dict kwargs: ``outputs_prefix` (``str``), `extract` (``bool``) and
+            ``dl_url_params`` (``dict``) can be provided as additional kwargs and will
+            override any other values defined in a configuration file or with
+            environment variables.
+        :returns: List of absolute paths to the downloaded products in the local
+            filesystem (e.g. ``['/tmp/product.zip']`` on Linux or
+            ``['C:\\Users\\username\\AppData\\Local\\Temp\\product.zip']`` on Windows)
+        :rtype: list
         """
+        # Products are going to be removed one by one from this sequence once
+        # downloaded.
+        products = products[:]
         paths = []
         # initiate retry loop
         start_time = datetime.now()
diff --git a/eodag/plugins/download/http.py b/eodag/plugins/download/http.py
index 11662a18..f54fff86 100644
--- a/eodag/plugins/download/http.py
+++ b/eodag/plugins/download/http.py
@@ -32,7 +32,7 @@ from eodag.plugins.download.base import (
     DEFAULT_DOWNLOAD_WAIT,
     Download,
 )
-from eodag.utils import get_progress_callback
+from eodag.utils import get_progress_callback, path_to_uri
 from eodag.utils.exceptions import (
     AuthenticationError,
     MisconfiguredError,
@@ -70,6 +70,8 @@ class HTTPDownload(Download):
         """
         fs_path, record_filename = self._prepare_download(product, **kwargs)
         if not fs_path or not record_filename:
+            if fs_path:
+                product.location = path_to_uri(fs_path)
             return fs_path
 
         # progress bar init
@@ -80,7 +82,7 @@ class HTTPDownload(Download):
 
         # download assets if exist instead of remote_location
         try:
-            return self._download_assets(
+            fs_path = self._download_assets(
                 product,
                 fs_path.replace(".zip", ""),
                 record_filename,
@@ -88,6 +90,8 @@ class HTTPDownload(Download):
                 progress_callback,
                 **kwargs
             )
+            product.location = path_to_uri(fs_path)
+            return fs_path
         except NotAvailableError:
             pass
 
@@ -218,8 +222,11 @@ class HTTPDownload(Download):
                                 )
                                 new_fs_path = fs_path[: fs_path.index(".zip")]
                                 shutil.move(fs_path, new_fs_path)
+                                product.location = path_to_uri(new_fs_path)
                                 return new_fs_path
-                            return self._finalize(fs_path, **kwargs)
+                            product_path = self._finalize(fs_path, **kwargs)
+                            product.location = path_to_uri(product_path)
+                            return product_path
 
                 except NotAvailableError as e:
                     if not getattr(self.config, "order_enabled", False):
diff --git a/eodag/plugins/download/s3rest.py b/eodag/plugins/download/s3rest.py
index 71876ea4..2f5628b9 100644
--- a/eodag/plugins/download/s3rest.py
+++ b/eodag/plugins/download/s3rest.py
@@ -29,7 +29,7 @@ from requests import HTTPError
 from eodag.api.product.metadata_mapping import OFFLINE_STATUS
 from eodag.plugins.download.aws import AwsDownload
 from eodag.plugins.download.http import HTTPDownload
-from eodag.utils import get_progress_callback, urljoin
+from eodag.utils import get_progress_callback, path_to_uri, urljoin
 from eodag.utils.exceptions import (
     AuthenticationError,
     DownloadError,
@@ -158,6 +158,7 @@ class S3RestDownload(AwsDownload):
         url_hash = hashlib.md5(product.remote_location.encode("utf-8")).hexdigest()
         record_filename = os.path.join(download_records_dir, url_hash)
         if os.path.isfile(record_filename) and os.path.exists(product_local_path):
+            product.location = path_to_uri(product_local_path)
             return product_local_path
         # Remove the record file if product_local_path is absent (e.g. it was deleted while record wasn't)
         elif os.path.isfile(record_filename):
@@ -215,4 +216,5 @@ class S3RestDownload(AwsDownload):
             fh.write(product.remote_location)
         logger.debug("Download recorded in %s", record_filename)
 
+        product.location = path_to_uri(product_local_path)
         return product_local_path
diff --git a/eodag/rest/utils.py b/eodag/rest/utils.py
index f48e3eb0..2c6f7e9d 100644
--- a/eodag/rest/utils.py
+++ b/eodag/rest/utils.py
@@ -463,7 +463,7 @@ def download_stac_item_by_id(catalogs, item_id, provider=None):
 
     product_path = eodag_api.download(product)
 
-    return product_path.replace("file://", "")
+    return product_path
 
 
 def get_stac_catalogs(url, root="/", catalogs=[], provider=None):
diff --git a/eodag/utils/__init__.py b/eodag/utils/__init__.py
index 70798110..0c21649b 100644
--- a/eodag/utils/__init__.py
+++ b/eodag/utils/__init__.py
@@ -35,6 +35,7 @@ import warnings
 from collections import defaultdict
 from datetime import datetime, timezone
 from itertools import repeat, starmap
+from pathlib import Path
 
 # All modules using these should import them from utils package
 from urllib.parse import (  # noqa; noqa
@@ -43,8 +44,10 @@ from urllib.parse import (  # noqa; noqa
     urlencode,
     urljoin,
     urlparse,
+    urlsplit,
     urlunparse,
 )
+from urllib.request import url2pathname
 
 import click
 import shapefile
@@ -203,6 +206,24 @@ def strip_accents(s):
     )
 
 
+def uri_to_path(uri):
+    """
+    Convert a file URI (e.g. 'file:///tmp') to a local path (e.g. '/tmp')
+    """
+    if not uri.startswith("file"):
+        raise ValueError("A file URI must be provided (e.g. 'file:///tmp'")
+    _, _, path, _, _ = urlsplit(uri)
+    # On Windows urlsplit returns the path starting with a slash ('/C:/User)
+    path = url2pathname(path)
+    # url2pathname removes it
+    return path
+
+
+def path_to_uri(path):
+    """Convert a local absolute path to a file URI"""
+    return Path(path).as_uri()
+
+
 def mutate_dict_in_place(func, mapping):
     """Apply func to values of mapping.
 
@@ -853,13 +874,13 @@ def _deprecated(reason="", version=None):
 
         @functools.wraps(callable)
         def wrapper(*args, **kwargs):
-            warnings.simplefilter("always", DeprecationWarning)
-            warnings.warn(
-                f"Call to deprecated {ctype} {cname} {reason_}{version_}",
-                category=DeprecationWarning,
-                stacklevel=2,
-            )
-            warnings.simplefilter("default", DeprecationWarning)
+            with warnings.catch_warnings():
+                warnings.simplefilter("always", DeprecationWarning)
+                warnings.warn(
+                    f"Call to deprecated {ctype} {cname} {reason_}{version_}",
+                    category=DeprecationWarning,
+                    stacklevel=2,
+                )
             return callable(*args, **kwargs)
 
         return wrapper


Test patch:
diff --git a/tests/context.py b/tests/context.py
index 96807bc8..df74276a 100644
--- a/tests/context.py
+++ b/tests/context.py
@@ -44,7 +44,13 @@ from eodag.plugins.manager import PluginManager
 from eodag.plugins.search.base import Search
 from eodag.rest import server as eodag_http_server
 from eodag.rest.utils import eodag_api, get_date
-from eodag.utils import get_geometry_from_various, get_timestamp, makedirs
+from eodag.utils import (
+    get_geometry_from_various,
+    get_timestamp,
+    makedirs,
+    path_to_uri,
+    uri_to_path,
+)
 from eodag.utils.exceptions import (
     AddressNotFound,
     AuthenticationError,
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 94e07cc3..a4171dbb 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -427,7 +427,7 @@ class TestEodagCli(unittest.TestCase):
             TEST_RESOURCES_PATH, "eodag_search_result.geojson"
         )
         config_path = os.path.join(TEST_RESOURCES_PATH, "file_config_override.yml")
-        dag.return_value.download_all.return_value = ["file:///fake_path"]
+        dag.return_value.download_all.return_value = ["/fake_path"]
         result = self.runner.invoke(
             eodag,
             ["download", "--search-results", search_results_path, "-f", config_path],
@@ -435,7 +435,7 @@ class TestEodagCli(unittest.TestCase):
         dag.assert_called_once_with(user_conf_file_path=config_path)
         dag.return_value.deserialize.assert_called_once_with(search_results_path)
         self.assertEqual(dag.return_value.download_all.call_count, 1)
-        self.assertEqual("Downloaded file:///fake_path\n", result.output)
+        self.assertEqual("Downloaded /fake_path\n", result.output)
 
         # Testing the case when no downloaded path is returned
         dag.return_value.download_all.return_value = [None]
diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py
index be2dee90..ca0a145c 100644
--- a/tests/test_end_to_end.py
+++ b/tests/test_end_to_end.py
@@ -18,15 +18,22 @@
 
 import datetime
 import glob
+import hashlib
 import multiprocessing
 import os
 import shutil
+import time
 import unittest
 from pathlib import Path
 
 from eodag.api.product.metadata_mapping import ONLINE_STATUS
 from tests import TEST_RESOURCES_PATH, TESTS_DOWNLOAD_PATH
-from tests.context import AuthenticationError, EODataAccessGateway
+from tests.context import (
+    AuthenticationError,
+    EODataAccessGateway,
+    SearchResult,
+    uri_to_path,
+)
 
 THEIA_SEARCH_ARGS = [
     "theia",
@@ -148,8 +155,8 @@ class EndToEndBase(unittest.TestCase):
         """
         search_criteria = {
             "productType": product_type,
-            "startTimeFromAscendingNode": start,
-            "completionTimeFromAscendingNode": end,
+            "start": start,
+            "end": end,
             "geom": geom,
         }
         if items_per_page:
@@ -190,8 +197,8 @@ class EndToEndBase(unittest.TestCase):
         - Return all the products
         """
         search_criteria = {
-            "startTimeFromAscendingNode": start,
-            "completionTimeFromAscendingNode": end,
+            "start": start,
+            "end": end,
             "geom": geom,
         }
         self.eodag.set_preferred_provider(provider)
@@ -428,6 +435,196 @@ class TestEODagEndToEnd(EndToEndBase):
         self.assertGreater(len(results), 10)
 
 
+class TestEODagEndToEndComplete(unittest.TestCase):
+    """Make real and complete test cases that search for products, download them and
+    extract them. There should be just a tiny number of these tests which can be quite
+    long to run.
+
+    There must be a user conf file in the test resources folder named user_conf.yml
+    """
+
+    @classmethod
+    def setUpClass(cls):
+
+        # use tests/resources/user_conf.yml if exists else default file ~/.config/eodag/eodag.yml
+        tests_user_conf = os.path.join(TEST_RESOURCES_PATH, "user_conf.yml")
+        if not os.path.isfile(tests_user_conf):
+            unittest.SkipTest("Missing user conf file with credentials")
+        cls.eodag = EODataAccessGateway(
+            user_conf_file_path=os.path.join(TEST_RESOURCES_PATH, "user_conf.yml")
+        )
+
+        # create TESTS_DOWNLOAD_PATH is not exists
+        if not os.path.exists(TESTS_DOWNLOAD_PATH):
+            os.makedirs(TESTS_DOWNLOAD_PATH)
+
+        for provider, conf in cls.eodag.providers_config.items():
+            # Change download directory to TESTS_DOWNLOAD_PATH for tests
+            if hasattr(conf, "download") and hasattr(conf.download, "outputs_prefix"):
+                conf.download.outputs_prefix = TESTS_DOWNLOAD_PATH
+            elif hasattr(conf, "api") and hasattr(conf.api, "outputs_prefix"):
+                conf.api.outputs_prefix = TESTS_DOWNLOAD_PATH
+            else:
+                # no outputs_prefix found for provider
+                pass
+            # Force all providers implementing RestoSearch and defining how to retrieve
+            # products by specifying the
+            # location scheme to use https, enabling actual downloading of the product
+            if (
+                getattr(getattr(conf, "search", {}), "product_location_scheme", "https")
+                == "file"
+            ):
+                conf.search.product_location_scheme = "https"
+
+    def tearDown(self):
+        """Clear the test directory"""
+        for p in Path(TESTS_DOWNLOAD_PATH).glob("**/*"):
+            try:
+                os.remove(p)
+            except OSError:
+                shutil.rmtree(p)
+
+    def test_end_to_end_complete_peps(self):
+        """Complete end-to-end test with PEPS for download and download_all"""
+        # Search for products that are ONLINE and as small as possible
+        today = datetime.date.today()
+        month_span = datetime.timedelta(weeks=4)
+        search_results, _ = self.eodag.search(
+            productType="S2_MSI_L1C",
+            start=(today - month_span).isoformat(),
+            end=today.isoformat(),
+            geom={"lonmin": 1, "latmin": 42, "lonmax": 5, "latmax": 46},
+            items_per_page=100,
+        )
+        prods_sorted_by_size = SearchResult(
+            sorted(search_results, key=lambda p: p.properties["resourceSize"])
+        )
+        prods_online = [
+            p for p in prods_sorted_by_size if p.properties["storageStatus"] == "ONLINE"
+        ]
+        if len(prods_online) < 2:
+            unittest.skip(
+                "Not enough ONLINE products found, update the search criteria."
+            )
+
+        # Retrieve one product to work with
+        product = prods_online[0]
+
+        prev_remote_location = product.remote_location
+        prev_location = product.location
+        # The expected product's archive filename is based on the product's title
+        expected_product_name = f"{product.properties['title']}.zip"
+
+        # Download the product, but DON'T extract it
+        archive_file_path = self.eodag.download(product, extract=False)
+
+        # The archive must have been downloaded
+        self.assertTrue(os.path.isfile(archive_file_path))
+        # Its name must be the "{product_title}.zip"
+        self.assertIn(
+            expected_product_name, os.listdir(product.downloader.config.outputs_prefix)
+        )
+        # Its size should be >= 5 KB
+        archive_size = os.stat(archive_file_path).st_size
+        self.assertGreaterEqual(archive_size, 5 * 2 ** 10)
+        # The product remote_location should be the same
+        self.assertEqual(prev_remote_location, product.remote_location)
+        # However its location should have been update
+        self.assertNotEqual(prev_location, product.location)
+        # The location must follow the file URI scheme
+        self.assertTrue(product.location.startswith("file://"))
+        # That points to the downloaded archive
+        self.assertEqual(uri_to_path(product.location), archive_file_path)
+        # A .downloaded folder must have been created
+        record_dir = os.path.join(TESTS_DOWNLOAD_PATH, ".downloaded")
+        self.assertTrue(os.path.isdir(record_dir))
+        # It must contain a file per product downloade, whose name is
+        # the MD5 hash of the product's remote location
+        expected_hash = hashlib.md5(product.remote_location.encode("utf-8")).hexdigest()
+        record_file = os.path.join(record_dir, expected_hash)
+        self.assertTrue(os.path.isfile(record_file))
+        # Its content must be the product's remote location
+        record_content = Path(record_file).read_text()
+        self.assertEqual(record_content, product.remote_location)
+
+        # The product should not be downloaded again if the download method
+        # is executed again
+        previous_archive_file_path = archive_file_path
+        previous_location = product.location
+        start_time = time.time()
+        archive_file_path = self.eodag.download(product, extract=False)
+        end_time = time.time()
+        self.assertLess(end_time - start_time, 2)  # Should be really fast (< 2s)
+        # The paths should be the same as before
+        self.assertEqual(archive_file_path, previous_archive_file_path)
+        self.assertEqual(product.location, previous_location)
+
+        # If we emulate that the product has just been found, it should not
+        # be downloaded again since the record file is still present.
+        product.location = product.remote_location
+        # Pretty much the same checks as with the previous step
+        previous_archive_file_path = archive_file_path
+        start_time = time.time()
+        archive_file_path = self.eodag.download(product, extract=False)
+        end_time = time.time()
+        self.assertLess(end_time - start_time, 2)  # Should be really fast (< 2s)
+        # The returned path should be the same as before
+        self.assertEqual(archive_file_path, previous_archive_file_path)
+        self.assertEqual(uri_to_path(product.location), archive_file_path)
+
+        # Remove the archive
+        os.remove(archive_file_path)
+
+        # Now, the archive is removed but its associated record file
+        # still exists. Downloading the product again should really
+        # download it, if its location points to the remote location.
+        # The product should be automatically extracted.
+        product.location = product.remote_location
+        product_dir_path = self.eodag.download(product)
+
+        # Its size should be >= 5 KB
+        downloaded_size = sum(
+            f.stat().st_size for f in Path(product_dir_path).glob("**/*") if f.is_file()
+        )
+        self.assertGreaterEqual(downloaded_size, 5 * 2 ** 10)
+        # The product remote_location should be the same
+        self.assertEqual(prev_remote_location, product.remote_location)
+        # However its location should have been update
+        self.assertNotEqual(prev_location, product.location)
+        # The location must follow the file URI scheme
+        self.assertTrue(product.location.startswith("file://"))
+        # The location must point to a SAFE directory
+        self.assertTrue(product.location.endswith("SAFE"))
+        # The path must point to a SAFE directory
+        self.assertTrue(os.path.isdir(product_dir_path))
+        self.assertTrue(product_dir_path.endswith("SAFE"))
+
+        # Remove the archive and extracted product and reset the product's location
+        os.remove(archive_file_path)
+        shutil.rmtree(Path(product_dir_path).parent)
+        product.location = product.remote_location
+
+        # Now let's check download_all
+        products = prods_sorted_by_size[:2]
+        # Pass a copy because download_all empties the list
+        archive_paths = self.eodag.download_all(products[:], extract=False)
+
+        # The returned paths must point to the downloaded archives
+        # Each product's location must be a URI path to the archive
+        for product, archive_path in zip(products, archive_paths):
+            self.assertTrue(os.path.isfile(archive_path))
+            self.assertEqual(uri_to_path(product.location), archive_path)
+
+        # Downloading the product again should not download them, since
+        # they are all already there.
+        prev_archive_paths = archive_paths
+        start_time = time.time()
+        archive_paths = self.eodag.download_all(products[:], extract=False)
+        end_time = time.time()
+        self.assertLess(end_time - start_time, 2)  # Should be really fast (< 2s)
+        self.assertEqual(archive_paths, prev_archive_paths)
+
+
 # @unittest.skip("skip auto run")
 class TestEODagEndToEndWrongCredentials(EndToEndBase):
     """Make real case tests with wrong credentials. This assumes the existence of a
@@ -462,7 +659,7 @@ class TestEODagEndToEndWrongCredentials(EndToEndBase):
                 raise_errors=True,
                 **dict(
                     zip(["productType", "start", "end", "geom"], AWSEOS_SEARCH_ARGS[1:])
-                )
+                ),
             )
 
     def test_end_to_end_good_apikey_wrong_credentials_aws_eos(self):
@@ -489,7 +686,7 @@ class TestEODagEndToEndWrongCredentials(EndToEndBase):
                 raise_errors=True,
                 **dict(
                     zip(["productType", "start", "end", "geom"], AWSEOS_SEARCH_ARGS[1:])
-                )
+                ),
             )
             self.assertGreater(len(results), 0)
             one_product = results[0]
@@ -525,5 +722,5 @@ class TestEODagEndToEndWrongCredentials(EndToEndBase):
                 raise_errors=True,
                 **dict(
                     zip(["productType", "start", "end", "geom"], USGS_SEARCH_ARGS[1:])
-                )
+                ),
             )
diff --git a/tests/units/test_core.py b/tests/units/test_core.py
index cb6022e4..69cf9a3e 100644
--- a/tests/units/test_core.py
+++ b/tests/units/test_core.py
@@ -367,7 +367,7 @@ class TestCoreGeometry(unittest.TestCase):
             "lonmax": 2,
             "latmax": 52,
         }
-        self.assertEquals(get_geometry_from_various([], geometry=geometry), ref_geom)
+        self.assertEqual(get_geometry_from_various([], geometry=geometry), ref_geom)
         # Bad dict with a missing key
         del geometry["lonmin"]
         self.assertRaises(
@@ -378,10 +378,10 @@ class TestCoreGeometry(unittest.TestCase):
         )
         # Tuple
         geometry = (0, 50, 2, 52)
-        self.assertEquals(get_geometry_from_various([], geometry=geometry), ref_geom)
+        self.assertEqual(get_geometry_from_various([], geometry=geometry), ref_geom)
         # List
         geometry = list(geometry)
-        self.assertEquals(get_geometry_from_various([], geometry=geometry), ref_geom)
+        self.assertEqual(get_geometry_from_various([], geometry=geometry), ref_geom)
         # List without 4 items
         geometry.pop()
         self.assertRaises(
@@ -392,7 +392,7 @@ class TestCoreGeometry(unittest.TestCase):
         )
         # WKT
         geometry = ref_geom_as_wkt
-        self.assertEquals(get_geometry_from_various([], geometry=geometry), ref_geom)
+        self.assertEqual(get_geometry_from_various([], geometry=geometry), ref_geom)
         # Some other shapely geom
         geometry = LineString([[0, 0], [1, 1]])
         self.assertIsInstance(
@@ -409,7 +409,7 @@ class TestCoreGeometry(unittest.TestCase):
             locations_config, locations=dict(country="FRA")
         )
         self.assertIsInstance(geom_france, MultiPolygon)
-        self.assertEquals(len(geom_france), 3)  # France + Guyana + Corsica
+        self.assertEqual(len(geom_france), 3)  # France + Guyana + Corsica
 
     def test_get_geometry_from_various_locations_with_wrong_location_name_in_kwargs(
         self,
@@ -444,7 +444,7 @@ class TestCoreGeometry(unittest.TestCase):
             locations_config, locations=dict(country="PA[A-Z]")
         )
         self.assertIsInstance(geom_regex_pa, MultiPolygon)
-        self.assertEquals(len(geom_regex_pa), 2)
+        self.assertEqual(len(geom_regex_pa), 2)
 
     def test_get_geometry_from_various_locations_no_match_raises_error(self):
         """If the location search doesn't match any of the feature attribute a ValueError must be raised"""
@@ -468,7 +468,7 @@ class TestCoreGeometry(unittest.TestCase):
         )
         self.assertIsInstance(geom_combined, MultiPolygon)
         # France + Guyana + Corsica + somewhere over Poland
-        self.assertEquals(len(geom_combined), 4)
+        self.assertEqual(len(geom_combined), 4)
         geometry = {
             "lonmin": 0,
             "latmin": 50,
@@ -480,7 +480,7 @@ class TestCoreGeometry(unittest.TestCase):
         )
         self.assertIsInstance(geom_combined, MultiPolygon)
         # The bounding box overlaps with France inland
-        self.assertEquals(len(geom_combined), 3)
+        self.assertEqual(len(geom_combined), 3)
 
 
 class TestCoreSearch(unittest.TestCase):
@@ -546,7 +546,6 @@ class TestCoreSearch(unittest.TestCase):
             "geometry": None,
             "productType": None,
         }
-        self.assertDictContainsSubset(expected, prepared_search)
         expected = set(["geometry", "productType", "auth", "search_plugin"])
         self.assertSetEqual(expected, set(prepared_search))
 
@@ -557,11 +556,10 @@ class TestCoreSearch(unittest.TestCase):
             "end": "2020-02-01",
         }
         prepared_search = self.dag._prepare_search(**base)
-        expected = {
-            "startTimeFromAscendingNode": base["start"],
-            "completionTimeFromAscendingNode": base["end"],
-        }
-        self.assertDictContainsSubset(expected, prepared_search)
+        self.assertEqual(prepared_search["startTimeFromAscendingNode"], base["start"])
+        self.assertEqual(
+            prepared_search["completionTimeFromAscendingNode"], base["end"]
+        )
 
     def test__prepare_search_geom(self):
         """_prepare_search must handle geom, box and bbox"""
@@ -608,8 +606,7 @@ class TestCoreSearch(unittest.TestCase):
         """_prepare_search must handle when a product type is given"""
         base = {"productType": "S2_MSI_L1C"}
         prepared_search = self.dag._prepare_search(**base)
-        expected = {"productType": base["productType"]}
-        self.assertDictContainsSubset(expected, prepared_search)
+        self.assertEqual(prepared_search["productType"], base["productType"])
 
     def test__prepare_search_product_type_guess_it(self):
         """_prepare_search must guess a product type when required to"""
@@ -621,8 +618,19 @@ class TestCoreSearch(unittest.TestCase):
             platformSerialIdentifier="S2A",
         )
         prepared_search = self.dag._prepare_search(**base)
-        expected = {"productType": "S2_MSI_L1C", **base}
-        self.assertDictContainsSubset(expected, prepared_search)
+        self.assertEqual(prepared_search["productType"], "S2_MSI_L1C")
+
+    def test__prepare_search_remove_guess_kwargs(self):
+        """_prepare_search must remove the guess kwargs"""
+        # Uses guess_product_type to find the product matching
+        # the best the given params.
+        base = dict(
+            instrument="MSI",
+            platform="SENTINEL2",
+            platformSerialIdentifier="S2A",
+        )
+        prepared_search = self.dag._prepare_search(**base)
+        self.assertEqual(len(base.keys() & prepared_search.keys()), 0)
 
     def test__prepare_search_with_id(self):
         """_prepare_search must handle a search by id"""
@@ -638,8 +646,8 @@ class TestCoreSearch(unittest.TestCase):
             "cloudCover": 10,
         }
         prepared_search = self.dag._prepare_search(**base)
-        expected = base
-        self.assertDictContainsSubset(expected, prepared_search)
+        self.assertEqual(prepared_search["productType"], base["productType"])
+        self.assertEqual(prepared_search["cloudCover"], base["cloudCover"])
 
     def test__prepare_search_search_plugin_has_known_product_properties(self):
         """_prepare_search must attach the product properties to the search plugin"""
diff --git a/tests/units/test_eoproduct.py b/tests/units/test_eoproduct.py
index f43aaa84..0faf7704 100644
--- a/tests/units/test_eoproduct.py
+++ b/tests/units/test_eoproduct.py
@@ -90,23 +90,18 @@ class TestEOProduct(EODagTestCase):
             self.provider, self.eoproduct_props, productType=self.product_type
         )
         geo_interface = geojson.loads(geojson.dumps(product))
-        self.assertDictContainsSubset(
-            {
-                "type": "Feature",
-                "geometry": self._tuples_to_lists(geometry.mapping(self.geometry)),
-            },
-            geo_interface,
+        self.assertEqual(geo_interface["type"], "Feature")
+        self.assertEqual(
+            geo_interface["geometry"],
+            self._tuples_to_lists(geometry.mapping(self.geometry)),
         )
-        self.assertDictContainsSubset(
-            {
-                "eodag_provider": self.provider,
-                "eodag_search_intersection": self._tuples_to_lists(
-                    geometry.mapping(product.search_intersection)
-                ),
-                "eodag_product_type": self.product_type,
-            },
-            geo_interface["properties"],
+        properties = geo_interface["properties"]
+        self.assertEqual(properties["eodag_provider"], self.provider)
+        self.assertEqual(
+            properties["eodag_search_intersection"],
+            self._tuples_to_lists(geometry.mapping(product.search_intersection)),
         )
+        self.assertEqual(properties["eodag_product_type"], self.product_type)
 
     def test_eoproduct_from_geointerface(self):
         """EOProduct must be build-able from its geo-interface"""
@@ -295,7 +290,6 @@ class TestEOProduct(EODagTestCase):
         self.requests_http_get.assert_called_with(
             self.download_url, stream=True, auth=None, params={}
         )
-        product_file_path = product_file_path[len("file://") :]
         download_records_dir = pathlib.Path(product_file_path).parent / ".downloaded"
         # A .downloaded folder should be created, including a text file that
         # lists the downloaded product by their url
@@ -332,7 +326,7 @@ class TestEOProduct(EODagTestCase):
 
         # Download
         product_dir_path = product.download()
-        product_dir_path = pathlib.Path(product_dir_path[len("file://") :])
+        product_dir_path = pathlib.Path(product_dir_path)
         # The returned path must be a directory.
         self.assertTrue(product_dir_path.is_dir())
         # Check that the extracted dir has at least one file, there are more
@@ -379,7 +373,7 @@ class TestEOProduct(EODagTestCase):
             self.download_url, stream=True, auth=None, params={"fakeparam": "dummy"}
         )
         # Check that "outputs_prefix" is respected.
-        product_dir_path = pathlib.Path(product_dir_path[len("file://") :])
+        product_dir_path = pathlib.Path(product_dir_path)
         self.assertEqual(product_dir_path.parent.name, output_dir_name)
         # We've asked to extract the product so there should be a directory.
         self.assertTrue(product_dir_path.is_dir())
diff --git a/tests/units/test_search_plugins.py b/tests/units/test_search_plugins.py
index 28369fee..09dd221d 100644
--- a/tests/units/test_search_plugins.py
+++ b/tests/units/test_search_plugins.py
@@ -106,8 +106,8 @@ class TestSearchPluginQueryStringSearch(BaseSearchPluginTest):
             info_message=mock.ANY,
             exception_message=mock.ANY,
         )
-        self.assertEquals(estimate, self.sobloo_products_count)
-        self.assertEquals(len(products), number_of_products)
+        self.assertEqual(estimate, self.sobloo_products_count)
+        self.assertEqual(len(products), number_of_products)
         self.assertIsInstance(products[0], EOProduct)
 
     @mock.patch(
@@ -144,7 +144,7 @@ class TestSearchPluginQueryStringSearch(BaseSearchPluginTest):
             exception_message=mock.ANY,
         )
         self.assertIsNone(estimate)
-        self.assertEquals(len(products), number_of_products)
+        self.assertEqual(len(products), number_of_products)
         self.assertIsInstance(products[0], EOProduct)
 
 
@@ -196,8 +196,8 @@ class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
             exception_message=mock.ANY,
         )
 
-        self.assertEquals(estimate, self.awseos_products_count)
-        self.assertEquals(len(products), number_of_products)
+        self.assertEqual(estimate, self.awseos_products_count)
+        self.assertEqual(len(products), number_of_products)
         self.assertIsInstance(products[0], EOProduct)
 
     @mock.patch(
@@ -232,7 +232,7 @@ class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
         )
 
         self.assertIsNone(estimate)
-        self.assertEquals(len(products), number_of_products)
+        self.assertEqual(len(products), number_of_products)
         self.assertIsInstance(products[0], EOProduct)
 
 
@@ -294,6 +294,6 @@ class TestSearchPluginODataV4Search(BaseSearchPluginTest):
             exception_message=mock.ANY,
         )
 
-        self.assertEquals(estimate, self.onda_products_count)
-        self.assertEquals(len(products), number_of_products)
+        self.assertEqual(estimate, self.onda_products_count)
+        self.assertEqual(len(products), number_of_products)
         self.assertIsInstance(products[0], EOProduct)
diff --git a/tests/units/test_search_result.py b/tests/units/test_search_result.py
index f1697117..d4e52303 100644
--- a/tests/units/test_search_result.py
+++ b/tests/units/test_search_result.py
@@ -32,9 +32,8 @@ class TestSearchResult(unittest.TestCase):
     def test_search_result_geo_interface(self):
         """SearchResult must provide a FeatureCollection geo-interface"""
         geo_interface = geojson.loads(geojson.dumps(self.search_result))
-        self.assertDictContainsSubset(
-            {"type": "FeatureCollection", "features": []}, geo_interface
-        )
+        self.assertEqual(geo_interface["type"], "FeatureCollection")
+        self.assertEqual(geo_interface["features"], [])
 
     def test_search_result_is_list_like(self):
         """SearchResult must provide a list interface"""
diff --git a/tests/units/test_stac_reader.py b/tests/units/test_stac_reader.py
index 8960ee81..f242fb38 100644
--- a/tests/units/test_stac_reader.py
+++ b/tests/units/test_stac_reader.py
@@ -47,10 +47,8 @@ class TestStacReader(unittest.TestCase):
         items = fetch_stac_items(self.child_cat)
         self.assertIsInstance(items, list)
         self.assertEqual(len(items), self.child_cat_len)
-        self.assertDictContainsSubset(
-            {"type": "Feature", "collection": "S2_MSI_L1C"},
-            items[0],
-        )
+        self.assertEqual(items[0]["type"], "Feature")
+        self.assertEqual(items[0]["collection"], "S2_MSI_L1C")
 
     def test_stac_reader_fetch_root_not_recursive(self):
         """fetch_stac_items from root must provide an empty list when no recursive"""
@@ -62,23 +60,20 @@ class TestStacReader(unittest.TestCase):
         items = fetch_stac_items(self.root_cat, recursive=True)
         self.assertEqual(len(items), self.root_cat_len)
         for item in items:
-            self.assertDictContainsSubset(
-                {"type": "Feature", "collection": "S2_MSI_L1C"}, item
-            )
+            self.assertEqual(item["type"], "Feature")
+            self.assertEqual(item["collection"], "S2_MSI_L1C")
 
     def test_stac_reader_fetch_item(self):
         """fetch_stac_items from an item must return it"""
         item = fetch_stac_items(self.item)
         self.assertIsInstance(item, list)
         self.assertEqual(len(item), 1)
-        self.assertDictContainsSubset(
-            {"type": "Feature", "collection": "S2_MSI_L1C"},
-            item[0],
-        )
+        self.assertEqual(item[0]["type"], "Feature")
+        self.assertEqual(item[0]["collection"], "S2_MSI_L1C")
 
     def test_stact_reader_fetch_singlefile_catalog(self):
         """fetch_stact_items must return all the items from a single file catalog"""
         items = fetch_stac_items(self.singlefile_cat)
         self.assertIsInstance(items, list)
         self.assertEqual(len(items), self.singlefile_cat_len)
-        self.assertDictContainsSubset({"type": "Feature"}, items[0])
+        self.assertEqual(items[0]["type"], "Feature")
diff --git a/tests/units/test_utils.py b/tests/units/test_utils.py
index 58732712..ef865dff 100644
--- a/tests/units/test_utils.py
+++ b/tests/units/test_utils.py
@@ -16,10 +16,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import sys
 import unittest
 from datetime import datetime
 
-from tests.context import get_timestamp
+from tests.context import get_timestamp, path_to_uri, uri_to_path
 
 
 class TestUtils(unittest.TestCase):
@@ -33,3 +34,21 @@ class TestUtils(unittest.TestCase):
         expected_dt = datetime.strptime(requested_date, date_format)
         actual_utc_dt = datetime.utcfromtimestamp(ts_in_secs)
         self.assertEqual(actual_utc_dt, expected_dt)
+
+    def test_uri_to_path(self):
+        if sys.platform == "win32":
+            expected_path = r"C:\tmp\file.txt"
+            tested_uri = r"file:///C:/tmp/file.txt"
+        else:
+            expected_path = "/tmp/file.txt"
+            tested_uri = "file:///tmp/file.txt"
+        actual_path = uri_to_path(tested_uri)
+        self.assertEqual(actual_path, expected_path)
+        with self.assertRaises(ValueError):
+            uri_to_path("not_a_uri")
+
+    def test_path_to_uri(self):
+        if sys.platform == "win32":
+            self.assertEqual(path_to_uri(r"C:\tmp\file.txt"), "file:///C:/tmp/file.txt")
+        else:
+            self.assertEqual(path_to_uri("/tmp/file.txt"), "file:///tmp/file.txt")