[Git][debian-gis-team/eodag][upstream] New upstream version 4.9.2+ds
Bas Couwenberg (@sebastic)
gitlab at salsa.debian.org
Thu Oct 8 17:36:25 BST 2026
Bas Couwenberg pushed to branch upstream at Debian GIS Project / eodag
Commits:
146ee8d0 by Bas Couwenberg at 2026-10-08T18:27:13+02:00
New upstream version 4.9.2+ds
- - - - -
13 changed files:
- CHANGES.rst
- docs/providers.rst
- eodag/api/product/_product.py
- eodag/api/product/metadata_mapping.py
- eodag/plugins/authentication/sas_auth.py
- eodag/plugins/manager.py
- eodag/plugins/search/qssearch.py
- eodag/resources/ext_collections.json
- eodag/resources/providers/planetary_computer.yml
- eodag/resources/providers/wekeo_main.yml
- pyproject.toml
- tests/units/test_auth_plugins.py
- tests/units/test_crunch.py
Changes:
=====================================
CHANGES.rst
=====================================
@@ -3,6 +3,46 @@ Release history
===============
+v4.9.2 (2026-10-07)
+===================
+
+Bug Fixes
+---------
+
+* **plugins**: SASAuth per collection cached token (`#2374`_, `8530060`_)
+
+Build System
+------------
+
+* Jsonpath-ng update (`#2380`_, `f3d4ae9`_)
+
+Documentation
+-------------
+
+* Update registration info for dedl provider (`#2379`_, `66d2d05`_)
+
+Refactoring
+-----------
+
+* **providers**: Update wekeo_main metadata mapping (`#2361`_, `f89e751`_)
+
+Testing
+-------
+
+* Adapt to shapely 2.2.0 (`#2378`_, `d0e0c5b`_)
+
+.. _#2361: https://github.com/CS-SI/eodag/pull/2361
+.. _#2374: https://github.com/CS-SI/eodag/pull/2374
+.. _#2378: https://github.com/CS-SI/eodag/pull/2378
+.. _#2379: https://github.com/CS-SI/eodag/pull/2379
+.. _#2380: https://github.com/CS-SI/eodag/pull/2380
+.. _66d2d05: https://github.com/CS-SI/eodag/commit/66d2d05b9a92bdfcc11645d7bf10bcf55aba1a0d
+.. _8530060: https://github.com/CS-SI/eodag/commit/853006005031d4de3e22f83e87da4fb1177a87b5
+.. _d0e0c5b: https://github.com/CS-SI/eodag/commit/d0e0c5b8f5dfdc1ecb65d49c3d8a39c89209a1fd
+.. _f3d4ae9: https://github.com/CS-SI/eodag/commit/f3d4ae9ef654454b3f7084b2091deeb26e1d0793
+.. _f89e751: https://github.com/CS-SI/eodag/commit/f89e7510ecb16a855a0beaa6136bbe2a6995d766
+
+
v4.9.1 (2026-09-23)
===================
=====================================
docs/providers.rst
=====================================
@@ -890,7 +890,7 @@ No credentials are needed
:columns: 2
:class: sd-text-right
- .. button-link:: https://hda.data.destination-earth.eu/ui
+ .. button-link:: https://platform.destine.eu
:color: primary
:outline:
:tooltip: DEDL website
@@ -905,8 +905,8 @@ No credentials are needed
You need a `DESP OpenID` account in order to authenticate.
To create one go `here
- <https://hda.data.destination-earth.eu/ui>`__, then click on `Sign In`, select the identity provider `DESP OpenID` and
- then click `Authenticate`. Finally click on `Register` to create a new account.
+ <https://platform.destine.eu>`__, then click on `Sign In`, and then `Register` to create a new account.
+ Then use your ``username``, ``password`` in eodag credentials.
----
=====================================
eodag/api/product/_product.py
=====================================
@@ -65,7 +65,6 @@ from eodag.api.product.metadata_mapping import (
NOT_AVAILABLE,
NOT_MAPPED,
OFFLINE_STATUS,
- ONLINE_STATUS,
normalize_bands,
)
from eodag.utils import (
@@ -484,25 +483,7 @@ class EOProduct:
the download and authentication plugins.
"""
download_plugin = plugins_manager.get_download_plugin(self)
- if len(self.assets) > 0:
- matching_url = next(iter(self.assets.values()))["href"]
- elif self.properties.get("order:status") != ONLINE_STATUS:
- matching_url = self.properties.get(
- "eodag:order_link"
- ) or self.properties.get("eodag:download_link")
- else:
- matching_url = self.properties.get("eodag:download_link")
-
- try:
- auth_plugin = next(
- plugins_manager.get_auth_plugins(
- self.provider,
- matching_url=matching_url,
- matching_conf=download_plugin.config,
- )
- )
- except StopIteration:
- auth_plugin = None
+ auth_plugin = plugins_manager.get_auth_plugin(download_plugin, self)
self.register_downloader(download_plugin, auth_plugin)
def register_downloader(
=====================================
eodag/api/product/metadata_mapping.py
=====================================
@@ -754,7 +754,7 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
def convert_dict_filter(
input_dict: dict[Any, Any], jsonpath_filter_str: str
) -> dict[Any, Any]:
- """Fitlers dict items using jsonpath"""
+ """Filters dict items using jsonpath"""
jsonpath_filter = string_to_jsonpath(jsonpath_filter_str, force=True)
if isinstance(jsonpath_filter, str) or not isinstance(input_dict, dict):
@@ -765,9 +765,17 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
result = {}
for match in matches:
# extract key index from matched jsonpath
- matched_jsonpath_str = str(match.full_path)
- matched_index = int(matched_jsonpath_str.split(".")[-1][1:-1])
- key = keys_list[matched_index]
+ if not hasattr(match.path, "index") and not hasattr(
+ match.path, "fields"
+ ):
+ continue
+ key = (
+ # jsonpath-ng <= 1.7.0
+ keys_list[match.path.index]
+ if hasattr(match.path, "index")
+ # jsonpath-ng >= 1.9.0
+ else match.path.fields[0]
+ )
result[key] = match.value
return result
@@ -775,7 +783,7 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
def convert_dict_filter_and_sub(
input_dict: dict[Any, Any], args: str
) -> Union[dict[Any, Any], list[Any]]:
- """Fitlers dict items using jsonpath and then apply recursive_sub_str"""
+ """Filters dict items using jsonpath and then apply recursive_sub_str"""
jsonpath_filter_str, old, new = ast.literal_eval(args)
filtered = MetadataFormatter.convert_dict_filter(
input_dict, jsonpath_filter_str
=====================================
eodag/plugins/authentication/sas_auth.py
=====================================
@@ -19,15 +19,29 @@ from __future__ import annotations
import logging
import re
+import threading
+from copy import copy
+from datetime import datetime, timedelta, timezone
from json import JSONDecodeError
from typing import TYPE_CHECKING, Optional
+from urllib.parse import urlsplit, urlunsplit
import requests
+from requests.adapters import HTTPAdapter
from requests.auth import AuthBase
+from urllib3 import Retry
from eodag.api.product._assets import Asset
from eodag.plugins.authentication.base import Authentication
-from eodag.utils import HTTP_REQ_TIMEOUT, USER_AGENT, deepcopy, format_dict_items
+from eodag.utils import (
+ HTTP_REQ_TIMEOUT,
+ REQ_RETRY_BACKOFF_FACTOR,
+ REQ_RETRY_STATUS_FORCELIST,
+ REQ_RETRY_TOTAL,
+ USER_AGENT,
+ deepcopy,
+ format_dict_items,
+)
from eodag.utils.exceptions import AuthenticationError, TimeOutError
if TYPE_CHECKING:
@@ -38,6 +52,32 @@ if TYPE_CHECKING:
logger = logging.getLogger("eodag.auth.sas_auth")
+# Tokens are shared by all RequestsSASAuth instances to avoid one token request per asset/product.
+_SAS_TOKENS: dict[str, tuple[str, Optional[datetime]]] = {}
+_SAS_TOKENS_LOCK = threading.Lock()
+_SAS_FETCH_LOCKS: dict[str, threading.Lock] = {}
+_SAS_EXPIRY_MARGIN = timedelta(seconds=60)
+
+
+def _parse_expiry(value: object) -> Optional[datetime]:
+ """Parse a token expiry date, return ``None`` if missing or invalid"""
+ try:
+ expiry = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ return expiry if expiry.tzinfo else expiry.replace(tzinfo=timezone.utc)
+
+
+def _get_cached_token(keys: list[str]) -> Optional[str]:
+ """Return the first non-expired cached token found for the given keys"""
+ now = datetime.now(timezone.utc)
+ with _SAS_TOKENS_LOCK:
+ for key in keys:
+ entry = _SAS_TOKENS.get(key)
+ if entry and (entry[1] is None or entry[1] - _SAS_EXPIRY_MARGIN > now):
+ return entry[0]
+ return None
+
class RequestsSASAuth(AuthBase):
"""A custom authentication class to be used with requests module"""
@@ -49,13 +89,66 @@ class RequestsSASAuth(AuthBase):
headers: Optional[dict[str, str]] = None,
ssl_verify: bool = True,
matching_url: Optional[Pattern[str]] = None,
+ provider_collection: Optional[str] = None,
+ retry_total: int = REQ_RETRY_TOTAL,
+ retry_backoff_factor: int = REQ_RETRY_BACKOFF_FACTOR,
+ retry_status_forcelist: Optional[list[int]] = None,
) -> None:
self.auth_uri = auth_uri
self.signed_url_key = signed_url_key
self.headers = headers
- self.signed_urls: dict[str, str] = {}
self.ssl_verify = ssl_verify
self.matching_url = matching_url
+ self.provider_collection = provider_collection
+ self.retry_total = retry_total
+ self.retry_backoff_factor = retry_backoff_factor
+ self.retry_status_forcelist = (
+ REQ_RETRY_STATUS_FORCELIST
+ if retry_status_forcelist is None
+ else retry_status_forcelist
+ )
+
+ @staticmethod
+ def _get_container_url(url: str) -> Optional[str]:
+ """Return the Azure blob container URL, or None for other URLs."""
+ parsed_url = urlsplit(url)
+ path_parts = parsed_url.path.strip("/").split("/", 1)
+ if (
+ parsed_url.hostname
+ and parsed_url.hostname.endswith(".blob.core.windows.net")
+ and path_parts[0]
+ ):
+ return f"{parsed_url.scheme}://{parsed_url.netloc}/{path_parts[0]}"
+ return None
+
+ @staticmethod
+ def _apply_sas_token(url: str, signed_url: str) -> Optional[str]:
+ """Apply a cached SAS token to another blob URL in the same container."""
+ parsed_url = urlsplit(url)
+ parsed_signed_url = urlsplit(signed_url)
+ container_key = RequestsSASAuth._get_container_url(url)
+ if not container_key:
+ return None
+
+ if parsed_signed_url.scheme and parsed_signed_url.netloc:
+ if RequestsSASAuth._get_container_url(signed_url) != container_key:
+ return None
+ token = parsed_signed_url.query
+ else:
+ token = signed_url.lstrip("?")
+
+ if not token:
+ return None
+
+ return urlunsplit(
+ (
+ parsed_url.scheme,
+ parsed_url.netloc,
+ parsed_url.path,
+ token,
+ parsed_url.fragment,
+ )
+ )
def __call__(self, request: PreparedRequest) -> PreparedRequest:
"""Perform the actual authentication"""
@@ -72,29 +165,75 @@ class RequestsSASAuth(AuthBase):
for k, v in self.headers.items():
request.headers[k] = v
- # request the signed_url
- req_signed_url = self.auth_uri.format(url=request.url)
- if req_signed_url not in self.signed_urls.keys():
- logger.debug(f"Signed URL request: {req_signed_url}")
- try:
- response = requests.get(
- req_signed_url,
- headers=self.headers,
- timeout=HTTP_REQ_TIMEOUT,
- verify=self.ssl_verify,
+ request.url = self.sign_url(request.url or "")
+
+ return request
+
+ def sign_url(self, url: str) -> str:
+ """Return ``url`` signed with a (cached or freshly requested) SAS token"""
+ # Azure SAS tokens are scoped to a container, not an individual blob.
+ container_key = self._get_container_url(url)
+ if "{_collection}" in self.auth_uri and not self.provider_collection:
+ raise AuthenticationError(
+ "A provider collection is required to request a SAS token"
+ )
+ req_signed_url = self.auth_uri.format(
+ url=url, _collection=self.provider_collection or ""
+ )
+
+ cache_keys = [k for k in (container_key, req_signed_url) if k]
+ signed_url = _get_cached_token(cache_keys)
+ if signed_url is None:
+ # one fetch at a time per container, other threads then reuse its result
+ with _SAS_TOKENS_LOCK:
+ fetch_lock = _SAS_FETCH_LOCKS.setdefault(
+ cache_keys[0], threading.Lock()
)
- response.raise_for_status()
- signed_url = response.json().get(self.signed_url_key)
- except requests.exceptions.Timeout as exc:
- raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
- except (requests.RequestException, JSONDecodeError, KeyError) as e:
- raise AuthenticationError("Could no get signed url", str(e)) from e
- else:
- self.signed_urls[req_signed_url] = signed_url
+ with fetch_lock:
+ signed_url = _get_cached_token(cache_keys)
+ if signed_url is None:
+ signed_url = self._fetch_signed_url(
+ url, req_signed_url, container_key
+ )
- request.url = self.signed_urls[req_signed_url]
+ return self._apply_sas_token(url, signed_url) or signed_url
- return request
+ def _fetch_signed_url(
+ self, url: str, req_signed_url: str, container_key: Optional[str]
+ ) -> str:
+ """Request a signed url / token and store it in the shared cache"""
+ logger.debug(f"Signed URL request: {req_signed_url}")
+ try:
+ session = requests.Session()
+ retries = Retry(
+ total=self.retry_total,
+ backoff_factor=self.retry_backoff_factor,
+ status_forcelist=self.retry_status_forcelist,
+ )
+ session.mount(req_signed_url, HTTPAdapter(max_retries=retries))
+ response = session.get(
+ req_signed_url,
+ headers=self.headers,
+ timeout=HTTP_REQ_TIMEOUT,
+ verify=self.ssl_verify,
+ )
+ response.raise_for_status()
+ body = response.json()
+ signed_url = body[self.signed_url_key]
+ except requests.exceptions.Timeout as exc:
+ raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
+ except (requests.RequestException, JSONDecodeError, KeyError) as e:
+ raise AuthenticationError("Could no get signed url", str(e)) from e
+
+ cache_key = (
+ container_key
+ if container_key and self._apply_sas_token(url, signed_url)
+ else req_signed_url
+ )
+ expiry = _parse_expiry(body.get("msft:expiry"))
+ with _SAS_TOKENS_LOCK:
+ _SAS_TOKENS[cache_key] = (signed_url, expiry)
+ return signed_url
class SASAuth(Authentication):
@@ -114,15 +253,27 @@ class SASAuth(Authentication):
apiKey is used**): headers to be added to the requests
* :attr:`~eodag.config.PluginConfig.ssl_verify` (``bool``): if the ssl certificates should be
verified in the requests; default: ``True``
+ * :attr:`~eodag.config.PluginConfig.retry_total` (``int``): total number of retries; default: ``3``
+ * :attr:`~eodag.config.PluginConfig.retry_backoff_factor` (``int``): retry backoff factor; default: ``2``
+ * :attr:`~eodag.config.PluginConfig.retry_status_forcelist` (``list[int]``): HTTP status codes to retry;
+ default: ``[401, 429, 500, 502, 503, 504]``
"""
+ provider_collection: Optional[str] = None
+
def validate_config_credentials(self) -> None:
"""Validate configured credentials"""
# credentials are optionnal
pass
- def authenticate(self) -> AuthBase:
+ def bind_collection(self, provider_collection: str) -> SASAuth:
+ """Return a product-specific auth plugin using its provider collection ID."""
+ auth_plugin = copy(self)
+ auth_plugin.provider_collection = provider_collection
+ return auth_plugin
+
+ def authenticate(self) -> RequestsSASAuth:
"""Authenticate"""
self.validate_config_credentials()
@@ -140,12 +291,23 @@ class SASAuth(Authentication):
)
headers.update(headers_update)
+ provider_collection = getattr(self, "provider_collection", None)
return RequestsSASAuth(
auth_uri=self.config.auth_uri,
signed_url_key=self.config.signed_url_key,
headers=headers,
ssl_verify=ssl_verify,
matching_url=matching_url,
+ provider_collection=provider_collection,
+ 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,
+ ),
)
def presign_url(
@@ -156,8 +318,7 @@ class SASAuth(Authentication):
"""This method is used to presign a url to download an asset.
:param asset: asset for which the url shall be presigned
- :param expires_in: expiration time of the presigned url in seconds
+ :param expires_in: ignored, the token lifetime is set by the provider
:returns: presigned url
"""
- url = asset["href"]
- return self.config.auth_uri.format(url=url)
+ return self.authenticate().sign_url(asset["href"])
=====================================
eodag/plugins/manager.py
=====================================
@@ -332,6 +332,27 @@ class PluginManager:
)
except StopIteration:
auth_plugin = None
+ if auth_plugin and product is not None and product.collection:
+ bind_collection = getattr(auth_plugin, "bind_collection", None)
+ if bind_collection:
+ provider_config = self.providers.get_config(product.provider)
+ collection_def_config = (
+ provider_config.products.get(product.collection, {})
+ if provider_config
+ else {}
+ )
+ if not collection_def_config and provider_config:
+ collection_def_config = provider_config.products.get(
+ GENERIC_COLLECTION, {}
+ )
+ provider_collection = collection_def_config.get(
+ "_collection", product.collection
+ )
+ if isinstance(provider_collection, str):
+ provider_collection = provider_collection.format(
+ collection=product.collection
+ )
+ auth_plugin = bind_collection(provider_collection)
return auth_plugin
def get_auth_plugins(
=====================================
eodag/plugins/search/qssearch.py
=====================================
@@ -1885,21 +1885,6 @@ class PostJsonSearch(QueryStringSearch):
)
product.properties["_dc_qs"] = quote_plus(_dc_qs)
- # workaround to add collection to wekeo cmems order links
- if (
- "eodag:order_link" in product.properties
- and "collection" in product.properties["eodag:order_link"]
- and "order" not in product.properties["eodag:order_link"]
- ):
- if product.collection is None:
- msg = (
- f"Cannot build order link for "
- f"{product}: collection is undefined"
- )
- raise MisconfiguredError(msg)
- product.properties["eodag:order_link"] = product.properties[
- "eodag:order_link"
- ].replace("collection", product.collection)
return normalized
def collect_search_urls(
=====================================
eodag/resources/ext_collections.json
=====================================
The diff for this file was not included because it is too large.
=====================================
eodag/resources/providers/planetary_computer.yml
=====================================
@@ -49,8 +49,8 @@ planetary_computer:
auth:
type: SASAuth
matching_url: https://[-\w\.]+.blob.core.windows.net
- auth_uri: 'https://planetarycomputer.microsoft.com/api/sas/v1/sign?href={url}'
- signed_url_key: href
+ auth_uri: 'https://planetarycomputer.microsoft.com/api/sas/v1/token/{_collection}'
+ signed_url_key: token
ssl_verify: true
headers:
Ocp-Apim-Subscription-Key: "{apikey}"
=====================================
eodag/resources/providers/wekeo_main.yml
=====================================
@@ -8,23 +8,22 @@ wekeo_main:
# anchors to avoid duplications
anchor_s1_sar: &s1_sar_params
processing:level:
- - '{{"processingLevel": "{processing:level}"}}'
- - '$.id.`sub(/^[^_]([^_]+)_([^_]+)_([^_]+)_([0-4]+).*/, LEVEL\\4)`'
+ - '{{"processingLevel": "{processing:level#replace_str("L", "LEVEL")}"}}'
+ - '$.id.`sub(/^[^_]([^_]+)_([^_]+)_([^_]+)_([0-4]+).*/, L\\4)`'
platform:
- - '{{"platformSerialIdentifier": "{platform}"}}'
- - '$.null'
- instruments:
- - '{{"instrumentShortName": "{instruments#csv_list}"}}'
- - '$.null'
+ - '{{"platformSerialIdentifier": "{platform#replace_str_tuple(("sentinel-1a", "A"), ("sentinel-1b", "B"), ("sentinel-1c", "C"))}"}}'
+ - '{$.properties.metadata.platformSerialIdentifier#replace_str_tuple(("A", "sentinel-1a"), ("B", "sentinel-1b"), ("C", "sentinel-1c"))}'
+ instruments: '{$.properties.metadata.instrumentShortName#split(,)}'
order:status:
- '{{"online": "{order:status#get_group_name((?P<true>succeeded)|(?P<false>orderable))}"}}'
- - '$.null'
+ - 'orderable'
sar:polarizations:
- '{{"polarisationChannels": "{sar:polarizations#csv_list(%26)}"}}'
- - '$.null'
+ - '{$.properties.metadata.polarisationChannels#split(&)}'
sat:relative_orbit:
- '{{"relativeOrbitNumber": "{sat:relative_orbit}"}}'
- - '$.null'
+ - '$.properties.metadata.relativeOrbitNumber'
+ constellation: '$.properties.metadata.platformShortName'
anchor_orbit_cycle: &orbit_cycle
sat:relative_orbit:
- '{{"relativeOrbitNumber": "{sat:relative_orbit}"}}'
@@ -35,14 +34,6 @@ wekeo_main:
sat:orbit_cycle:
- '{{"cycle": "{sat:orbit_cycle}"}}'
- '$.null'
- anchor_id_from_date: &id_from_date
- id:
- - |
- {{
- "startdate": "{id#replace_str(r'^.*_([0-9][0-9][0-9][0-9])([0-9][0-9])([0-9][0-9])([0-9][0-9])([0-9][0-9])_.*$',r'\1-\2-\3T\4%3A\5%3A00Z')}",
- "enddate": "{id#replace_str(r'^.*_([0-9][0-9][0-9][0-9])([0-9][0-9])([0-9][0-9])([0-9][0-9])([0-9][0-9])_.*$',r'\1-\2-\3T\4%3A\5%3A00Z')}"
- }}
- - '{$.id#remove_extension}'
search:
type: WekeoSearch
api_endpoint: https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search
@@ -96,16 +87,16 @@ wekeo_main:
order:status: 'orderable'
processing:level:
- '{{"processingLevel": "{processing:level}"}}'
- - '$.null'
+ - '$.properties.metadata.processingLevel'
product:type:
- '{{"productType": "{product:type}"}}'
- - '$.null'
+ - '$.properties.metadata.productType'
product:timeliness:
- '{{"timeliness": "{product:timeliness}"}}'
- - '$.null'
+ - '$.properties.metadata.timeliness'
sat:orbit_state:
- - '{{"orbitDirection": "{sat:orbit_state}"}}'
- - '$.null'
+ - '{{"orbitDirection": "{sat:orbit_state#to_upper}"}}'
+ - '{$.properties.metadata.orbitDirection#to_lower}'
variable:
- '{{"variable": "{variable}"}}'
- '$.null'
@@ -170,81 +161,90 @@ wekeo_main:
S2_MSI_L1C:
_collection: EO:ESA:DAT:SENTINEL-2
product:type: S2MSI1C
- processing:level: S2MSI1C
+ processing:level: L1
metadata_mapping:
+ id:
+ - |
+ {{
+ "startdate": "{id#replace_str(r'^.*_([0-9][0-9][0-9][0-9])([0-9][0-9])([0-9][0-9])T([0-9][0-9])([0-9][0-9])([0-9][0-9])_.*$',r'\1-\2-\3T00%3A00%3A00Z')}",
+ "enddate": "{id#replace_str(r'^.*_([0-9][0-9][0-9][0-9])([0-9][0-9])([0-9][0-9])T([0-9][0-9])([0-9][0-9])([0-9][0-9])_.*$',r'\1-\2-\3T23%3A59%3A59Z')}"
+ }}
+ - '{$.id#remove_extension}'
processing:level:
- - '{{"processingLevel": "{processing:level}"}}'
- - '$.id.`sub(/^[^_]([^_]+)_([^_]+)_.*/, S2\\2)`'
+ - '{{"processingLevel": "{processing:level#replace_str_tuple(("L1", "S2MSI1C"), ("L2", "S2MSI2A"))}"}}'
+ - '{$.properties.metadata.processingLevel#replace_str_tuple(("S2MSI1C", "L1"), ("S2MSI2A", "L2"))}'
platform:
- - '{{"platformSerialIdentifier": "{platform}"}}'
- - '$.null'
+ - '{{"platformSerialIdentifier": "{platform#replace_str_tuple(("sentinel-2a", "A"), ("sentinel-2b", "B"), ("sentinel-2c", "C"))}"}}'
+ - '{$.properties.metadata.platformSerialIdentifier#replace_str_tuple(("A", "sentinel-2a"), ("B", "sentinel-2b"), ("C", "sentinel-2c"))}'
instruments:
- '{{"instrumentShortName": "{instruments#csv_list}"}}'
- - '$.null'
+ - '{$.properties.metadata.instrumentShortName#split(,)}'
sat:relative_orbit:
- '{{"relativeOrbitNumber": "{sat:relative_orbit}"}}'
- - '$.null'
+ - '$.properties.metadata.relativeOrbitNumber'
+ constellation: '$.properties.metadata.platformShortName'
eo:cloud_cover:
- '{{"cloudCover": "{eo:cloud_cover}"}}'
- - '$.null'
+ - '$.properties.metadata.cloudCover'
eodag:order_link: 'https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/download?{{"location": "{eodag:download_link}","product_id":"{id}.SAFE", "dataset_id": "EO:ESA:DAT:SENTINEL-2"}}'
S2_MSI_L2A:
_collection: EO:ESA:DAT:SENTINEL-2
product:type: S2MSI2A
- processing:level: S2MSI2A
+ processing:level: L2
metadata_mapping_from_product: S2_MSI_L1C
S3_LAN_HY:
_collection: EO:ESA:DAT:SENTINEL-3
product:type: SR_2_LAN_HY
- processing:level: 2
+ processing:level: L2
metadata_mapping:
id:
- |
{{
- "startdate": "{id#replace_str(r'^.*[A-Z]_([0-9][0-9][0-9][0-9])([0-9][0-9])([0-9][0-9])T([0-9][0-9])([0-9][0-9])([0-9][0-9])_.*$',r'\1-\2-\3T\4%3A00%3A00Z')}",
- "enddate": "{id#replace_str(r'^.*[A-Z]_([0-9][0-9][0-9][0-9])([0-9][0-9])([0-9][0-9])T([0-9][0-9])([0-9][0-9])([0-9][0-9])_.*$',r'\1-\2-\3T\4%3A59%3A59Z')}"
+ "startdate": "{id#replace_str(r'^.*[A-Z_]_([0-9][0-9][0-9][0-9])([0-9][0-9])([0-9][0-9])T([0-9][0-9])([0-9][0-9])([0-9][0-9])_.*$',r'\1-\2-\3T\4%3A00%3A00Z')}",
+ "enddate": "{id#replace_str(r'^.*[A-Z_]_([0-9][0-9][0-9][0-9])([0-9][0-9])([0-9][0-9])T([0-9][0-9])([0-9][0-9])([0-9][0-9])_.*$',r'\1-\2-\3T\4%3A59%3A59Z')}"
}}
- '{$.id#remove_extension}'
processing:level:
- - '{{"processingLevel": "{processing:level}"}}'
- - '2'
+ - '{{"processingLevel": "{processing:level#replace_str("L", "")}"}}'
+ - 'L2'
platform:
- - '{{"platformSerialIdentifier": "{platform}"}}'
- - '$.null'
+ - '{{"platformSerialIdentifier": "{platform#replace_str_tuple(("sentinel-3a", "A"), ("sentinel-3b", "B"), ("sentinel-3c", "C"))}"}}'
+ - '{$.properties.metadata.platformSerialIdentifier#replace_str_tuple(("A", "sentinel-3a"), ("B", "sentinel-3b"), ("C", "sentinel-3c"))}'
instruments:
- '{{"instrumentShortName": "{instruments#csv_list}"}}'
- - '$.null'
+ - '{$.properties.metadata.instrumentShortName#split(,)}'
sat:relative_orbit:
- '{{"relativeOrbitNumber": "{sat:relative_orbit}"}}'
- - '$.null'
+ - '$.properties.metadata.relativeOrbitNumber'
+ constellation: '$.properties.metadata.platformShortName'
eo:cloud_cover:
- '{{"cloudCover": "{eo:cloud_cover}"}}'
- - '$.null'
+ - '$.properties.metadata.cloudCover'
eodag:order_link: 'https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/download?{{"location": "{eodag:download_link}","product_id":"{id}.SEN3", "dataset_id": "EO:ESA:DAT:SENTINEL-3"}}'
S3_LAN_SI:
_collection: EO:ESA:DAT:SENTINEL-3
product:type: SR_2_LAN_SI
- processing:level: 2
+ processing:level: L2
metadata_mapping_from_product: S3_LAN_HY
S3_LAN_LI:
_collection: EO:ESA:DAT:SENTINEL-3
product:type: SR_2_LAN_LI
- processing:level: 2
+ processing:level: L2
metadata_mapping_from_product: S3_LAN_HY
S3_OLCI_L2LFR:
_collection: EO:ESA:DAT:SENTINEL-3
product:type: OL_2_LFR___
- processing:level: 2
+ processing:level: L2
metadata_mapping_from_product: S3_LAN_HY
S3_OLCI_L2LRR:
_collection: EO:ESA:DAT:SENTINEL-3
product:type: OL_2_LRR___
- processing:level: 2
+ processing:level: L2
metadata_mapping_from_product: S3_LAN_HY
S3_SLSTR_L2:
_collection: EO:ESA:DAT:SENTINEL-3
product:type: SL_2_LST___
- processing:level: 2
+ processing:level: L2
metadata_mapping_from_product: S3_LAN_HY
S3_EFR:
_collection: EO:EUM:DAT:SENTINEL-3:OL_1_EFR___
@@ -310,20 +310,27 @@ wekeo_main:
eodag:order_link: 'https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/download?{{"location": "{eodag:download_link}","product_id":"{id}.SEN3", "dataset_id": "EO:EUM:DAT:SENTINEL-3:SR_2_WAT___"}}'
S5P_L1B_IR_ALL:
_collection: EO:ESA:DAT:SENTINEL-5P
- processing:level: L1b
+ processing:level: L1
metadata_mapping:
+ id:
+ - |
+ {{
+ "startdate": "{id#replace_str(r'^.*[A-Z]_([0-9][0-9][0-9][0-9])([0-9][0-9])([0-9][0-9])T([0-9][0-9])([0-9][0-9])([0-9][0-9])_.*$',r'\1-\2-\3T\4%3A00%3A00Z')}",
+ "enddate": "{id#replace_str(r'^.*[A-Z]_([0-9][0-9][0-9][0-9])([0-9][0-9])([0-9][0-9])T([0-9][0-9])([0-9][0-9])([0-9][0-9])_.*$',r'\1-\2-\3T\4%3A59%3A59Z')}"
+ }}
+ - '{$.id#remove_extension}'
processing:level:
- - '{{"processingLevel": "{processing:level}"}}'
+ - '{{"processingLevel": "{processing:level#replace_str("L1", "L1b")}"}}'
- '$.id.`sub(/^[^_]([^_]+)_([^_]+)_([^_]+)_.*/, \\3)`'
instruments:
- '{{"instrumentShortName": "{instruments#csv_list}"}}'
- - '$.null'
+ - '{$.properties.metadata.instrumentShortName#split(,)}'
processing_mode:
- '{{"processingMode": "{processing_mode}"}}'
- - '$.null'
+ - '$.properties.metadata.processingMode'
sat:absolute_orbit:
- '{{"orbitNumber": "{sat:absolute_orbit}"}}'
- - '$.null'
+ - '$.properties.metadata.orbitNumber'
eodag:order_link: 'https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/download?{{"location": "{eodag:download_link}","product_id":"{id}.nc", "dataset_id": "EO:ESA:DAT:SENTINEL-5P"}}'
S5P_L2_IR_ALL:
_collection: EO:ESA:DAT:SENTINEL-5P
@@ -381,10 +388,10 @@ wekeo_main:
- '$.null'
instruments:
- '{{"instrumentShortName": "{instruments#csv_list}"}}'
- - '$.null'
+ - '{$.properties.metadata.instrumentShortName#split(,)}'
format:
- '{{"fileFormat": "{format}"}}'
- - '$.null'
+ - '$.properties.metadata.fileFormat'
eodag:order_link: 'https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/download?{{"location": "{eodag:download_link}","product_id":"{id}", "dataset_id": "EO:JRC:DAT:CLMS"}}'
CLMS_GLO_NDVI_1KM_LTS:
_collection: EO:JRC:DAT:CLMS
@@ -415,7 +422,7 @@ wekeo_main:
CLMS_GLO_DMP_333M:
_collection: EO:JRC:DAT:CLMS
product:type: dry-gross_dry_matter_productivity
- _dataset_identifier: dry-gross_dry_matter_productivity/dmp_global_300m_10daily_v1
+ _dataset_identifier: dmp_global_300m_10daily_v1
metadata_mapping_from_product: CLMS_GLO_NDVI_333M
CLMS_GLO_GDMP_333M:
_collection: EO:JRC:DAT:CLMS
=====================================
pyproject.toml
=====================================
@@ -40,8 +40,7 @@ dependencies = [
"click",
"geojson",
"importlib_metadata >= 5.0",
- # temporarily pinned, see https://github.com/h2non/jsonpath-ng/issues/216
- "jsonpath-ng < 1.8.0",
+ "jsonpath-ng != 1.8.0",
"lxml",
"orjson",
"pydantic >= 2.13.0",
@@ -212,7 +211,7 @@ include = ["eodag*"]
"*" = ["LICENSE", "NOTICE", "py.typed"]
[tool.setuptools_scm]
-fallback_version = "4.9.2.dev0"
+fallback_version = "4.9.3.dev0"
[tool.isort]
multi_line_output = 3
=====================================
tests/units/test_auth_plugins.py
=====================================
@@ -18,6 +18,7 @@
import datetime as dt
import pickle
+import time
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
@@ -26,6 +27,7 @@ from unittest import mock
import boto3
import requests
import responses
+from concurrent.futures import ThreadPoolExecutor
from mypy_boto3_s3.service_resource import BucketObjectsCollection
from pystac.utils import now_in_utc
from requests import Request, Response, Timeout
@@ -34,6 +36,7 @@ from requests.exceptions import RequestException
from eodag.api.product._product import EOProduct
from eodag.api.provider import ProvidersDict
+from eodag.plugins.authentication import sas_auth
from eodag.plugins.authentication.eoiam import _EOIAMSessionAuth
from eodag.utils import MockResponse
from tests.context import (
@@ -1720,7 +1723,10 @@ class TestAuthPluginSASAuth(BaseAuthPluginTest):
providers = ProvidersDict.from_configs(
{
"foo_provider": {
- "products": {"foo_product": {}},
+ "products": {
+ "foo_product": {"_collection": "landsat-c2-l2"},
+ },
+ "download": {"type": "HTTPDownload"},
"auth": {
"type": "SASAuth",
"auth_uri": "http://foo.bar?href={url}",
@@ -1734,6 +1740,11 @@ class TestAuthPluginSASAuth(BaseAuthPluginTest):
)
cls.plugins_manager = PluginManager(providers)
+ def setUp(self):
+ super().setUp()
+ sas_auth._SAS_TOKENS.clear()
+ self.addCleanup(sas_auth._SAS_TOKENS.clear)
+
def test_plugins_auth_sasauth_validate_credentials_ok(self):
"""SASAuth.validate_credentials must be ok on empty or non-empty credentials"""
auth_plugin = self.get_auth_plugin("foo_provider")
@@ -1743,7 +1754,32 @@ class TestAuthPluginSASAuth(BaseAuthPluginTest):
auth_plugin.config.credentials = {"apikey": "foo"}
auth_plugin.validate_config_credentials()
- @mock.patch("eodag.plugins.authentication.sas_auth.requests.get", autospec=True)
+ @mock.patch(
+ "eodag.plugins.authentication.sas_auth.requests.Session.get", autospec=True
+ )
+ def test_plugins_auth_sasauth_uses_configured_retries(self, mock_requests_get):
+ auth_plugin = self.get_auth_plugin("foo_provider")
+ auth_plugin.config.retry_total = 5
+ auth_plugin.config.retry_backoff_factor = 3
+ auth_plugin.config.retry_status_forcelist = [429, 503]
+ mock_requests_get.return_value = mock.Mock()
+ mock_requests_get.return_value.json.return_value = {
+ "href": "https://example.com/blob?sig=token"
+ }
+
+ with mock.patch(
+ "eodag.plugins.authentication.sas_auth.HTTPAdapter"
+ ) as mock_http_adapter:
+ auth_plugin.authenticate()(mock.Mock(headers={}, url="url"))
+
+ retries = mock_http_adapter.call_args.kwargs["max_retries"]
+ self.assertEqual(retries.total, 5)
+ self.assertEqual(retries.backoff_factor, 3)
+ self.assertEqual(retries.status_forcelist, [429, 503])
+
+ @mock.patch(
+ "eodag.plugins.authentication.sas_auth.requests.Session.get", autospec=True
+ )
def test_plugins_auth_sasauth_text_token_authenticate_with_credentials(
self, mock_requests_get
):
@@ -1774,11 +1810,13 @@ class TestAuthPluginSASAuth(BaseAuthPluginTest):
# check SAS get request call arguments
args, kwargs = mock_requests_get.call_args
- self.assertEqual(args[0], auth_plugin.config.auth_uri.format(url=url))
+ self.assertEqual(args[1], auth_plugin.config.auth_uri.format(url=url))
auth_plugin_headers = {"Ocp-Apim-Subscription-Key": "foo"}
self.assertDictEqual(kwargs["headers"], dict(auth_plugin_headers, **USER_AGENT))
- @mock.patch("eodag.plugins.authentication.sas_auth.requests.get", autospec=True)
+ @mock.patch(
+ "eodag.plugins.authentication.sas_auth.requests.Session.get", autospec=True
+ )
def test_plugins_auth_sasauth_text_token_authenticate_without_credentials(
self, mock_requests_get
):
@@ -1808,11 +1846,181 @@ class TestAuthPluginSASAuth(BaseAuthPluginTest):
# check SAS get request call arguments
args, kwargs = mock_requests_get.call_args
- self.assertEqual(args[0], auth_plugin.config.auth_uri.format(url=url))
+ self.assertEqual(args[1], auth_plugin.config.auth_uri.format(url=url))
# check if headers only has the user agent as a request call argument
self.assertEqual(kwargs["headers"], USER_AGENT)
- @mock.patch("eodag.plugins.authentication.sas_auth.requests.get", autospec=True)
+ @mock.patch(
+ "eodag.plugins.authentication.sas_auth.requests.Session.get", autospec=True
+ )
+ def test_plugins_auth_sasauth_reuses_token_for_blobs_in_container(
+ self, mock_requests_get
+ ):
+ auth_plugin = self.get_auth_plugin("foo_provider")
+ mock_requests_get.return_value = mock.Mock()
+ mock_requests_get.return_value.json.return_value = {
+ "href": "https://account.blob.core.windows.net/container/blob1?sig=token"
+ }
+
+ auth = auth_plugin.authenticate()
+ first_request = mock.Mock(
+ headers={},
+ url="https://account.blob.core.windows.net/container/blob1",
+ )
+ second_request = mock.Mock(
+ headers={},
+ url="https://account.blob.core.windows.net/container/blob2",
+ )
+
+ auth(first_request)
+ auth(second_request)
+
+ self.assertEqual(
+ first_request.url,
+ "https://account.blob.core.windows.net/container/blob1?sig=token",
+ )
+ self.assertEqual(
+ second_request.url,
+ "https://account.blob.core.windows.net/container/blob2?sig=token",
+ )
+ mock_requests_get.assert_called_once()
+
+ @mock.patch(
+ "eodag.plugins.authentication.sas_auth.requests.Session.get", autospec=True
+ )
+ def test_plugins_auth_sasauth_shares_token_between_auth_instances(
+ self, mock_requests_get
+ ):
+ auth_plugin = self.get_auth_plugin("foo_provider")
+ mock_requests_get.return_value = mock.Mock()
+ mock_requests_get.return_value.json.return_value = {
+ "href": "https://account.blob.core.windows.net/container/blob1?sig=token"
+ }
+ url = "https://account.blob.core.windows.net/container/blob{}"
+
+ for i in range(3):
+ req = mock.Mock(headers={}, url=url.format(i))
+ auth_plugin.authenticate()(req)
+ self.assertEqual(req.url, url.format(i) + "?sig=token")
+
+ mock_requests_get.assert_called_once()
+
+ @mock.patch(
+ "eodag.plugins.authentication.sas_auth.requests.Session.get", autospec=True
+ )
+ def test_plugins_auth_sasauth_concurrent_requests_fetch_token_once(
+ self, mock_requests_get
+ ):
+ auth_plugin = self.get_auth_plugin("foo_provider")
+ mock_requests_get.side_effect = lambda *a, **k: (
+ time.sleep(0.05)
+ or mock.Mock(
+ json=lambda: {
+ "href": "https://account.blob.core.windows.net/container/b?sig=t"
+ }
+ )
+ )
+ url = "https://account.blob.core.windows.net/container/blob{}"
+
+ def call_auth(i):
+ req = mock.Mock(headers={}, url=url.format(i))
+ auth_plugin.authenticate()(req)
+ return req.url
+
+ with ThreadPoolExecutor(max_workers=8) as executor:
+ urls = list(executor.map(call_auth, range(8)))
+
+ self.assertEqual(urls, [url.format(i) + "?sig=t" for i in range(8)])
+ mock_requests_get.assert_called_once()
+
+ @mock.patch(
+ "eodag.plugins.authentication.sas_auth.requests.Session.get", autospec=True
+ )
+ def test_plugins_auth_sasauth_refreshes_expired_token(self, mock_requests_get):
+ auth_plugin = self.get_auth_plugin("foo_provider")
+ expired = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat()
+ mock_requests_get.return_value = mock.Mock()
+ mock_requests_get.return_value.json.return_value = {
+ "href": "https://account.blob.core.windows.net/container/b?sig=t",
+ "msft:expiry": expired,
+ }
+ url = "https://account.blob.core.windows.net/container/blob"
+
+ auth_plugin.authenticate()(mock.Mock(headers={}, url=url))
+ auth_plugin.authenticate()(mock.Mock(headers={}, url=url))
+
+ self.assertEqual(mock_requests_get.call_count, 2)
+
+ @mock.patch(
+ "eodag.plugins.authentication.sas_auth.requests.Session.get", autospec=True
+ )
+ def test_plugins_auth_sasauth_requests_collection_token(self, mock_requests_get):
+ mock_requests_get.return_value = mock.Mock()
+ mock_requests_get.return_value.json.return_value = {"token": "sig=token"}
+ product = EOProduct(
+ "foo_provider",
+ {"id": "test-item"},
+ collection="foo_product",
+ )
+ product.assets["data"] = {
+ "href": "https://account.blob.core.windows.net/landsat-c2/asset.tif"
+ }
+ download_plugin = self.plugins_manager.get_download_plugin(product)
+ auth_plugin = self.plugins_manager.get_auth_plugin(download_plugin, product)
+ self.assertIsNotNone(auth_plugin)
+ req = mock.Mock(
+ headers={},
+ url="https://account.blob.core.windows.net/landsat-c2/asset.tif",
+ )
+
+ with (
+ mock.patch.object(
+ auth_plugin.config,
+ "auth_uri",
+ "https://auth.example/token/{_collection}",
+ ),
+ mock.patch.object(auth_plugin.config, "signed_url_key", "token"),
+ ):
+ auth_plugin.authenticate()(req)
+
+ self.assertEqual(
+ mock_requests_get.call_args.args[1],
+ "https://auth.example/token/landsat-c2-l2",
+ )
+ self.assertEqual(
+ req.url,
+ "https://account.blob.core.windows.net/landsat-c2/asset.tif?sig=token",
+ )
+
+ @mock.patch(
+ "eodag.plugins.authentication.sas_auth.requests.Session.get", autospec=True
+ )
+ def test_plugins_auth_sasauth_skips_unmatched_url(self, mock_requests_get):
+ auth_plugin = self.get_auth_plugin("foo_provider")
+ with mock.patch.object(
+ auth_plugin.config,
+ "matching_url",
+ r"https://[-\w\.]+.blob.core.windows.net",
+ create=True,
+ ):
+ auth = auth_plugin.authenticate()
+
+ req = mock.Mock(
+ headers={},
+ url="https://planetarycomputer.microsoft.com/api/data/v1/item/preview.png",
+ )
+ auth(req)
+
+ self.assertEqual(
+ req.url,
+ "https://planetarycomputer.microsoft.com/api/data/v1/item/preview.png",
+ )
+ self.assertEqual(req.headers, {})
+ mock_requests_get.assert_not_called()
+
+ @mock.patch(
+ "eodag.plugins.authentication.sas_auth.requests.Session.get", autospec=True
+ )
def test_plugins_auth_sasauth_request_error(self, mock_requests_get):
"""SASAuth.authenticate must raise an AuthenticationError if an error occurs"""
auth_plugin = self.get_auth_plugin("foo_provider")
@@ -1830,8 +2038,15 @@ class TestAuthPluginSASAuth(BaseAuthPluginTest):
with self.assertRaises(AuthenticationError):
auth(req)
- def test_plugins_download_http_presign_url(self):
+ @mock.patch(
+ "eodag.plugins.authentication.sas_auth.requests.Session.get", autospec=True
+ )
+ def test_plugins_download_http_presign_url(self, mock_requests_get):
"""should create a presigned url to download via HTTP"""
+ mock_requests_get.return_value = mock.Mock()
+ mock_requests_get.return_value.json.return_value = {
+ "href": "http://foo.bar.com/b1/a1/a1.json?sig=t"
+ }
provider = "foo_provider"
collection = "LANDSAT_C2_L1"
product = EOProduct(
@@ -1848,10 +2063,11 @@ class TestAuthPluginSASAuth(BaseAuthPluginTest):
auth_plugin = self.get_auth_plugin("foo_provider")
url = auth_plugin.presign_url(product.assets["a1"])
- expected_url = "http://foo.bar?href={url}".format(
- url="http://foo.bar.com/b1/a1/a1.json"
+ self.assertEqual("http://foo.bar.com/b1/a1/a1.json?sig=t", url)
+ self.assertEqual(
+ mock_requests_get.call_args.args[1],
+ "http://foo.bar?href=http://foo.bar.com/b1/a1/a1.json",
)
- self.assertEqual(expected_url, url)
class TestAuthPluginKeycloakOIDCPasswordAuth(BaseAuthPluginTest):
=====================================
tests/units/test_crunch.py
=====================================
@@ -20,9 +20,9 @@ from collections import UserList
from typing import Any
from unittest import mock
-import shapely
from shapely import Polygon, geometry
from shapely.errors import ShapelyError
+from shapely.geometry.base import BaseGeometry
from eodag.api.product import EOProduct
from eodag.api.search_result import SearchResult
@@ -575,16 +575,15 @@ class TestPluginCrunch(unittest.TestCase):
self.assertEqual(len(filtered_result), 1)
# Force fail search_geom intersection
- def shapely_intersect_forced_exception(a, b, grid_size=None, **kwargs):
- print("Mocked: no shapely.intersection allowed")
- raise ShapelyError("No intersection allowed")
-
with mock.patch.object(
- shapely, "intersection", new=shapely_intersect_forced_exception
- ):
+ BaseGeometry,
+ "intersection",
+ side_effect=ShapelyError("No intersection allowed"),
+ ) as mocked_intersection:
filtered_result = search_results.crunch(
filter, geometry=geometry.box(10, 30, 20, 40)
)
+ mocked_intersection.assert_called_once()
self.assertEqual(len(filtered_result), 0)
def test_crunch_property(self):
View it on GitLab: https://salsa.debian.org/debian-gis-team/eodag/-/commit/146ee8d08c9cd44a6a559d79e8570a75823ac3e5
--
View it on GitLab: https://salsa.debian.org/debian-gis-team/eodag/-/commit/146ee8d08c9cd44a6a559d79e8570a75823ac3e5
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/20261008/b26213f9/attachment-0001.htm>
More information about the Pkg-grass-devel
mailing list