[Git][debian-gis-team/eodag][upstream] New upstream version 4.9.0+ds
Antonio Valentino (@antonio.valentino)
gitlab at salsa.debian.org
Wed Sep 16 20:46:44 BST 2026
Antonio Valentino pushed to branch upstream at Debian GIS Project / eodag
Commits:
0c00002c by Antonio Valentino at 2026-09-16T19:38:52+00:00
New upstream version 4.9.0+ds
- - - - -
10 changed files:
- CHANGES.rst
- eodag/api/product/_product.py
- eodag/plugins/download/http.py
- eodag/plugins/search/qssearch.py
- eodag/resources/stac_provider.yml
- pyproject.toml
- tests/units/test_download_plugins.py
- tests/units/test_eoproduct.py
- + tests/units/test_plugin_manager.py
- tests/units/test_search_plugins.py
Changes:
=====================================
CHANGES.rst
=====================================
@@ -3,6 +3,32 @@ Release history
===============
+v4.9.0 (2026-09-16)
+===================
+
+Features
+--------
+
+* **product**: Keep STAC item links in EOProduct (`#2352`_, `99473fb`_)
+
+Bug Fixes
+---------
+
+* **plugins**: Strip undesired headers from HTTPDownload.stream_download (`#2354`_, `37042c7`_)
+
+Testing
+-------
+
+* Add more PluginManager tests (`#2353`_, `14589c5`_)
+
+.. _#2352: https://github.com/CS-SI/eodag/pull/2352
+.. _#2353: https://github.com/CS-SI/eodag/pull/2353
+.. _#2354: https://github.com/CS-SI/eodag/pull/2354
+.. _14589c5: https://github.com/CS-SI/eodag/commit/14589c520213eb3f3990dae46c71cdd75bebe4a8
+.. _37042c7: https://github.com/CS-SI/eodag/commit/37042c7d913398fd1e0f71b2e89d1b9693247d2f
+.. _99473fb: https://github.com/CS-SI/eodag/commit/99473fb2756fbd3b5cef797ae157e03e3400b5d5
+
+
v4.8.0 (2026-09-11)
===================
=====================================
eodag/api/product/_product.py
=====================================
@@ -151,6 +151,8 @@ class EOProduct:
remote_location: str
#: Assets of the product
assets: AssetsDict
+ #: STAC item links of the product
+ links: list[dict[str, str]]
#: Driver enables additional methods to be called on the EOProduct
driver: DatasetDriver
#: Product data filename, stored during download
@@ -180,6 +182,7 @@ class EOProduct:
)
self.location = self.remote_location = properties.get("eodag:download_link", "")
self.assets = AssetsDict(self)
+ self.links = []
self.properties = {
key: value
for key, value in properties.items()
@@ -334,6 +337,8 @@ class EOProduct:
"href": f"{self.collection}.json",
"type": "application/json",
},
+ # replace the remote collection link with the serialized one
+ *(link for link in self.links if link.get("rel") != "collection"),
],
"stac_extensions": list(stac_extensions),
"stac_version": STAC_VERSION,
@@ -970,6 +975,14 @@ class EOProduct:
thumbnail_style = (
"style='padding-top: 1.5em; min-width:100px; vertical-align: top;'"
)
+ links_html = dict_to_html_table(
+ {f"[{i}] {link.get('rel', '')}": link for i, link in enumerate(self.links)},
+ depth=1,
+ )
+ links_details = (
+ "<details><summary style='color: grey; margin-top: 10px;'>"
+ f"links: ({len(self.links)})</summary>{links_html}</details>"
+ )
return f"""<table>
<thead><tr style='background-color: transparent;'><td style='text-align: left; color: grey;'>
@@ -994,6 +1007,7 @@ class EOProduct:
dict_to_html_table(self.properties, depth=1)}</details>
<details><summary style='color: grey; margin-top: 10px;'>assets: ({len(
self.assets)})</summary>{self.assets._repr_html_(embeded=True)}</details>
+ {links_details}
</td>
<td {geom_style} title='geometry'>geometry<br />{self.geometry._repr_svg_()}</td>
<td {thumbnail_style} title='properties["thumbnail"]'>{thumbnail_html}</td>
@@ -1061,6 +1075,7 @@ class EOProduct:
obj = cls(provider, properties, collection=collection)
obj.search_intersection = geometry.shape(search_intersection)
obj.assets.update(feature.get("assets", {}))
+ obj.links = feature.get("links", [])
if plugins_manager is not None:
# register
=====================================
eodag/plugins/download/http.py
=====================================
@@ -90,6 +90,21 @@ if TYPE_CHECKING:
logger = logging.getLogger("eodag.download.http")
+# hop-by-hop / transport-specific headers that must not be forwarded from the
+# provider's response to the eodag client
+EXCLUDED_RESPONSE_HEADERS = {
+ "connection",
+ "content-encoding",
+ "content-length",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade",
+}
+
class HTTPDownload(Download):
"""HTTPDownload plugin. Handles product download over HTTP protocol
@@ -1073,7 +1088,13 @@ class HTTPDownload(Download):
self._process_exception(None, product, ordered_message)
stream_size = self._check_stream_size(product) or None
- product.headers = product._stream.headers
+ product.headers = CaseInsensitiveDict(
+ {
+ k: v
+ for k, v in product._stream.headers.items()
+ if k.lower() not in EXCLUDED_RESPONSE_HEADERS
+ }
+ )
filename = self._check_product_filename(product)
content_type = product.headers.get("Content-Type")
guessed_content_type = (
=====================================
eodag/plugins/search/qssearch.py
=====================================
@@ -1326,6 +1326,8 @@ class QueryStringSearch(Search):
# batch-update once to avoid assets sort and clean up on every update
product.assets.update({**additional_assets, **normalized_assets})
+ # move links from properties to product's attr
+ product.links = product.properties.pop("links", [])
product._normalize_bands()
products.append(product)
return products
=====================================
eodag/resources/stac_provider.yml
=====================================
@@ -115,3 +115,5 @@ search:
order:status: '{$.null#replace_str("Not Available","succeeded")}'
# Normalization code moves assets from properties to product's attr
assets: '$.assets'
+ # Normalization code moves links from properties to product's attr
+ links: '$.links'
=====================================
pyproject.toml
=====================================
@@ -212,7 +212,7 @@ include = ["eodag*"]
"*" = ["LICENSE", "NOTICE", "py.typed"]
[tool.setuptools_scm]
-fallback_version = "4.8.1.dev0"
+fallback_version = "4.9.1.dev0"
[tool.isort]
multi_line_output = 3
=====================================
tests/units/test_download_plugins.py
=====================================
@@ -1061,6 +1061,57 @@ class TestDownloadPluginHttp(BaseDownloadPluginTest):
self.assertEqual(list(response.content), [b"first_chunk", b"second_chunk"])
self.assertEqual(response.headers, self.product.headers)
+ def test_stream_download_strips_hop_by_hop_headers(self):
+ """HTTPDownload.stream_download() must strip hop-by-hop headers but keep useful ones"""
+
+ plugin = self.get_download_plugin(self.product)
+ # plugin instances are cached per provider, remove any leftover mock from other tests
+ plugin.__dict__.pop("_raw_stream_download", None)
+
+ self.product.assets = mock.Mock()
+ self.product.assets.get_values.return_value = []
+ self.product.assets.__len__ = lambda self=self.product.assets: 0
+ self.product.location = self.product.remote_location = "http://somewhere"
+
+ fake_response = mock.Mock()
+ fake_response.headers = CaseInsensitiveDict(
+ {
+ # hop-by-hop headers that must be stripped
+ "Connection": "keep-alive",
+ "Content-Encoding": "br",
+ "Transfer-Encoding": "chunked",
+ "Keep-Alive": "timeout=5",
+ # useful headers that must be kept
+ "Content-Type": "application/octet-stream",
+ "Content-Disposition": 'attachment; filename="foo.zip"',
+ "ETag": '"abc123"',
+ "content-length": "12",
+ }
+ )
+ fake_response.status_code = 200
+ fake_response.url = "http://somewhere/foo.zip"
+ fake_response.raise_for_status = mock.Mock()
+ fake_response.iter_content = mock.Mock(return_value=iter([b"some_content"]))
+
+ with mock.patch(
+ "eodag.plugins.download.http.requests.Session.request",
+ return_value=fake_response,
+ ):
+ response = plugin.stream_download(self.product, output_dir=self.output_dir)
+
+ for excluded_header in (
+ "Connection",
+ "Content-Encoding",
+ "Transfer-Encoding",
+ "Keep-Alive",
+ ):
+ self.assertNotIn(excluded_header, response.headers)
+
+ self.assertEqual(response.headers["Content-Type"], "application/octet-stream")
+ self.assertEqual(response.headers["ETag"], '"abc123"')
+ # filename is derived from Content-Disposition and re-set by StreamResponse
+ self.assertIn("foo.zip", response.headers["Content-Disposition"])
+
def test_stream_download_product_empty_raises(self):
"""HTTPDownload.stream_download() must raise NotAvailableError if no asset and no product headers"""
=====================================
tests/units/test_eoproduct.py
=====================================
@@ -126,6 +126,59 @@ class TestEOProduct(EODagTestBase):
product = self._dummy_product(collection=self.NOT_ASSOCIATED_COLLECTION)
self.assertIsInstance(product.driver, DatasetDriver)
+ def test_eoproduct_links_from_geointerface(self):
+ """EOProduct links must be kept through serialization and deserialization"""
+ product = self._dummy_product()
+ product.links = [
+ {
+ "rel": "self",
+ "href": "https://example.com/items/1.json",
+ "type": "application/json",
+ },
+ {
+ "rel": "root",
+ "href": "https://example.com/catalog.json",
+ "type": "application/json",
+ },
+ ]
+
+ feature = geojson.loads(geojson.dumps(product))
+ self.assertEqual(feature["links"][-2:], product.links)
+
+ same_product = EOProduct.from_dict(geojson.loads(geojson.dumps(product)))
+ self.assertEqual(same_product.links, feature["links"])
+
+ def test_eoproduct_links_collection_link_replaced(self):
+ """The serialized collection link must replace the remote one"""
+ product = self._dummy_product()
+ product.links = [
+ {
+ "rel": "self",
+ "href": "https://example.com/items/1.json",
+ "type": "application/json",
+ },
+ {
+ "rel": "collection",
+ "href": "https://example.com/collections/1.json",
+ "type": "application/json",
+ },
+ {
+ "rel": "root",
+ "href": "https://example.com/catalog.json",
+ "type": "application/json",
+ },
+ ]
+
+ links = product.as_dict()["links"]
+
+ collection_links = [link for link in links if link["rel"] == "collection"]
+ self.assertEqual(len(collection_links), 1)
+ self.assertEqual(collection_links[0]["href"], f"{product.collection}.json")
+ self.assertNotIn(
+ "https://example.com/collections/1.json",
+ [link["href"] for link in links],
+ )
+
def test_eoproduct_geointerface(self):
"""EOProduct must provide a geo-interface with a set of specific properties"""
product = self._dummy_product()
@@ -695,6 +748,11 @@ class TestEOProduct(EODagTestBase):
asset_repr = html.fromstring(product.assets._repr_html_())
self.assertIn("Asset", asset_repr.xpath("//thead/tr/td")[0].text)
+ # links
+ product.links = [{"rel": "self", "href": "foo.href"}]
+ self.assertIn("foo.href", product._repr_html_())
+ self.assertIn("[0] self", product._repr_html_())
+
def test_eoproduct_assets_get_values(self):
"""eoproduct.assets.get_values must return the expected values"""
product = self._dummy_product()
=====================================
tests/units/test_plugin_manager.py
=====================================
@@ -0,0 +1,227 @@
+import unittest
+from types import SimpleNamespace
+from unittest import mock
+
+from tests.context import (
+ GENERIC_COLLECTION,
+ Authentication,
+ Download,
+ FilterDate,
+ MisconfiguredError,
+ PluginManager,
+ ProvidersDict,
+ UnsupportedProvider,
+)
+
+
+class TestPluginManager(unittest.TestCase):
+ def setUp(self):
+ self.providers = ProvidersDict.from_configs(
+ {
+ "low": {
+ "products": {"FOO": {"metadata_mapping": {"title": "$.title"}}},
+ "search": {
+ "type": "QueryStringSearch",
+ "api_endpoint": "https://low.example",
+ "metadata_mapping": {"title": "$.title"},
+ },
+ "priority": 1,
+ },
+ "high": {
+ "products": {"FOO": {"metadata_mapping": {"title": "$.title"}}},
+ "search": {
+ "type": "QueryStringSearch",
+ "api_endpoint": "https://high.example",
+ "metadata_mapping": {"title": "$.title"},
+ },
+ "priority": 2,
+ },
+ "download": {
+ "products": {"BAR": {}},
+ "download": {"type": "HTTPDownload"},
+ },
+ }
+ )
+ self.manager = PluginManager(self.providers)
+
+ def test_rebuild_replaces_provider_mapping_and_cache(self):
+ """Rebuilding replaces the provider map and clears cached plugins."""
+ self.manager._built_plugins_cache[("low", "Search", "")] = mock.sentinel.plugin
+
+ replacement = ProvidersDict.from_configs(
+ {
+ "replacement": {
+ "products": {"BAR": {}},
+ "search": {
+ "type": "QueryStringSearch",
+ "api_endpoint": "https://replacement.example",
+ "metadata_mapping": {"title": "$.title"},
+ },
+ }
+ }
+ )
+ self.manager.rebuild(replacement)
+
+ self.assertIs(self.manager.providers, replacement)
+ self.assertEqual(list(self.manager.collection_to_provider_config_map), ["BAR"])
+ self.assertEqual(self.manager._built_plugins_cache, {})
+
+ def test_build_collection_map_sorts_by_priority(self):
+ """Collection providers are ordered by descending priority."""
+ configs = self.manager.collection_to_provider_config_map["FOO"]
+
+ self.assertEqual([config.name for config in configs], ["high", "low"])
+
+ def test_get_skipped_plugin_messages(self):
+ """Skipped plugin messages are returned for configured plugin types."""
+ provider_config = self.providers["low"].config
+ provider_config.search.type = "MissingSearch"
+ self.manager.skipped_plugins = {"MissingSearch": "missing dependency"}
+
+ self.assertEqual(
+ self.manager.get_skipped_plugin_messages(provider_config),
+ ["missing dependency"],
+ )
+
+ def test_check_provider_available(self):
+ """Provider availability checks accept known and reject unknown providers."""
+ self.manager.check_provider_available("low")
+
+ with self.assertRaises(UnsupportedProvider):
+ self.manager.check_provider_available("unknown")
+
+ def test_get_search_plugins_uses_collection_and_priority(self):
+ """Search plugins use collection settings and priority ordering."""
+ plugins = list(self.manager.get_search_plugins(collection="FOO"))
+
+ self.assertEqual([plugin.provider for plugin in plugins], ["high", "low"])
+
+ def test_get_search_plugins_uses_generic_collection_fallback(self):
+ """Unsupported collections fall back to generic collection settings."""
+ generic_provider = ProvidersDict.from_configs(
+ {
+ "generic": {
+ "products": {
+ GENERIC_COLLECTION: {"metadata_mapping": {"title": "$.title"}}
+ },
+ "search": {
+ "type": "QueryStringSearch",
+ "api_endpoint": "https://generic.example",
+ "metadata_mapping": {"title": "$.title"},
+ },
+ }
+ }
+ )
+ manager = PluginManager(generic_provider)
+
+ plugins = list(manager.get_search_plugins(collection="missing"))
+
+ self.assertEqual([plugin.provider for plugin in plugins], ["generic"])
+
+ def test_get_search_plugins_rejects_provider_without_search_or_api(self):
+ """Search plugin construction fails for providers without a search plugin."""
+ providers = ProvidersDict.from_configs(
+ {
+ "broken": {
+ "products": {GENERIC_COLLECTION: {}},
+ "search": {
+ "type": "QueryStringSearch",
+ "api_endpoint": "https://broken.example",
+ "metadata_mapping": {"title": "$.title"},
+ },
+ }
+ }
+ )
+ manager = PluginManager(providers)
+ manager.providers["broken"].config.search = None
+
+ with self.assertRaisesRegex(MisconfiguredError, "No search plugin configured"):
+ list(manager.get_search_plugins(collection="FOO"))
+
+ @mock.patch.object(PluginManager, "_build_plugin")
+ def test_get_download_plugin_builds_download_plugin(self, build_plugin):
+ """Download selection builds the configured download plugin type."""
+ expected = mock.sentinel.download
+ build_plugin.return_value = expected
+ product = SimpleNamespace(provider="download")
+
+ self.assertIs(self.manager.get_download_plugin(product), expected)
+ build_plugin.assert_called_once()
+ self.assertIs(build_plugin.call_args.args[2], Download)
+
+ def test_get_download_plugin_rejects_unknown_provider(self):
+ """Download selection rejects products from unknown providers."""
+ with self.assertRaisesRegex(UnsupportedProvider, "Provider unknown not found"):
+ self.manager.get_download_plugin(SimpleNamespace(provider="unknown"))
+
+ @mock.patch.object(PluginManager, "_build_plugin")
+ def test_get_auth_plugins_matches_url(self, build_plugin):
+ """Authentication plugins match a configured URL pattern."""
+ auth_type = "TokenAuth"
+ matching_pattern = "provider-a"
+ matching_url = "provider-a-endpoint"
+ auth_config = SimpleNamespace(type=auth_type, matching_url=matching_pattern)
+ self.providers["low"].config.auth = auth_config
+ build_plugin.return_value = mock.sentinel.auth
+
+ plugins = list(self.manager.get_auth_plugins("low", matching_url=matching_url))
+
+ self.assertEqual(plugins, [mock.sentinel.auth])
+ build_plugin.assert_called_once_with("low", auth_config, Authentication)
+
+ @mock.patch.object(PluginManager, "get_auth_plugins")
+ def test_get_auth_plugin_uses_associated_plugin(self, get_auth_plugins):
+ """Associated plugin settings select the authentication plugin."""
+ associated_plugin = SimpleNamespace(
+ provider="low", config=SimpleNamespace(api_endpoint="https://low.example")
+ )
+ get_auth_plugins.return_value = iter([mock.sentinel.auth])
+
+ plugin = self.manager.get_auth_plugin(associated_plugin)
+
+ self.assertIs(plugin, mock.sentinel.auth)
+ get_auth_plugins.assert_called_once_with(
+ "low",
+ matching_url="https://low.example",
+ matching_conf=associated_plugin.config,
+ )
+
+ def test_get_crunch_plugin(self):
+ """Crunch plugin construction returns the requested plugin class."""
+ plugin = PluginManager.get_crunch_plugin("FilterDate", start="2024-01-01")
+
+ self.assertIsInstance(plugin, FilterDate)
+ self.assertEqual(plugin.config.start, "2024-01-01")
+
+ @mock.patch.object(PluginManager, "get_auth_plugins")
+ def test_get_auth_returns_first_successful_authentication(self, get_auth_plugins):
+ """Authentication continues until the first plugin succeeds."""
+ first = mock.Mock(spec=Authentication)
+ first.authenticate.side_effect = MisconfiguredError("not ready")
+ second = mock.Mock(spec=Authentication)
+ second.authenticate.return_value = mock.sentinel.authenticated
+ get_auth_plugins.return_value = iter([first, second])
+
+ result = self.manager.get_auth("low")
+
+ self.assertIs(result, mock.sentinel.authenticated)
+ first.authenticate.assert_called_once_with()
+ second.authenticate.assert_called_once_with()
+
+ @mock.patch.object(PluginManager, "get_auth_plugins", return_value=iter([]))
+ def test_get_auth_returns_none_when_no_plugin_matches(self, get_auth_plugins):
+ """Authentication returns None when no plugin matches."""
+ self.assertIsNone(self.manager.get_auth("low"))
+ get_auth_plugins.assert_called_once_with("low", None, None)
+
+ def test_set_priority_updates_configs_and_cached_plugins(self):
+ """Setting priority updates provider configuration and cached plugins."""
+ plugin = next(self.manager.get_search_plugins(provider="low"))
+ self.assertEqual(plugin.config.priority, 1)
+
+ self.manager.set_priority("low", 10)
+
+ self.assertEqual(plugin.priority, 10)
+ self.assertEqual(
+ self.manager.collection_to_provider_config_map["FOO"][1].priority, 2
+ )
=====================================
tests/units/test_search_plugins.py
=====================================
@@ -2361,6 +2361,52 @@ class TestSearchPluginStacSearch(BaseSearchPluginTest):
)
self.assertEqual(products[1].geometry.bounds, (-180.0, -90.0, 180.0, 90.0))
+ @mock.patch("eodag.plugins.search.qssearch.StacSearch._request", autospec=True)
+ def test_plugins_search_stacsearch_normalize_links(self, mock__request):
+ """STAC item links must be mapped to the product links attribute"""
+ geojson_geometry = self.search_criteria_s2_msi_l1c["geometry"].__geo_interface__
+ expected_links = [
+ {
+ "rel": "self",
+ "href": "https://example.com/items/1.json",
+ "type": "application/json",
+ },
+ {
+ "rel": "collection",
+ "href": "https://example.com/collections/1.json",
+ "type": "application/json",
+ },
+ {
+ "rel": "root",
+ "href": "https://example.com/catalog.json",
+ "type": "application/json",
+ },
+ ]
+ mock__request.return_value = mock.Mock()
+ mock__request.return_value.json.side_effect = [
+ {
+ "features": [
+ {
+ "id": "foo",
+ "geometry": geojson_geometry,
+ "properties": {
+ "s2:product_uri": "S2B_MSIL1C_20201009T012345_N0209_R008_T31TCJ_20201009T123456.SAFE",
+ },
+ "links": expected_links,
+ }
+ ],
+ },
+ ]
+
+ search_plugin = self.get_search_plugin(self.collection, "earth_search")
+ products = search_plugin.query(
+ prep=PreparedSearch(page=1, limit=1),
+ **self.search_criteria_s2_msi_l1c,
+ )
+
+ self.assertEqual(products[0].links, expected_links)
+ self.assertNotIn("links", products[0].properties)
+
@mock.patch("eodag.plugins.search.geodes.GeodesSearch._request", autospec=True)
@mock.patch(
"eodag.api.product.drivers.base.DatasetDriver.guess_asset_key_and_roles",
View it on GitLab: https://salsa.debian.org/debian-gis-team/eodag/-/commit/0c00002c04ba83d1b8777c94d4fbadc0ffd14e99
--
View it on GitLab: https://salsa.debian.org/debian-gis-team/eodag/-/commit/0c00002c04ba83d1b8777c94d4fbadc0ffd14e99
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/20260916/870075a8/attachment-0001.htm>
More information about the Pkg-grass-devel
mailing list