diff --git a/docs/cli_user_guide.rst b/docs/cli_user_guide.rst index d6504ddd66..54a38b6fa3 100644 --- a/docs/cli_user_guide.rst +++ b/docs/cli_user_guide.rst @@ -119,12 +119,19 @@ Collections .. command-output:: eodag list --help -* To list all available collections and supported providers: +* To list all available collection identifiers, one per line: .. code-block:: console eodag list +* To include full collection information, use ``--json`` or ``--yaml``: + +.. code-block:: console + + eodag list --json + eodag list --yaml + * To list available collections on a specified supported provider: .. code-block:: console diff --git a/eodag/cli.py b/eodag/cli.py index 67b8d402e9..e21628ab0d 100755 --- a/eodag/cli.py +++ b/eodag/cli.py @@ -42,12 +42,12 @@ import functools import json import sys -import textwrap from importlib.metadata import metadata from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional from urllib.parse import parse_qs import click +import yaml from eodag.utils import DEFAULT_LIMIT, DEFAULT_PAGE @@ -64,6 +64,14 @@ ] +class IndentedSafeDumper(yaml.SafeDumper): + """PyYAML dumper that indents block sequence items under their mapping key.""" + + def increase_indent(self, flow: bool = False, indentless: bool = False) -> None: + """Force indentation for block sequence items.""" + return super().increase_indent(flow, False) + + class MutuallyExclusiveOption(click.Option): """Mutually Exclusive Options for Click from https://gist.github.com/jacobtolar/fb80d5552a9a9dfc32b12a829fa21c0c @@ -438,6 +446,20 @@ def search_crunch(ctx: Context, **kwargs: Any) -> None: @click.option( "--no-fetch", is_flag=True, help="Do not fetch providers for new collections" ) +@click.option( + "--json", + is_flag=True, + cls=MutuallyExclusiveOption, + mutually_exclusive=["yaml"], + help="Print full collection information as JSON", +) +@click.option( + "--yaml", + is_flag=True, + cls=MutuallyExclusiveOption, + mutually_exclusive=["json"], + help="Print full collection information as YAML", +) @click.pass_context def list_col(ctx: Context, **kwargs: Any) -> None: """Print the list of supported collections""" @@ -450,7 +472,8 @@ def list_col(ctx: Context, **kwargs: Any) -> None: dag = EODataAccessGateway() provider = kwargs.pop("provider") fetch_providers = not kwargs.pop("no_fetch") - text_wrapper = textwrap.TextWrapper() + json_output = kwargs.pop("json") + yaml_output = kwargs.pop("yaml") guessed_collections = CollectionsList([]) try: guessed_collections = dag.guess_collection( @@ -491,17 +514,25 @@ def list_col(ctx: Context, **kwargs: Any) -> None: collections = dag.list_collections( provider=provider, fetch_providers=fetch_providers ) - click.echo("Listing available collections:") - for collection in collections: - click.echo("\n* {}: ".format(collection.id)) - for prop, value in collection.model_dump().items(): - if prop != "id": - text_wrapper.initial_indent = " - {}: ".format(prop) - text_wrapper.subsequent_indent = " " * len( - text_wrapper.initial_indent - ) - if value is not None: - click.echo(text_wrapper.fill(str(value))) + formatted_collections = { + collection.id: collection.model_dump( + mode="json", exclude_none=True, exclude={"id"} + ) + for collection in collections + } + if json_output: + click.echo(json.dumps(formatted_collections)) + elif yaml_output: + click.echo( + yaml.dump( + formatted_collections, + Dumper=IndentedSafeDumper, + sort_keys=False, + allow_unicode=True, + ) + ) + elif collections: + click.echo("\n".join(collection.id for collection in collections)) except UnsupportedProvider: click.echo("Unsupported provider. You may have a typo") click.echo("Available providers: {}".format(", ".join(dag.providers.names))) diff --git a/tests/test_cli.py b/tests/test_cli.py index 59c13b00f2..6e56d5bcb6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,6 +16,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import datetime as dt +import json import logging import os import re @@ -29,6 +30,7 @@ import click import responses import shapely +import yaml from click.testing import CliRunner from faker import Faker from packaging import version @@ -739,8 +741,8 @@ def test_eodag_list_collection_ok(self): exit_code, output, error = self.eodag_command(["list", "--no-fetch"]) self.assertEqual(exit_code, 0) self.assertIsNone(error) - for col in all_supported_collections: - self.assertIn(col, output) + self.assertNotIn("Listing available collections:", output) + self.assertCountEqual(output.splitlines(), all_supported_collections) def test_eodag_list_collection_with_provider_ok(self): """Calling eodag list with provider should return all supported collections of specified provider""" # noqa @@ -756,12 +758,9 @@ def test_eodag_list_collection_with_provider_ok(self): ) self.assertEqual(exit_code, 0) self.assertIsNone(error) - for col in provider_supported_collections: - self.assertIn( - col, - output, - f"{col} was not found in {provider} supported collections", - ) + self.assertNotIn("Listing available collections:", output) + for collection in provider_supported_collections: + self.assertIn(collection, output) def test_eodag_list_collection_with_provider_ko(self): """Calling eodag list with unsupported provider should fail and print a list of available providers""" # noqa @@ -785,21 +784,21 @@ def test_eodag_list_collection_fetch(self, mock_fetch_collections_list): exit_code, output, error = self.eodag_command(["list", "--no-fetch"]) self.assertEqual(exit_code, 0) - self.assertIn("Listing available collections:", output) + self.assertNotIn("Listing available collections:", output) self.assertIsNone(error) assert not mock_fetch_collections_list.called exit_code, output, error = self.eodag_command(["list"]) self.assertEqual(exit_code, 0) - self.assertIn("Listing available collections:", output) + self.assertNotIn("Listing available collections:", output) self.assertIsNone(error) mock_fetch_collections_list.assert_called_once_with(mock.ANY, provider=None) exit_code, output, error = self.eodag_command(["list", "-p", "cop_dataspace"]) self.assertEqual(exit_code, 0) - self.assertIn("Listing available collections:", output) + self.assertNotIn("Listing available collections:", output) self.assertIsNone(error) mock_fetch_collections_list.assert_called_with( @@ -807,6 +806,58 @@ def test_eodag_list_collection_fetch(self, mock_fetch_collections_list): ) self.assertEqual(mock_fetch_collections_list.call_count, 2) + @mock.patch("eodag.api.core.EODataAccessGateway", autospec=True) + def test_eodag_list_collection_formatted_output(self, dag): + """Calling eodag list with a format option should return collection metadata.""" + collections = CollectionsList( + [ + Collection.create_with_dag( + dag=dag, + id="foo", + title="Foo collection", + description="Foo description", + keywords=["foo", "bar"], + ), + Collection.create_with_dag( + dag=dag, + id="bar", + title="Bar collection", + description="Donn\u00e9es bar", + ), + ] + ) + dag.return_value.list_collections.return_value = collections + dag.return_value.guess_collection.return_value = CollectionsList([]) + expected_collections = { + collection.id: collection.model_dump( + mode="json", exclude_none=True, exclude={"id"} + ) + for collection in collections + } + + exit_code, output, error = self.eodag_command(["list", "--json", "--no-fetch"]) + self.assertEqual(exit_code, 0) + self.assertIsNone(error) + self.assertEqual(json.loads(output), expected_collections) + + exit_code, output, error = self.eodag_command(["list", "--yaml", "--no-fetch"]) + self.assertEqual(exit_code, 0) + self.assertIsNone(error) + self.assertNotIn("Listing available collections:", output) + self.assertEqual(yaml.safe_load(output), expected_collections) + self.assertIn(" keywords:\n - foo\n - bar\n", output) + self.assertIn("Donn\u00e9es bar", output) + self.assertNotIn("\\xE9", output) + + def test_eodag_list_collection_format_options_are_mutually_exclusive(self): + """Calling eodag list with both output formats should fail.""" + exit_code, output, error = self.eodag_command( + ["list", "--json", "--yaml", "--no-fetch"] + ) + self.assertNotEqual(exit_code, 0) + self.assertIsInstance(error, SystemExit) + self.assertIn("mutually exclusive", output) + @mock.patch("eodag.api.core.EODataAccessGateway", autospec=True) def test_eodag_guess_collection_ok(self, dag): """Calling eodag list with one or several valid collection feature(s) should return