Repository: CS-SI/eodag (python)

Issue:
Two consecutive executions will fully download twice the same products
**Describe the bug**
When running an eodag request twice in a row, products are being fully downloaded twice while eodag would be expected to detect that files already exist locally and jump on to next search result.

**Code To Reproduce**
```py
self._gateway.download_all(search_result, output_dir=self._download_dir, extract=False)
```
with search_result being :
```py
[EOProduct(id=S2A_MSIL1C_20231006T102911_N0510_R108_T31TFK_20241102T002017, provider=geodes), EOProduct(id=S2A_MSIL1C_20231006T102911_N0509_R108_T31TFK_20231006T141008, provider=geodes)]
```

**Output**
```
2025-07-01 10:33:00,174 eodag.config                     [DEBUG   ] Loading configuration from /usr/local/lib/python3.12/site-packages/eodag/resources/providers.yml
2025-07-01 10:33:00,612 eodag.plugins.manager            [DEBUG   ] CSWSearch plugin skipped, eodag[csw] or eodag[all] needed
2025-07-01 10:33:00,866 eodag.plugins.manager            [DEBUG   ] EcmwfApi plugin skipped, eodag[ecmwf] or eodag[all] needed
2025-07-01 10:33:00,867 eodag.plugins.manager            [DEBUG   ] UsgsApi plugin skipped, eodag[usgs] or eodag[all] needed
2025-07-01 10:33:00,873 eodag.config                     [INFO    ] Loading user configuration from: /app/eodag_config/eodag.yml
2025-07-01 10:33:00,882 eodag.core                       [DEBUG   ] usgs: provider needing unavailable plugin has been removed
2025-07-01 10:33:00,882 eodag.core                       [INFO    ] aws_eos: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,882 eodag.core                       [DEBUG   ] ecmwf: provider needing unavailable plugin has been removed
2025-07-01 10:33:00,882 eodag.core                       [INFO    ] cop_ads: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,882 eodag.core                       [INFO    ] cop_cds: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,882 eodag.core                       [INFO    ] meteoblue: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,882 eodag.core                       [INFO    ] hydroweb_next: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,882 eodag.core                       [INFO    ] wekeo_main: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,882 eodag.core                       [INFO    ] wekeo_ecmwf: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,882 eodag.core                       [INFO    ] wekeo_cmems: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,882 eodag.core                       [INFO    ] creodias_s3: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,883 eodag.core                       [INFO    ] dedt_lumi: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,883 eodag.core                       [INFO    ] dedl: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,883 eodag.core                       [INFO    ] geodes_s3: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,883 eodag.core                       [INFO    ] cop_ewds: provider needing auth for search has been pruned because no credentials could be found
2025-07-01 10:33:00,888 eodag.core                       [DEBUG   ] Opening product types index in /home/user/.config/eodag/.index
2025-07-01 10:33:00,894 eodag.core                       [INFO    ] Locations configuration loaded from /app/eodag_config/locations.yml
2025-07-01 10:33:02,336 eodag.core                       [INFO    ] Searching for all the products with provider geodes and a maximum of 1000 items per page.
2025-07-01 10:33:02,336 eodag.core                       [INFO    ] Iterate search over multiple pages: page #1
2025-07-01 10:33:02,337 eodag.core                       [INFO    ] Searching on provider geodes
2025-07-01 10:33:02,337 eodag.search.base                [INFO    ] geodes is configured with default sorting by 'startTimeFromAscendingNode' in ascending order
2025-07-01 10:33:02,337 eodag.search.base                [DEBUG   ] Mapping eodag product type to provider product type
2025-07-01 10:33:02,337 eodag.search.base                [DEBUG   ] Getting provider product type definition parameters for S1_SAR_GRD
2025-07-01 10:33:02,337 eodag.search.qssearch            [DEBUG   ] Building the query string that will be used for search
2025-07-01 10:33:02,337 eodag.product.metadata_mapping   [DEBUG   ] Retrieving queryable metadata from metadata_mapping
2025-07-01 10:33:02,340 eodag.search.qssearch            [INFO    ] Sending search request: https://geodes-portal.cnes.fr/api/stac/search
2025-07-01 10:33:02,340 eodag.search.qssearch            [DEBUG   ] Query parameters: {"query": {"dataset": {"eq": "PEPS_S1_L1"}, "end_datetime": {"gte": "2023-10-06T00:00:00.000Z"}, "start_datetime": {"lte": "2023-10-07T00:00:00.000Z"}, "product:type": {"eq": "GRD"}}, "intersects": {"type": "Polygon", "coordinates": [[[8.999756774, 42.4526911987], [10.3348915669, 42.4449177239], [10.3144181193, 41.4562418971], [8.9997605057, 41.4637518867], [8.999756774, 42.4526911987]]]}, "sortBy": [{"field": "start_datetime", "direction": "asc"}], "limit": 1000, "page": 1}
2025-07-01 10:33:02,340 eodag.search.qssearch            [DEBUG   ] Query kwargs: {}
/usr/local/lib/python3.12/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host 'geodes-portal.cnes.fr'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
  warnings.warn(
2025-07-01 10:33:02,891 eodag.search.qssearch            [DEBUG   ] Next page URL could not be collected
2025-07-01 10:33:02,891 eodag.search.qssearch            [DEBUG   ] Next page Query-object could not be collected
2025-07-01 10:33:02,891 eodag.search.qssearch            [DEBUG   ] Next page merge could not be collected
2025-07-01 10:33:02,912 eodag.search.qssearch            [DEBUG   ] Adapting 2 plugin results to eodag product representation
2025-07-01 10:33:02,923 eodag.core                       [DEBUG   ] Iterate over pages: last products found on page 1
2025-07-01 10:33:02,923 eodag.core                       [INFO    ] Found 2 result(s) on provider 'geodes'
2025-07-01 10:33:02,923 eodag.core                       [INFO    ] Downloading 2 products
Downloaded products:   0%|                                                              2025-07-01 10:33:02,942 eodag.download.base              [INFO    ] Download url: https://geodes-portal.cnes.fr/api/download/URN:FEATURE:DATA:gdh:2250ecbb-f4b2-3e9c-8894-d8cb429894b3:V1/files/6605f5caae059dd997970057bd9afaa9
/usr/local/lib/python3.12/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host 'geodes-portal.cnes.fr'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
  warnings.warn(
                                                                                                                                                        2025-07-01 10:34:44,398 eodag.download.http              [DEBUG   ] Download recorded in /app/tests/download_dir/.downloaded/a43acb537944a793c0fcf06647663d4f
                                                                                                                                                        2025-07-01 10:34:44,399 eodag.download.base              [INFO    ] Extraction not activated. The product is available as is.1:39<00:00, 8222326.54file/s]
S1A_IW_GRDH_1SDV_20231006T052826_20231006T052851_050640_0619D2_D8CE: 100%|██████████████████████████████████████████████| 1/1 [00:00<00:00, 841.72file/s]
2025-07-01 10:34:44,401 eodag.product                    [DEBUG   ] Product location updated from 'https://geodes-portal.cnes.fr/api/download/URN:FEATURE:DATA:gdh:2250ecbb-f4b2-3e9c-8894-d8cb429894b3:V1/files/6605f5caae059dd997970057bd9afaa9' to 'file:///app/tests/download_dir/S1A_IW_GRDH_1SDV_20231006T052826_20231006T052851_050640_0619D2_D8CE.zip'
2025-07-01 10:34:44,402 eodag.product                    [INFO    ] Remote location of the product is still available through its 'remote_location' property: https://geodes-portal.cnes.fr/api/download/URN:FEATURE:DATA:gdh:2250ecbb-f4b2-3e9c-8894-d8cb429894b3:V1/files/6605f5caae059dd997970057bd9afaa9
Downloaded products:  50%|█████████████████████████████████████████████▌                2025-07-01 10:34:44,403 eodag.download.base              [INFO    ] Download url: https://geodes-portal.cnes.fr/api/download/URN:FEATURE:DATA:gdh:71b5ee81-ca6c-3587-838e-6a359d1a3baf:V1/files/8c5a6c26032ec164f2b7d3e6ec5d1a6b
/usr/local/lib/python3.12/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host 'geodes-portal.cnes.fr'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
  warnings.warn(
                                                                                                                                                        2025-07-01 10:36:47,807 eodag.download.http              [DEBUG   ] Download recorded in /app/tests/download_dir/.downloaded/62896b85ca582d15f7ae39cffd75f9eb
                                                                                                                                                        2025-07-01 10:36:47,809 eodag.download.base              [INFO    ] Extraction not activated. The product is available as is.2:01<00:00, 3117439.03file/s]
S1A_IW_GRDH_1SDV_20231006T052851_20231006T052916_050640_0619D2_3711: 100%|█████████████████████████████████████████████| 1/1 [00:00<00:00, 1122.67file/s]
2025-07-01 10:36:47,810 eodag.product                    [DEBUG   ] Product location updated from 'https://geodes-portal.cnes.fr/api/download/URN:FEATURE:DATA:gdh:71b5ee81-ca6c-3587-838e-6a359d1a3baf:V1/files/8c5a6c26032ec164f2b7d3e6ec5d1a6b' to 'file:///app/tests/download_dir/S1A_IW_GRDH_1SDV_20231006T052851_20231006T052916_050640_0619D2_3711.zip'
2025-07-01 10:36:47,811 eodag.product                    [INFO    ] Remote location of the product is still available through its 'remote_location' property: https://geodes-portal.cnes.fr/api/download/URN:FEATURE:DATA:gdh:71b5ee81-ca6c-3587-838e-6a359d1a3baf:V1/files/8c5a6c26032ec164f2b7d3e6ec5d1a6b
Downloaded products: 100%|███████████████████████████████████████████████████████████████████████████████████████████| 2/2 [03:44<00:00, 112.43s/product]
```
**Environment:**
Python 3.12.11
eodag (Earth Observation Data Access Gateway): version 3.5.1

