Repository: CS-SI/eodag
Pull request #1716 (2025-07-01 14:38:59)

feat(cli): download output directory
Allows user to specify download directory using `--output-dir` option:

```sh
eodag download results.geojson --output-dir /path/to/downloads
```

See [CLI User Guide / Download](https://eodag.readthedocs.io/en/latest/cli_user_guide.html)

eodag-bot: ![badge](https://raw.githubusercontent.com/CS-SI/eodag/_xml_coverage_reports_win/data/cli-output-dir/./badge.svg)


## Code Coverage (Windows)

<details>


```
Filename                                     Stmts    Miss  Cover    Missing
-----------------------------------------  -------  ------  -------  ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
__init__.py                                      8       0  100.00%
cli.py                                         320      64  80.00%   62-63, 87, 687-750, 852-903, 907
config.py                                      450      31  93.11%   81-83, 92, 100, 104-106, 177, 188, 205-206, 723-725, 849-852, 896-897, 906-907, 941, 1021, 1050, 1080-1085, 1087
crunch.py                                        5       5  0.00%    20-24
api/__init__.py                                  0       0  100.00%
api/core.py                                    818      80  90.22%   380-381, 431, 700, 731, 775-778, 816, 860, 894, 939-944, 970, 1061, 1129, 1352-1364, 1404, 1406, 1436, 1440-1451, 1464-1470, 1555-1558, 1595-1615, 1684-1689, 1701-1704, 1722-1734, 2019, 2043-2049, 2300, 2304-2308, 2317-2319, 2363-2364, 2393-2394
api/search_result.py                           126      11  91.27%   87, 96, 103, 117, 282, 287-288, 336, 359-360, 366
api/product/__init__.py                         18       1  94.44%   62
api/product/_assets.py                          52       5  90.38%   97, 183, 191, 194-198
api/product/_product.py                        213      19  91.08%   72-74, 238-239, 345, 372, 473, 491, 515-518, 527-530, 585-588
api/product/metadata_mapping.py                708      63  91.10%   122-124, 222, 254-255, 312-324, 326, 337, 362, 402-403, 440, 461-464, 487, 495-496, 589-590, 614-615, 621-624, 639-640, 789, 840, 993, 1002-1006, 1023-1028, 1155, 1169-1189, 1209, 1214, 1366, 1380, 1405, 1451, 1512, 1535-1536, 1556-1560, 1576, 1584
api/product/drivers/__init__.py                 20       2  90.00%   33-36
api/product/drivers/base.py                     29       1  96.55%   98
api/product/drivers/generic.py                   7       0  100.00%
api/product/drivers/sentinel1.py                15       0  100.00%
api/product/drivers/sentinel2.py                15       0  100.00%
plugins/__init__.py                              0       0  100.00%
plugins/base.py                                 21       3  85.71%   48, 55, 68
plugins/manager.py                             173      19  89.02%   105-110, 145-150, 154, 192, 214, 218, 242, 281-282, 382-385, 397-398
plugins/apis/__init__.py                         0       0  100.00%
plugins/apis/base.py                             4       0  100.00%
plugins/apis/ecmwf.py                           97      10  89.69%   165-167, 214-215, 241-243, 296-297
plugins/apis/usgs.py                           180      26  85.56%   156, 258, 292, 327-329, 334, 360-361, 366, 396-403, 414-419, 441-447, 478
plugins/authentication/__init__.py               6       1  83.33%   31
plugins/authentication/aws_auth.py              20       0  100.00%
plugins/authentication/base.py                  17       2  88.24%   45, 58
plugins/authentication/generic.py               16       3  81.25%   50, 55, 65
plugins/authentication/header.py                19       0  100.00%
plugins/authentication/keycloak.py              46       7  84.78%   153-156, 177-182
plugins/authentication/oauth.py                 13       7  46.15%   43-45, 49-52
plugins/authentication/openid_connect.py       231      28  87.88%   92-93, 105-123, 170, 176-204, 212, 351-354, 380, 421
plugins/authentication/qsauth.py                34       1  97.06%   91
plugins/authentication/sas_auth.py              47       1  97.87%   76
plugins/authentication/token.py                128       9  92.97%   180, 217, 289-290, 340-344
plugins/authentication/token_exchange.py        36      14  61.11%   75, 93-121
plugins/crunch/__init__.py                       0       0  100.00%
plugins/crunch/base.py                          10       1  90.00%   43
plugins/crunch/filter_date.py                   59      14  76.27%   52-57, 69, 78, 87, 90, 100-102, 109-111, 118
plugins/crunch/filter_latest_intersect.py       47      33  29.79%   49-54, 67-112
plugins/crunch/filter_latest_tpl_name.py        31       1  96.77%   83
plugins/crunch/filter_overlap.py                66      18  72.73%   28-30, 66-69, 76-79, 85, 93, 104-120
plugins/crunch/filter_property.py               30       5  83.33%   55-60, 63-64
plugins/download/__init__.py                     0       0  100.00%
plugins/download/aws.py                        528     158  70.08%   272, 285, 365-368, 382-386, 476-478, 482, 515-516, 522-526, 556, 589, 593, 600, 630-638, 642, 677-685, 696-698, 729-810, 828-886, 897-902, 955, 970-972, 975, 985-993, 1001-1015, 1025-1048, 1055-1068, 1106, 1132, 1177-1179, 1399
plugins/download/base.py                       256      44  82.81%   128, 156, 223-225, 236-239, 292-293, 341-345, 351-352, 394, 397-411, 423, 427, 491-495, 525-526, 551-559, 561-565, 615, 636, 658, 666
plugins/download/creodias_s3.py                 25       9  64.00%   55-69
plugins/download/http.py                       542      93  82.84%   228, 270-273, 334-337, 340, 347-352, 383-385, 402, 417, 471, 506, 520, 534, 542-546, 562-567, 578, 596, 632-635, 656, 666, 673, 693, 770, 789-800, 808-813, 823-840, 858, 888-897, 933, 958-959, 978-983, 992, 1007-1009, 1013, 1016, 1031-1032, 1067, 1129, 1144, 1203-1204, 1210, 1220, 1256, 1292, 1312, 1347-1349
plugins/download/s3rest.py                     120      26  78.33%   119, 153, 160, 195, 222-229, 232-234, 238, 249-255, 263-264, 267-271, 286-289, 298, 319-322
plugins/search/__init__.py                      22       0  100.00%
plugins/search/base.py                         151      11  92.72%   101, 105, 129-135, 276, 297, 436
plugins/search/build_search_result.py          479      63  86.85%   245-246, 282, 285, 310, 313, 344-346, 554-565, 692, 694, 755, 762, 786, 823, 874, 890, 924-939, 987, 1012, 1015, 1019, 1140-1141, 1150-1159, 1222, 1237, 1243, 1262-1271, 1388-1389, 1434, 1443-1445, 1505, 1551
plugins/search/cop_marine.py                   252      47  81.35%   56, 64-66, 76-77, 82, 87-88, 104, 106, 109, 175-176, 232, 238, 242, 246, 259, 270-271, 279, 310, 314, 335, 339, 343, 347, 351-355, 361-364, 367-381, 398, 447-451, 456, 468
plugins/search/creodias_s3.py                   25       1  96.00%   51
plugins/search/csw.py                          105      81  22.86%   98-99, 103-104, 112-160, 166-179, 187-219, 237-278
plugins/search/data_request_search.py          207      72  65.22%   190-193, 209, 220, 224-225, 236, 241, 246, 253, 266-269, 323-324, 328, 338-344, 349, 367-371, 387-390, 398-409, 426, 428, 435-438, 440-441, 459-463, 496, 506, 517, 530, 536-551, 556
plugins/search/qssearch.py                     710     100  85.92%   448, 506, 520, 524-530, 545-546, 558-562, 675-687, 732-735, 806-807, 858, 877, 884, 896, 953, 974, 977-978, 987-988, 997-998, 1007-1008, 1035, 1119-1124, 1128-1137, 1171, 1194, 1267-1268, 1274, 1364, 1461-1465, 1525, 1528, 1534-1535, 1556, 1584-1596, 1603, 1635-1637, 1647-1653, 1683, 1706, 1711-1712, 1727, 1743, 1818, 1830, 1837, 1848, 1871-1876, 1885, 1893-1903, 1921, 1937-1941
plugins/search/stac_list_assets.py              25      10  60.00%   44-51, 75-85
plugins/search/static_stac_search.py            79      13  83.54%   100-126, 170, 183
rest/__init__.py                                 4       2  50.00%   21-22
rest/cache.py                                   33      22  33.33%   35-37, 44-70
rest/config.py                                  25       1  96.00%   35
rest/constants.py                                6       0  100.00%
rest/core.py                                   260     153  41.15%   157, 159, 161, 164-165, 179-189, 198-199, 205, 208, 249-303, 316-352, 384-422, 437-453, 469-478, 485-524, 541, 583-668, 707, 714-767
rest/errors.py                                  72      51  29.17%   63, 68-111, 116-119, 126-129, 137-167, 175-180, 195-201
rest/server.py                                 194     194  0.00%    18-596
rest/stac.py                                   333      77  76.88%   246, 312, 334, 386-389, 416-443, 474-476, 499, 531-532, 614-654, 676-692, 719, 784-788, 795, 850-851, 857, 861-866, 876-880, 929, 967, 1000, 1019-1021
rest/types/__init__.py                           0       0  100.00%
rest/types/collections_search.py                13      13  0.00%    18-44
rest/types/eodag_search.py                     180      20  88.89%   225-229, 262-264, 282, 285, 291, 295, 353, 371-386
rest/types/queryables.py                        64      20  68.75%   54-55, 60-63, 68-79, 105-110, 119-120, 174
rest/types/stac_search.py                      125      11  91.20%   126-128, 174, 189-191, 199, 203, 251, 254
rest/utils/__init__.py                          94      30  68.09%   72-78, 98, 101-102, 121-123, 136, 143, 168-176, 183-204
rest/utils/cql_evaluate.py                      48       5  89.58%   69, 76, 90, 97, 105
rest/utils/rfc3339.py                           23       5  78.26%   43-44, 48, 60, 62
types/__init__.py                              161      41  74.53%   67, 80-84, 95-107, 135-137, 144-149, 214, 252, 262-278, 283, 285, 307, 312, 320, 330
types/bbox.py                                   43      19  55.81%   46-61, 72-74, 85-87, 99-101, 113-115, 123
types/download_args.py                           9       0  100.00%
types/queryables.py                            111       0  100.00%
types/search_args.py                            70      18  74.29%   60-64, 71-88, 103
types/whoosh.py                                 81      16  80.25%   129-132, 136-143, 155-161, 174-176
utils/__init__.py                              551      40  92.74%   81, 208-209, 218-245, 248, 263, 343-347, 420-424, 443-445, 459, 551, 566, 606-607, 636, 1011-1014, 1022-1023, 1061-1062, 1109-1110, 1250
utils/cache.py                                  22       0  100.00%
utils/env.py                                     3       0  100.00%
utils/exceptions.py                             46       0  100.00%
utils/import_system.py                          28      19  32.14%   64-78, 89-99
utils/logging.py                                28       1  96.43%   41
utils/notebook.py                               44      23  47.73%   25-29, 36-41, 58-62, 72-78, 83-87
utils/repr.py                                   38       8  78.95%   51, 53, 57, 98, 122-129
utils/requests.py                               55      29  47.27%   51-52, 64, 85-96, 107-124, 128
utils/rest.py                                   36       1  97.22%   55
utils/s3.py                                     69       3  95.65%   162-163, 226
utils/stac_reader.py                           113      44  61.06%   63-85, 95-97, 101, 138, 154-159, 206-216, 226-256
TOTAL                                        10643    2079  80.47%
```

</details>


### Diff against develop

```
Filename      Stmts    Miss  Cover
----------  -------  ------  -------
cli.py           +2       0  +0.13%
TOTAL            +2       0  +0.01%
```


Results for commit: 63df3bbb33b1a4a919e22511f05b237333e46b90


_Minimum allowed coverage is `70%`_


:recycle: This comment has been updated with latest results


<!-- Sticky Pull Request Commentartifacts/unit-test-results-python3.13-windows-latest/coverage.xml -->

Patch:
diff --git a/eodag/cli.py b/eodag/cli.py
index 92aa2f23..5ce808e4 100755
--- a/eodag/cli.py
+++ b/eodag/cli.py
@@ -563,6 +563,11 @@ Examples:
     show_default=False,
     help="Download only quicklooks of products instead full set of files",
 )
+@click.option(
+    "--output-dir",
+    type=click.Path(dir_okay=True, file_okay=False),
+    help="Products or quicklooks download directory (Default: local temporary directory)",
+)
 @click.pass_context
 def download(ctx: Context, **kwargs: Any) -> None:
     """Download a bunch of products from a serialized search result"""
@@ -587,7 +592,9 @@ def download(ctx: Context, **kwargs: Any) -> None:
     if stac_items:
         search_results.extend(satim_api.import_stac_items(list(stac_items)))
 
+    output_dir = kwargs.pop("output_dir")
     get_quicklooks = kwargs.pop("quicklooks")
+
     if get_quicklooks:
         # Download only quicklooks
         click.echo(
@@ -595,7 +602,7 @@ def download(ctx: Context, **kwargs: Any) -> None:
         )
 
         for idx, product in enumerate(search_results):
-            downloaded_file = product.get_quicklook()
+            downloaded_file = product.get_quicklook(output_dir=output_dir)
             if not downloaded_file:
                 click.echo(
                     "A quicklook may have been downloaded but we cannot locate it. "
@@ -606,7 +613,7 @@ def download(ctx: Context, **kwargs: Any) -> None:
 
     else:
         # Download products
-        downloaded_files = satim_api.download_all(search_results)
+        downloaded_files = satim_api.download_all(search_results, output_dir=output_dir)
         if downloaded_files and len(downloaded_files) > 0:
             for downloaded_file in downloaded_files:
                 if downloaded_file is None:


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index de6bd40e..e3f058b3 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -411,7 +411,9 @@ class TestEodagCli(unittest.TestCase):
             api_obj.search.assert_called_once_with(
                 count=False, items_per_page=DEFAULT_ITEMS_PER_PAGE, page=1, **criteria
             )
-            api_obj.download_all.assert_called_once_with(search_results)
+            api_obj.download_all.assert_called_once_with(
+                search_results, output_dir=None
+            )
 
     @mock.patch("eodag.cli.EODataAccessGateway", autospec=True)
     def test_eodag_search_all(self, dag):
@@ -832,6 +834,24 @@ class TestEodagCli(unittest.TestCase):
             "A file may have been downloaded but we cannot locate it\n", result.output
         )
 
+        # Testing output directory
+        output_dir = os.path.join(self.tmp_home_dir.name, "downloads")
+        result = self.runner.invoke(
+            eodag,
+            [
+                "download",
+                "--search-results",
+                search_results_path,
+                "-f",
+                config_path,
+                "--output-dir",
+                output_dir,
+            ],
+        )
+        dag.return_value.download_all.assert_called_with(
+            mock.ANY, output_dir=output_dir
+        )
+
     @mock.patch("eodag.cli.EODataAccessGateway", autospec=True)
     def test_eodag_download_ko(self, dag):
         """Calling eodag download with all args well formed fails"""
@@ -870,7 +890,9 @@ class TestEodagCli(unittest.TestCase):
             dag.return_value.import_stac_items.assert_called_once_with(
                 ["foo", "bar"],
             )
-            dag.return_value.download_all.assert_called_once_with(fake_result)
+            dag.return_value.download_all.assert_called_once_with(
+                fake_result, output_dir=None
+            )
 
     def test_eodag_download_missingcredentials(self):
         """Calling eodag download with missing credentials must raise MisconfiguredError"""
@@ -945,6 +967,23 @@ class TestEodagCli(unittest.TestCase):
         self.assertEqual(mock_get_quicklook.call_count, 2)
         self.assertIn("Downloaded /fake_path\n", result.output)
 
+        # change output dir
+        output_dir = os.path.join(self.tmp_home_dir.name, "quicklooks")
+        result = self.runner.invoke(
+            eodag,
+            [
+                "download",
+                "--search-results",
+                search_results_path,
+                "-f",
+                config_path,
+                "--quicklooks",
+                "--output-dir",
+                output_dir,
+            ],
+        )
+        mock_get_quicklook.assert_called_with(mock.ANY, output_dir=output_dir)
+
         # Testing the case when no quicklook path is returned
         mock_get_quicklook.return_value = None
         result = self.runner.invoke(