ee-client 3.2.1__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.1 → ee_client-3.3.0}/PKG-INFO +1 -1
  2. {ee_client-3.2.1 → ee_client-3.3.0}/ee_client.egg-info/PKG-INFO +1 -1
  3. {ee_client-3.2.1 → ee_client-3.3.0}/ee_client.egg-info/SOURCES.txt +2 -0
  4. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/__init__.py +1 -1
  5. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/client.py +13 -3
  6. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/data.py +58 -0
  7. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/interfaces/operations.py +10 -0
  8. {ee_client-3.2.1 → ee_client-3.3.0}/pyproject.toml +2 -2
  9. ee_client-3.3.0/tests/test_compute_pixels.py +97 -0
  10. ee_client-3.3.0/tests/test_rest_call_raw.py +107 -0
  11. {ee_client-3.2.1 → ee_client-3.3.0}/LICENSE +0 -0
  12. {ee_client-3.2.1 → ee_client-3.3.0}/README.rst +0 -0
  13. {ee_client-3.2.1 → ee_client-3.3.0}/ee_client.egg-info/dependency_links.txt +0 -0
  14. {ee_client-3.2.1 → ee_client-3.3.0}/ee_client.egg-info/requires.txt +0 -0
  15. {ee_client-3.2.1 → ee_client-3.3.0}/ee_client.egg-info/top_level.txt +0 -0
  16. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/cache.py +0 -0
  17. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/credential_mixin.py +0 -0
  18. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/exceptions.py +0 -0
  19. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/export/__init__.py +0 -0
  20. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/export/image.py +0 -0
  21. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/export/table.py +0 -0
  22. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/helpers.py +0 -0
  23. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/interfaces/__init__.py +0 -0
  24. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/interfaces/export.py +0 -0
  25. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/interfaces/tasks.py +0 -0
  26. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/loopstate.py +0 -0
  27. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/models.py +0 -0
  28. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/providers.py +0 -0
  29. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/sepal_credential_mixin.py +0 -0
  30. {ee_client-3.2.1 → ee_client-3.3.0}/eeclient/tasks.py +0 -0
  31. {ee_client-3.2.1 → ee_client-3.3.0}/setup.cfg +0 -0
  32. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_agnostic_auth_integration.py +0 -0
  33. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_cache.py +0 -0
  34. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_client.py +0 -0
  35. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_credential_mixin_close.py +0 -0
  36. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_data.py +0 -0
  37. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_export_image.py +0 -0
  38. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_export_image_pyramiding.py +0 -0
  39. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_export_table.py +0 -0
  40. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_factories.py +0 -0
  41. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_integration_get_assets.py +0 -0
  42. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_loop_scoping.py +0 -0
  43. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_models.py +0 -0
  44. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_providers.py +0 -0
  45. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_resolution.py +0 -0
  46. {ee_client-3.2.1 → ee_client-3.3.0}/tests/test_tasks.py +0 -0
  47. {ee_client-3.2.1 → 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.1
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.1
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.1"
3
+ __version__ = "3.3.0"
4
4
 
5
5
  __author__ = "Daniel Guerrero"
6
6
  __email__ = "dfgm2006@gmail.com"
@@ -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.1"
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.1"
69
+ version = "3.3.0"
70
70
  version_files = [
71
71
  "pyproject.toml:version",
72
72
  "eeclient/__init__.py:__version__",
@@ -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