ee-client 2.4.0__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {ee_client-2.4.0 → ee_client-2.5.0}/PKG-INFO +1 -1
  2. {ee_client-2.4.0 → ee_client-2.5.0}/ee_client.egg-info/PKG-INFO +1 -1
  3. {ee_client-2.4.0 → ee_client-2.5.0}/ee_client.egg-info/SOURCES.txt +3 -0
  4. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/__init__.py +1 -1
  5. ee_client-2.5.0/eeclient/cache.py +114 -0
  6. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/client.py +4 -0
  7. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/data.py +33 -20
  8. {ee_client-2.4.0 → ee_client-2.5.0}/pyproject.toml +2 -2
  9. ee_client-2.5.0/tests/test_cache.py +253 -0
  10. {ee_client-2.4.0 → ee_client-2.5.0}/tests/test_data.py +0 -2
  11. ee_client-2.5.0/tests/test_integration_get_assets.py +84 -0
  12. {ee_client-2.4.0 → ee_client-2.5.0}/LICENSE +0 -0
  13. {ee_client-2.4.0 → ee_client-2.5.0}/README.rst +0 -0
  14. {ee_client-2.4.0 → ee_client-2.5.0}/ee_client.egg-info/dependency_links.txt +0 -0
  15. {ee_client-2.4.0 → ee_client-2.5.0}/ee_client.egg-info/requires.txt +0 -0
  16. {ee_client-2.4.0 → ee_client-2.5.0}/ee_client.egg-info/top_level.txt +0 -0
  17. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/exceptions.py +0 -0
  18. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/export/__init__.py +0 -0
  19. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/export/image.py +0 -0
  20. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/export/table.py +0 -0
  21. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/helpers.py +0 -0
  22. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/interfaces/__init__.py +0 -0
  23. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/interfaces/export.py +0 -0
  24. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/interfaces/operations.py +0 -0
  25. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/interfaces/tasks.py +0 -0
  26. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/models.py +0 -0
  27. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/oauth_app.py +0 -0
  28. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/sepal_credential_mixin.py +0 -0
  29. {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/tasks.py +0 -0
  30. {ee_client-2.4.0 → ee_client-2.5.0}/setup.cfg +0 -0
  31. {ee_client-2.4.0 → ee_client-2.5.0}/tests/test_client.py +0 -0
  32. {ee_client-2.4.0 → ee_client-2.5.0}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ee-client
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: extends the capabilities of the earthengine-api by providing custom session management and client interactions
5
5
  Author-email: Daniel Guerrero <dfgm2006@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/dfguerrerom/ee-client
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ee-client
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: extends the capabilities of the earthengine-api by providing custom session management and client interactions
5
5
  Author-email: Daniel Guerrero <dfgm2006@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/dfguerrerom/ee-client
@@ -7,6 +7,7 @@ ee_client.egg-info/dependency_links.txt
7
7
  ee_client.egg-info/requires.txt
8
8
  ee_client.egg-info/top_level.txt
9
9
  eeclient/__init__.py
10
+ eeclient/cache.py
10
11
  eeclient/client.py
11
12
  eeclient/data.py
12
13
  eeclient/exceptions.py
@@ -22,6 +23,8 @@ eeclient/interfaces/__init__.py
22
23
  eeclient/interfaces/export.py
23
24
  eeclient/interfaces/operations.py
24
25
  eeclient/interfaces/tasks.py
26
+ tests/test_cache.py
25
27
  tests/test_client.py
26
28
  tests/test_data.py
29
+ tests/test_integration_get_assets.py
27
30
  tests/test_models.py
@@ -1,6 +1,6 @@
1
1
  __title__ = "eeclient"
2
2
  __summary__ = "A client for Google Earth Engine"
3
- __version__ = "2.4.0"
3
+ __version__ = "2.5.0"
4
4
 
5
5
  __author__ = "Daniel Guerrero"
6
6
  __email__ = "dfgm2006@gmail.com"
@@ -0,0 +1,114 @@
1
+ import asyncio
2
+ import hashlib
3
+ import json
4
+ import time
5
+ from collections import OrderedDict
6
+ from typing import Any, Optional
7
+
8
+
9
+ class CacheEntry:
10
+ """Represents a cached response with TTL and task tracking."""
11
+
12
+ def __init__(
13
+ self,
14
+ value: Any = None,
15
+ error: Optional[Exception] = None,
16
+ task: Optional[asyncio.Task] = None,
17
+ ):
18
+ self.value = value
19
+ self.error = error
20
+ self.task = task
21
+ self.timestamp = time.time()
22
+
23
+ def is_expired(self, ttl: float) -> bool:
24
+ """Check if the cache entry has exceeded its TTL."""
25
+ return (time.time() - self.timestamp) > ttl
26
+
27
+ def get_result(self):
28
+ """Get the cached result or raise the cached error."""
29
+ if self.error:
30
+ raise self.error
31
+ return self.value
32
+
33
+
34
+ class ResponseCache:
35
+ """Simple LRU cache with TTL for async function responses.
36
+
37
+ Features:
38
+ - Fixed TTL (10 seconds by default)
39
+ - LRU eviction at max size (100 entries by default)
40
+ - Concurrent request deduplication (singleflight)
41
+ - Error caching to prevent retry storms
42
+ """
43
+
44
+ def __init__(self, ttl: float = 10.0, max_size: int = 100):
45
+ self.ttl = ttl
46
+ self.max_size = max_size
47
+ self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
48
+ self._lock = asyncio.Lock()
49
+
50
+ def make_cache_key(self, *args, **kwargs) -> str:
51
+ """Generate a stable cache key from function arguments."""
52
+ sorted_kwargs = sorted(kwargs.items())
53
+ key_data = {"args": args, "kwargs": sorted_kwargs}
54
+ key_json = json.dumps(key_data, sort_keys=True, default=str)
55
+ return hashlib.sha256(key_json.encode()).hexdigest()
56
+
57
+ async def get_or_fetch(self, key: str, fetch_func, *args, **kwargs) -> Any:
58
+ """Get cached result or fetch new one, with concurrent request deduplication.
59
+
60
+ Args:
61
+ key: Cache key
62
+ fetch_func: Async function to call if cache miss
63
+ *args, **kwargs: Arguments to pass to fetch_func
64
+
65
+ Returns:
66
+ The cached or freshly fetched result
67
+ """
68
+ # Check cache and get task reference if needed
69
+ async with self._lock:
70
+ if key in self._cache:
71
+ entry = self._cache[key]
72
+
73
+ # If there's an in-flight task, we'll wait for it
74
+ if entry.task and not entry.task.done():
75
+ in_flight_task = entry.task
76
+ elif not entry.is_expired(self.ttl):
77
+ # Valid cached result - return it
78
+ self._cache.move_to_end(key)
79
+ return entry.get_result()
80
+ else:
81
+ # Expired entry - remove it and fetch new
82
+ del self._cache[key]
83
+ in_flight_task = None
84
+ else:
85
+ in_flight_task = None
86
+
87
+ # No in-flight task - create a new one
88
+ if in_flight_task is None:
89
+ task = asyncio.create_task(fetch_func(*args, **kwargs))
90
+ self._cache[key] = CacheEntry(task=task)
91
+
92
+ # LRU eviction
93
+ while len(self._cache) > self.max_size:
94
+ self._cache.popitem(last=False)
95
+ else:
96
+ # Use the existing in-flight task
97
+ task = in_flight_task
98
+
99
+ # Wait for the task outside the lock to avoid deadlock
100
+ try:
101
+ result = await task
102
+ async with self._lock:
103
+ if key in self._cache:
104
+ self._cache[key] = CacheEntry(value=result)
105
+ return result
106
+ except Exception as e:
107
+ async with self._lock:
108
+ if key in self._cache:
109
+ self._cache[key] = CacheEntry(error=e)
110
+ raise
111
+
112
+ def clear(self):
113
+ """Clear all cached entries."""
114
+ self._cache.clear()
@@ -9,6 +9,7 @@ from contextlib import asynccontextmanager
9
9
  from eeclient.exceptions import EEClientError, EERestException
10
10
  from eeclient.models import GEEHeaders, SepalHeaders
11
11
  from eeclient.sepal_credential_mixin import SepalCredentialMixin
12
+ from eeclient.cache import ResponseCache
12
13
 
13
14
  import eeclient.export as _export_module
14
15
  import eeclient.data as _operations_module
@@ -77,6 +78,8 @@ class EESession(SepalCredentialMixin):
77
78
  self._client: httpx.AsyncClient | None = None
78
79
  self._client_lock = asyncio.Lock()
79
80
 
81
+ self._assets_cache = ResponseCache(ttl=10.0, max_size=100)
82
+
80
83
  self.enforce_project_id = enforce_project_id
81
84
  super().__init__(sepal_headers)
82
85
 
@@ -181,6 +184,7 @@ class EESession(SepalCredentialMixin):
181
184
  if self._client is not None:
182
185
  await self._client.aclose()
183
186
  self._client = None
187
+ self._assets_cache.clear()
184
188
 
185
189
  async def rest_call(
186
190
  self,
@@ -167,32 +167,45 @@ async def _list_assets_concurrently(client: "EESession", folders):
167
167
  async def get_assets_async(client: "EESession", folder: str = "") -> List[dict]:
168
168
  """Get all assets in a folder recursively (async version).
169
169
 
170
+ Responses are cached for 10 seconds to prevent redundant API calls.
171
+ Concurrent requests with identical parameters are deduplicated.
172
+
170
173
  Args:
171
174
  client: The asynchronous session object.
172
175
  folder: The starting folder name or id.
173
176
 
174
177
  Returns:
175
-
178
+ List of assets with their type, name, and id.
176
179
  """
177
- folder_queue = asyncio.Queue()
178
- await folder_queue.put(folder)
179
- asset_list = []
180
-
181
- while not folder_queue.empty():
182
- current_folders = [
183
- await folder_queue.get() for _ in range(folder_queue.qsize())
184
- ]
185
- assets_groups = await _list_assets_concurrently(client, current_folders)
186
-
187
- for assets in assets_groups:
188
- for asset in assets:
189
- asset_list.append(
190
- {"type": asset["type"], "name": asset["name"], "id": asset["id"]}
191
- )
192
- if asset["type"] == "FOLDER":
193
- await folder_queue.put(asset["name"])
194
-
195
- return asset_list
180
+ cache_key = client._assets_cache.make_cache_key(folder)
181
+
182
+ async def _fetch_assets():
183
+ folder_queue = asyncio.Queue()
184
+ await folder_queue.put(folder)
185
+ asset_list = []
186
+
187
+ while not folder_queue.empty():
188
+ current_folders = [
189
+ await folder_queue.get() for _ in range(folder_queue.qsize())
190
+ ]
191
+ assets_groups = await _list_assets_concurrently(client, current_folders)
192
+
193
+ for assets in assets_groups:
194
+ for asset in assets:
195
+ asset_list.append(
196
+ {
197
+ "type": asset["type"],
198
+ "name": asset["name"],
199
+ "id": asset["id"],
200
+ }
201
+ )
202
+ if asset["type"] == "FOLDER":
203
+ await folder_queue.put(asset["name"])
204
+
205
+ return asset_list
206
+
207
+ # Don't pass any arguments since _fetch_assets is a closure
208
+ return await client._assets_cache.get_or_fetch(cache_key, _fetch_assets)
196
209
 
197
210
 
198
211
  async def create_folder_async(client: "EESession", folder: Union[Path, str]) -> str:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ee-client"
7
- version = "2.4.0"
7
+ version = "2.5.0"
8
8
  description = "extends the capabilities of the earthengine-api by providing custom session management and client interactions"
9
9
  readme = { file = "README.rst", content-type = "text/x-rst" }
10
10
  authors = [
@@ -66,7 +66,7 @@ branch = true
66
66
  [tool.commitizen]
67
67
  tag_format = "v$major.$minor.$patch$prerelease"
68
68
  update_changelog_on_bump = false
69
- version = "2.4.0"
69
+ version = "2.5.0"
70
70
  version_files = [
71
71
  "pyproject.toml:version",
72
72
  "eeclient/__init__.py:__version__",
@@ -0,0 +1,253 @@
1
+ import asyncio
2
+ import pytest
3
+ import time
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from eeclient.cache import ResponseCache, CacheEntry
7
+ from eeclient.data import get_assets_async
8
+
9
+
10
+ # CacheEntry Tests
11
+
12
+
13
+ def test_cache_entry_with_value():
14
+ entry = CacheEntry(value="test_value")
15
+ assert entry.value == "test_value"
16
+ assert entry.error is None
17
+ assert entry.task is None
18
+ assert entry.get_result() == "test_value"
19
+
20
+
21
+ def test_cache_entry_with_error():
22
+ error = ValueError("test error")
23
+ entry = CacheEntry(error=error)
24
+ assert entry.value is None
25
+ assert entry.error == error
26
+
27
+ with pytest.raises(ValueError, match="test error"):
28
+ entry.get_result()
29
+
30
+
31
+ def test_cache_entry_ttl_expiry():
32
+ entry = CacheEntry(value="test")
33
+ assert not entry.is_expired(ttl=10.0)
34
+
35
+ entry.timestamp = time.time() - 15.0
36
+ assert entry.is_expired(ttl=10.0)
37
+
38
+
39
+ # ResponseCache Tests
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_cache_basic_caching():
44
+ cache = ResponseCache(ttl=10.0, max_size=100)
45
+ call_count = 0
46
+
47
+ async def fetch_data(value):
48
+ nonlocal call_count
49
+ call_count += 1
50
+ await asyncio.sleep(0.01)
51
+ return f"result_{value}"
52
+
53
+ key = cache.make_cache_key("test_arg")
54
+
55
+ result1 = await cache.get_or_fetch(key, fetch_data, "test_arg")
56
+ result2 = await cache.get_or_fetch(key, fetch_data, "test_arg")
57
+
58
+ assert result1 == "result_test_arg"
59
+ assert result2 == "result_test_arg"
60
+ assert call_count == 1
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_cache_ttl_expiry():
65
+ cache = ResponseCache(ttl=0.1, max_size=100)
66
+ call_count = 0
67
+
68
+ async def fetch_data(value):
69
+ nonlocal call_count
70
+ call_count += 1
71
+ return f"result_{value}"
72
+
73
+ key = cache.make_cache_key("test_arg")
74
+
75
+ result1 = await cache.get_or_fetch(key, fetch_data, "test_arg")
76
+ assert result1 == "result_test_arg"
77
+ assert call_count == 1
78
+
79
+ await asyncio.sleep(0.15)
80
+
81
+ result2 = await cache.get_or_fetch(key, fetch_data, "test_arg")
82
+ assert result2 == "result_test_arg"
83
+ assert call_count == 2
84
+
85
+
86
+ @pytest.mark.asyncio
87
+ async def test_cache_concurrent_request_deduplication():
88
+ cache = ResponseCache(ttl=10.0, max_size=100)
89
+ call_count = 0
90
+
91
+ async def fetch_data(value):
92
+ nonlocal call_count
93
+ call_count += 1
94
+ await asyncio.sleep(0.05)
95
+ return f"result_{value}"
96
+
97
+ key = cache.make_cache_key("test_arg")
98
+
99
+ tasks = [
100
+ asyncio.create_task(cache.get_or_fetch(key, fetch_data, "test_arg"))
101
+ for _ in range(10)
102
+ ]
103
+
104
+ results = await asyncio.wait_for(asyncio.gather(*tasks), timeout=5.0)
105
+
106
+ assert all(r == "result_test_arg" for r in results)
107
+ assert call_count == 1
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_cache_different_params_different_cache():
112
+ cache = ResponseCache(ttl=10.0, max_size=100)
113
+ call_count = 0
114
+
115
+ async def fetch_data(value):
116
+ nonlocal call_count
117
+ call_count += 1
118
+ return f"result_{value}"
119
+
120
+ key1 = cache.make_cache_key("arg1")
121
+ key2 = cache.make_cache_key("arg2")
122
+
123
+ result1 = await cache.get_or_fetch(key1, fetch_data, "arg1")
124
+ result2 = await cache.get_or_fetch(key2, fetch_data, "arg2")
125
+
126
+ assert result1 == "result_arg1"
127
+ assert result2 == "result_arg2"
128
+ assert call_count == 2
129
+
130
+
131
+ @pytest.mark.asyncio
132
+ async def test_cache_error_caching():
133
+ cache = ResponseCache(ttl=10.0, max_size=100)
134
+ call_count = 0
135
+
136
+ async def fetch_data_with_error():
137
+ nonlocal call_count
138
+ call_count += 1
139
+ await asyncio.sleep(0.01)
140
+ raise ValueError("API error")
141
+
142
+ key = cache.make_cache_key("error_test")
143
+
144
+ with pytest.raises(ValueError, match="API error"):
145
+ await cache.get_or_fetch(key, fetch_data_with_error)
146
+
147
+ assert call_count == 1
148
+
149
+ with pytest.raises(ValueError, match="API error"):
150
+ await cache.get_or_fetch(key, fetch_data_with_error)
151
+
152
+ assert call_count == 1
153
+
154
+
155
+ @pytest.mark.asyncio
156
+ async def test_cache_lru_eviction():
157
+ cache = ResponseCache(ttl=10.0, max_size=3)
158
+
159
+ async def fetch_data(value):
160
+ return f"result_{value}"
161
+
162
+ for i in range(5):
163
+ key = cache.make_cache_key(f"arg_{i}")
164
+ await cache.get_or_fetch(key, fetch_data, f"arg_{i}")
165
+
166
+ assert len(cache._cache) == 3
167
+
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_cache_clear():
171
+ cache = ResponseCache(ttl=10.0, max_size=100)
172
+
173
+ async def fetch_data(value):
174
+ return f"result_{value}"
175
+
176
+ key = cache.make_cache_key("test")
177
+ await cache.get_or_fetch(key, fetch_data, "test")
178
+
179
+ assert len(cache._cache) == 1
180
+
181
+ cache.clear()
182
+
183
+ assert len(cache._cache) == 0
184
+
185
+
186
+ def test_cache_key_consistency():
187
+ cache = ResponseCache()
188
+
189
+ key1 = cache.make_cache_key("folder1", param1="value1", param2="value2")
190
+ key2 = cache.make_cache_key("folder1", param2="value2", param1="value1")
191
+
192
+ assert key1 == key2
193
+
194
+
195
+ # get_assets_async Caching Tests
196
+
197
+
198
+ @pytest.mark.asyncio
199
+ async def test_get_assets_caching():
200
+ """Test that identical get_assets calls use cache."""
201
+ mock_client = MagicMock()
202
+ mock_client._assets_cache = ResponseCache(ttl=10.0, max_size=100)
203
+
204
+ call_count = 0
205
+
206
+ async def mock_list_assets(client, folders):
207
+ nonlocal call_count
208
+ call_count += 1
209
+ await asyncio.sleep(0.01)
210
+ return [
211
+ [
212
+ {"type": "IMAGE", "name": "test_image", "id": "test_id_1"},
213
+ {"type": "IMAGE", "name": "test_image2", "id": "test_id_2"},
214
+ ]
215
+ ]
216
+
217
+ with patch("eeclient.data._list_assets_concurrently", new=mock_list_assets):
218
+ result1 = await get_assets_async(mock_client, "projects/test/assets/folder")
219
+ assert len(result1) == 2
220
+ assert call_count == 1
221
+
222
+ result2 = await get_assets_async(mock_client, "projects/test/assets/folder")
223
+ assert result1 == result2
224
+ assert call_count == 1
225
+
226
+
227
+ @pytest.mark.asyncio
228
+ async def test_get_assets_concurrent_deduplication():
229
+ """Test that concurrent identical requests are deduplicated."""
230
+ mock_client = MagicMock()
231
+ mock_client._assets_cache = ResponseCache(ttl=10.0, max_size=100)
232
+
233
+ call_count = 0
234
+
235
+ async def mock_list_assets(client, folders):
236
+ nonlocal call_count
237
+ call_count += 1
238
+ await asyncio.sleep(0.05)
239
+ return [[{"type": "IMAGE", "name": "test_image", "id": "test_id"}]]
240
+
241
+ with patch("eeclient.data._list_assets_concurrently", new=mock_list_assets):
242
+ tasks = [
243
+ asyncio.create_task(
244
+ get_assets_async(mock_client, "projects/test/assets/folder")
245
+ )
246
+ for _ in range(10)
247
+ ]
248
+
249
+ results = await asyncio.wait_for(asyncio.gather(*tasks), timeout=5.0)
250
+
251
+ assert all(len(r) == 1 for r in results)
252
+ assert all(r[0]["id"] == "test_id" for r in results)
253
+ assert call_count == 1
@@ -158,9 +158,7 @@ async def test_list_assets_concurrently(sepal_headers):
158
158
  assets_lists = await _list_assets_concurrently(session, folders)
159
159
  assert isinstance(assets_lists, list)
160
160
 
161
- # Depending on your project, the list might be empty or not.
162
161
  print(f"Assets listed: {assets_lists}")
163
- # Clean up
164
162
  await delete_folder(session, folder_id=folder_id, recursive=True)
165
163
 
166
164
 
@@ -0,0 +1,84 @@
1
+ import os
2
+ import pytest
3
+ import time
4
+ import logging
5
+ from unittest.mock import patch
6
+
7
+ from eeclient.helpers import get_sepal_headers_from_auth
8
+ from eeclient.client import EESession
9
+ from eeclient.data import get_assets_async
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @pytest.mark.asyncio
15
+ async def test_real_get_assets_cache():
16
+ """Integration test against a real SEPAL server.
17
+
18
+ Requires the following environment variables to be set:
19
+ - SEPAL_HOST
20
+ - LOCAL_SEPAL_USER
21
+ - LOCAL_SEPAL_PASSWORD
22
+
23
+ The test will be skipped if those are not available.
24
+ """
25
+ sepal_host = os.getenv("SEPAL_HOST")
26
+ sepal_user = os.getenv("LOCAL_SEPAL_USER")
27
+ sepal_password = os.getenv("LOCAL_SEPAL_PASSWORD")
28
+
29
+ if not (sepal_host and sepal_user and sepal_password):
30
+ pytest.skip("SEPAL_HOST, LOCAL_SEPAL_USER or LOCAL_SEPAL_PASSWORD not set")
31
+
32
+ sepal_headers = get_sepal_headers_from_auth(
33
+ sepal_user=sepal_user, sepal_password=sepal_password, sepal_host=sepal_host
34
+ )
35
+
36
+ session = await EESession.create(sepal_headers=sepal_headers)
37
+
38
+ try:
39
+ folder = await session.get_assets_folder()
40
+
41
+ assert len(session._assets_cache._cache) == 0
42
+
43
+ logger.info(f"Making first call to get_assets_async({folder})")
44
+ start_time = time.time()
45
+ assets = await get_assets_async(session, folder)
46
+ first_call_duration = time.time() - start_time
47
+ logger.info(
48
+ f"First call completed in {first_call_duration:.3f}s, "
49
+ f"retrieved {len(assets)} assets"
50
+ )
51
+
52
+ assert isinstance(assets, list)
53
+
54
+ key = session._assets_cache.make_cache_key(folder)
55
+ assert key in session._assets_cache._cache
56
+ logger.debug(f"Cache entry created with key: {key[:16]}...")
57
+
58
+ cache_entry = session._assets_cache._cache[key]
59
+ assert cache_entry.value is not None
60
+ assert cache_entry.task is None
61
+ logger.debug("Cache entry contains value (no pending task)")
62
+
63
+ logger.info("Making second call to get_assets_async (should use cache)")
64
+
65
+ with patch("eeclient.data._list_assets_concurrently") as mock_list:
66
+ start_time = time.time()
67
+ assets2 = await get_assets_async(session, folder)
68
+ second_call_duration = time.time() - start_time
69
+ logger.info(f"Second call completed in {second_call_duration:.3f}s")
70
+
71
+ assert (
72
+ mock_list.call_count == 0
73
+ ), "Network call should NOT happen for cached result"
74
+ logger.info("Confirmed: NO network call made (mock not called)")
75
+
76
+ assert assets2 == assets
77
+ logger.debug("Results are identical")
78
+
79
+ speedup = first_call_duration / second_call_duration
80
+ logger.info(f"Cache speedup: {speedup:.1f}x faster")
81
+ assert speedup > 10, f"Cache should be much faster! Got only {speedup:.1f}x"
82
+
83
+ finally:
84
+ await session.aclose()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes