[Git][debian-gis-team/eodag][master] 6 commits: New upstream version 4.7.2+ds
Antonio Valentino (@antonio.valentino)
gitlab at salsa.debian.org
Fri Aug 28 19:41:25 BST 2026
Antonio Valentino pushed to branch master at Debian GIS Project / eodag
Commits:
5229ace2 by Antonio Valentino at 2026-08-28T16:09:10+00:00
New upstream version 4.7.2+ds
- - - - -
a404b206 by Antonio Valentino at 2026-08-28T16:09:18+00:00
Update upstream source from tag 'upstream/4.7.2+ds'
Update to upstream version '4.7.2+ds'
with Debian dir 185416945536f3abe3a900036a85261e9e5f8721
- - - - -
6102e32b by Antonio Valentino at 2026-08-28T16:10:38+00:00
New upstream release
- - - - -
7efa0e09 by Antonio Valentino at 2026-08-28T16:14:49+00:00
Refresh all patches
- - - - -
158a1d6b by Antonio Valentino at 2026-08-28T16:57:33+00:00
Skip broken tests
- - - - -
e5e2e3d9 by Antonio Valentino at 2026-08-28T16:57:40+00:00
Set distribution to unstable
- - - - -
30 changed files:
- .github/workflows/benchmark.yml
- CHANGES.rst
- debian/changelog
- debian/patches/0001-System-importlib.metadata.patch
- debian/patches/0002-no-mypy-boto3-s3.patch
- debian/rules
- eodag/config.py
- eodag/plugins/authentication/base.py
- eodag/plugins/download/aws.py
- eodag/plugins/manager.py
- eodag/plugins/search/csw.py
- eodag/plugins/search/qssearch.py
- eodag/resources/ext_collections.json
- eodag/resources/providers/cop_dataspace_s3.yml
- eodag/resources/providers/creodias_s3.yml
- eodag/resources/providers/fedeo_ceda.yml
- eodag/resources/user_conf_template.yml
- eodag/utils/__init__.py
- pyproject.toml
- tests/context.py
- tests/integration/test_core_search.py
- tests/test_cli.py
- tests/units/test_auth_plugins.py
- tests/units/test_core.py
- tests/units/test_download_plugins.py
- tests/units/test_search_plugins.py
- tests/units/test_search_types.py
- tests/units/test_stac_reader.py
- tests/units/test_utils.py
- utils/ext_product_types_cmp.py
Changes:
=====================================
.github/workflows/benchmark.yml
=====================================
@@ -2,7 +2,6 @@ name: Run benchmark
on:
pull_request:
- branches: [develop, v5_download_link_asset]
permissions:
contents: read
@@ -26,15 +25,17 @@ jobs:
cache-dependency-glob: |
pyproject.toml
tox.ini
- - name: Run benchmark on develop and current ref, then compare
+ - name: Run benchmark on target branch and current ref, then compare
shell: bash
+ env:
+ TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
CURRENT_SHA="$(git rev-parse HEAD)"
- echo "Running benchmark on develop"
- git checkout --detach origin/develop
+ echo "Running benchmark on ${TARGET_BRANCH}"
+ git checkout --detach "origin/${TARGET_BRANCH}"
uvx --python 3.14 --with tox-uv tox -e benchmark -- --benchmark-json baseline.json
echo "Running benchmark on current ref"
@@ -51,7 +52,7 @@ jobs:
{
echo "## Benchmark comparison"
echo
- echo "Compared baseline: \`origin/develop\`"
+ echo "Compared baseline: \`origin/${TARGET_BRANCH}\`"
echo "Compared candidate: ${CURRENT_SHA}"
echo
echo '```text'
=====================================
CHANGES.rst
=====================================
@@ -3,6 +3,55 @@ Release history
===============
+v4.7.2 (2026-08-28)
+===================
+
+Bug Fixes
+---------
+
+* **plugins**: CSWSearch products build and supported geometries (`#2325`_, `a00249b`_)
+
+* **plugins**: Retry PostJsonSearch requests (`#2328`_, `8edf378`_)
+
+* **providers**: Cop_dataspace_s3 and creodias_s3 auth matching url (`#2332`_, `f368e7c`_)
+
+* **providers**: Disable ssl verify on fedeo_ceda (`#2331`_, `5ddd4e4`_)
+
+Continuous Integration
+----------------------
+
+* Always use PR target branch as benchmark reference (`#2317`_, `fafcb36`_)
+
+* Empty fetched product types fix (`#2333`_, `8239d4b`_)
+
+Refactoring
+-----------
+
+* **config**: Simplify user config template (`#2321`_, `c808210`_)
+
+Testing
+-------
+
+* Add more tests for core, plugins and utils (`#2326`_, `df8bb25`_)
+
+.. _#2317: https://github.com/CS-SI/eodag/pull/2317
+.. _#2321: https://github.com/CS-SI/eodag/pull/2321
+.. _#2325: https://github.com/CS-SI/eodag/pull/2325
+.. _#2326: https://github.com/CS-SI/eodag/pull/2326
+.. _#2328: https://github.com/CS-SI/eodag/pull/2328
+.. _#2331: https://github.com/CS-SI/eodag/pull/2331
+.. _#2332: https://github.com/CS-SI/eodag/pull/2332
+.. _#2333: https://github.com/CS-SI/eodag/pull/2333
+.. _5ddd4e4: https://github.com/CS-SI/eodag/commit/5ddd4e41011bc74488eef6f6f2e6382f22db72e3
+.. _8239d4b: https://github.com/CS-SI/eodag/commit/8239d4b2e2a00c1208fb61e800a40c16772078a7
+.. _8edf378: https://github.com/CS-SI/eodag/commit/8edf378467ce7e20af7acf6582923e78121bdef6
+.. _a00249b: https://github.com/CS-SI/eodag/commit/a00249b42fbec89345f902b3ecf1560a0b576052
+.. _c808210: https://github.com/CS-SI/eodag/commit/c8082108834159c83b21fda94048ddcc952dc4b7
+.. _df8bb25: https://github.com/CS-SI/eodag/commit/df8bb2590f8edcc673200f135bb2c46d1bf834d1
+.. _f368e7c: https://github.com/CS-SI/eodag/commit/f368e7c11f8017a0247903b667afe369b0ceb91a
+.. _fafcb36: https://github.com/CS-SI/eodag/commit/fafcb3663925e5fd8b85d5d8845b787bc00b81c3
+
+
v4.7.1 (2026-08-20)
===================
=====================================
debian/changelog
=====================================
@@ -1,3 +1,12 @@
+eodag (4.7.2+ds-1) unstable; urgency=medium
+
+ * New upstream release.
+ * debian/patches:
+ - Refresh all patches.
+ * Skip broken tests.
+
+ -- Antonio Valentino <antonio.valentino at tiscali.it> Fri, 28 Aug 2026 16:15:00 +0000
+
eodag (4.7.1+ds-1) unstable; urgency=medium
* New upstream release.
=====================================
debian/patches/0001-System-importlib.metadata.patch
=====================================
@@ -34,7 +34,7 @@ index ed80918..de12cca 100644
):
try:
diff --git a/eodag/plugins/manager.py b/eodag/plugins/manager.py
-index 5fd4a7d..c4cc0d3 100644
+index 1d8f56b..689aad6 100644
--- a/eodag/plugins/manager.py
+++ b/eodag/plugins/manager.py
@@ -23,7 +23,7 @@ import re
@@ -65,7 +65,7 @@ index 5fd4a7d..c4cc0d3 100644
"""Return provider config paths exposed by an external plugin distribution."""
config_paths: list[pathlib.Path] = []
diff --git a/pyproject.toml b/pyproject.toml
-index ba93069..bc1c287 100644
+index e5c62d0..8103aa1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,7 +39,6 @@ dependencies = [
@@ -99,12 +99,12 @@ index 870ede8..f5083d4 100644
default_libs = stdlib_list()
except FileNotFoundError:
diff --git a/tests/units/test_core.py b/tests/units/test_core.py
-index 7f42f30..0f22fb9 100644
+index 8c65fb2..67447fd 100644
--- a/tests/units/test_core.py
+++ b/tests/units/test_core.py
-@@ -1617,7 +1617,7 @@ class TestCore(TestCoreBase):
- os.environ.pop("EODAG__SARA__SEARCH__NEED_AUTH", None)
- os.environ.pop("EODAG__SARA__AUTH__CREDENTIALS__USERNAME", None)
+@@ -1656,7 +1656,7 @@ class TestCore(TestCoreBase):
+ if "api" in provider_conf:
+ self.assertEqual(set(provider_conf["api"]), {"credentials"})
- @mock.patch("eodag.plugins.manager.importlib_metadata.entry_points", autospec=True)
+ @mock.patch("eodag.plugins.manager.importlib.metadata.entry_points", autospec=True)
=====================================
debian/patches/0002-no-mypy-boto3-s3.patch
=====================================
@@ -8,10 +8,10 @@ Forwarded: not-needed
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/tests/units/test_auth_plugins.py b/tests/units/test_auth_plugins.py
-index 1ded0cb..3065496 100644
+index 7dedb15..8458c8d 100644
--- a/tests/units/test_auth_plugins.py
+++ b/tests/units/test_auth_plugins.py
-@@ -25,7 +25,12 @@ from unittest import mock
+@@ -26,7 +26,12 @@ from unittest import mock
import boto3
import requests
import responses
@@ -25,7 +25,7 @@ index 1ded0cb..3065496 100644
from pystac.utils import now_in_utc
from requests import Request, Response, Timeout
from requests.auth import AuthBase
-@@ -810,6 +815,7 @@ class TestAuthPluginAwsAuth(BaseAuthPluginTest):
+@@ -814,6 +819,7 @@ class TestAuthPluginAwsAuth(BaseAuthPluginTest):
keys_dict = {"aws_profile": self.profile_name}
self.assertDictEqual(keys_dict, plugin_auth_profile.config.credentials)
=====================================
debian/rules
=====================================
@@ -11,7 +11,8 @@ and not test_core_object_creates_config_standard_location \
and not test_core_object_creates_locations_standard_location \
and not test_read_only_home_dir \
and not test_eoproduct_download_progress_bar \
-and not test_all_requirements" \
+and not test_all_requirements \
+and not test_plugins_search_stac_list_assets_register_downloader_updates_assets" \
--ignore={dir}/tests/test_end_to_end.py \
{dir}/tests
=====================================
eodag/config.py
=====================================
@@ -647,7 +647,11 @@ class PluginConfig(yaml.YAMLObject):
)
def matches_target_auth(self, target_config: Self):
- """Check if the target auth configuration matches this one"""
+ """Check whether this auth configuration can share credentials with another.
+
+ Matching criteria must be identical: when both ``matching_url`` and
+ ``matching_conf`` are configured, both must match.
+ """
target_matching_conf = getattr(target_config, "matching_conf", {})
target_matching_url = getattr(target_config, "matching_url", None)
=====================================
eodag/plugins/authentication/base.py
=====================================
@@ -36,10 +36,13 @@ class Authentication(PluginTopic):
:param provider: provider name
:param config: Authentication plugin configuration:
- * :attr:`~eodag.config.PluginConfig.matching_url` (``str``): URL pattern to match with search plugin endpoint or
- download link
+ * :attr:`~eodag.config.PluginConfig.matching_url` (``str``): URL pattern to match with a search plugin endpoint
+ or product download link
* :attr:`~eodag.config.PluginConfig.matching_conf` (``dict[str, Any]``): Part of the search or download plugin
- configuration that needs authentication and helps identifying it
+ configuration that needs authentication and helps identifying it.
+
+ At runtime, an auth plugin matches when either criterion matches.
+ When credentials are shared between provider configurations, all configured matching criteria must be identical.
"""
entrypoint_group = "auth"
=====================================
eodag/plugins/download/aws.py
=====================================
@@ -806,8 +806,10 @@ class AwsDownload(Download):
if flatten_top_dirs:
rel_path = os.path.join(
product.properties["title"],
- re.sub(rf"^{common_path}/?", "", rel_path),
+ re.sub(rf"^{re.escape(common_path)}/?", "", rel_path),
)
+ # Normalize to forward slashes for S3 path consistency across platforms
+ rel_path = rel_path.replace("\\", "/")
asset_match = assets_by_path.get(f"{obj.bucket_name}/{obj.key}")
data_type = asset_match.get("type") if asset_match else None
@@ -847,7 +849,8 @@ class AwsDownload(Download):
chunk_paths.append(
self.get_chunk_dest_path(product, product_chunk, build_safe=build_safe)
)
- return os.path.commonpath(chunk_paths)
+ # Normalize to forward slashes for S3 path consistency across platforms
+ return os.path.commonpath(chunk_paths).replace("\\", "/")
def get_product_bucket_name_and_prefix(
self, product: EOProduct, url: Optional[str] = None
=====================================
eodag/plugins/manager.py
=====================================
@@ -319,13 +319,16 @@ class PluginManager:
matching_url: Optional[str] = None,
matching_conf: Optional[PluginConfig] = None,
) -> Iterator[Authentication]:
- """Build and return the authentication plugin for the given collection and
- provider
+ """Build and return authentication plugins matching the given criteria.
+
+ An auth plugin matches when either its ``matching_url`` pattern matches
+ ``matching_url`` or its ``matching_conf`` is a subset of ``matching_conf``.
+ The requested provider is considered first, followed by other providers.
:param provider: The provider for which to get the authentication plugin
:param matching_url: url to compare with plugin matching_url pattern
:param matching_conf: configuration to compare with plugin matching_conf
- :returns: All the Authentication plugins for the given criteria
+ :returns: Authentication plugins matching the given criteria
"""
auth_conf: Optional[PluginConfig] = None
=====================================
eodag/plugins/search/csw.py
=====================================
@@ -199,14 +199,16 @@ class CSWSearch(Search):
self.config.search_definition.get("resource_location_filter", "")
)
for ref in rec.references:
- if ref["scheme"] in SUPPORTED_REFERENCE_SCHEMES:
- if resource_filter.pattern and resource_filter.search(ref["url"]):
- download_url = ref["url"]
- else:
- download_url = ref["url"] # noqa
- break
+ if ref["scheme"] not in SUPPORTED_REFERENCE_SCHEMES:
+ continue
+ if resource_filter.pattern and not resource_filter.search(ref["url"]):
+ continue
+ download_url = ref["url"]
+ break
properties = properties_from_xml(rec.xml, self.config.metadata_mapping)
- if not properties["geometry"]:
+ if download_url:
+ properties.setdefault("eodag:download_link", download_url)
+ if not properties.get("geometry"):
bbox = rec.bbox_wgs84
if not bbox:
code = "EPSG:4326"
@@ -262,9 +264,11 @@ class CSWSearch(Search):
# `footprint`
fp = params.get("geometry")
if fp:
- constraints.append(
- BBox([fp["lonmin"], fp["latmin"], fp["lonmax"], fp["latmax"]])
- )
+ if hasattr(fp, "bounds"):
+ bbox = fp.bounds
+ else:
+ bbox = [fp["lonmin"], fp["latmin"], fp["lonmax"], fp["latmax"]]
+ constraints.append(BBox(bbox))
# dates
start, end = (
=====================================
eodag/plugins/search/qssearch.py
=====================================
@@ -2025,6 +2025,13 @@ class PostJsonSearch(QueryStringSearch):
exception_message = prep.exception_message
timeout = getattr(self.config, "timeout", DEFAULT_SEARCH_TIMEOUT)
ssl_verify = getattr(self.config, "ssl_verify", True)
+ retry_total = getattr(self.config, "retry_total", REQ_RETRY_TOTAL)
+ retry_backoff_factor = getattr(
+ self.config, "retry_backoff_factor", REQ_RETRY_BACKOFF_FACTOR
+ )
+ retry_status_forcelist = getattr(
+ self.config, "retry_status_forcelist", REQ_RETRY_STATUS_FORCELIST
+ )
try:
# auth if needed
RequestsKwargs = TypedDict(
@@ -2054,7 +2061,16 @@ class PostJsonSearch(QueryStringSearch):
logger.debug("Query kwargs: %s" % geojson.dumps(kwargs))
except TypeError:
logger.debug("Query kwargs: %s" % kwargs)
- response = requests.post(
+ session = requests.Session()
+ retries = Retry(
+ total=retry_total,
+ backoff_factor=retry_backoff_factor,
+ status_forcelist=retry_status_forcelist,
+ allowed_methods={"POST"},
+ raise_on_status=False,
+ )
+ session.mount(url, HTTPAdapter(max_retries=retries))
+ response = session.post(
url,
json=prep.query_params,
headers=USER_AGENT,
=====================================
eodag/resources/ext_collections.json
=====================================
The diff for this file was not included because it is too large.
=====================================
eodag/resources/providers/cop_dataspace_s3.yml
=====================================
@@ -251,8 +251,7 @@ cop_dataspace_s3:
auth_error_code: 403
s3_endpoint: 'https://eodata.dataspace.copernicus.eu'
support_presign_url: False
- matching_conf:
- s3_endpoint: 'https://eodata.dataspace.copernicus.eu'
+ matching_url: 's3://eodata'
products:
# S2
S2_MSI_L1C:
=====================================
eodag/resources/providers/creodias_s3.yml
=====================================
@@ -251,8 +251,7 @@ creodias_s3:
auth_error_code: 403
s3_endpoint: 'https://eodata.cloudferro.com'
support_presign_url: False
- matching_conf:
- s3_endpoint: 'https://eodata.cloudferro.com'
+ matching_url: 's3://eodata'
products:
# S1
S1_AUX_GNSSRD:
=====================================
eodag/resources/providers/fedeo_ceda.yml
=====================================
@@ -7,8 +7,9 @@ fedeo_ceda:
search:
type: StacSearch
api_endpoint: 'https://fedeo.ceos.org/search'
- ssl_verify: true
+ ssl_verify: false
timeout: 60
+ retry_status_forcelist: [400, 401, 429, 500, 502, 503, 504]
pagination:
next_page_url_tpl: '{url}?startRecord={next_page_token}'
next_page_query_obj: '{{"limit":{limit}}}'
=====================================
eodag/resources/user_conf_template.yml
=====================================
@@ -15,9 +15,10 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
+# Set priority and credentials here. See the documentation for the complete
+# provider configuration.
aws_eos:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
search_auth:
credentials:
api_key:
@@ -26,70 +27,39 @@ aws_eos:
aws_access_key_id:
aws_secret_access_key:
aws_profile:
- download:
- output_dir:
cop_ads:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- extract:
- output_dir:
auth:
credentials:
apikey:
cop_cds:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- extract:
- output_dir:
auth:
credentials:
apikey:
cop_dataspace:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- extract:
- output_dir:
auth:
credentials:
username:
password:
cop_dataspace_s3:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- output_dir:
auth:
credentials:
aws_access_key_id:
aws_secret_access_key:
cop_ewds:
priority: # Lower value means lower priority (Default: 0)
- download:
- output_dir:
auth:
credentials:
apikey:
cop_ghsl:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- extract:
- output_dir:
cop_marine:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- extract:
- output_dir:
creodias:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- extract:
- output_dir:
auth:
credentials:
username:
@@ -97,81 +67,56 @@ creodias:
totp: # set totp dynamically, see https://eodag.readthedocs.io/en/latest/getting_started_guide/configure.html#authenticate-using-an-otp-one-time-password-two-factor-authentication
creodias_s3:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- output_dir:
auth:
credentials:
aws_access_key_id:
aws_secret_access_key:
dedl:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- output_dir:
auth:
credentials:
username:
password:
dedt_lumi:
priority: # Lower value means lower priority (Default: 0)
- search:
- download:
- output_dir:
auth:
credentials:
username:
password:
dedt_mn5:
priority: # Lower value means lower priority (Default: 0)
- search:
- download:
- output_dir:
auth:
credentials:
username:
password:
dedt_leonardo:
priority: # Lower value means lower priority (Default: 0)
- search:
- download:
- output_dir:
auth:
credentials:
username:
password:
dlr_eoc_geoservice:
priority: # Lower value means lower priority (Default: 0)
- search:
- download:
- output_dir:
auth:
credentials:
username:
password:
earth_search:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
auth:
credentials:
aws_access_key_id:
aws_secret_access_key:
aws_profile:
- download:
- output_dir:
earth_search_gcs:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
auth:
credentials:
aws_access_key_id:
aws_secret_access_key:
- download:
- output_dir:
ecmwf:
priority: # Lower value means lower priority (Default: 0)
api:
- output_dir:
credentials:
username:
password:
@@ -183,78 +128,47 @@ eocat:
password:
eumetsat_ds:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- output_dir:
auth:
credentials:
username:
password:
- download:
- output_dir:
fedeo_ceda:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- extract:
- output_dir:
geodes:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
auth:
credentials:
apikey:
- download:
- extract:
- output_dir:
geodes_s3:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
auth:
credentials:
aws_access_key_id:
aws_secret_access_key:
aws_session_token:
- download:
- extract:
- output_dir:
hydroweb_next:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
auth:
credentials:
apikey:
- download:
- output_dir:
meteoblue:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
auth:
credentials:
apikey:
- download:
- output_dir:
planetary_computer:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
auth:
credentials:
apikey:
- download:
- output_dir:
sara:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- extract:
- output_dir:
- delete_archive:
auth:
credentials:
username:
password:
theia:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
auth:
credentials:
access_key:
@@ -262,46 +176,30 @@ theia:
usgs:
priority: # Lower value means lower priority (Default: 0)
api:
- extract:
- output_dir:
- dl_url_params:
- product_location_scheme:
credentials:
username:
password:
usgs_satapi_aws:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
auth:
credentials:
aws_access_key_id:
aws_secret_access_key:
aws_profile:
- download:
- output_dir:
wekeo_cmems:
priority: # Lower value means lower priority (Default: 0)
- search:
- download:
- output_dir:
auth:
credentials:
username:
password:
wekeo_ecmwf:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- output_dir:
auth:
credentials:
username:
password:
wekeo_main:
priority: # Lower value means lower priority (Default: 0)
- search: # Search parameters configuration
- download:
- output_dir:
auth:
credentials:
username:
=====================================
eodag/utils/__init__.py
=====================================
@@ -424,6 +424,11 @@ def mutate_dict_in_place(func: Callable[[Any], Any], mapping: dict[Any, Any]) ->
allowing to also modify values of nested dicts that may be level-1 values of
mapping.
+ >>> mapping = {"a": 1, "nested": {"b": 2}}
+ >>> mutate_dict_in_place(lambda value: value * 10, mapping)
+ >>> mapping
+ {'a': 10, 'nested': {'b': 20}}
+
:param func: A function to apply to each value of mapping which is not a dict object
:param mapping: A Python dict object
:returns: None
@@ -506,7 +511,14 @@ def merge_mappings(mapping1: dict[Any, Any], mapping2: dict[Any, Any]) -> None:
def maybe_generator(obj: Any) -> Iterator[Any]:
"""Generator function that get an arbitrary object and generate values from it if
- the object is a generator."""
+ the object is a generator.
+
+ >>> list(maybe_generator(value for value in (1, 2, 3)))
+ [1, 2, 3]
+ >>> list(maybe_generator("value"))
+ ['value']
+ """
+
if isinstance(obj, types.GeneratorType):
for elt in obj:
yield elt
@@ -1003,6 +1015,8 @@ def string_to_jsonpath(*args: Any, force: bool = False) -> Union[str, JSONPath]:
... Slice(start=None, end=None, step=None),
... )
True
+ >>> string_to_jsonpath("$.foo[bar]")
+ Child(Child(Root(), Fields('foo')), Fields('bar'))
:param args: Last arg as input string value, to be converted
:param force: force conversion even if input string is not detected as a :class:`jsonpath_ng.JSONPath`
@@ -1655,6 +1669,8 @@ def guess_file_type(file: str) -> str:
'image/tiff'
>>> guess_file_type('foo.grib')
'application/x-grib'
+ >>> guess_file_type('foo.unknown_eodag_extension')
+ 'application/octet-stream'
:param file: file url or path
:returns: guessed mime type
@@ -1828,6 +1844,9 @@ def get_collection_dates(
>>> get_collection_dates({})
(None, None)
+
+ >>> get_collection_dates({"extent": {"temporal": {"interval": []}}})
+ (None, None)
"""
extent_interval = (
collection_dict.get("extent", {})
=====================================
pyproject.toml
=====================================
@@ -211,7 +211,7 @@ include = ["eodag*"]
"*" = ["LICENSE", "NOTICE", "py.typed"]
[tool.setuptools_scm]
-fallback_version = "4.7.2.dev0"
+fallback_version = "4.7.3.dev0"
[tool.isort]
multi_line_output = 3
=====================================
tests/context.py
=====================================
@@ -45,21 +45,21 @@ from eodag.api.product.metadata_mapping import (
from eodag.api.collection import Collection, CollectionsDict, CollectionsList
from eodag.api.search_result import SearchResult
from eodag.cli import download, eodag_cli, list_col, search_crunch
+from eodag.api.provider import Provider, ProviderConfig, ProvidersDict
from eodag.config import (
- load_default_config,
- load_stac_provider_config,
- get_ext_collections_conf,
AUTH_TOPIC_KEYS,
EXT_COLLECTIONS_CONF_URI,
+ PluginConfig,
+ get_ext_collections_conf,
+ load_default_config,
+ load_stac_provider_config,
)
-from eodag.api.provider import ProviderConfig, ProvidersDict, Provider
-from eodag.config import PluginConfig
from eodag.plugins.apis.ecmwf import EcmwfApi
from eodag.plugins.authentication.base import Authentication
-from eodag.plugins.authentication.aws_auth import AwsAuth
-from eodag.plugins.authentication.header import HeaderAuth
+from eodag.plugins.authentication.aws_auth import AwsAuth, raise_if_auth_error
+from eodag.plugins.authentication.header import HeaderAuth, HTTPHeaderAuth
from eodag.plugins.authentication.openid_connect import CodeAuthorizedAuth
-from eodag.plugins.authentication.header import HTTPHeaderAuth
+from eodag.plugins.authentication.token_exchange import OIDCTokenExchangeAuth
from eodag.plugins.authentication.qsauth import HttpQueryStringAuth
from eodag.plugins.base import PluginTopic
from eodag.plugins.crunch.filter_date import FilterDate
@@ -73,10 +73,12 @@ from eodag.plugins.download.base import (
)
from eodag.plugins.download.http import HTTPDownload
from eodag.plugins.manager import PluginManager
-from eodag.plugins.search import PreparedSearch
from eodag.plugins.search.base import Search
from eodag.plugins.search.build_search_result import ecmwf_temporal_to_eodag
+from eodag.plugins.search.csw import CSWSearch
+from eodag.plugins.search import PreparedSearch
from eodag.plugins.search.qssearch import QueryStringSearch
+from eodag.types.bbox import BBox
from eodag.types import model_fields_to_annotated
from eodag.types.queryables import CommonQueryables, Queryables, QueryablesDict
from eodag.utils import (
@@ -87,6 +89,8 @@ from eodag.utils import (
DEFAULT_SEARCH_TIMEOUT,
USER_AGENT,
get_bucket_name_and_prefix,
+ get_geometry_from_ecmwf_area,
+ get_geometry_from_ecmwf_feature,
get_geometry_from_various,
makedirs,
merge_mappings,
@@ -109,7 +113,9 @@ from eodag.utils import (
from eodag.utils.yaml import cached_yaml_load_all
from eodag.utils.dates import get_timestamp, to_iso_utc_string
from eodag.utils.env import is_env_var_true
-from eodag.utils.requests import fetch_json
+from eodag.utils.requests import LocalFileAdapter, fetch_json
+from eodag.utils.import_system import import_all_modules, patch_owslib_requests
+from eodag.utils.notebook import NotebookWidgets, check_ipython, check_notebook
from eodag.utils.s3 import (
list_files_in_s3_zipped_object,
update_assets_from_s3,
@@ -142,5 +148,5 @@ from eodag.utils.exceptions import (
)
from eodag.utils.stac_reader import fetch_stac_items, _TextOpener
from tests import TEST_RESOURCES_PATH
-from usgs.api import USGSAuthExpiredError, USGSError
from usgs.api import TMPFILE as USGS_TMPFILE
+from usgs.api import USGSAuthExpiredError, USGSError
=====================================
tests/integration/test_core_search.py
=====================================
@@ -95,7 +95,7 @@ class TestCoreSearch(unittest.TestCase):
autospec=True,
)
@mock.patch(
- "eodag.plugins.search.qssearch.requests.post",
+ "eodag.plugins.search.qssearch.requests.Session.post",
autospec=True,
side_effect=RequestException,
)
@@ -127,7 +127,7 @@ class TestCoreSearch(unittest.TestCase):
autospec=True,
)
@mock.patch(
- "eodag.plugins.search.qssearch.requests.post",
+ "eodag.plugins.search.qssearch.requests.Session.post",
autospec=True,
side_effect=RequestException,
)
@@ -230,7 +230,7 @@ class TestCoreSearch(unittest.TestCase):
autospec=True,
)
@mock.patch(
- "eodag.plugins.search.qssearch.requests.post",
+ "eodag.plugins.search.qssearch.requests.Session.post",
autospec=True,
side_effect=RequestException,
)
@@ -272,7 +272,7 @@ class TestCoreSearch(unittest.TestCase):
side_effect=RequestException,
)
@mock.patch(
- "eodag.plugins.search.qssearch.requests.post",
+ "eodag.plugins.search.qssearch.requests.Session.post",
autospec=True,
side_effect=RequestException,
)
@@ -822,7 +822,7 @@ class TestCoreSearch(unittest.TestCase):
self.assertEqual("DEDT_LUMI_123-456", result[0].properties["title"])
@mock.patch(
- "eodag.plugins.search.qssearch.requests.post",
+ "eodag.plugins.search.qssearch.requests.Session.post",
autospec=True,
)
@mock.patch(
=====================================
tests/test_cli.py
=====================================
@@ -57,12 +57,13 @@ class TestEodagCli(unittest.TestCase):
@contextmanager
def user_conf(self, conf_file="user_conf.yml", content=b"key: to unused conf"):
"""Utility method"""
- with self.runner.isolated_filesystem():
- with open(conf_file, "wb") as fd:
+ with TemporaryDirectory() as tmp_dir:
+ conf_file_path = os.path.join(tmp_dir, conf_file)
+ with open(conf_file_path, "wb") as fd:
fd.write(
content if isinstance(content, bytes) else content.encode("utf-8")
)
- yield conf_file
+ yield conf_file_path
def setUp(self):
super(TestEodagCli, self).setUp()
=====================================
tests/units/test_auth_plugins.py
=====================================
@@ -20,6 +20,7 @@ import datetime as dt
import pickle
import unittest
from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
from unittest import mock
import boto3
@@ -34,16 +35,19 @@ from requests.exceptions import RequestException
from eodag.api.product._product import EOProduct
from eodag.api.provider import ProvidersDict
from eodag.plugins.authentication.eoiam import _EOIAMSessionAuth
-from eodag.plugins.authentication.openid_connect import CodeAuthorizedAuth
from eodag.utils import MockResponse
-from eodag.utils.exceptions import RequestError
from tests.context import (
HTTP_REQ_TIMEOUT,
USER_AGENT,
AuthenticationError,
+ CodeAuthorizedAuth,
HeaderAuth,
MisconfiguredError,
+ OIDCTokenExchangeAuth,
PluginManager,
+ RequestError,
+ TimeOutError,
+ raise_if_auth_error,
)
@@ -885,6 +889,59 @@ class TestAuthPluginAwsAuth(BaseAuthPluginTest):
self.assertIn("Expires", url)
+class TestAuthPluginTokenExchange(unittest.TestCase):
+ def test_plugins_auth_oidc_token_exchange_success_and_timeout(self):
+ """OIDC token exchange returns a token and closes sessions on timeout."""
+ plugin = OIDCTokenExchangeAuth.__new__(OIDCTokenExchangeAuth)
+ response = mock.Mock()
+ response.json.return_value = {"access_token": "target-token"}
+ session = mock.Mock()
+ session.post.return_value = response
+ plugin.subject = mock.Mock(
+ session=session,
+ authenticate=mock.Mock(
+ return_value=CodeAuthorizedAuth("subject-token", where="header")
+ ),
+ )
+ plugin.config = SimpleNamespace(
+ subject_issuer="issuer",
+ token_uri="https://token.test",
+ client_id="client",
+ audience="audience",
+ token_key="access_token",
+ ssl_verify=False,
+ )
+ result = plugin.authenticate()
+ self.assertEqual(result.token, "target-token")
+ session.close.assert_called_once_with()
+ self.assertEqual(
+ session.post.call_args.kwargs["data"]["grant_type"], plugin.GRANT_TYPE
+ )
+
+ session.post.side_effect = requests.exceptions.Timeout()
+ with self.assertRaises(TimeOutError):
+ plugin.authenticate()
+ self.assertEqual(session.close.call_count, 2)
+
+
+class TestAwsAuthHelpers(unittest.TestCase):
+ def test_plugins_auth_aws_auth_error_only_for_known_credential_errors(self):
+ """AWS credential errors raise only for recognized credential messages."""
+ from botocore.exceptions import ClientError
+
+ error = ClientError(
+ {
+ "Error": {"Code": "AccessDenied", "Message": "bad key"},
+ "ResponseMetadata": {"HTTPStatusCode": 403},
+ },
+ "GetObject",
+ )
+ with self.assertRaises(AuthenticationError):
+ raise_if_auth_error(error, "provider")
+ error.response["Error"]["Message"] = "not a credential problem"
+ raise_if_auth_error(error, "provider")
+
+
class TestAuthPluginEOIAMAuth(BaseAuthPluginTest):
@classmethod
def setUpClass(cls):
=====================================
tests/units/test_core.py
=====================================
@@ -47,6 +47,7 @@ from tests import TEST_RESOURCES_PATH, TEST_RESOURCES_PROVIDERS_PATH
from tests.context import (
DEFAULT_LIMIT,
DEFAULT_MAX_LIMIT,
+ AuthenticationError,
CommonQueryables,
EODataAccessGateway,
EOProduct,
@@ -1186,6 +1187,28 @@ class TestCore(TestCoreBase):
self.dag.update_collections_list(ext_collections_conf)
self.assertNotIn("earth_search", self.dag._providers)
+ def test_update_collections_list_unsupported_provider(self):
+ """Core api.update_collections_list must ignore providers raising UnsupportedProvider"""
+ with open(os.path.join(TEST_RESOURCES_PATH, "ext_collections.json")) as f:
+ ext_collections_conf = json.load(f)
+ provider_conf = self.dag._providers.configs["earth_search"]
+ with (
+ mock.patch.object(
+ self.dag._providers.__class__,
+ "__getitem__",
+ side_effect=UnsupportedProvider("earth_search"),
+ ),
+ mock.patch.object(
+ self.dag._plugins_manager,
+ "build_collection_to_provider_config_map",
+ ),
+ ):
+ self.dag.update_collections_list(ext_collections_conf)
+
+ self.assertIs(self.dag._providers.configs["earth_search"], provider_conf)
+ self.assertNotIn("foo", self.dag.collections_config)
+ self.assertNotIn("bar", self.dag.collections_config)
+
@mock.patch(
"eodag.plugins.search.qssearch.QueryStringSearch.discover_collections",
autospec=True,
@@ -1617,6 +1640,22 @@ class TestCore(TestCoreBase):
os.environ.pop("EODAG__SARA__SEARCH__NEED_AUTH", None)
os.environ.pop("EODAG__SARA__AUTH__CREDENTIALS__USERNAME", None)
+ def test_user_conf_template_keeps_priority_and_credentials(self):
+ """The user configuration template should not include runtime settings."""
+ template_path = res_files("eodag") / "resources" / "user_conf_template.yml"
+ with template_path.open(encoding="utf-8") as file:
+ user_conf = yaml.safe_load(file)
+
+ for provider_conf in user_conf.values():
+ self.assertIn("priority", provider_conf)
+ self.assertTrue(
+ set(provider_conf).issubset(
+ {"priority", "auth", "search_auth", "download_auth", "api"}
+ )
+ )
+ if "api" in provider_conf:
+ self.assertEqual(set(provider_conf["api"]), {"credentials"})
+
@mock.patch("eodag.plugins.manager.importlib_metadata.entry_points", autospec=True)
def test_prune_providers_list_skipped_plugin(self, mock_iter_ep):
"""Providers needing skipped plugin must be pruned on init"""
@@ -2195,6 +2234,50 @@ class TestCore(TestCoreBase):
self.assertIn(original_url, form_url)
mock__fetch_data.reset_mock()
+ @mock.patch(
+ "eodag.plugins.search.qssearch.StacSearch.discover_queryables",
+ autospec=True,
+ return_value={},
+ )
+ @mock.patch("eodag.plugins.manager.PluginManager.get_auth_plugin", autospec=True)
+ def test_list_queryables_logs_authentication_error(
+ self, mock_get_auth_plugin, mock_discover_queryables
+ ):
+ """list_queryables must ignore auth errors and log them at debug level"""
+ search_plugin = mock.Mock(provider="dummy_provider")
+ search_plugin.config.need_auth = True
+ search_plugin.list_queryables.return_value = QueryablesDict(
+ additional_properties=False
+ )
+ auth_plugin = mock.Mock()
+ auth_plugin.authenticate.side_effect = AuthenticationError("auth failed")
+ mock_get_auth_plugin.return_value = auth_plugin
+
+ with (
+ mock.patch.object(
+ self.dag,
+ "list_collections",
+ return_value=[mock.Mock(id="S2_MSI_L1C")],
+ ),
+ mock.patch.object(
+ self.dag,
+ "_attach_collection_config",
+ ),
+ mock.patch.object(
+ self.dag._plugins_manager,
+ "get_search_plugins",
+ return_value=[search_plugin],
+ ),
+ self.assertLogs("eodag.core", level="DEBUG") as cm,
+ ):
+ queryables = self.dag.list_queryables(provider="dummy_provider")
+
+ self.assertIsInstance(queryables, QueryablesDict)
+ self.assertIn(
+ "queryables from provider dummy_provider could not be fetched due to an authentication error",
+ str(cm.output),
+ )
+
def test_queryables_repr(self):
"""The HTML representation of queryables must be correct"""
queryables = self.dag.list_queryables(
@@ -3366,6 +3449,83 @@ class TestCoreSearch(TestCoreBase):
self.assertEqual(found.number_matched, 1)
self.assertEqual(len(found), 1)
+ @mock.patch(
+ "eodag.plugins.manager.PluginManager.get_search_plugins",
+ autospec=True,
+ )
+ def test__search_by_id_handles_plugin_exceptions(self, mock_get_search_plugins):
+ """_search_by_id must store plugin exceptions and return empty results"""
+ search_plugin = mock.Mock(provider="dummy_provider")
+ search_plugin.config.pagination = {"max_limit": 100}
+ mock_get_search_plugins.return_value = [search_plugin]
+
+ with mock.patch.object(
+ self.dag,
+ "search_iter_page_plugin",
+ side_effect=RequestError("search failed"),
+ ):
+ found = self.dag._search_by_id(uid="foo", provider="dummy_provider")
+
+ self.assertEqual(len(found), 0)
+ self.assertEqual(found.number_matched, 0)
+ self.assertEqual(len(found.errors), 1)
+ self.assertEqual(found.errors[0][0], "dummy_provider")
+ self.assertIsInstance(found.errors[0][1], RequestError)
+
+ @mock.patch(
+ "eodag.plugins.manager.PluginManager.get_search_plugins",
+ autospec=True,
+ )
+ def test__search_by_id_raises_plugin_exceptions(self, mock_get_search_plugins):
+ """_search_by_id must raise plugin exceptions when raise_errors is True"""
+ search_plugin = mock.Mock(provider="dummy_provider")
+ search_plugin.config.pagination = {"max_limit": 100}
+ mock_get_search_plugins.return_value = [search_plugin]
+
+ with mock.patch.object(
+ self.dag,
+ "search_iter_page_plugin",
+ side_effect=RequestError("search failed"),
+ ):
+ with self.assertRaises(RequestError):
+ self.dag._search_by_id(
+ uid="foo", provider="dummy_provider", raise_errors=True
+ )
+
+ @mock.patch(
+ "eodag.plugins.manager.PluginManager.get_search_plugins",
+ autospec=True,
+ )
+ def test__search_by_id_guesses_collection_and_resets_driver(
+ self, mock_get_search_plugins
+ ):
+ """_search_by_id must guess missing collection and reset product driver"""
+ search_plugin = mock.Mock(provider="dummy_provider")
+ search_plugin.config.pagination = {"max_limit": 100}
+ mock_get_search_plugins.return_value = [search_plugin]
+ product = EOProduct("dummy_provider", {"id": "foo"})
+ product.collection = None
+ initial_driver = product.driver
+
+ with (
+ mock.patch.object(
+ self.dag,
+ "search_iter_page_plugin",
+ return_value=iter([SearchResult([product], 1)]),
+ ),
+ mock.patch.object(
+ self.dag,
+ "guess_collection",
+ return_value=[mock.Mock(id="S2_MSI_L1C")],
+ ) as mock_guess_collection,
+ ):
+ found = self.dag._search_by_id(uid="foo", provider="dummy_provider")
+
+ self.assertEqual(found.number_matched, 1)
+ self.assertEqual(found[0].collection, "S2_MSI_L1C")
+ self.assertIsNot(found[0].driver, initial_driver)
+ mock_guess_collection.assert_called_once_with(**product.properties)
+
@mock.patch("eodag.plugins.search.qssearch.QueryStringSearch", autospec=True)
def test__do_search_support_itemsperpage_higher_than_maximum(self, search_plugin):
"""_do_search must support itemsperpage higher than maximum"""
@@ -3723,6 +3883,132 @@ class TestCoreSearch(TestCoreBase):
validate=False,
)
+ @mock.patch("eodag.api.core.EODataAccessGateway._do_search", autospec=True)
+ @mock.patch("eodag.api.core.EODataAccessGateway._prepare_search", autospec=True)
+ def test_search_warns_on_deprecated_page_and_items_per_page(
+ self, mock_prepare_search, mock_do_search
+ ):
+ """search must warn when deprecated page and items_per_page are used"""
+ search_plugin = mock.Mock(provider="cop_dataspace")
+ search_plugin.config.pagination = {}
+ mock_prepare_search.return_value = (
+ [search_plugin],
+ {"collection": "S2_MSI_L1C"},
+ )
+ mock_do_search.return_value = self.search_results
+
+ with pytest.warns(DeprecationWarning) as warnings_records:
+ self.dag.search(
+ page=2,
+ items_per_page=3,
+ validate=False,
+ collection="S2_MSI_L1C",
+ )
+
+ warning_messages = [str(record.message) for record in warnings_records]
+ self.assertTrue(
+ any("deprecated search parameter 'page'" in msg for msg in warning_messages)
+ )
+ self.assertTrue(
+ any(
+ "deprecated search parameter 'items_per_page'" in msg
+ for msg in warning_messages
+ )
+ )
+ mock_do_search.assert_called_once_with(
+ self.dag,
+ search_plugin,
+ count=False,
+ raise_errors=False,
+ validate=False,
+ collection="S2_MSI_L1C",
+ page=2,
+ limit=3,
+ )
+
+ @mock.patch("eodag.api.core.EODataAccessGateway.search_iter_page_plugin")
+ @mock.patch("eodag.api.core.EODataAccessGateway._prepare_search")
+ def test_search_iter_page_warns_on_deprecated_items_per_page(
+ self, mock_prepare_search, mock_search_iter_page_plugin
+ ):
+ """search_iter_page must warn when deprecated items_per_page is used"""
+ search_plugin = mock.Mock(provider="cop_dataspace")
+ mock_prepare_search.return_value = (
+ [search_plugin],
+ {"collection": "S2_MSI_L1C"},
+ )
+ mock_search_iter_page_plugin.return_value = iter([self.search_results])
+
+ with pytest.warns(
+ DeprecationWarning, match="deprecated search parameter 'items_per_page'"
+ ):
+ page_iterator = self.dag.search_iter_page(
+ items_per_page=3, collection="S2_MSI_L1C"
+ )
+
+ self.assertEqual(list(page_iterator), [self.search_results])
+ mock_search_iter_page_plugin.assert_called_once_with(
+ limit=3,
+ search_plugin=search_plugin,
+ collection="S2_MSI_L1C",
+ )
+
+ @mock.patch("eodag.api.core.EODataAccessGateway.search")
+ def test_search_all_warns_on_deprecated_items_per_page(self, mock_search):
+ """search_all must warn when deprecated items_per_page is used"""
+ mock_search.return_value = SearchResult([])
+
+ with pytest.warns(
+ DeprecationWarning, match="deprecated search parameter 'items_per_page'"
+ ):
+ results = self.dag.search_all(
+ items_per_page=3,
+ collection="S2_MSI_L1C",
+ )
+
+ self.assertEqual(len(results), 0)
+ mock_search.assert_called_once_with(
+ limit=3,
+ start=None,
+ end=None,
+ geom=None,
+ locations=None,
+ collection="S2_MSI_L1C",
+ )
+
+ @mock.patch("eodag.api.core.EODataAccessGateway._do_search", autospec=True)
+ def test_search_iter_page_plugin_warns_on_deprecated_items_per_page(
+ self, mock_do_search
+ ):
+ """search_iter_page_plugin must warn when deprecated items_per_page is used"""
+ search_plugin = mock.Mock(provider="cop_dataspace")
+ mock_do_search.return_value = SearchResult([])
+
+ with pytest.warns(DeprecationWarning) as warnings_records:
+ list(
+ self.dag.search_iter_page_plugin(
+ search_plugin=search_plugin,
+ items_per_page=3,
+ collection="S2_MSI_L1C",
+ )
+ )
+
+ warning_messages = [str(record.message) for record in warnings_records]
+ self.assertTrue(
+ any(
+ "deprecated search parameter 'items_per_page'" in msg
+ for msg in warning_messages
+ )
+ )
+ mock_do_search.assert_called_once_with(
+ self.dag,
+ search_plugin,
+ raise_errors=True,
+ collection="S2_MSI_L1C",
+ page=1,
+ limit=3,
+ )
+
@mock.patch("eodag.api.core.EODataAccessGateway.search_iter_page_plugin")
@mock.patch("eodag.api.core.EODataAccessGateway._prepare_search")
def test_search_iter_page_requesterror_retry(
@@ -4307,6 +4593,23 @@ class TestCoreSearch(TestCoreBase):
),
)
+ @mock.patch("eodag.api.search_result.SearchResult.next_page")
+ @mock.patch("eodag.api.core.EODataAccessGateway.search")
+ def test_search_all_ignores_next_page_request_error(
+ self, mock_search, mock_next_page
+ ):
+ """search_all must return partial results when pagination raises RequestError"""
+ mock_search.return_value = SearchResult([self.search_results.data[0]], 1)
+ mock_next_page.side_effect = RequestError("next page failed")
+
+ with self.assertLogs("eodag.core", level="WARNING") as cm:
+ results = self.dag.search_all(collection="S2_MSI_L1C")
+
+ self.assertEqual(len(results), 1)
+ self.assertEqual(results.number_matched, 1)
+ self.assertTrue(results.raise_errors)
+ self.assertIn("but it may be incomplete", str(cm.output))
+
@mock.patch(
"eodag.api.core.EODataAccessGateway._do_search",
autospec=True,
@@ -4610,6 +4913,20 @@ class TestCoreProductAlias(TestCoreBase):
with self.assertRaises(NoMatchingCollection):
self.dag.get_collection_from_alias("JUST_A_TYPE")
+ def test_get_collection_from_alias_multiple_matches(self):
+ """get_collection_from_alias must raise NoMatchingCollection if alias is ambiguous"""
+ products = self.dag.collections_config
+ products["S2_MSI_L2A_ALIAS"] = Collection.create_with_dag(
+ self.dag,
+ alias="S2_MSI_ALIAS",
+ **products["S2_MSI_L2A"].model_dump(exclude={"alias"}),
+ )
+
+ with self.assertRaises(NoMatchingCollection):
+ self.dag.get_collection_from_alias("S2_MSI_ALIAS")
+
+ products.pop("S2_MSI_L2A_ALIAS")
+
class TestCoreProviderGroup(TestCoreBase):
# create a group with a provider which has collection discovery mechanism
@@ -4644,6 +4961,17 @@ class TestCoreProviderGroup(TestCoreBase):
self.assertCountEqual(self.dag.providers.groups, providers)
+ def test_available_providers(self) -> None:
+ """available_providers must list available provider names sorted like providers"""
+ self.assertEqual(self.dag.available_providers(), self.dag.providers.names)
+
+ def test_available_providers_for_collection(self) -> None:
+ """available_providers must filter providers by collection"""
+ self.assertEqual(
+ self.dag.available_providers(collection="S2_MSI_L1C"),
+ self.dag.providers.filter("S2_MSI_L1C").names,
+ )
+
def test_list_collections(self) -> None:
"""
List the collections for the provider group.
=====================================
tests/units/test_download_plugins.py
=====================================
@@ -52,6 +52,8 @@ from tests.context import (
PluginConfig,
PluginManager,
ProvidersDict,
+ S3FileInfo,
+ StreamResponse,
load_default_config,
path_to_uri,
uri_to_path,
@@ -2077,6 +2079,104 @@ class TestDownloadPluginAws(BaseDownloadPluginTest):
)
self.assertEqual((bucket, prefix), ("default_bucket", "somewhere/else"))
+ def test_plugins_download_aws_get_commonpath(self):
+ """AwsDownload._get_commonpath() must return common chunk destination path"""
+ plugin = self.get_download_plugin(self.product)
+ product_chunks = {
+ mock.Mock(key="path/to/some/product/file1.tif"),
+ mock.Mock(key="path/to/some/product/sub/file2.tif"),
+ }
+
+ common_path = plugin._get_commonpath(
+ self.product, product_chunks, build_safe=False
+ )
+
+ self.assertEqual(common_path, "path/to/some/product")
+
+ @mock.patch("eodag.plugins.download.aws.stream_download_from_s3", autospec=True)
+ def test_plugins_download_aws_stream_download(self, mock_stream_download_from_s3):
+ """AwsDownload.stream_download() must stream S3 objects with flattened paths"""
+ expected_response = StreamResponse(iter([b"content"]))
+ mock_stream_download_from_s3.return_value = expected_response
+ plugin = self.get_download_plugin(self.product)
+ plugin.config.flatten_top_dirs = True
+ plugin.config.products[self.product.collection]["build_safe"] = False
+ plugin.config.products[self.product.collection]["complementary_url_key"] = []
+ s3_client = mock.Mock()
+ s3_resource = mock.Mock()
+ s3_resource.meta.client = s3_client
+ product_chunks = [
+ mock.Mock(
+ bucket_name="somebucket",
+ key="path/to/some/product/file1.tif",
+ size=1,
+ ),
+ mock.Mock(
+ bucket_name="somebucket",
+ key="path/to/some/product/sub/file2.tif",
+ size=2,
+ ),
+ ]
+ authenticated_objects = {"somebucket": mock.Mock()}
+ authenticated_objects["somebucket"].filter.side_effect = lambda Prefix: [
+ chunk for chunk in product_chunks if chunk.key.startswith(Prefix)
+ ]
+ auth_plugin = mock.Mock()
+ auth_plugin.authenticate_objects.return_value = authenticated_objects
+ self.product.downloader_auth = auth_plugin
+ self.product.assets.clear()
+ self.product.assets.update(
+ {
+ "file1": {
+ "href": "s3://somebucket/path/to/some/product/file1.tif",
+ "type": "image/tiff",
+ },
+ "file2": {
+ "href": "s3://somebucket/path/to/some/product/sub/file2.tif",
+ },
+ }
+ )
+
+ response = plugin.stream_download(
+ self.product,
+ auth=s3_resource,
+ byte_range=(0, 10),
+ compress="zip",
+ )
+
+ self.assertIs(response, expected_response)
+ auth_plugin.authenticate_objects.assert_called_once_with(
+ [
+ ("somebucket", "path/to/some/product/file1.tif"),
+ ("somebucket", "path/to/some/product/sub/file2.tif"),
+ ]
+ )
+ mock_stream_download_from_s3.assert_called_once()
+ args = mock_stream_download_from_s3.call_args.args
+ self.assertIs(args[0], s3_client)
+ files_info = sorted(args[1], key=lambda file_info: file_info.key)
+ self.assertEqual(
+ files_info,
+ [
+ S3FileInfo(
+ key="path/to/some/product/file1.tif",
+ size=1,
+ bucket_name="somebucket",
+ rel_path="dummy_product/file1.tif",
+ data_type="image/tiff",
+ ),
+ S3FileInfo(
+ key="path/to/some/product/sub/file2.tif",
+ size=2,
+ bucket_name="somebucket",
+ rel_path="dummy_product/sub/file2.tif",
+ ),
+ ],
+ )
+ self.assertEqual(args[2], (0, 10))
+ self.assertEqual(args[3], "zip")
+ self.assertEqual(args[4], "dummy_product")
+
@mock.patch(
"eodag.plugins.download.aws.AwsDownload._get_unique_products", autospec=True
)
=====================================
tests/units/test_search_plugins.py
=====================================
@@ -25,12 +25,15 @@ import unittest
from copy import deepcopy as copy_deepcopy
from importlib import import_module
from pathlib import Path
+from types import SimpleNamespace
from typing import Annotated, Literal, Union, get_args, get_origin
from unittest import mock
from unittest.mock import call
+from urllib.parse import quote_plus
import boto3
import botocore
+import geojson
import pytest
import requests
import responses
@@ -46,6 +49,11 @@ from eodag.api.product import AssetsDict
from eodag.api.product.metadata_mapping import get_queryable_from_provider
from eodag.api.provider import Provider, ProvidersDict
from eodag.api.search_result import RawSearchResult
+from eodag.plugins.search.build_search_result import (
+ _check_id,
+ _request_params_to_properties,
+ _update_properties_from_element,
+)
from eodag.plugins.search.cop_ghsl import (
_convert_bbox_to_lonlat_EPSG3035,
_convert_bbox_to_lonlat_mollweide,
@@ -54,6 +62,7 @@ from eodag.plugins.search.cop_ghsl import (
)
from eodag.utils import deepcopy
from eodag.utils.exceptions import (
+ DownloadError,
PluginImplementationError,
QuotaExceededError,
UnsupportedCollection,
@@ -61,14 +70,17 @@ from eodag.utils.exceptions import (
)
from tests.context import (
DEFAULT_SEARCH_TIMEOUT,
+ GENERIC_COLLECTION,
HTTP_REQ_TIMEOUT,
NOT_AVAILABLE,
TEST_RESOURCES_PATH,
USER_AGENT,
AuthenticationError,
+ CSWSearch,
EOProduct,
MisconfiguredError,
NotAvailableError,
+ PluginConfig,
PluginManager,
PreparedSearch,
QueryablesDict,
@@ -1162,6 +1174,119 @@ class TestSearchPluginQueryStringSearch(BaseSearchPluginTest):
with pytest.raises(MisconfiguredError):
search_plugin.count_hits("http://fake.url")
+ def test_plugins_search_querystringsearch_request_without_exception_message_logs_and_raises(
+ self,
+ ):
+ """QueryStringSearch._request must log the default fallback message and raise RequestError."""
+ prep = PreparedSearch(url="https://example.test/search")
+ prep.query_params = {}
+ with mock.patch(
+ "eodag.plugins.search.qssearch.requests.Session.get",
+ side_effect=requests.RequestException("boom"),
+ ):
+ with self.assertLogs("eodag.search.qssearch", level="ERROR") as cm:
+ with self.assertRaises(RequestError):
+ self.sara_search_plugin._request(prep)
+ self.assertIn("Skipping error while requesting", "\n".join(cm.output))
+
+ def test_plugins_search_querystringsearch_build_raw_search_results_sets_next_page_token(
+ self,
+ ):
+ """Raw search result building must extract the next page marker from a `next_page_query_obj` response."""
+ prep = PreparedSearch(limit=2, next_page_token_key="page")
+ prep.query_params = {"foo": "bar"}
+ prep.collection_def_params = {}
+ prep.next_page_token = 2
+ self.sara_search_plugin.config.pagination["next_page_query_obj_key_path"] = (
+ "$.data.next"
+ )
+ raw = self.sara_search_plugin._build_raw_search_results(
+ results=[{"id": "A"}],
+ resp_as_json={"data": {"next": {"page": 3}}},
+ search_kwargs={},
+ limit=2,
+ prep=prep,
+ )
+ self.assertEqual(raw.next_page_token, 3)
+ self.assertEqual(raw.next_page_token_key, "page")
+
+ def test_plugins_search_querystringsearch_init_raises_on_empty_metadata_mapping_from_product(
+ self,
+ ):
+ """QueryStringSearch.__init__ must reject an empty metadata_mapping inherited from another product."""
+ provider = "earth_search"
+ plugin_cfg = copy_deepcopy(self.get_search_plugin(provider=provider).config)
+ plugin_cfg.products["S1_SAR_GRD"][
+ "metadata_mapping_from_product"
+ ] = "S2_MSI_L1C"
+ plugin_cfg.products["S2_MSI_L1C"]["metadata_mapping"] = {}
+
+ with self.assertRaises(MisconfiguredError):
+ QueryStringSearch(provider, plugin_cfg)
+
+ def test_plugins_search_querystringsearch_clear_resets_pagination_state(self):
+ """QueryStringSearch.clear must reset URLs, parameters, and page state."""
+ self.sara_search_plugin.search_urls = ["https://example.test"]
+ self.sara_search_plugin.query_params = {"foo": "bar"}
+ self.sara_search_plugin.query_string = "foo=bar"
+ self.sara_search_plugin.next_page_url = "https://example.test/next"
+ self.sara_search_plugin.next_page_query_obj = {"page": 2}
+ self.sara_search_plugin.next_page_merge = {"features": []}
+
+ self.sara_search_plugin.clear()
+
+ self.assertEqual(self.sara_search_plugin.search_urls, [])
+ self.assertEqual(self.sara_search_plugin.query_params, {})
+ self.assertEqual(self.sara_search_plugin.query_string, "")
+ self.assertIsNone(self.sara_search_plugin.next_page_url)
+ self.assertIsNone(self.sara_search_plugin.next_page_query_obj)
+ self.assertIsNone(self.sara_search_plugin.next_page_merge)
+
+ def test_plugins_search_querystringsearch_generic_collection_returns_empty(self):
+ """QueryStringSearch must not search the internal generic collection."""
+ result = self.sara_search_plugin.query(
+ prep=PreparedSearch(count=True), collection=GENERIC_COLLECTION
+ )
+ self.assertEqual(result.data, [])
+ self.assertEqual(result.number_matched, 0)
+
+ def test_plugins_search_querystringsearch_collect_urls_requires_template(self):
+ """Numeric pagination must reject configurations without a URL template."""
+ self.assertEqual(
+ self.sara_search_plugin.config.pagination["next_page_url_tpl"],
+ "{url}?{search}&maxRecords={limit}&page={next_page_token}",
+ )
+ self.sara_search_plugin.config.pagination.pop("next_page_url_tpl", None)
+ prep = PreparedSearch(limit=2, count=False)
+ prep.query_string = ""
+ prep.query_params = {}
+ with self.assertRaises(MisconfiguredError):
+ self.sara_search_plugin.collect_search_urls(
+ prep, collection=self.collection
+ )
+
+ def test_plugins_search_querystringsearch_collect_urls_formats_collection_endpoint(
+ self,
+ ):
+ """Pagination URL formatting must substitute the provider collection."""
+ self.assertEqual(
+ self.sara_search_plugin.config.api_endpoint,
+ "https://copernicus.nci.org.au/sara.server/1.0/api/collections/{_collection}/search.json",
+ )
+ prep = PreparedSearch(limit=None)
+ prep.query_string = ""
+ prep.query_params = {}
+ urls, _ = self.sara_search_plugin.collect_search_urls(
+ prep, collection=self.collection
+ )
+ self.assertEqual(
+ urls,
+ [
+ "https://copernicus.nci.org.au/sara.server/1.0/api/collections/"
+ "S2_MSI_L1C/search.json"
+ ],
+ )
+
class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
def setUp(self):
@@ -1197,6 +1322,27 @@ class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
run()
+ def test_plugins_search_postjsonsearch_retries_status_errors(self):
+ """A PostJsonSearch request must retry configured status errors."""
+ search_plugin = self.get_search_plugin(provider="fedeo_ceda")
+ search_plugin.config.retry_total = 1
+ search_plugin.config.retry_backoff_factor = 0
+ url = search_plugin.config.api_endpoint
+ prep = PreparedSearch(url=url)
+ prep.query_params = {}
+
+ @responses.activate(registry=responses.registries.FirstMatchRegistry)
+ def run():
+ responses.add(responses.POST, url, status=400)
+ responses.add(responses.POST, url, status=200)
+
+ response = search_plugin._request(prep)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(responses.calls), 2)
+
+ run()
+
def test_plugins_search_postjsonsearch_request_auth_error(self):
"""A query with a PostJsonSearch must handle auth errors"""
@@ -1218,7 +1364,7 @@ class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
run()
- @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
+ @mock.patch("eodag.plugins.search.qssearch.requests.Session.post", autospec=True)
def test_plugins_search_postjsonsearch_search_quota_exceeded(self, mock__request):
"""A query with a PostJsonSearch must handle a 429 response returned by the provider"""
@@ -1336,7 +1482,7 @@ class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
"eodag.plugins.search.qssearch.QueryStringSearch.normalize_results",
autospec=True,
)
- @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
+ @mock.patch("eodag.plugins.search.qssearch.requests.Session.post", autospec=True)
def test_plugins_search_postjsonsearch_search_cloudcover_awseos(
self, mock_requests_post, mock_normalize_results
):
@@ -1404,7 +1550,7 @@ class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
)
self.assertNotIn("bar", products.data[0].properties)
- @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
+ @mock.patch("eodag.plugins.search.qssearch.requests.Session.post", autospec=True)
@mock.patch(
"eodag.plugins.search.qssearch.PostJsonSearch.normalize_results", autospec=True
)
@@ -1427,6 +1573,7 @@ class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
},
)
mock_request.assert_called_with(
+ mock.ANY,
"https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
json={
"year": "2020",
@@ -1448,6 +1595,7 @@ class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
start_datetime="2021-02-01T03:00:00Z",
)
mock_request.assert_called_with(
+ mock.ANY,
"https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
json={
"year": ["2021"],
@@ -1498,6 +1646,7 @@ class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
)
search_plugin.query(collection="ERA5_SL", prep=PreparedSearch())
mock_request.assert_called_with(
+ mock.ANY,
"https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
json={
"dataset_id": "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
@@ -1540,6 +1689,7 @@ class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
)
search_plugin.query(collection="CAMS_EAC4", prep=PreparedSearch())
mock_request.assert_called_with(
+ mock.ANY,
"https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
json={
"dataset_id": "EO:ECMWF:DAT:CAMS_GLOBAL_REANALYSIS_EAC4",
@@ -1650,6 +1800,70 @@ class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
}
_test_query_params(search_criteria, raw_result, expected_query_params)
+ def test_plugins_search_postjsonsearch_request_rejects_empty_url(self):
+ """PostJsonSearch must reject requests without a URL."""
+ with self.assertRaises(ValidationError):
+ self.awseos_search_plugin._request(PreparedSearch())
+
+ @mock.patch("eodag.plugins.search.qssearch.requests.Session.post", autospec=True)
+ def test_plugins_search_postjsonsearch_request_translates_errors(self, mock_post):
+ """PostJsonSearch must translate timeout, auth, quota, and generic request errors."""
+ response = requests.Response()
+ response.status_code = 403
+ response._content = b"forbidden"
+ mock_post.return_value = response
+ self.assertEqual(
+ self.awseos_search_plugin.config.auth_error_code,
+ [402, 403],
+ )
+ prep = PreparedSearch(url=self.awseos_url)
+ prep.query_params = {}
+ with self.assertRaises(AuthenticationError):
+ self.awseos_search_plugin._request(prep)
+
+ response.status_code = 429
+ with self.assertRaises(QuotaExceededError):
+ self.awseos_search_plugin._request(prep)
+
+ mock_post.side_effect = requests.exceptions.RequestException("boom")
+ with self.assertRaises(RequestError):
+ self.awseos_search_plugin._request(prep)
+
+ mock_post.side_effect = requests.exceptions.Timeout()
+ with self.assertRaises(TimeOutError):
+ self.awseos_search_plugin._request(prep)
+
+ def test_plugins_search_postjsonsearch_collect_search_urls_missing_api_format_key_raises(
+ self,
+ ):
+ """PostJsonSearch.collect_search_urls must reject a missing API endpoint format key."""
+ prep = PreparedSearch(limit=2, count=False, auth_plugin=self.awseos_auth_plugin)
+ prep.query_params = {}
+ self.awseos_search_plugin.config.api_endpoint = "https://example.test/{missing}"
+ with self.assertRaises(MisconfiguredError):
+ self.awseos_search_plugin.collect_search_urls(
+ prep, collection=self.collection
+ )
+
+ @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
+ def test_plugins_search_postjsonsearch_query_accepts_dc_qs_payload(
+ self, mock_request
+ ):
+ """_dc_qs should decode serialized provider payloads and send them as the request body."""
+ payload = {"foo": "bar", "page": 1}
+ mock_request.return_value = mock.Mock()
+ mock_request.return_value.json.return_value = {"features": []}
+
+ self.awseos_search_plugin.query(
+ prep=PreparedSearch(count=False),
+ collection=self.collection,
+ _dc_qs=quote_plus(json.dumps(payload)),
+ )
+
+ query_params = mock_request.call_args[0][1].query_params
+ self.assertEqual(query_params["foo"], payload["foo"])
+ self.assertIn("page", query_params)
+
class TestSearchPluginODataV4Search(BaseSearchPluginTest):
def setUp(self):
@@ -1897,6 +2111,21 @@ class TestSearchPluginODataV4Search(BaseSearchPluginTest):
# products count non extracted from search results as count endpoint is specified
self.assertFalse(hasattr(self.onda_search_plugin, "total_items_nb"))
+ @mock.patch("eodag.plugins.search.qssearch.requests.get", autospec=True)
+ @mock.patch(
+ "eodag.plugins.search.qssearch.QueryStringSearch.do_search", autospec=True
+ )
+ def test_plugins_search_odatav4search_do_search_timeout(
+ self, mock_parent_do_search, mock_requests_get
+ ):
+ """ODataV4Search.do_search must raise TimeOutError when metadata fetch times out."""
+ self.onda_search_plugin.config.per_product_metadata_query = True
+ mock_parent_do_search.return_value = [{"id": "product-1"}]
+ mock_requests_get.side_effect = requests.exceptions.Timeout()
+
+ with self.assertRaises(TimeOutError):
+ self.onda_search_plugin.do_search(PreparedSearch())
+
@mock.patch("eodag.plugins.search.qssearch.requests.get", autospec=True)
@mock.patch(
"eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
@@ -1904,7 +2133,7 @@ class TestSearchPluginODataV4Search(BaseSearchPluginTest):
def test_plugins_search_odatav4search_count_and_search_onda_per_product_metadata_query_request_error(
self, mock__request, mock_requests_get
):
- """A query with a ODataV4Search (here onda) must handle requests errors for query per product metadata""" # noqa
+ """A query with a ODataV4Search (here onda) must handle requests errors for query per product metadata, including quota responses.""" # noqa
# per_product_metadata_query parameter is updated to True if it is necessary
per_product_metadata_query = (
self.onda_search_plugin.config.per_product_metadata_query
@@ -1925,7 +2154,10 @@ class TestSearchPluginODataV4Search(BaseSearchPluginTest):
mock_requests_get.return_value.json.return_value = dict(
value=[dict(id="dummy_metadata", value="dummy_metadata_val")]
)
- mock_requests_get.side_effect = RequestException()
+ mock_requests_get.side_effect = [
+ RequestException(response=mock.Mock(status_code=429)),
+ RequestException(),
+ ]
with self.assertLogs(level="ERROR") as cm:
self.onda_search_plugin.query(
@@ -1952,8 +2184,9 @@ class TestSearchPluginODataV4Search(BaseSearchPluginTest):
error_message_indexes_list = [
i.start() for i in re.finditer(error_message, str(cm.output))
]
- # we check that two errors have been logged, one per product
+ # we check that two errors have been logged, one per product, including a quota warning for 429s
self.assertEqual(len(error_message_indexes_list), 2)
+ self.assertIn("Too many requests on provider", str(cm.output))
@mock.patch(
"eodag.plugins.search.qssearch.QueryStringSearch.normalize_results",
@@ -2214,7 +2447,7 @@ class TestSearchPluginStacSearch(BaseSearchPluginTest):
self.assertIn("some-asset", products[0].assets)
self.assertEqual(products[0].assets["some-asset"]["title"], "My custom title")
- @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
+ @mock.patch("eodag.plugins.search.qssearch.requests.Session.post", autospec=True)
def test_plugins_search_stacsearch_opened_time_intervals(self, mock_requests_post):
"""Opened time intervals must be handled by StacSearch plugin"""
mock_requests_post.return_value = mock.Mock()
@@ -2326,6 +2559,28 @@ class TestSearchPluginStacSearch(BaseSearchPluginTest):
"MGRS-31TCJ",
)
+ def test_plugins_search_stacsearch_discover_queryables_requires_fetch_url(self):
+ """StacSearch.discover_queryables must reject configurations without a queryables URL."""
+ plugin = self.get_search_plugin(provider="wekeo_main")
+ plugin.config.discover_queryables = {
+ "fetch_url": None,
+ "collection_fetch_url": None,
+ }
+ with self.assertRaises(NotImplementedError):
+ plugin.discover_queryables(collection="COP_DEM_GLO90_DGED")
+
+ @mock.patch(
+ "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
+ )
+ def test_plugins_search_stacsearch_discover_queryables_request_error(
+ self, mock_request
+ ):
+ """StacSearch.discover_queryables must raise RequestError on provider request failure."""
+ mock_request.side_effect = RequestError("boom")
+ plugin = self.get_search_plugin(provider="wekeo_main")
+ with self.assertRaises(RequestError):
+ plugin.discover_queryables(collection="COP_DEM_GLO90_DGED")
+
@mock.patch(
"eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
)
@@ -3040,7 +3295,7 @@ class TestSearchPluginMeteoblueSearch(BaseSearchPluginTest):
self.auth_plugin.config.credentials = {"cred": "entials"}
self.auth = self.auth_plugin.authenticate()
- @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
+ @mock.patch("eodag.plugins.search.qssearch.requests.Session.post", autospec=True)
def test_plugins_search_buildpostsearchresult_count_and_search(
self, mock_requests_post
):
@@ -3056,6 +3311,7 @@ class TestSearchPluginMeteoblueSearch(BaseSearchPluginTest):
)
mock_requests_post.assert_called_with(
+ mock.ANY,
self.search_plugin.config.api_endpoint,
json=mock.ANY,
headers=USER_AGENT,
@@ -3605,6 +3861,232 @@ class TestSearchPluginECMWFSearch(unittest.TestCase):
("2022-02-15T00:00:00.000Z", "2022-02-15T00:00:00.000Z"),
)
+ def test_plugins_search_ecmwfsearch_update_properties_from_element(self):
+ """_update_properties_from_element must build JSON schema fragments"""
+ prop = {}
+ _update_properties_from_element(
+ prop,
+ {"type": "StringListWidget", "help": "Choose several values"},
+ ["b", "a"],
+ )
+ self.assertDictEqual(
+ prop,
+ {
+ "type": "array",
+ "items": {"type": "string", "enum": ["a", "b"]},
+ "description": "Choose several values",
+ },
+ )
+
+ prop = {}
+ _update_properties_from_element(
+ prop,
+ {"type": "DateRangeWidget"},
+ ["2020-01-01/2020-01-31"],
+ )
+ self.assertEqual(prop["type"], "string")
+ self.assertEqual(prop["enum"], ["2020-01-01/2020-01-31"])
+ self.assertEqual(
+ prop["description"], "date formatted like yyyy-mm-dd/yyyy-mm-dd"
+ )
+
+ prop = {}
+ _update_properties_from_element(prop, {"type": "GeographicExtentWidget"}, [])
+ self.assertEqual(prop["type"], "array")
+ self.assertEqual(prop["minItems"], 4)
+ self.assertEqual(len(prop["items"]), 4)
+
+ prop = {}
+ _update_properties_from_element(prop, {"type": "GeographicLocationWidget"}, [])
+ self.assertEqual(prop["type"], "object")
+ self.assertIn("longitude", prop["properties"])
+ self.assertIn("latitude", prop["properties"])
+
+ def test_plugins_search_ecmwfsearch_queryables_by_values(self):
+ """queryables_by_values must expose defaults, aliases and required fields"""
+ queryables = self.search_plugin.queryables_by_values(
+ {"variable": ["a", "b"], "product_type": ["analysis"]},
+ ["variable"],
+ {"product_type": "analysis"},
+ )
+
+ self.assertIn("ecmwf_variable", queryables)
+ self.assertIn("ecmwf_product_type", queryables)
+ variable_field = get_args(queryables["ecmwf_variable"])[1]
+ product_type_field = get_args(queryables["ecmwf_product_type"])[1]
+ self.assertTrue(variable_field.is_required())
+ self.assertFalse(product_type_field.is_required())
+ self.assertEqual("analysis", product_type_field.get_default())
+ self.assertEqual("ecmwf:variable", variable_field.serialization_alias)
+
+ def test_plugins_search_wekeo_ecmwf_build_query_string_with_empty_dc_qs(self):
+ """WekeoECMWFSearch.build_query_string must ignore _dc_qs=None"""
+ search_plugin = self.get_search_plugin(provider="wekeo_ecmwf")
+
+ query_params, query_string = search_plugin.build_query_string(
+ "ERA5_SL",
+ {
+ "dataset_id": "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
+ "_dc_qs": None,
+ },
+ )
+
+ self.assertNotIn("_dc_qs", query_params)
+ self.assertNotIn("_dc_qs", query_string)
+
+ def test_plugins_search_ecmwfsearch_preprocess_search_params_with_dc_qs(self):
+ """_preprocess_search_params must decode date and area from _dc_qs"""
+ dc_query_params = {
+ "date": "2020-01-01/to/2020-01-02",
+ "area": "44/1/43/2",
+ }
+ dc_qs = quote_plus(geojson.dumps(dc_query_params))
+
+ params = self.search_plugin._preprocess_search_params(
+ {
+ "_dc_qs": dc_qs,
+ "ecmwf:variable": "temperature",
+ }
+ )
+
+ self.assertEqual(params["start_datetime"], "2020-01-01")
+ self.assertEqual(params["end_datetime"], "2020-01-02")
+ self.assertEqual(params["variable"], "temperature")
+ self.assertEqual(params["_dc_qs"], dc_qs)
+ self.assertEqual(params["geometry"].bounds, (43.0, 1.0, 44.0, 2.0))
+
+ def test_plugins_search_ecmwfsearch_normalize_results_with_dc_qs_and_result(self):
+ """normalize_results must use _dc_qs while preserving non-empty result properties"""
+ dc_query_params = {
+ "variable": "temperature",
+ "area": [44.0, 1.0, 43.0, 2.0],
+ "format": "grib",
+ }
+ raw_search_results = RawSearchResult(
+ [
+ {
+ "dataset": self.product_dataset,
+ "date": "2020-01-01/2020-01-02",
+ "time": "00:00",
+ "area": [90.0, -180.0, -90.0, 180.0],
+ "__hidden": "ignored",
+ "eodag:request_params": {"product_type": "analysis"},
+ }
+ ]
+ )
+ raw_search_results.query_params = {"page": 1}
+ raw_search_results.collection_def_params = {}
+
+ product = self.search_plugin.normalize_results(
+ raw_search_results,
+ collection=self.collection,
+ _dc_qs=quote_plus(geojson.dumps(dc_query_params)),
+ )[0]
+
+ self.assertEqual(product.properties["ecmwf:dataset"], self.product_dataset)
+ self.assertEqual(product.properties["ecmwf:product_type"], "analysis")
+ self.assertNotIn("__hidden", product.properties)
+ self.assertEqual(product.geometry.bounds, (1.0, 43.0, 2.0, 44.0))
+ self.assertEqual(
+ product.properties["start_datetime"], "2020-01-01T00:00:00.000Z"
+ )
+ self.assertEqual(product.properties["end_datetime"], "2020-01-02T00:00:00.000Z")
+ self.assertNotIn("_dc_qs", product.properties)
+ self.assertNotIn("ecmwf:area", product.properties)
+
+ def test_plugins_search_ecmwfsearch_check_id_error_handling(self):
+ """_check_id must translate order status errors into ValidationError"""
+ product = EOProduct("cop_ads", {"id": "generated", "geometry": "POINT (0 0)"})
+ product.search_kwargs = {"id": "123"}
+ product.collection = "ERA5_SL"
+ downloader = mock.Mock()
+ downloader.config.order_on_response = {"metadata_mapping": {}}
+ product.downloader = downloader
+
+ self.assertIs(_check_id(product), product)
+ downloader._order_status.assert_not_called()
+
+ downloader.config.order_on_response = {"metadata_mapping": {"foo": "bar"}}
+ downloader._order_status.side_effect = DownloadError(
+ "order status could not be checked"
+ )
+
+ with self.assertRaises(ValidationError) as context:
+ _check_id(product)
+
+ self.assertIn(
+ "Requested data is not available on cop_ads (123).",
+ context.exception.message,
+ )
+
+ downloader._order_status.side_effect = RuntimeError("boom")
+ with self.assertRaises(ValidationError) as context:
+ _check_id(product)
+
+ self.assertEqual("boom", context.exception.message)
+
+ def test_plugins_search_ecmwfsearch_request_params_to_properties_geometries(self):
+ """_request_params_to_properties must convert supported ECMWF geometry request params"""
+ cases = [
+ (
+ "feature",
+ {
+ "type": "polygon",
+ "shape": [[1, 43], [1, 44], [2, 44], [2, 43], [1, 43]],
+ },
+ (43.0, 1.0, 44.0, 2.0),
+ ),
+ ("area", [44.0, 1.0, 43.0, 2.0], (1.0, 43.0, 2.0, 44.0)),
+ ("location", {"latitude": 43.5, "longitude": 1.5}, (1.5, 43.5, 1.5, 43.5)),
+ ]
+
+ for key, geometry_value, expected_bounds in cases:
+ product = EOProduct(
+ "cop_ads",
+ {
+ "id": key,
+ "geometry": "POINT (0 0)",
+ "eodag:request_params": {
+ key: geometry_value,
+ "date": "2020-01-01/2020-01-02",
+ "variable": "temperature",
+ },
+ },
+ )
+
+ _request_params_to_properties(product)
+
+ self.assertEqual(product.geometry.bounds, expected_bounds)
+ self.assertEqual(product.properties["ecmwf:variable"], "temperature")
+ self.assertEqual(
+ product.properties["start_datetime"], "2020-01-01T00:00:00.000Z"
+ )
+ self.assertEqual(
+ product.properties["end_datetime"], "2020-01-02T00:00:00.000Z"
+ )
+
+ def test_plugins_search_wekeo_ecmwf_do_search_with_order_id(self):
+ """WekeoECMWFSearch.do_search must fake raw results for non-ORDERABLE ids"""
+ search_plugin = self.get_search_plugin(provider="wekeo_ecmwf")
+ prep = PreparedSearch()
+ prep.query_params = {"foo": "bar"}
+ prep.collection_def_params = {
+ "dataset_id": "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS"
+ }
+
+ with mock.patch.object(search_plugin, "_request") as mock_request:
+ raw_results = search_plugin.do_search(
+ prep=prep, id="123", collection="ERA5_SL"
+ )
+
+ self.assertEqual([{}], raw_results.data)
+ self.assertEqual(
+ {"id": "123", "collection": "ERA5_SL"}, raw_results.search_params
+ )
+ self.assertEqual(prep.query_params, raw_results.query_params)
+ self.assertEqual(prep.collection_def_params, raw_results.collection_def_params)
+ mock_request.assert_not_called()
+
def test_plugins_search_ecmwfsearch_normalize_results(self):
"""ECMWFSearch should add request params to properties and set
start/end datetime if year/month/day/time are present in normalize_results"""
@@ -5096,6 +5578,122 @@ class TestSearchPluginCopMarineSearch(BaseSearchPluginTest):
id="item_20200204_20200205_niznjvnqkrf_20210101",
)
+ def test_plugins_search_cop_marine_query_pagination_disabled(self):
+ """CopMarineSearch.query must only return one page when pagination is disabled"""
+ search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
+
+ for prep in [
+ mock.Mock(limit=1, page=None, next_page_token=None, count=True),
+ mock.Mock(limit=None, page=1, next_page_token=None, count=True),
+ mock.Mock(limit=0, page=2, next_page_token=None, count=True),
+ ]:
+ result = search_plugin.query(prep=prep, collection="PRODUCT_A")
+
+ self.assertEqual([], result.data)
+ self.assertEqual(0, result.number_matched)
+
+ def test_plugins_search_cop_marine_query_skips_invalid_s3_url(self):
+ """CopMarineSearch.query must skip datasets with invalid bucket or prefix"""
+ search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
+
+ with (
+ mock.patch.object(
+ search_plugin,
+ "_get_collection_info",
+ return_value=(self.product_data, [self.dataset1_data]),
+ ),
+ mock.patch(
+ "eodag.plugins.search.cop_marine.get_bucket_name_and_prefix",
+ return_value=(None, None),
+ ),
+ mock.patch(
+ "eodag.plugins.search.cop_marine._get_s3_client"
+ ) as mock_get_s3_client,
+ self.assertLogs("eodag.search.cop_marine", level="WARNING") as cm,
+ ):
+ result = search_plugin.query(
+ prep=PreparedSearch(limit=1, count=True), collection="PRODUCT_A"
+ )
+
+ self.assertEqual([], result.data)
+ self.assertEqual(0, result.number_matched)
+ mock_get_s3_client.assert_not_called()
+ self.assertIn("Unable to get bucket and prefix", str(cm.output))
+
+ def test_plugins_search_cop_marine_query_direct_nc_asset(self):
+ """CopMarineSearch.query must create a product when the collection path is a nc file"""
+ search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
+ dataset_item = deepcopy(self.dataset1_data)
+ dataset_item["assets"]["native"]["href"] = (
+ "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-one/"
+ "item_20200102_20200103_direct_20210101.nc"
+ )
+ s3_client = mock.Mock()
+ s3_client.head_object.return_value = {
+ "ResponseMetadata": {
+ "HTTPStatusCode": 200,
+ "HTTPHeaders": {
+ "content-length": "123",
+ "etag": '"d41d8cd98f00b204e9800998ecf8427e"',
+ "last-modified": dt.datetime(2020, 1, 4, tzinfo=dt.timezone.utc),
+ },
+ }
+ }
+
+ with (
+ mock.patch.object(
+ search_plugin,
+ "_get_collection_info",
+ return_value=(self.product_data, [dataset_item]),
+ ),
+ mock.patch(
+ "eodag.plugins.search.cop_marine._get_s3_client",
+ return_value=s3_client,
+ ),
+ ):
+ result = search_plugin.query(
+ prep=PreparedSearch(limit=1, count=True),
+ collection="PRODUCT_A",
+ start_datetime="2020-01-01T00:00:00Z",
+ end_datetime="2020-01-31T00:00:00Z",
+ )
+
+ self.assertEqual(1, result.number_matched)
+ self.assertEqual(1, len(result.data))
+ product = result.data[0]
+ self.assertEqual(
+ "item_20200102_20200103_direct_20210101", product.properties["id"]
+ )
+ self.assertEqual("native", next(iter(product.assets.keys())))
+ asset = product.assets["native"]
+ self.assertEqual(123, asset["file:size"])
+ self.assertEqual("d41d8cd98f00b204e9800998ecf8427e", asset["file:checksum"])
+ self.assertEqual("2020-01-04T00:00:00.000Z", asset["updated"])
+
+ def test_plugins_search_cop_marine_query_returns_empty_without_s3_contents(self):
+ """CopMarineSearch.query must return an empty counted result if S3 has no Contents"""
+ search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
+ s3_client = mock.Mock()
+ s3_client.list_objects.return_value = {}
+
+ with (
+ mock.patch.object(
+ search_plugin,
+ "_get_collection_info",
+ return_value=(self.product_data, [self.dataset1_data]),
+ ),
+ mock.patch(
+ "eodag.plugins.search.cop_marine._get_s3_client",
+ return_value=s3_client,
+ ),
+ ):
+ result = search_plugin.query(
+ prep=PreparedSearch(limit=1, count=True), collection="PRODUCT_A"
+ )
+
+ self.assertEqual([], result.data)
+ self.assertEqual(0, result.number_matched)
+
@mock.patch("eodag.plugins.search.cop_marine.requests.get")
def test_plugins_search_cop_marine_normalize_results(self, mock_requests_get):
"""Normalized query results must include asset information fetched from S3"""
@@ -5690,6 +6288,20 @@ class TestSearchPluginCopGhslSearch(BaseSearchPluginTest):
self.assertIn("month", params)
self.assertListEqual(["08", "09", "10", "11"], params["month"])
+ def test_plugins_search_cop_ghsl_get_start_and_end_from_year(self):
+ """_get_start_and_end_from_properties must use a full year date interval"""
+ plugin = next(self.plugins_manager.get_search_plugins(provider="cop_ghsl"))
+
+ datetimes = plugin._get_start_and_end_from_properties({"year": "2020"})
+
+ self.assertDictEqual(
+ datetimes,
+ {
+ "start_date": "2020-01-01T00:00:00.000Z",
+ "end_date": "2020-12-31T23:59:59.000Z",
+ },
+ )
+
@mock.patch("eodag.plugins.search.cop_ghsl.CopGhslSearch._fetch_constraints")
def test_plugins_search_cop_ghsl_check_input_parameters_valid(
self, mock_fetch_constraints
@@ -5836,6 +6448,152 @@ class TestSearchPluginCopGhslSearch(BaseSearchPluginTest):
]
)
+ @mock.patch("eodag.plugins.search.cop_ghsl.CopGhslSearch._fetch_constraints")
+ @mock.patch("eodag.plugins.search.cop_ghsl.requests.get")
+ def test_plugins_search_cop_ghsl_get_tiles_for_filters_exceptions(
+ self, mock_requests_get, mock_fetch_constraints
+ ):
+ """_get_tiles_for_filters must handle missing config and request errors"""
+ mock_fetch_constraints.return_value = {"constraints": self.constraints}
+ collection = "GHS_BUILT_S"
+ plugin = next(
+ self.plugins_manager.get_search_plugins(
+ collection=collection, provider="cop_ghsl"
+ )
+ )
+ params = {
+ "year": "2000",
+ "proj:code": "EPSG:4326",
+ "tile_size": "3ss",
+ "collection": collection,
+ }
+
+ with self.assertRaises(MisconfiguredError):
+ plugin._get_tiles_for_filters({}, deepcopy(params))
+
+ product_type_config = deepcopy(plugin.config.products.get(collection, {}))
+ mock_requests_get.return_value = MockResponse({}, status_code=404)
+ self.assertIsNone(
+ plugin._get_tiles_for_filters(product_type_config, deepcopy(params))
+ )
+
+ product_type_config = deepcopy(plugin.config.products.get(collection, {}))
+ mock_requests_get.side_effect = requests.exceptions.Timeout()
+ with self.assertRaises(TimeOutError):
+ plugin._get_tiles_for_filters(product_type_config, deepcopy(params))
+
+ product_type_config = deepcopy(plugin.config.products.get(collection, {}))
+ mock_requests_get.side_effect = requests.exceptions.RequestException("boom")
+ with self.assertRaises(RequestError):
+ plugin._get_tiles_for_filters(product_type_config, deepcopy(params))
+
+ @mock.patch("eodag.plugins.search.cop_ghsl.requests.get")
+ def test_plugins_search_cop_ghsl_fetch_constraints(self, mock_requests_get):
+ """_fetch_constraints must return provider constraints and handle failures"""
+ plugin = next(self.plugins_manager.get_search_plugins(provider="cop_ghsl"))
+ constraints = {"constraints": self.constraints}
+
+ mock_requests_get.return_value = MockResponse(constraints, status_code=200)
+ self.assertDictEqual(plugin._fetch_constraints("TEST_CONSTRAINTS"), constraints)
+ mock_requests_get.assert_called_once_with(
+ "https://s3.central.data.destination-earth.eu/swift/v1/constraints/cop_ghsl_dev/TEST_CONSTRAINTS.json",
+ timeout=HTTP_REQ_TIMEOUT,
+ headers=USER_AGENT,
+ )
+
+ mock_requests_get.reset_mock()
+ mock_requests_get.return_value = MockResponse({}, status_code=404)
+ self.assertDictEqual(
+ plugin._fetch_constraints("TEST_CONSTRAINTS_404"), {"constraints": {}}
+ )
+
+ mock_requests_get.side_effect = requests.exceptions.Timeout()
+ with self.assertRaises(TimeOutError):
+ plugin._fetch_constraints("TEST_CONSTRAINTS_TIMEOUT")
+
+ mock_requests_get.side_effect = requests.exceptions.RequestException("boom")
+ with self.assertRaises(RequestError):
+ plugin._fetch_constraints("TEST_CONSTRAINTS_ERROR")
+
+ def test_plugins_search_cop_ghsl_query(self):
+ """query must create SearchResult for tiled and non-tiled Cop GHSL products"""
+ collection = "GHS_BUILT_S"
+ plugin = next(
+ self.plugins_manager.get_search_plugins(
+ collection=collection, provider="cop_ghsl"
+ )
+ )
+ product = EOProduct(
+ "cop_ghsl",
+ {"id": "product-id", "geometry": "POINT (0 0)", "title": "product-id"},
+ collection=collection,
+ )
+ tiles = {"2000": [{"tileID": "R3_C3", "BBox": [0, 0, 1, 1]}]}
+
+ with (
+ mock.patch.object(
+ plugin,
+ "_get_tiles_for_filters",
+ return_value=(tiles, "lat/lon"),
+ ) as mock_get_tiles,
+ mock.patch.object(
+ plugin,
+ "_fetch_constraints",
+ return_value={"additional_filter": "classification"},
+ ) as mock_fetch_constraints,
+ mock.patch.object(
+ plugin,
+ "_create_products_from_tiles",
+ return_value=([product], 1),
+ ) as mock_create_products_from_tiles,
+ ):
+ result = plugin.query(
+ prep=PreparedSearch(limit=1, count=True),
+ collection=collection,
+ year="2000",
+ classification="TOTAL",
+ )
+
+ self.assertEqual([product], result.data)
+ self.assertEqual(1, result.number_matched)
+ self.assertEqual("page", result.next_page_token_key)
+ self.assertEqual("2", result.next_page_token)
+ self.assertTrue(result.raise_errors)
+ mock_get_tiles.assert_called_once()
+ mock_fetch_constraints.assert_called_once_with(collection)
+ mock_create_products_from_tiles.assert_called_once_with(
+ tiles,
+ "lat/lon",
+ collection,
+ mock.ANY,
+ additional_filter="classification",
+ need_count=True,
+ )
+
+ with (
+ mock.patch.object(
+ plugin,
+ "_get_tiles_for_filters",
+ return_value=None,
+ ),
+ mock.patch.object(
+ plugin,
+ "_create_products_without_tiles",
+ return_value=([product], 1),
+ ) as mock_create_products_without_tiles,
+ ):
+ result = plugin.query(
+ prep=PreparedSearch(collection=collection, limit=1, count=True),
+ year="2000",
+ )
+
+ self.assertEqual([product], result.data)
+ self.assertEqual(1, result.number_matched)
+ mock_create_products_without_tiles.assert_called_once()
+
+ with self.assertRaises(MisconfiguredError):
+ plugin.query(prep=PreparedSearch(), collection=[collection])
+
@mock.patch("eodag.plugins.search.cop_ghsl.CopGhslSearch._fetch_constraints")
@mock.patch("eodag.plugins.search.cop_ghsl.requests.get")
def test_plugins_search_cop_ghsl_get_tile_from_product_id(
@@ -6010,6 +6768,61 @@ class TestSearchPluginCopGhslSearch(BaseSearchPluginTest):
)
self.assertEqual(geometry, products[0].geometry)
+ def test_plugins_search_cop_ghsl_create_products_from_tiles_mollweide_bbox(self):
+ """_create_products_from_tiles must convert Mollweide metre bboxes"""
+ bbox = ["-6 041 000", "7 000 000", "-5 041 000", "6 000 000"]
+ tiles = {"2000": [{"tileID": "R3_C3", "BBox": bbox}]}
+ collection = "GHS_BUILT_S"
+ plugin = next(
+ self.plugins_manager.get_search_plugins(
+ collection=collection, provider="cop_ghsl"
+ )
+ )
+ params = deepcopy(plugin.config.products.get(collection, {}))
+ params["year"] = "2000"
+ params["proj:code"] = "EPSG:54009"
+ params["tile_size"] = "10m"
+ params["classification"] = "TOTAL"
+ params["per_page"] = 5
+ params["page"] = 1
+
+ products, count = plugin._create_products_from_tiles(
+ tiles, "metres", collection, params, "classification", need_count=True
+ )
+
+ self.assertEqual(count, 1)
+ self.assertEqual(len(products), 1)
+ expected_geometry = get_geometry_from_various(
+ geometry=_convert_bbox_to_lonlat_mollweide(bbox)
+ )
+ self.assertEqual(expected_geometry, products[0].geometry)
+
+ def test_plugins_search_cop_ghsl_create_products_from_tiles_epsg3035_bbox(self):
+ """_create_products_from_tiles must convert EPSG:3035 metre bboxes"""
+ bbox = ["1,944,000", "1,042,000", "2,044,000", "942,000"]
+ tiles = {"2015": [{"tileID": "R3_C3", "BBox_3035": bbox}]}
+ collection = "GHS_ESM"
+ plugin = next(
+ self.plugins_manager.get_search_plugins(
+ collection=collection, provider="cop_ghsl"
+ )
+ )
+ params = deepcopy(plugin.config.products.get(collection, {}))
+ params["tile_size"] = "10m"
+ params["per_page"] = 5
+ params["page"] = 1
+
+ products, count = plugin._create_products_from_tiles(
+ tiles, "metres", collection, params, need_count=True
+ )
+
+ self.assertEqual(count, 1)
+ self.assertEqual(len(products), 1)
+ expected_geometry = get_geometry_from_various(
+ geometry=_convert_bbox_to_lonlat_EPSG3035(bbox)
+ )
+ self.assertEqual(expected_geometry, products[0].geometry)
+
def test_plugins_search_cop_ghsl_create_products_without_tiles(self):
"""test if products are created correctly for product types without tiles"""
@@ -6213,3 +7026,211 @@ class TestSearchPluginEumetsatDsSearch(BaseSearchPluginTest):
},
},
)
+
+
+class TestSearchPluginCSWSearch(unittest.TestCase):
+ def _get_plugin(self, search_definition):
+ return CSWSearch(
+ "provider",
+ PluginConfig.from_mapping(
+ {
+ "type": "CSWSearch",
+ "api_endpoint": "https://csw.example",
+ "search_definition": search_definition,
+ "metadata_mapping": {"title": "//title/text()"},
+ "products": {"collection": {"collection": "provider-collection"}},
+ }
+ ),
+ )
+
+ def test_csw_query_constraints_cover_matching_geometry_and_dates(self):
+ """CSW constraints support matching modes, geometry, and date filters."""
+ plugin = self._get_plugin(
+ {
+ "collection_tags": [],
+ "date_tags": {"start": "begin", "end": "finish"},
+ }
+ )
+ for matching, expected in (
+ ("prefix", "collection%"),
+ ("postfix", "%collection"),
+ ("exact", "collection"),
+ ("unknown", "%collection%"),
+ ):
+ constraints = plugin._CSWSearch__convert_query_params(
+ {"name": "title", "matching": matching}, "collection", {}
+ )
+ self.assertEqual(constraints[0].literal, expected)
+ constraints = plugin._CSWSearch__convert_query_params(
+ {"name": "title"},
+ "collection",
+ {
+ "geometry": {"lonmin": 1, "latmin": 2, "lonmax": 3, "latmax": 4},
+ "start_datetime": "2020-01-01",
+ "end_datetime": "2020-01-02",
+ },
+ )
+ self.assertEqual(len(constraints), 1)
+ self.assertEqual(len(constraints[0]), 4)
+
+ @mock.patch("eodag.plugins.search.csw.properties_from_xml")
+ def test_csw_build_product_selects_filtered_reference_and_download_link(
+ self, properties_from_xml
+ ):
+ """CSW products select filtered references and expose download links."""
+ plugin = self._get_plugin(
+ {"collection_tags": [], "resource_location_filter": "preferred"}
+ )
+ properties_from_xml.return_value = {"geometry": None}
+ record = SimpleNamespace(
+ xml=b"<record />",
+ bbox_wgs84=(1, 2, 3, 4),
+ references=[
+ {"scheme": "WWW:DOWNLOAD-1.0-http--download", "url": "other"},
+ {"scheme": "WWW:DOWNLOAD-1.0-http--download", "url": "preferred"},
+ ],
+ )
+ product = plugin._CSWSearch__build_product(record, "collection")
+ self.assertEqual(product.location, "preferred")
+ self.assertEqual(product.properties["eodag:download_link"], "preferred")
+ self.assertEqual(product.geometry.bounds, (1.0, 2.0, 3.0, 4.0))
+
+ def test_csw_constraints_accept_shapely_geometry(self):
+ """CSW constraints accept Shapely geometry bounds."""
+ from shapely.geometry import box
+
+ plugin = self._get_plugin({"collection_tags": []})
+ constraints = plugin._CSWSearch__convert_query_params(
+ {"name": "title"}, "collection", {"geometry": box(1, 2, 3, 4)}
+ )
+ self.assertEqual(constraints[0][1].bbox, (1.0, 2.0, 3.0, 4.0))
+
+ def test_csw_clear_resets_catalog(self):
+ """CSW clear resets the cached catalog."""
+ plugin = self._get_plugin({"collection_tags": []})
+ plugin.catalog = object()
+ plugin.clear()
+ self.assertIsNone(plugin.catalog)
+
+ def test_csw_query_without_collection_returns_empty_result(self):
+ """CSW query without a collection returns an empty counted result."""
+ plugin = self._get_plugin({"collection_tags": []})
+ result = plugin.query(prep=PreparedSearch(count=True))
+ self.assertEqual(result.data, [])
+ self.assertEqual(result.number_matched, 0)
+
+ @mock.patch("eodag.plugins.search.csw.CatalogueServiceWeb")
+ def test_csw_init_catalog_uses_credentials_and_caches_result(
+ self, catalogue_service
+ ):
+ """CSW catalog initialization passes credentials and avoids recreation."""
+ plugin = self._get_plugin({"collection_tags": []})
+ plugin.config.api_endpoint = "https://csw.example"
+ plugin.config.version = "2.0.2"
+ plugin._CSWSearch__init_catalog("user", "password")
+ catalogue_service.assert_called_once_with(
+ "https://csw.example",
+ version="2.0.2",
+ username="user",
+ password="password",
+ )
+ plugin._CSWSearch__init_catalog("other", "credentials")
+ catalogue_service.assert_called_once()
+
+ @mock.patch(
+ "eodag.plugins.search.csw.CatalogueServiceWeb",
+ side_effect=RuntimeError("catalog unavailable"),
+ )
+ def test_csw_init_catalog_failure_leaves_catalog_unset(self, catalogue_service):
+ """CSW catalog initialization failures are logged and leave no catalog."""
+ plugin = self._get_plugin({"collection_tags": []})
+ plugin.config.api_endpoint = "https://csw.example"
+ with self.assertLogs("eodag.search.csw", level="WARNING"):
+ plugin._CSWSearch__init_catalog()
+ self.assertIsNone(plugin.catalog)
+
+ @mock.patch("eodag.plugins.search.csw.CatalogueServiceWeb")
+ def test_csw_query_continues_after_exception_report(self, catalogue_service):
+ """CSW query skips failed collection tags and returns remaining results."""
+ from owslib.ows import ExceptionReport
+
+ plugin = self._get_plugin(
+ {"collection_tags": [{"name": "title"}, {"name": "alternate"}]}
+ )
+ exception_report = ExceptionReport.__new__(ExceptionReport)
+ catalog = mock.Mock(records={})
+ catalog.getrecords2.side_effect = [exception_report, None]
+ catalogue_service.return_value = catalog
+ result = plugin.query(collection="collection")
+ self.assertEqual(result.data, [])
+ self.assertEqual(catalog.getrecords2.call_count, 2)
+
+
+class TestSearchPluginStacListAssets(BaseSearchPluginTest):
+ @mock.patch("eodag.plugins.search.stac_list_assets.update_assets_from_s3")
+ def test_plugins_search_stac_list_assets_register_downloader_updates_assets(
+ self, mock_update_assets_from_s3
+ ):
+ """StacListAssets must patch the product downloader registration to refresh S3 assets."""
+ search_plugin = self.get_search_plugin(provider="geodes_s3")
+
+ products = search_plugin.normalize_results(
+ [
+ {
+ "id": "foo",
+ "geometry": {"type": "Point", "coordinates": [0.0, 0.0]},
+ "properties": {
+ "identifier": "foo",
+ "start_datetime": "2020-01-01T00:00:00Z",
+ "end_datetime": "2020-01-02T00:00:00Z",
+ },
+ "assets": {
+ "data": {
+ "href": "s3://bucket/path/data.tif",
+ "roles": ["data"],
+ "title": "data",
+ }
+ },
+ }
+ ]
+ )
+
+ self.assertEqual(len(products), 1)
+ product = products[0]
+ self.assertTrue(hasattr(product, "register_downloader_only"))
+ self.assertIsNot(product.register_downloader, product.register_downloader_only)
+
+ downloader = mock.Mock()
+ downloader.config = mock.Mock(s3_endpoint="https://s3.example.com")
+ authenticator = mock.Mock()
+
+ product.register_downloader(downloader, authenticator)
+
+ self.assertIs(product.downloader, downloader)
+ self.assertIs(product.downloader_auth, authenticator)
+ mock_update_assets_from_s3.assert_called_once_with(
+ product, authenticator, "https://s3.example.com"
+ )
+
+ @mock.patch(
+ "eodag.plugins.search.stac_list_assets.update_assets_from_s3",
+ side_effect=botocore.exceptions.BotoCoreError(),
+ )
+ def test_plugins_search_stac_list_assets_register_downloader_request_error(
+ self, mock_update_assets_from_s3
+ ):
+ """S3 asset refresh failures are exposed as RequestError."""
+ search_plugin = self.get_search_plugin(provider="geodes_s3")
+ product = search_plugin.normalize_results(
+ [
+ {
+ "id": "foo",
+ "geometry": {"type": "Point", "coordinates": [0.0, 0.0]},
+ "properties": {"identifier": "foo"},
+ }
+ ]
+ )[0]
+ downloader = mock.Mock(config=mock.Mock(s3_endpoint="https://s3.example.com"))
+ with self.assertRaises(RequestError):
+ product.register_downloader(downloader, mock.Mock())
+ mock_update_assets_from_s3.assert_called_once()
=====================================
tests/units/test_search_types.py
=====================================
@@ -25,6 +25,7 @@ from typing_extensions import get_args, get_origin
from eodag.types import json_field_definition_to_python, queryables, search_args
from eodag.utils.exceptions import ValidationError as EodagValidationError
+from tests.context import BBox
class TestStacSearch(unittest.TestCase):
@@ -100,6 +101,38 @@ class TestStacSearch(unittest.TestCase):
)
+class TestBBox(unittest.TestCase):
+ def test_bbox_valid_inputs_and_polygon(self):
+ """BBox accepts supported inputs and produces the expected polygon."""
+ values = (
+ [1, 2, 3, 4],
+ (1, 2, 3, 4),
+ {"lonmin": 1, "latmin": 2, "lonmax": 3, "latmax": 4},
+ )
+ for value in values:
+ bbox = BBox(value)
+ self.assertEqual(
+ (bbox.lonmin, bbox.latmin, bbox.lonmax, bbox.latmax),
+ (1, 2, 3, 4),
+ )
+ self.assertEqual(bbox.to_polygon().bounds, (1.0, 2.0, 3.0, 4.0))
+
+ def test_bbox_rejects_invalid_shape_and_coordinates(self):
+ """BBox rejects invalid dimensions, coordinate ranges, and ordering."""
+ with self.assertRaises(ValueError):
+ BBox([1, 2, 3])
+ for value in (
+ [-181, 0, 1, 1],
+ [0, -91, 1, 1],
+ [0, 0, 181, 1],
+ [0, 0, 1, 91],
+ [2, 0, 1, 1],
+ [0, 2, 1, 1],
+ ):
+ with self.assertRaises(ValidationError):
+ BBox(value)
+
+
class TestQueryables(unittest.TestCase):
def setUp(self):
super(TestQueryables, self).setUp()
=====================================
tests/units/test_stac_reader.py
=====================================
@@ -17,6 +17,7 @@
# limitations under the License.
import os
import unittest
+from unittest import mock
from tests import TEST_RESOURCES_PATH
from tests.context import STACOpenerError, _TextOpener, fetch_stac_items
@@ -79,3 +80,10 @@ class TestStacReader(unittest.TestCase):
"http://data.example.org/",
True,
)
+
+ def test_stac_reader_text_opener_falls_back_to_http(self):
+ """The STAC text opener falls back when local reading fails."""
+ opener = _TextOpener(timeout=3, ssl_verify=True)
+ opener.openers[0] = mock.Mock(side_effect=STACOpenerError("not local"))
+ opener.openers[1] = mock.Mock(return_value={"id": "item"})
+ self.assertEqual(opener("file.json", as_json=True), {"id": "item"})
=====================================
tests/units/test_utils.py
=====================================
@@ -18,37 +18,58 @@
import copy
import datetime as dt
+import json
import logging
import os
import ssl
import sys
+import tempfile
import unittest
+import warnings
from contextlib import closing
from io import StringIO
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
+import requests
+from click.exceptions import BadParameter
from dateutil import parser as dateutil_parser
from requests.exceptions import RequestException
from shapely.geometry import Point, Polygon
-from eodag.utils import get_geometry_from_ecmwf_area, get_geometry_from_ecmwf_feature
+import eodag.utils as utils
+from eodag.utils import (
+ _build_float_range_cls,
+ _deprecated_class,
+ format_string,
+ nested_pairs2dict,
+)
+from eodag.utils.exceptions import MisconfiguredError
from eodag.utils.logging import TqdmLoggingHandler
from tests.context import (
HTTP_REQ_TIMEOUT,
USER_AGENT,
DownloadedCallback,
+ LocalFileAdapter,
+ NotebookWidgets,
ProgressCallback,
RequestError,
+ TimeOutError,
+ check_ipython,
+ check_notebook,
deepcopy,
fetch_json,
flatten_top_directories,
get_bucket_name_and_prefix,
+ get_geometry_from_ecmwf_area,
+ get_geometry_from_ecmwf_feature,
get_ssl_context,
get_timestamp,
+ import_all_modules,
is_env_var_true,
merge_mappings,
+ patch_owslib_requests,
path_to_uri,
setup_logging,
uri_to_path,
@@ -71,6 +92,79 @@ class TestUtils(unittest.TestCase):
logger.handlers = []
logger.level = 0
+ def test_build_float_range_cls(self):
+ """Test FloatRange conversion and range validation."""
+ float_range = _build_float_range_cls()
+ parameter_type = float_range(0, 100)
+
+ self.assertEqual(parameter_type.convert("42.5", None, None), 42.5)
+ with self.assertRaises(BadParameter):
+ parameter_type.convert("-1", None, None)
+ with self.assertRaises(BadParameter):
+ parameter_type.convert("101", None, None)
+
+ self.assertEqual(float_range(max=100).convert(42, None, None), 42.0)
+ self.assertEqual(float_range(min=0).convert(42, None, None), 42.0)
+
+ def test_utils_getattr_float_range(self):
+ """Test __getattr__ lazily creates and caches FloatRange."""
+ previous_float_range = utils.__dict__.pop("FloatRange", None)
+ try:
+ float_range = getattr(utils, "FloatRange")
+ self.assertIs(getattr(utils, "FloatRange"), float_range)
+ self.assertEqual(float_range(0, 1).convert("0.5", None, None), 0.5)
+ with self.assertRaises(AttributeError):
+ getattr(utils, "missing_attribute")
+ finally:
+ utils.__dict__.pop("FloatRange", None)
+ if previous_float_range is not None:
+ utils.__dict__["FloatRange"] = previous_float_range
+
+ def test_deprecated_class(self):
+ """Test _deprecated_class preserves identity and warns on constructors."""
+
+ class Example:
+ def __init__(self, value):
+ self.value = value
+
+ @classmethod
+ def model_validate(cls, value):
+ return cls(value)
+
+ decorated_class = _deprecated_class(reason="legacy", version="3.0")(Example)
+ self.assertIs(decorated_class, Example)
+
+ with warnings.catch_warnings(record=True) as caught_warnings:
+ warnings.simplefilter("always")
+ instance = Example("value")
+ self.assertEqual(instance.value, "value")
+ self.assertEqual(len(caught_warnings), 1)
+ self.assertIn(
+ "Example (legacy) -- Deprecated since v3.0", str(caught_warnings[0].message)
+ )
+
+ with warnings.catch_warnings(record=True) as caught_warnings:
+ warnings.simplefilter("always")
+ validated = Example.model_validate("validated")
+ self.assertEqual(validated.value, "validated")
+ self.assertEqual(len(caught_warnings), 2)
+
+ def test_format_string_exception_handling(self):
+ """Test format_string handles malformed and colon-containing formats."""
+ with self.assertRaisesRegex(MisconfiguredError, "Unable to format"):
+ format_string(None, "{invalid", value="unused")
+
+ self.assertEqual(
+ format_string(None, "{foo:bar}", **{"foo:bar": "value"}),
+ "value",
+ )
+
+ def test_nested_pairs2dict_value_error(self):
+ """Test nested_pairs2dict returns malformed pairs unchanged."""
+ pairs = [["valid", "pair"], ["invalid"]]
+
+ self.assertIs(nested_pairs2dict(pairs), pairs)
+
def test_utils_get_timestamp(self):
"""Test get_timestamp returns correct UNIX timestamp for various date formats"""
# Date to timestamp to date, this assumes the date is in UTC
@@ -500,6 +594,33 @@ class TestUtils(unittest.TestCase):
)
)
+ def test_get_geometry_from_ecmwf_feature_exceptions(self):
+ """ECMWF feature validation raises TypeError for invalid geometries."""
+ invalid_geometries = [
+ None,
+ {},
+ {"type": "polygon"},
+ {"type": "polygon", "shape": "invalid"},
+ {"type": "boundingbox"},
+ {"type": "boundingbox", "points": "invalid"},
+ {"type": "position"},
+ {"type": "position", "points": []},
+ {"type": "trajectory"},
+ {"type": "trajectory", "points": [[43.0, 1.0]]},
+ {
+ "type": "trajectory",
+ "points": [[43.0, 1.0], [43.5, 1.5]],
+ },
+ {"type": "circle"},
+ {"type": "circle", "center": [43.5, 1.5]},
+ {"type": "unsupported"},
+ ]
+
+ for geometry in invalid_geometries:
+ with self.subTest(geometry=geometry):
+ with self.assertRaises(TypeError):
+ get_geometry_from_ecmwf_feature(geometry)
+
def test_get_geometry_from_ecmwf_area_accepts_list_and_string(self):
"""``get_geometry_from_ecmwf_area`` must accept both list and slash-separated string formats."""
# list format: [max_lat, min_lon, min_lat, max_lon]
@@ -522,3 +643,87 @@ class TestUtils(unittest.TestCase):
# invalid string: non-numeric content
with self.assertRaises(ValueError):
get_geometry_from_ecmwf_area("a/b/c/d")
+
+ def test_patch_owslib_requests_restores_functions(self):
+ """OWSLib request patches apply verification and restore originals."""
+ import owslib.util
+
+ original_request = owslib.util.requests.request
+ original_post = owslib.util.requests.post
+ with patch_owslib_requests(verify=False):
+ self.assertFalse(owslib.util.requests.request.keywords["verify"])
+ self.assertFalse(owslib.util.requests.post.keywords["verify"])
+ self.assertIs(owslib.util.requests.request, original_request)
+ self.assertIs(owslib.util.requests.post, original_post)
+ with self.assertRaisesRegex(RuntimeError, "boom"):
+ with patch_owslib_requests():
+ raise RuntimeError("boom")
+ self.assertIs(owslib.util.requests.request, original_request)
+
+ def test_import_all_modules_honors_exclude(self):
+ """Module discovery skips excluded entries."""
+ from types import SimpleNamespace
+
+ package = SimpleNamespace(__name__="test_package", __path__=["unused"])
+ modules = [
+ (None, "module", False),
+ (None, "subpackage", True),
+ (None, "excluded", False),
+ ]
+ with (
+ mock.patch(
+ "eodag.utils.import_system.pkgutil.iter_modules", return_value=modules
+ ),
+ mock.patch(
+ "eodag.utils.import_system.importlib.import_module"
+ ) as import_module,
+ ):
+ import_all_modules(package, depth=1, exclude=("excluded",))
+ import_module.assert_called_once_with(".module", package="test_package")
+
+ def test_notebook_detection_and_non_notebook_widgets(self):
+ """Notebook widgets are no-ops outside a notebook."""
+ self.assertFalse(check_ipython())
+ self.assertFalse(check_notebook())
+ widgets = NotebookWidgets()
+ self.assertIsNone(widgets.display_html("ignored"))
+ self.assertIsNone(widgets.clear_html())
+
+ def test_notebook_detection_shells(self):
+ """Notebook detection distinguishes Jupyter and terminal IPython shells."""
+ with mock.patch("eodag.utils.notebook.get_ipython", create=True) as get_ipython:
+ get_ipython.return_value.__class__.__name__ = "ZMQInteractiveShell"
+ self.assertTrue(check_notebook())
+ get_ipython.return_value.__class__.__name__ = "TerminalInteractiveShell"
+ self.assertFalse(check_notebook())
+
+ def test_local_file_adapter_statuses_and_fetch_json(self):
+ """Local file requests return expected statuses and parse JSON."""
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".json", delete=False
+ ) as file:
+ json.dump({"value": 1}, file)
+ path = file.name
+ try:
+ self.assertEqual(fetch_json(path), {"value": 1})
+ self.assertEqual(LocalFileAdapter._chkpath("put", path)[0], 501)
+ self.assertEqual(LocalFileAdapter._chkpath("patch", path)[0], 405)
+ self.assertEqual(LocalFileAdapter._chkpath("get", path)[0], 200)
+ self.assertEqual(
+ LocalFileAdapter._chkpath("get", path + "-missing")[0], 404
+ )
+ self.assertEqual(
+ LocalFileAdapter._chkpath("get", os.path.dirname(path))[0], 400
+ )
+ finally:
+ os.unlink(path)
+
+ @mock.patch("eodag.utils.requests.requests.sessions.Session.get", autospec=True)
+ def test_fetch_json_timeout_and_request_error(self, mock_get):
+ """fetch_json translates timeout and request failures to EODAG errors."""
+ mock_get.side_effect = requests.exceptions.Timeout()
+ with self.assertRaises(TimeOutError):
+ fetch_json("https://example.test")
+ mock_get.side_effect = requests.exceptions.RequestException()
+ with self.assertRaises(RequestError):
+ fetch_json("https://example.test")
=====================================
utils/ext_product_types_cmp.py
=====================================
@@ -126,8 +126,8 @@ def compare_product_types(
# Compare each provider
common_providers = providers1 & providers2
for provider in sorted(common_providers):
- provider_data1 = data1[provider]
- provider_data2 = data2[provider]
+ provider_data1 = data1[provider] or {}
+ provider_data2 = data2[provider] or {}
# Compare providers_config items content (no list diff, just content)
providers_config1 = set(provider_data1.get("providers_config", {}).keys())
View it on GitLab: https://salsa.debian.org/debian-gis-team/eodag/-/compare/08b4b9c32ccea34a95e035e95a681f122f967040...e5e2e3d9bd0e7d367dcb17592c9eab2e9eafd28b
--
View it on GitLab: https://salsa.debian.org/debian-gis-team/eodag/-/compare/08b4b9c32ccea34a95e035e95a681f122f967040...e5e2e3d9bd0e7d367dcb17592c9eab2e9eafd28b
You're receiving this email because of your account on salsa.debian.org. Manage all notifications: https://salsa.debian.org/-/profile/notifications | Help: https://salsa.debian.org/help
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20260828/334637d3/attachment-0001.htm>
More information about the Pkg-grass-devel
mailing list