**Additional context**
provider = geodes
product type = S2_MSI_L1C
product id = S2A_MSIL1C_20231006T102911_N0509_R108_T31TFK_20231006T141008.zip and S2A_MSIL1C_20231006T102911_N0510_R108_T31TFK_20241102T002017.zip


Pull request:
fix(core): do not download again unextracted products
Fixes #1713

Do not download again unextracted products

Patch:
diff --git a/eodag/plugins/download/base.py b/eodag/plugins/download/base.py
index b286a77d..80216f82 100644
--- a/eodag/plugins/download/base.py
+++ b/eodag/plugins/download/base.py
@@ -25,6 +25,7 @@ import tarfile
 import tempfile
 import zipfile
 from datetime import datetime, timedelta
+from pathlib import Path
 from time import sleep
 from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union
 
@@ -232,12 +233,23 @@ class Download(PluginTopic):
         )
         if os.path.isfile(old_record_filename):
             os.rename(old_record_filename, record_filename)
-        if os.path.isfile(record_filename) and os.path.isfile(fs_path):
+
+        # path with or without extension
+        path_obj = Path(fs_path)
+        matched_paths = list(path_obj.parent.glob(f"{path_obj.stem}.*"))
+        fs_path_with_ext = matched_paths[0] if matched_paths else fs_path
+        if (
+            os.path.isfile(record_filename)
+            and fs_path_with_ext
+            and os.path.isfile(fs_path_with_ext)
+        ):
             logger.info(
-                f"Product already downloaded: {fs_path}",
+                f"Product already downloaded: {fs_path_with_ext}",
             )
             return (
-                self._finalize(fs_path, progress_callback=progress_callback, **kwargs),
+                self._finalize(
+                    str(fs_path_with_ext), progress_callback=progress_callback, **kwargs
+                ),
                 None,
             )
         elif os.path.isfile(record_filename) and os.path.isdir(fs_dir_path):


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index d7488dbd..29cc5b01 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -306,7 +306,7 @@ class EODagTestCase(unittest.TestCase):
         self._set_download_simulation()
         self.tmp_download_dir = tempfile.TemporaryDirectory()
         if output_dir is None:
