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.
- {ee_client-2.4.0 → ee_client-2.5.0}/PKG-INFO +1 -1
- {ee_client-2.4.0 → ee_client-2.5.0}/ee_client.egg-info/PKG-INFO +1 -1
- {ee_client-2.4.0 → ee_client-2.5.0}/ee_client.egg-info/SOURCES.txt +3 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/__init__.py +1 -1
- ee_client-2.5.0/eeclient/cache.py +114 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/client.py +4 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/data.py +33 -20
- {ee_client-2.4.0 → ee_client-2.5.0}/pyproject.toml +2 -2
- ee_client-2.5.0/tests/test_cache.py +253 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/tests/test_data.py +0 -2
- ee_client-2.5.0/tests/test_integration_get_assets.py +84 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/LICENSE +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/README.rst +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/ee_client.egg-info/dependency_links.txt +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/ee_client.egg-info/requires.txt +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/ee_client.egg-info/top_level.txt +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/exceptions.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/export/__init__.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/export/image.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/export/table.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/helpers.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/interfaces/__init__.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/interfaces/export.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/interfaces/operations.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/interfaces/tasks.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/models.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/oauth_app.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/sepal_credential_mixin.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/eeclient/tasks.py +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/setup.cfg +0 -0
- {ee_client-2.4.0 → ee_client-2.5.0}/tests/test_client.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|