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.
Files changed (47) hide show
  1. {ee_client-3.2.0 → ee_client-3.3.0}/PKG-INFO +1 -1
  2. {ee_client-3.2.0 → ee_client-3.3.0}/ee_client.egg-info/PKG-INFO +1 -1
  3. {ee_client-3.2.0 → ee_client-3.3.0}/ee_client.egg-info/SOURCES.txt +2 -0
  4. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/__init__.py +1 -1
  5. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/cache.py +37 -33
  6. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/client.py +13 -3
  7. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/data.py +58 -0
  8. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/interfaces/operations.py +10 -0
  9. {ee_client-3.2.0 → ee_client-3.3.0}/pyproject.toml +2 -2
  10. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_cache.py +73 -0
  11. ee_client-3.3.0/tests/test_compute_pixels.py +97 -0
  12. ee_client-3.3.0/tests/test_rest_call_raw.py +107 -0
  13. {ee_client-3.2.0 → ee_client-3.3.0}/LICENSE +0 -0
  14. {ee_client-3.2.0 → ee_client-3.3.0}/README.rst +0 -0
  15. {ee_client-3.2.0 → ee_client-3.3.0}/ee_client.egg-info/dependency_links.txt +0 -0
  16. {ee_client-3.2.0 → ee_client-3.3.0}/ee_client.egg-info/requires.txt +0 -0
  17. {ee_client-3.2.0 → ee_client-3.3.0}/ee_client.egg-info/top_level.txt +0 -0
  18. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/credential_mixin.py +0 -0
  19. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/exceptions.py +0 -0
  20. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/export/__init__.py +0 -0
  21. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/export/image.py +0 -0
  22. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/export/table.py +0 -0
  23. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/helpers.py +0 -0
  24. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/interfaces/__init__.py +0 -0
  25. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/interfaces/export.py +0 -0
  26. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/interfaces/tasks.py +0 -0
  27. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/loopstate.py +0 -0
  28. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/models.py +0 -0
  29. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/providers.py +0 -0
  30. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/sepal_credential_mixin.py +0 -0
  31. {ee_client-3.2.0 → ee_client-3.3.0}/eeclient/tasks.py +0 -0
  32. {ee_client-3.2.0 → ee_client-3.3.0}/setup.cfg +0 -0
  33. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_agnostic_auth_integration.py +0 -0
  34. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_client.py +0 -0
  35. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_credential_mixin_close.py +0 -0
  36. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_data.py +0 -0
  37. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_export_image.py +0 -0
  38. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_export_image_pyramiding.py +0 -0
  39. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_export_table.py +0 -0
  40. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_factories.py +0 -0
  41. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_integration_get_assets.py +0 -0
  42. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_loop_scoping.py +0 -0
  43. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_models.py +0 -0
  44. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_providers.py +0 -0
  45. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_resolution.py +0 -0
  46. {ee_client-3.2.0 → ee_client-3.3.0}/tests/test_tasks.py +0 -0
  47. {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.2.0
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.2.0
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,6 +1,6 @@
1
1
  __title__ = "eeclient"
2
2
  __summary__ = "A client for Google Earth Engine"
3
- __version__ = "3.2.0"
3
+ __version__ = "3.3.0"
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."""
@@ -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
- ) -> Dict[str, Any]:
354
- """Async REST call with retry logic"""
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.2.0"
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.2.0"
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