Repository: CS-SI/eodag
Pull request #1698 (2025-06-20 15:25:47)

fix(plugins): lru caching when fetching constraints with ECMWF
- custom decorator to handle cache per instance properly


eodag-bot: ![badge](https://raw.githubusercontent.com/CS-SI/eodag/_xml_coverage_reports_win/data/fix-cache/./badge.svg)


## Code Coverage (Windows)

<details>


```
Filename                                     Stmts    Miss  Cover    Missing
-----------------------------------------  -------  ------  -------  ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
__init__.py                                      8       0  100.00%
cache.py                                        22       0  100.00%
cli.py                                         323      64  80.19%   62-63, 87, 674-737, 839-890, 894
config.py                                      450      31  93.11%   80-82, 91, 99, 103-105, 176, 187, 204-205, 722-724, 853-856, 900-901, 910-911, 945, 1025, 1054, 1084-1089, 1091
crunch.py                                        5       5  0.00%    20-24
api/__init__.py                                  0       0  100.00%
api/core.py                                    812      80  90.15%   380-381, 431, 700, 731, 775-778, 816, 860, 894, 939-944, 970, 1061, 1129, 1352-1364, 1400, 1402, 1430, 1434-1445, 1458-1464, 1547-1550, 1583-1603, 1672-1677, 1689-1692, 1710-1722, 2029, 2053-2059, 2310, 2314-2318, 2327-2329, 2373-2374, 2403-2404
api/search_result.py                            56       4  92.86%   80, 89, 96, 110
api/product/__init__.py                          6       0  100.00%
api/product/_assets.py                          52       5  90.38%   97, 183, 191, 194-198
api/product/_product.py                        201      19  90.55%   70-72, 236-237, 312, 339, 440, 458, 482-485, 494-497, 552-555
api/product/metadata_mapping.py                708      63  91.10%   122-124, 222, 254-255, 312-324, 326, 337, 362, 402-403, 440, 461-464, 487, 495-496, 589-590, 614-615, 621-624, 639-640, 789, 840, 993, 1002-1006, 1023-1028, 1155, 1169-1189, 1209, 1214, 1366, 1380, 1405, 1451, 1512, 1535-1536, 1556-1560, 1576, 1584
api/product/drivers/__init__.py                 20       2  90.00%   33-36
api/product/drivers/base.py                     29       1  96.55%   98
api/product/drivers/generic.py                   7       0  100.00%
api/product/drivers/sentinel1.py                15       0  100.00%
api/product/drivers/sentinel2.py                15       0  100.00%
plugins/__init__.py                              0       0  100.00%
plugins/base.py                                 21       3  85.71%   48, 55, 68
plugins/manager.py                             173      19  89.02%   105-110, 145-150, 154, 192, 214, 218, 242, 281-282, 382-385, 397-398
plugins/apis/__init__.py                         0       0  100.00%
plugins/apis/base.py                             4       0  100.00%
plugins/apis/ecmwf.py                           97      10  89.69%   165-167, 214-215, 241-243, 296-297
plugins/apis/usgs.py                           180      26  85.56%   156, 258, 292, 327-329, 334, 360-361, 366, 396-403, 414-419, 441-447, 478
plugins/authentication/__init__.py               6       1  83.33%   31
plugins/authentication/aws_auth.py              20       0  100.00%
plugins/authentication/base.py                  17       2  88.24%   45, 58
plugins/authentication/generic.py               16       3  81.25%   50, 55, 65
plugins/authentication/header.py                19       0  100.00%
plugins/authentication/keycloak.py              46       7  84.78%   153-156, 177-182
plugins/authentication/oauth.py                 13       7  46.15%   43-45, 49-52
plugins/authentication/openid_connect.py       231      28  87.88%   92-93, 105-123, 170, 176-204, 212, 351-354, 380, 421
plugins/authentication/qsauth.py                34       1  97.06%   91
plugins/authentication/sas_auth.py              47       1  97.87%   76
plugins/authentication/token.py                128       9  92.97%   180, 217, 289-290, 340-344
plugins/authentication/token_exchange.py        36      14  61.11%   75, 93-121
plugins/crunch/__init__.py                       0       0  100.00%
plugins/crunch/base.py                          10       1  90.00%   43
plugins/crunch/filter_date.py                   59      14  76.27%   52-57, 69, 78, 87, 90, 100-102, 109-111, 118
plugins/crunch/filter_latest_intersect.py       47      33  29.79%   49-54, 67-112
plugins/crunch/filter_latest_tpl_name.py        31       1  96.77%   83
plugins/crunch/filter_overlap.py                66      18  72.73%   28-30, 66-69, 76-79, 85, 93, 104-120
plugins/crunch/filter_property.py               30       5  83.33%   55-60, 63-64
plugins/download/__init__.py                     0       0  100.00%
plugins/download/aws.py                        528     158  70.08%   272, 285, 365-368, 382-386, 476-478, 482, 515-516, 522-526, 556, 589, 593, 600, 630-638, 642, 677-685, 696-698, 729-810, 828-886, 897-902, 955, 970-972, 975, 985-993, 1001-1015, 1025-1048, 1055-1068, 1106, 1132, 1177-1179, 1399
plugins/download/base.py                       256      44  82.81%   128, 156, 223-225, 236-239, 292-293, 341-345, 351-352, 394, 397-411, 423, 427, 491-495, 525-526, 551-559, 561-565, 615, 636, 658, 666
plugins/download/creodias_s3.py                 25       9  64.00%   55-69
plugins/download/http.py                       542      93  82.84%   228, 270-273, 334-337, 340, 347-352, 383-385, 402, 417, 471, 506, 520, 534, 542-546, 562-567, 578, 596, 632-635, 656, 666, 673, 693, 770, 789-800, 808-813, 823-840, 858, 888-897, 933, 958-959, 978-983, 992, 1007-1009, 1013, 1016, 1031-1032, 1067, 1129, 1144, 1203-1204, 1210, 1220, 1256, 1292, 1312, 1347-1349
plugins/download/s3rest.py                     120      26  78.33%   119, 153, 160, 195, 222-229, 232-234, 238, 249-255, 263-264, 267-271, 286-289, 298, 319-322
plugins/search/__init__.py                      22       0  100.00%
plugins/search/base.py                         151      11  92.72%   101, 105, 129-135, 276, 297, 436
plugins/search/build_search_result.py          477      63  86.79%   245-246, 282, 285, 310, 313, 344-346, 552-563, 690, 692, 753, 760, 784, 821, 872, 888, 922-937, 985, 1010, 1013, 1017, 1138-1139, 1148-1157, 1220, 1235, 1241, 1260-1269, 1386-1387, 1432, 1441-1443, 1503, 1549
plugins/search/cop_marine.py                   252      47  81.35%   56, 64-66, 76-77, 82, 87-88, 104, 106, 109, 175-176, 232, 238, 242, 246, 259, 270-271, 279, 310, 314, 335, 339, 343, 347, 351-355, 361-364, 367-381, 398, 447-451, 456, 468
plugins/search/creodias_s3.py                   25       1  96.00%   51
plugins/search/csw.py                          105      81  22.86%   98-99, 103-104, 112-160, 166-179, 187-219, 237-278
plugins/search/data_request_search.py          207      72  65.22%   190-193, 209, 220, 224-225, 236, 241, 246, 253, 266-269, 323-324, 328, 338-344, 349, 367-371, 387-390, 398-409, 426, 428, 435-438, 440-441, 459-463, 496, 506, 517, 530, 536-551, 556
plugins/search/qssearch.py                     710     100  85.92%   448, 506, 520, 524-530, 545-546, 558-562, 675-687, 732-735, 806-807, 858, 877, 884, 896, 953, 974, 977-978, 987-988, 997-998, 1007-1008, 1035, 1119-1124, 1128-1137, 1171, 1191, 1264-1265, 1271, 1361, 1458-1462, 1522, 1525, 1531-1532, 1553, 1581-1593, 1600, 1632-1634, 1644-1650, 1680, 1703, 1708-1709, 1724, 1740, 1815, 1827, 1834, 1845, 1868-1873, 1882, 1890-1900, 1918, 1934-1938
plugins/search/stac_list_assets.py              25      10  60.00%   44-51, 75-85
plugins/search/static_stac_search.py            79      13  83.54%   100-126, 170, 183
rest/__init__.py                                 4       2  50.00%   21-22
rest/cache.py                                   33      22  33.33%   35-37, 44-70
rest/config.py                                  25       1  96.00%   35
rest/constants.py                                6       0  100.00%
rest/core.py                                   260     153  41.15%   157, 159, 161, 164-165, 179-189, 198-199, 205, 208, 249-303, 316-352, 384-422, 437-453, 469-478, 485-524, 541, 583-668, 707, 714-767
rest/errors.py                                  72      51  29.17%   63, 68-111, 116-119, 126-129, 137-167, 175-180, 195-201
rest/server.py                                 192     192  0.00%    18-594
rest/stac.py                                   333      77  76.88%   246, 312, 334, 386-389, 416-443, 474-476, 499, 531-532, 614-654, 676-692, 719, 784-788, 795, 850-851, 857, 861-866, 876-880, 929, 967, 1000, 1019-1021
rest/types/__init__.py                           0       0  100.00%
rest/types/collections_search.py                13      13  0.00%    18-44
rest/types/eodag_search.py                     180      20  88.89%   225-229, 262-264, 282, 285, 291, 295, 353, 371-386
rest/types/queryables.py                        64      20  68.75%   54-55, 60-63, 68-79, 105-110, 119-120, 174
rest/types/stac_search.py                      125      11  91.20%   126-128, 174, 189-191, 199, 203, 251, 254
rest/utils/__init__.py                          94      30  68.09%   72-78, 98, 101-102, 121-123, 136, 143, 168-176, 183-204
rest/utils/cql_evaluate.py                      48       5  89.58%   69, 76, 90, 97, 105
rest/utils/rfc3339.py                           23       5  78.26%   43-44, 48, 60, 62
types/__init__.py                              161      41  74.53%   67, 80-84, 95-107, 135-137, 144-149, 214, 252, 262-278, 283, 285, 307, 312, 320, 330
types/bbox.py                                   43      19  55.81%   46-61, 72-74, 85-87, 99-101, 113-115, 123
types/download_args.py                           9       0  100.00%
types/queryables.py                            111       0  100.00%
types/search_args.py                            70      18  74.29%   60-64, 71-88, 103
types/whoosh.py                                 81      16  80.25%   129-132, 136-143, 155-161, 174-176
utils/__init__.py                              549      40  92.71%   81, 201-202, 211-238, 241, 256, 336-340, 413-417, 436-438, 452, 544, 559, 599-600, 629, 1004-1007, 1015-1016, 1054-1055, 1102-1103, 1243
utils/env.py                                     3       0  100.00%
utils/exceptions.py                             46       0  100.00%
utils/import_system.py                          28      19  32.14%   64-78, 89-99
utils/logging.py                                28       1  96.43%   41
utils/notebook.py                               44      23  47.73%   25-29, 36-41, 58-62, 72-78, 83-87
utils/repr.py                                   38       8  78.95%   51, 53, 57, 98, 122-129
utils/requests.py                               55      29  47.27%   51-52, 64, 85-96, 107-124, 128
utils/rest.py                                   36       1  97.22%   55
utils/s3.py                                     69       3  95.65%   162-163, 226
utils/stac_reader.py                           113      44  61.06%   63-85, 95-97, 101, 138, 154-159, 206-216, 226-256
TOTAL                                        10540    2069  80.37%
```

</details>


### Diff against develop

```
Filename                                 Stmts    Miss  Cover
-------------------------------------  -------  ------  --------
cache.py                                   +22       0  +100.00%
plugins/search/build_search_result.py       +1       0  +0.03%
TOTAL                                      +23       0  +0.04%
```


Results for commit: 31a7006ebe8a53e2dfc450adcfdc55b9f0930ba6


_Minimum allowed coverage is `70%`_


:recycle: This comment has been updated with latest results


<!-- Sticky Pull Request Commentartifacts/unit-test-results-python3.13-windows-latest/coverage.xml -->
github-actions[bot]: ## Test Results
    4 files  ±0      4 suites  ±0   5m 54s ⏱️ +4s
  643 tests +2    640 ✅ +2    3 💤 ±0  0 ❌ ±0 
2 572 runs  +8  2 470 ✅ +8  102 💤 ±0  0 ❌ ±0 

Results for commit 8f03fba8. ± Comparison against base commit d80eba79.

[test-results]:data:application/gzip;base64,H4sIAKp+VWgC/02Myw6DIBBFf8Ww7oIyINifaXgmpCoNj1XTfy9YRZfnzJ3zQc7PNqHHQG8DSsXnDqZEmX1YKwJrop5yO44UDnqmovWm8Kle/t1+unDSz1WcCxtjiLuJZW1NwjjZ6WgSynFX/+Yd99EluvG1qcOy+FwBCYfBKSlgMoYwrQxjcuIKJAcKhkmnjR0F1uj7A4Rzxo0JAQAA


Patch:
diff --git a/eodag/plugins/search/build_search_result.py b/eodag/plugins/search/build_search_result.py
index e8a678e1..db008784 100644
--- a/eodag/plugins/search/build_search_result.py
+++ b/eodag/plugins/search/build_search_result.py
@@ -17,7 +17,6 @@
 # limitations under the License.
 from __future__ import annotations
 
-import functools
 import hashlib
 import logging
 import re
@@ -61,6 +60,7 @@ from eodag.utils import (
     get_geometry_from_various,
     is_range_in_range,
 )
+from eodag.utils.cache import instance_cached_method
 from eodag.utils.exceptions import DownloadError, NotAvailableError, ValidationError
 from eodag.utils.requests import fetch_json
 
@@ -1099,6 +1099,7 @@ class ECMWFSearch(PostJsonSearch):
 
         return qp
 
+    @instance_cached_method()
     def _fetch_data(self, url: str) -> Any:
         """
         fetches from a provider elements like constraints or forms.
@@ -1115,7 +1116,7 @@ class ECMWFSearch(PostJsonSearch):
             else None
         )
         timeout = getattr(self.config, "timeout", DEFAULT_SEARCH_TIMEOUT)
-        return functools.lru_cache()(fetch_json)(url, auth=auth, timeout=timeout)
+        return fetch_json(url, auth=auth, timeout=timeout)
 
     def normalize_results(
         self, results: RawSearchResult, **kwargs: Any
diff --git a/eodag/utils/cache.py b/eodag/utils/cache.py
new file mode 100644
index 00000000..21d38041
--- /dev/null
+++ b/eodag/utils/cache.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+# Copyright 2025, CS GROUP - France, https://www.cs-soprasteria.com/
+#
+# This file is part of EODAG project
+#     https://www.github.com/CS-SI/EODAG
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# 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.
+import functools
+import logging
+from typing import Any, Callable, TypeVar
+
+logger = logging.getLogger("eodag.cache")
+
+R = TypeVar("R")
+
+
+def instance_cached_method(
+    maxsize: int = 128,
+) -> Callable[[Callable[..., R]], Callable[..., R]]:
+    """
+    Decorator to cache an instance method using functools.lru_cache on a per-instance basis.
+
+    This decorator creates a separate LRU cache for each instance of the class,
+    ensuring that cached results are not shared across instances.
+
+    The cache stores up to `maxsize` entries (default 128), which matches the
+    default cache size of functools.lru_cache. This default provides a good balance
+    between memory consumption and cache hit rate for most use cases.
+
+    :param maxsize: Maximum number of cached calls to store per instance.
+                    Defaults to 128, consistent with functools.lru_cache.
+    :return: Decorated method with per-instance caching enabled.
+    """
+
+    def decorator(method: Callable[..., R]) -> Callable[..., R]:
+        @functools.wraps(method)
+        def wrapper(self: Any, *args: Any, **kwargs: Any) -> R:
+            cache_name = f"_cached_{method.__name__}"
+            if not hasattr(self, cache_name):
+                cached = functools.lru_cache(maxsize=maxsize)(
+                    method.__get__(self, type(self))
+                )
+                setattr(self, cache_name, cached)
+
+            cached_func = getattr(self, cache_name)
+            before_hits = cached_func.cache_info().hits
+            result = cached_func(*args, **kwargs)
+            after_hits = cached_func.cache_info().hits
+
+            if after_hits > before_hits:
+                logger.debug(
+                    f"Cache hit for {method.__qualname__} with args={args}, kwargs={kwargs}"
+                )
+            return result
+
+        return wrapper
+
+    return decorator


Test patch:
diff --git a/tests/units/test_utils_cache.py b/tests/units/test_utils_cache.py
new file mode 100644
index 00000000..c17ae653
--- /dev/null
+++ b/tests/units/test_utils_cache.py
@@ -0,0 +1,87 @@
+# -*- coding: utf-8 -*-
+# Copyright 2025, CS GROUP - France, https://www.cs-soprasteria.com/
+#
+# This file is part of EODAG project
+#     https://www.github.com/CS-SI/EODAG
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# 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.
+import unittest
+
+from eodag.utils.cache import instance_cached_method
+
+
+class TestCachedMethodDecorator(unittest.TestCase):
+    def test_per_instance_caching(self):
+        class TestClass:
+            def __init__(self):
+                self.call_count = 0
+
+            @instance_cached_method(maxsize=2)
+            def method(self, x):
+                self.call_count += 1
+                return x * 2
+
+        obj1 = TestClass()
+        obj2 = TestClass()
+
+        # Call with same argument twice on obj1 - second should hit cache
+        result1 = obj1.method(10)
+        result2 = obj1.method(10)
+        self.assertEqual(result1, 20)
+        self.assertEqual(result2, 20)
+        self.assertEqual(
+            obj1.call_count, 1, "Method should be called once due to caching"
+        )
+
+        # Different argument - cache miss, increments call_count
+        result3 = obj1.method(5)
+        self.assertEqual(result3, 10)
+        self.assertEqual(obj1.call_count, 2)
+
+        # obj2 is a separate instance - cache is separate, so call_count should start fresh
+        result4 = obj2.method(10)
+        self.assertEqual(result4, 20)
+        self.assertEqual(obj2.call_count, 1)
+
+        # Calling again on obj2 with same arg hits cache
+        result5 = obj2.method(10)
+        self.assertEqual(result5, 20)
+        self.assertEqual(obj2.call_count, 1)
+
+    def test_cache_eviction(self):
+        class TestClass:
+            def __init__(self):
+                self.call_count = 0
+
+            @instance_cached_method(maxsize=2)
+            def method(self, x):
+                self.call_count += 1
+                return x
+
+        obj = TestClass()
+
+        obj.method(1)
+        obj.method(2)
+        self.assertEqual(obj.call_count, 2)
+
+        # Cache is full, next unique call evicts oldest
+        obj.method(3)
+        self.assertEqual(obj.call_count, 3)
+
+        # Calling with 1 again is a cache miss (evicted), so call_count increments
+        obj.method(1)
+        self.assertEqual(obj.call_count, 4)
+
+
+if __name__ == "__main__":
+    unittest.main()