ee-client 3.2.0__tar.gz → 3.3.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-3.2.0 → ee_client-3.3.0}/PKG-INFO +1 -1
- {ee_client-3.2.0 → ee_client-3.3.0}/ee_client.egg-info/PKG-INFO +1 -1
- {ee_client-3.2.0 → ee_client-3.3.0}/ee_client.egg-info/SOURCES.txt +2 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/__init__.py +1 -1
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/cache.py +37 -33
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/client.py +13 -3
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/data.py +58 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/interfaces/operations.py +10 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/pyproject.toml +2 -2
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_cache.py +73 -0
- ee_client-3.3.0/tests/test_compute_pixels.py +97 -0
- ee_client-3.3.0/tests/test_rest_call_raw.py +107 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/LICENSE +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/README.rst +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/ee_client.egg-info/dependency_links.txt +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/ee_client.egg-info/requires.txt +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/ee_client.egg-info/top_level.txt +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/credential_mixin.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/exceptions.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/export/__init__.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/export/image.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/export/table.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/helpers.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/interfaces/__init__.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/interfaces/export.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/interfaces/tasks.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/loopstate.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/models.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/providers.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/sepal_credential_mixin.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/tasks.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/setup.cfg +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_agnostic_auth_integration.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_client.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_credential_mixin_close.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_data.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_export_image.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_export_image_pyramiding.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_export_table.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_factories.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_integration_get_assets.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_loop_scoping.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_models.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_providers.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_resolution.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_tasks.py +0 -0
- {ee_client-3.2.0 → ee_client-3.3.0}/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.
|
|
3
|
+
Version: 3.3.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: 3.
|
|
3
|
+
Version: 3.3.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
|
|
@@ -28,6 +28,7 @@ eeclient/interfaces/tasks.py
|
|
|
28
28
|
tests/test_agnostic_auth_integration.py
|
|
29
29
|
tests/test_cache.py
|
|
30
30
|
tests/test_client.py
|
|
31
|
+
tests/test_compute_pixels.py
|
|
31
32
|
tests/test_credential_mixin_close.py
|
|
32
33
|
tests/test_data.py
|
|
33
34
|
tests/test_export_image.py
|
|
@@ -39,5 +40,6 @@ tests/test_loop_scoping.py
|
|
|
39
40
|
tests/test_models.py
|
|
40
41
|
tests/test_providers.py
|
|
41
42
|
tests/test_resolution.py
|
|
43
|
+
tests/test_rest_call_raw.py
|
|
42
44
|
tests/test_tasks.py
|
|
43
45
|
tests/test_tls_verify.py
|
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
# Use the existing in-flight task
|
|
97
|
-
task = in_flight_task
|
|
91
|
+
entry.waiters += 1
|
|
98
92
|
|
|
99
|
-
#
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Dict, Literal, Optional
|
|
3
|
+
from typing import Any, Dict, Literal, Optional, Union
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import asyncio
|
|
@@ -350,8 +350,15 @@ class EESession(CredentialMixin):
|
|
|
350
350
|
max_attempts: int = 4,
|
|
351
351
|
initial_wait: float = 1,
|
|
352
352
|
max_wait: float = 60,
|
|
353
|
-
|
|
354
|
-
|
|
353
|
+
raw: bool = False,
|
|
354
|
+
) -> Union[Dict[str, Any], bytes]:
|
|
355
|
+
"""Async REST call with retry logic.
|
|
356
|
+
|
|
357
|
+
``raw=True`` returns the response body as bytes instead of decoding it
|
|
358
|
+
as JSON, for endpoints that answer with an image rather than a
|
|
359
|
+
document (``image:computePixels``). Errors are unaffected: the failure
|
|
360
|
+
path already inspects ``Content-Type`` before parsing.
|
|
361
|
+
"""
|
|
355
362
|
|
|
356
363
|
attempt = 0
|
|
357
364
|
last_error = None
|
|
@@ -399,6 +406,9 @@ class EESession(CredentialMixin):
|
|
|
399
406
|
)
|
|
400
407
|
raise EERestException(error_data)
|
|
401
408
|
|
|
409
|
+
if raw:
|
|
410
|
+
return response.content
|
|
411
|
+
|
|
402
412
|
try:
|
|
403
413
|
return response.json()
|
|
404
414
|
except Exception as e:
|
|
@@ -74,6 +74,64 @@ async def get_map_id_async(
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
async def compute_pixels_async(
|
|
78
|
+
client: "EESession",
|
|
79
|
+
ee_image: Image,
|
|
80
|
+
grid: Optional[dict] = None,
|
|
81
|
+
bands: Optional[List[str]] = None,
|
|
82
|
+
file_format: str = "GEO_TIFF",
|
|
83
|
+
visualization_options: Optional[dict] = None,
|
|
84
|
+
workload_tag: Optional[str] = None,
|
|
85
|
+
) -> bytes:
|
|
86
|
+
"""Async version of ee.data.computePixels.
|
|
87
|
+
|
|
88
|
+
Computes an image and returns its pixels as an encoded file, on this
|
|
89
|
+
session's credentials rather than the ambient ``ee`` module's, so a
|
|
90
|
+
multi-user host fetches as the connected user.
|
|
91
|
+
|
|
92
|
+
Earth Engine caps a single request at 48 MiB counted on **uncompressed**
|
|
93
|
+
pixels (width x height x bytes per pixel x bands), independent of how well
|
|
94
|
+
the result compresses; a larger grid is refused outright. A caller wanting
|
|
95
|
+
a big area tiles the grid itself and requests each tile in turn. The
|
|
96
|
+
session's rate limiter and inflight semaphore already meter those requests,
|
|
97
|
+
and ``rest_call`` already retries 429/401/5xx, so the caller needs no retry
|
|
98
|
+
loop of its own.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
client: The asynchronous session object.
|
|
102
|
+
ee_image: The image to compute.
|
|
103
|
+
grid: The pixel grid to fetch, as ``crsCode`` / ``affineTransform`` /
|
|
104
|
+
``dimensions``. Defaults to the data's native grid.
|
|
105
|
+
bands: The bands to return. Defaults to all of them.
|
|
106
|
+
file_format: An Earth Engine image file format, ``GEO_TIFF`` by
|
|
107
|
+
default. The client-side conversions ``ee.data.computePixels``
|
|
108
|
+
offers (``NUMPY_NDARRAY``) are not applied; this returns the bytes
|
|
109
|
+
the service sent.
|
|
110
|
+
visualization_options: Visualization options to apply before the
|
|
111
|
+
pixels are computed.
|
|
112
|
+
workload_tag: An optional workload tag.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The encoded image, as bytes.
|
|
116
|
+
"""
|
|
117
|
+
url = "https://earthengine.googleapis.com/v1/projects/{project}/image:computePixels"
|
|
118
|
+
|
|
119
|
+
request_body = {
|
|
120
|
+
"expression": serializer.encode(ee_image, for_cloud_api=True),
|
|
121
|
+
"fileFormat": _cloud_api_utils.convert_to_image_file_format(file_format),
|
|
122
|
+
}
|
|
123
|
+
if grid is not None:
|
|
124
|
+
request_body["grid"] = grid
|
|
125
|
+
if bands is not None:
|
|
126
|
+
request_body["bandIds"] = _cloud_api_utils.convert_to_band_list(bands)
|
|
127
|
+
if visualization_options is not None:
|
|
128
|
+
request_body["visualizationOptions"] = visualization_options
|
|
129
|
+
if workload_tag is not None:
|
|
130
|
+
request_body["workloadTag"] = workload_tag
|
|
131
|
+
|
|
132
|
+
return await client.rest_call("POST", url, data=request_body, raw=True)
|
|
133
|
+
|
|
134
|
+
|
|
77
135
|
async def get_info_async(
|
|
78
136
|
client: "EESession",
|
|
79
137
|
ee_object: Union[ComputedObject, None] = None,
|
|
@@ -14,6 +14,16 @@ class OperationsProtocol(Protocol):
|
|
|
14
14
|
format: Optional[str] = None,
|
|
15
15
|
) -> dict: ...
|
|
16
16
|
|
|
17
|
+
async def compute_pixels_async(
|
|
18
|
+
self,
|
|
19
|
+
ee_image: Image,
|
|
20
|
+
grid: Optional[dict] = None,
|
|
21
|
+
bands: Optional[List[str]] = None,
|
|
22
|
+
file_format: str = "GEO_TIFF",
|
|
23
|
+
visualization_options: Optional[dict] = None,
|
|
24
|
+
workload_tag: Optional[str] = None,
|
|
25
|
+
) -> bytes: ...
|
|
26
|
+
|
|
17
27
|
async def get_info_async(
|
|
18
28
|
self,
|
|
19
29
|
ee_object: Union[ComputedObject, None] = None,
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ee-client"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.3.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 = "3.
|
|
69
|
+
version = "3.3.0"
|
|
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
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from unittest.mock import AsyncMock, patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from eeclient.data import compute_pixels_async
|
|
6
|
+
|
|
7
|
+
FAKE_EXPRESSION = {"values": {}, "result": "0"}
|
|
8
|
+
GRID = {
|
|
9
|
+
"dimensions": {"width": 256, "height": 256},
|
|
10
|
+
"affineTransform": {
|
|
11
|
+
"scaleX": 0.00025,
|
|
12
|
+
"shearX": 0,
|
|
13
|
+
"translateX": 16.0,
|
|
14
|
+
"shearY": 0,
|
|
15
|
+
"scaleY": -0.00025,
|
|
16
|
+
"translateY": 1.4,
|
|
17
|
+
},
|
|
18
|
+
"crsCode": "EPSG:4326",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _client(payload=b"\x49\x49\x2a\x00tiff bytes"):
|
|
23
|
+
client = AsyncMock()
|
|
24
|
+
client.rest_call = AsyncMock(return_value=payload)
|
|
25
|
+
return client
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _compute(client, **kwargs):
|
|
29
|
+
with patch("eeclient.data.serializer.encode", return_value=FAKE_EXPRESSION):
|
|
30
|
+
return await compute_pixels_async(client=client, ee_image=object(), **kwargs)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.asyncio
|
|
34
|
+
async def test_returns_the_bytes_the_service_sent():
|
|
35
|
+
client = _client()
|
|
36
|
+
|
|
37
|
+
result = await _compute(client, grid=GRID, bands=["lossyear"])
|
|
38
|
+
|
|
39
|
+
assert result == b"\x49\x49\x2a\x00tiff bytes"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_posts_to_the_compute_pixels_endpoint_asking_for_raw_bytes():
|
|
44
|
+
client = _client()
|
|
45
|
+
|
|
46
|
+
await _compute(client, grid=GRID, bands=["lossyear"])
|
|
47
|
+
|
|
48
|
+
assert client.rest_call.await_count == 1
|
|
49
|
+
args, kwargs = client.rest_call.call_args
|
|
50
|
+
assert args[0] == "POST"
|
|
51
|
+
assert args[1].endswith("/image:computePixels")
|
|
52
|
+
# the response is an image, so it must not be decoded as JSON
|
|
53
|
+
assert kwargs["raw"] is True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_sends_expression_grid_bands_and_format():
|
|
58
|
+
client = _client()
|
|
59
|
+
|
|
60
|
+
await _compute(client, grid=GRID, bands=["lossyear"])
|
|
61
|
+
|
|
62
|
+
payload = client.rest_call.call_args.kwargs["data"]
|
|
63
|
+
assert payload["expression"] == FAKE_EXPRESSION
|
|
64
|
+
assert payload["fileFormat"] == "GEO_TIFF"
|
|
65
|
+
assert payload["grid"] == GRID
|
|
66
|
+
assert payload["bandIds"] == ["lossyear"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.asyncio
|
|
70
|
+
async def test_omits_the_optional_fields_when_they_are_not_given():
|
|
71
|
+
client = _client()
|
|
72
|
+
|
|
73
|
+
await _compute(client)
|
|
74
|
+
|
|
75
|
+
payload = client.rest_call.call_args.kwargs["data"]
|
|
76
|
+
assert set(payload) == {"expression", "fileFormat"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
async def test_passes_visualization_options_and_workload_tag_through():
|
|
81
|
+
client = _client()
|
|
82
|
+
vis = {"ranges": [{"min": 0, "max": 23}]}
|
|
83
|
+
|
|
84
|
+
await _compute(client, visualization_options=vis, workload_tag="sbae")
|
|
85
|
+
|
|
86
|
+
payload = client.rest_call.call_args.kwargs["data"]
|
|
87
|
+
assert payload["visualizationOptions"] == vis
|
|
88
|
+
assert payload["workloadTag"] == "sbae"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.asyncio
|
|
92
|
+
async def test_a_single_band_string_is_accepted_as_a_band_list():
|
|
93
|
+
client = _client()
|
|
94
|
+
|
|
95
|
+
await _compute(client, bands="lossyear")
|
|
96
|
+
|
|
97
|
+
assert client.rest_call.call_args.kwargs["data"]["bandIds"] == ["lossyear"]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""``rest_call(raw=True)`` hands back the response body undecoded.
|
|
2
|
+
|
|
3
|
+
``image:computePixels`` answers with an encoded image, not a document, so the
|
|
4
|
+
JSON decode at the end of ``rest_call`` has to be skippable. These tests drive
|
|
5
|
+
a real local HTTP server rather than a mock, so the bytes make the whole round
|
|
6
|
+
trip through httpx exactly as they would against Earth Engine.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from eeclient.client import EESession
|
|
18
|
+
from eeclient.exceptions import EERestException
|
|
19
|
+
from eeclient.providers import CredentialSnapshot
|
|
20
|
+
|
|
21
|
+
# a TIFF magic number followed by bytes that are not valid UTF-8, so a decode
|
|
22
|
+
# attempt anywhere along the way would fail loudly instead of silently passing
|
|
23
|
+
TIFF_BYTES = b"II*\x00" + bytes(range(256))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _StubProvider:
|
|
27
|
+
"""Credentials that never expire and never touch the network."""
|
|
28
|
+
|
|
29
|
+
auth_mode = "stub"
|
|
30
|
+
auth_source = "stub"
|
|
31
|
+
user = "tester"
|
|
32
|
+
verify_ssl = True
|
|
33
|
+
|
|
34
|
+
def initial_snapshot(self):
|
|
35
|
+
return CredentialSnapshot(
|
|
36
|
+
access_token="token",
|
|
37
|
+
project_id="ee-project",
|
|
38
|
+
expiry_date=int((time.time() + 3600) * 1000),
|
|
39
|
+
native=object(),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def refresh_sync(self):
|
|
43
|
+
raise AssertionError("credentials must not be refreshed in these tests")
|
|
44
|
+
|
|
45
|
+
async def refresh(self):
|
|
46
|
+
raise AssertionError("credentials must not be refreshed in these tests")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
50
|
+
protocol_version = "HTTP/1.1"
|
|
51
|
+
|
|
52
|
+
def do_POST(self):
|
|
53
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
54
|
+
self.rfile.read(length)
|
|
55
|
+
|
|
56
|
+
if self.path.endswith("/boom"):
|
|
57
|
+
body = json.dumps({"error": {"code": 400, "message": "bad grid"}}).encode()
|
|
58
|
+
self.send_response(400)
|
|
59
|
+
self.send_header("Content-Type", "application/json")
|
|
60
|
+
else:
|
|
61
|
+
body = TIFF_BYTES
|
|
62
|
+
self.send_response(200)
|
|
63
|
+
self.send_header("Content-Type", "image/tiff")
|
|
64
|
+
|
|
65
|
+
self.send_header("Content-Length", str(len(body)))
|
|
66
|
+
self.end_headers()
|
|
67
|
+
self.wfile.write(body)
|
|
68
|
+
|
|
69
|
+
def log_message(self, *args):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.fixture(scope="module")
|
|
74
|
+
def base_url():
|
|
75
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
|
76
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
77
|
+
yield f"http://127.0.0.1:{server.server_address[1]}"
|
|
78
|
+
server.shutdown()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.fixture()
|
|
82
|
+
def session():
|
|
83
|
+
return EESession(_provider=_StubProvider())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_raw_returns_the_body_verbatim(session, base_url):
|
|
87
|
+
result = asyncio.run(session.rest_call("POST", f"{base_url}/pixels", raw=True))
|
|
88
|
+
|
|
89
|
+
assert isinstance(result, bytes)
|
|
90
|
+
assert result == TIFF_BYTES
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_without_raw_the_same_body_is_rejected_as_json(session, base_url):
|
|
94
|
+
"""The guard that makes `raw` necessary: bytes are not a document."""
|
|
95
|
+
with pytest.raises(EERestException) as excinfo:
|
|
96
|
+
asyncio.run(session.rest_call("POST", f"{base_url}/pixels"))
|
|
97
|
+
|
|
98
|
+
assert "Invalid JSON response" in str(excinfo.value)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_raw_still_raises_on_an_error_response(session, base_url):
|
|
102
|
+
"""A failure is JSON even when success is not, and must not reach the caller
|
|
103
|
+
as bytes."""
|
|
104
|
+
with pytest.raises(EERestException) as excinfo:
|
|
105
|
+
asyncio.run(session.rest_call("POST", f"{base_url}/boom", raw=True))
|
|
106
|
+
|
|
107
|
+
assert excinfo.value.code == 400
|
|
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
|
|
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
|