ee-client 3.2.0__tar.gz → 3.2.1__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 (45) hide show
  1. {ee_client-3.2.0 → ee_client-3.2.1}/PKG-INFO +1 -1
  2. {ee_client-3.2.0 → ee_client-3.2.1}/ee_client.egg-info/PKG-INFO +1 -1
  3. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/__init__.py +1 -1
  4. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/cache.py +37 -33
  5. {ee_client-3.2.0 → ee_client-3.2.1}/pyproject.toml +2 -2
  6. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_cache.py +73 -0
  7. {ee_client-3.2.0 → ee_client-3.2.1}/LICENSE +0 -0
  8. {ee_client-3.2.0 → ee_client-3.2.1}/README.rst +0 -0
  9. {ee_client-3.2.0 → ee_client-3.2.1}/ee_client.egg-info/SOURCES.txt +0 -0
  10. {ee_client-3.2.0 → ee_client-3.2.1}/ee_client.egg-info/dependency_links.txt +0 -0
  11. {ee_client-3.2.0 → ee_client-3.2.1}/ee_client.egg-info/requires.txt +0 -0
  12. {ee_client-3.2.0 → ee_client-3.2.1}/ee_client.egg-info/top_level.txt +0 -0
  13. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/client.py +0 -0
  14. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/credential_mixin.py +0 -0
  15. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/data.py +0 -0
  16. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/exceptions.py +0 -0
  17. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/export/__init__.py +0 -0
  18. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/export/image.py +0 -0
  19. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/export/table.py +0 -0
  20. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/helpers.py +0 -0
  21. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/interfaces/__init__.py +0 -0
  22. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/interfaces/export.py +0 -0
  23. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/interfaces/operations.py +0 -0
  24. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/interfaces/tasks.py +0 -0
  25. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/loopstate.py +0 -0
  26. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/models.py +0 -0
  27. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/providers.py +0 -0
  28. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/sepal_credential_mixin.py +0 -0
  29. {ee_client-3.2.0 → ee_client-3.2.1}/eeclient/tasks.py +0 -0
  30. {ee_client-3.2.0 → ee_client-3.2.1}/setup.cfg +0 -0
  31. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_agnostic_auth_integration.py +0 -0
  32. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_client.py +0 -0
  33. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_credential_mixin_close.py +0 -0
  34. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_data.py +0 -0
  35. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_export_image.py +0 -0
  36. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_export_image_pyramiding.py +0 -0
  37. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_export_table.py +0 -0
  38. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_factories.py +0 -0
  39. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_integration_get_assets.py +0 -0
  40. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_loop_scoping.py +0 -0
  41. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_models.py +0 -0
  42. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_providers.py +0 -0
  43. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_resolution.py +0 -0
  44. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_tasks.py +0 -0
  45. {ee_client-3.2.0 → ee_client-3.2.1}/tests/test_tls_verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ee-client
3
- Version: 3.2.0
3
+ Version: 3.2.1
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: 3.2.0
3
+ Version: 3.2.1
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
  __title__ = "eeclient"
2
2
  __summary__ = "A client for Google Earth Engine"
3
- __version__ = "3.2.0"
3
+ __version__ = "3.2.1"
4
4
 
5
5
  __author__ = "Daniel Guerrero"
6
6
  __email__ = "dfgm2006@gmail.com"
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import contextlib
2
3
  import hashlib
3
4
  import json
4
5
  import time
@@ -19,6 +20,7 @@ class CacheEntry:
19
20
  self.error = error
20
21
  self.task = task
21
22
  self.timestamp = time.time()
23
+ self.waiters = 0
22
24
 
23
25
  def is_expired(self, ttl: float) -> bool:
24
26
  """Check if the cache entry has exceeded its TTL."""
@@ -65,49 +67,51 @@ class ResponseCache:
65
67
  Returns:
66
68
  The cached or freshly fetched result
