Repository: CS-SI/eodag
Pull request #1714 (2025-07-01 14:15:27)

feat(cli): commands chaining
Uses [click command chaining](https://click.palletsprojects.com/en/stable/commands/#command-chaining) and allow user to chain `search` and `download` in a single command:
```sh
eodag search -p S2_MSI_L1C -s 2025-06-25 download
```

Reverts https://github.com/CS-SI/eodag/pull/1706


Patch:
diff --git a/docs/cli_user_guide.rst b/docs/cli_user_guide.rst
index 9fbb7031..6e217def 100644
--- a/docs/cli_user_guide.rst
+++ b/docs/cli_user_guide.rst
@@ -108,11 +108,11 @@ Download
 
         eodag download --stac-item https://foo/collections/bar/items/item-1-id --stac-item /path/to/item2.json
 
-* Download can also directly be executed after a ``search`` with a single command:
+* Using commands chaining, ``download`` can also directly be executed after a ``search`` in a single command:
 
 .. code-block:: console
 
-        eodag search --download --productType S2_MSI_L1C --bbox 1 43 2 44 --start 2025-03-01
+        eodag search --productType S2_MSI_L1C --bbox 1 43 2 44 --start 2025-03-01 download
 
 Product Types
 -------------
diff --git a/eodag/cli.py b/eodag/cli.py
index e1027033..92aa2f23 100755
--- a/eodag/cli.py
+++ b/eodag/cli.py
@@ -116,7 +116,7 @@ class MutuallyExclusiveOption(click.Option):
         return super(MutuallyExclusiveOption, self).handle_parse_result(ctx, opts, args)
 
 
-@click.group()
+@click.group(chain=True)
 @click.option(
     "-v",
     "--verbose",
@@ -278,11 +278,6 @@ def version() -> None:
     "slow down search requests for some providers, and might be unavailable for some"
     "others).",
 )
-@click.option(
-    "--download",
-    is_flag=True,
-    help="Directly download search results.",
-)
 @click.pass_context
 def search_crunch(ctx: Context, **kwargs: Any) -> None:
     """Search product types and optionnaly apply crunchers to search results"""
@@ -296,7 +291,6 @@ def search_crunch(ctx: Context, **kwargs: Any) -> None:
     id_ = kwargs.pop("id")
     locations_qs = kwargs.pop("locations")
     custom = kwargs.pop("query")
-    download = kwargs.pop("download")
     if not any(
         [
             product_type,
@@ -415,20 +409,7 @@ def search_crunch(ctx: Context, **kwargs: Any) -> None:
         storage_filepath += ".geojson"
     result_storage = gateway.serialize(results, filename=storage_filepath)
     click.echo("Results stored at '{}'".format(result_storage))
-    if download:
-        downloaded_files = gateway.download_all(results)
-        if downloaded_files and len(downloaded_files) > 0:
-            for downloaded_file in downloaded_files:
-                if downloaded_file is None:
-                    click.echo(
-                        "A file may have been downloaded but we cannot locate it"
-                    )
-                else:
-                    click.echo("Downloaded {}".format(downloaded_file))
-        else:
-            click.echo(
-                "Error during download, a file may have been downloaded but we cannot locate it"
-            )
+    ctx.obj["search_results"] = results
 
 
 @eodag.command(name="list", help="List supported product types")
@@ -587,7 +568,8 @@ def download(ctx: Context, **kwargs: Any) -> None:
     """Download a bunch of products from a serialized search result"""
     search_result_path = kwargs.pop("search_results")
     stac_items = kwargs.pop("stac_item")
-    if not search_result_path and not stac_items:
+    search_results = ctx.obj.get("search_results")
+    if not search_result_path and not stac_items and search_results is None:
         with click.Context(download) as ctx:
             click.echo("Nothing to do (no search results file or stac item provided)")
             click.echo(download.get_help(ctx))
@@ -599,7 +581,7 @@ def download(ctx: Context, **kwargs: Any) -> None:
 
     satim_api = EODataAccessGateway(user_conf_file_path=conf_file)
 
-    search_results = SearchResult([])
+    search_results = search_results or SearchResult([])
     if search_result_path:
         search_results.extend(satim_api.deserialize_and_register(search_result_path))
     if stac_items:


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 346fd8f6..de6bd40e 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -91,7 +91,10 @@ class TestEodagCli(unittest.TestCase):
     def test_eodag_without_args(self):
         """Calling eodag without arguments should print help message"""
         result = self.runner.invoke(eodag)
-        self.assertIn("Usage: eodag [OPTIONS] COMMAND [ARGS]...", result.output)
+        self.assertIn(
+            "Usage: eodag [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...",
+            result.output,
+        )
         # Exit status 2 with no_args_is_help starting click >= 8.2.0
         self.assertIn(result.exit_code, (0, 2))
 
@@ -399,13 +402,12 @@ class TestEodagCli(unittest.TestCase):
             )
             self.runner.invoke(
                 eodag,
-                ["search", "-f", conf_file, "-p", product_type, "--download"],
+                ["search", "-f", conf_file, "-p", product_type, "download"],
             )
 
             # Assertions
-            dag.assert_called_once_with(
-                user_conf_file_path=conf_file, locations_conf_path=None
-            )
+            self.assertEqual(dag.call_count, 2)
+            dag.assert_any_call(user_conf_file_path=conf_file, locations_conf_path=None)
             api_obj.search.assert_called_once_with(
                 count=False, items_per_page=DEFAULT_ITEMS_PER_PAGE, page=1, **criteria
             )