-            output_dir = str(Path(self.tmp_download_dir.name).parent)
+            output_dir = str(Path(self.tmp_download_dir.name))
         dl_config = config.PluginConfig.from_mapping(
             {
                 "type": "HTTPDownload",
diff --git a/tests/units/test_download_plugins.py b/tests/units/test_download_plugins.py
index ccdb597b..0fd0204a 100644
--- a/tests/units/test_download_plugins.py
+++ b/tests/units/test_download_plugins.py
@@ -149,7 +149,8 @@ class TestDownloadPluginBase(BaseDownloadPluginTest):
 
             # < v3.0.0b1 formatted record file and product path exist, record file moved to new format
             product_path_dir.mkdir()
-            (product_path_dir / "foo").touch()
+            file_in_dir = product_path_dir / "foo"
+            file_in_dir.touch()
             old_recordfile_path.touch()
             with self.assertLogs(level="INFO") as cm:
                 plugin._prepare_download(self.product, **download_kwargs)
@@ -163,6 +164,24 @@ class TestDownloadPluginBase(BaseDownloadPluginTest):
                 self.assertTrue(os.path.isfile(recordfile_path))
                 self.assertIn("Product already downloaded", str(cm.output))
 
+            # already downloaded product without extension
+            file_in_dir.unlink()
+            product_path_dir.rmdir()
+            product_path_dir.touch()
+            with self.assertLogs(level="INFO") as cm:
+                plugin._prepare_download(self.product, **download_kwargs)
+                self.assertIn("Product already downloaded", str(cm.output))
+                self.assertIn(
+                    "Remove existing partially downloaded file", str(cm.output)
+                )
+
+            # already downloaded product with extension
+            product_path_with_ext = product_path_dir.with_suffix(".xyz")
+            product_path_with_ext.touch()
+            with self.assertLogs(level="INFO") as cm:
+                plugin._prepare_download(self.product, **download_kwargs)
+                self.assertIn("Product already downloaded", str(cm.output))
+
     def test_plugins_download_base_prepare_download_no_url(self):
         """Download._prepare_download must return None when no download url"""