67
69
  """
68
- # Check cache and get task reference if needed
69
70
  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
71
+ entry = self._cache.get(key)
72
+ if entry is not None and entry.task is not None and entry.task.done():
73
+ # Its starter was cancelled before it could settle the entry.
74
+ self._settle(key, entry)
75
+ entry = self._cache.get(key)
76
+ if entry is not None and entry.task is None:
77
+ if not entry.is_expired(self.ttl):
78
78
  self._cache.move_to_end(key)
79
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:
80
+ del self._cache[key]
81
+ entry = None
82
+ if entry is None:
89
83
  task = asyncio.create_task(fetch_func(*args, **kwargs))
90
- self._cache[key] = CacheEntry(task=task)
84
+ entry = CacheEntry(task=task)
85
+ self._cache[key] = entry
86
+ task.add_done_callback(lambda _: self._settle(key, entry))
91
87
 
92
88
  # LRU eviction
93
89
  while len(self._cache) > self.max_size:
94
90
  self._cache.popitem(last=False)
95
- else:
96
- # Use the existing in-flight task
97
- task = in_flight_task
91
+ entry.waiters += 1
98
92
 
99
- # Wait for the task outside the lock to avoid deadlock
93
+ # Shielded, so one cancelled caller does not cancel the fetch the others
94
+ # (and the cache) wait on. The last caller to leave cancels it instead: a
95
+ # fetch must not outlive its callers, nor its loop.
100
96
  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
97
+ return await asyncio.shield(entry.task)
98
+ finally:
99
+ entry.waiters -= 1
100
+ if entry.waiters == 0 and not entry.task.done():
101
+ entry.task.cancel()
102
+ with contextlib.suppress(asyncio.CancelledError):
103
+ await asyncio.wait({entry.task})
104
+
105
+ def _settle(self, key: str, entry: CacheEntry) -> None:
106
+ """Keep what a finished fetch produced, or drop it when it was cancelled."""
107
+ if self._cache.get(key) is not entry or not entry.task.done():
108
+ return
109
+ if entry.task.cancelled():
110
+ del self._cache[key]
111
+ elif entry.task.exception() is not None:
112
+ self._cache[key] = CacheEntry(error=entry.task.exception())
113
+ else:
114
+ self._cache[key] = CacheEntry(value=entry.task.result())
111
115
 
112
116
  def clear(self):
113
117
  """Clear all cached entries."""
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ee-client"
7
- version = "3.2.0"
7
+ version = "3.2.1"
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 = "3.2.0"
69
+ version = "3.2.1"
70
70
  version_files = [
71
71
  "pyproject.toml:version",
72
72
  "eeclient/__init__.py:__version__",
@@ -250,3 +250,76 @@ async def test_get_assets_concurrent_deduplication():
250
250
  assert all(len(r) == 1 for r in results)
251
251
  assert all(r[0]["id"] == "test_id" for r in results)
252
252
  assert call_count == 1
253
+
254
+
255
+ # Cancellation
256
+
257
+
258
+ async def _cancel(task):
259
+ task.cancel()
260
+ await asyncio.gather(task, return_exceptions=True)
261
+
262
+
263
+ @pytest.mark.asyncio
264
+ async def test_cache_cancelled_caller_leaves_no_stale_none():
265
+ """A second call within the TTL must fetch again, not read a cancelled entry."""
266
+ cache = ResponseCache(ttl=10.0, max_size=100)
267
+ call_count = 0
268
+
269
+ async def fetch_data():
270
+ nonlocal call_count
271
+ call_count += 1
272
+ await asyncio.sleep(0.05)
273
+ return ["asset"]
274
+
275
+ key = cache.make_cache_key("folder")
276
+ first = asyncio.create_task(cache.get_or_fetch(key, fetch_data))
277
+ await asyncio.sleep(0.01)
278
+ await _cancel(first)
279
+
280
+ assert await cache.get_or_fetch(key, fetch_data) == ["asset"]
281
+ assert call_count == 2
282
+
283
+
284
+ @pytest.mark.asyncio
285
+ async def test_cache_last_caller_to_leave_cancels_the_fetch():
286
+ """A fetch nobody waits for must not run on, nor stay in the cache."""
287
+ cache = ResponseCache(ttl=10.0, max_size=100)
288
+ cancelled = asyncio.Event()
289
+
290
+ async def fetch_data():
291
+ try:
292
+ await asyncio.sleep(300)
293
+ except asyncio.CancelledError:
294
+ cancelled.set()
295
+ raise
296
+
297
+ key = cache.make_cache_key("folder")
298
+ caller = asyncio.create_task(cache.get_or_fetch(key, fetch_data))
299
+ await asyncio.sleep(0.01)
300
+ await _cancel(caller)
301
+
302
+ assert cancelled.is_set()
303
+ assert key not in cache._cache
304
+
305
+
306
+ @pytest.mark.asyncio
307
+ async def test_cache_cancelled_caller_does_not_cancel_the_others():
308
+ """Callers deduplicated onto one fetch must not lose it when one of them leaves."""
309
+ cache = ResponseCache(ttl=10.0, max_size=100)
310
+ call_count = 0
311
+
312
+ async def fetch_data():
313
+ nonlocal call_count
314
+ call_count += 1
315
+ await asyncio.sleep(0.05)
316
+ return ["asset"]
317
+
318
+ key = cache.make_cache_key("folder")
319
+ leaver = asyncio.create_task(cache.get_or_fetch(key, fetch_data))
320
+ stayer = asyncio.create_task(cache.get_or_fetch(key, fetch_data))
321
+ await asyncio.sleep(0.01)
322
+ await _cancel(leaver)
323
+
324
+ assert await asyncio.wait_for(stayer, timeout=5.0) == ["asset"]
325
+ assert call_count == 1
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