[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