ee-client 2.2.0__tar.gz → 2.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 (30) hide show
  1. {ee_client-2.2.0 → ee_client-2.3.0}/PKG-INFO +2 -2
  2. {ee_client-2.2.0 → ee_client-2.3.0}/ee_client.egg-info/PKG-INFO +2 -2
  3. {ee_client-2.2.0 → ee_client-2.3.0}/ee_client.egg-info/requires.txt +1 -1
  4. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/__init__.py +1 -1
  5. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/client.py +101 -60
  6. ee_client-2.3.0/eeclient/exceptions.py +100 -0
  7. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/sepal_credential_mixin.py +36 -7
  8. {ee_client-2.2.0 → ee_client-2.3.0}/pyproject.toml +3 -3
  9. ee_client-2.2.0/eeclient/exceptions.py +0 -26
  10. {ee_client-2.2.0 → ee_client-2.3.0}/LICENSE +0 -0
  11. {ee_client-2.2.0 → ee_client-2.3.0}/README.rst +0 -0
  12. {ee_client-2.2.0 → ee_client-2.3.0}/ee_client.egg-info/SOURCES.txt +0 -0
  13. {ee_client-2.2.0 → ee_client-2.3.0}/ee_client.egg-info/dependency_links.txt +0 -0
  14. {ee_client-2.2.0 → ee_client-2.3.0}/ee_client.egg-info/top_level.txt +0 -0
  15. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/data.py +0 -0
  16. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/export/__init__.py +0 -0
  17. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/export/image.py +0 -0
  18. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/export/table.py +0 -0
  19. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/helpers.py +0 -0
  20. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/interfaces/__init__.py +0 -0
  21. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/interfaces/export.py +0 -0
  22. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/interfaces/operations.py +0 -0
  23. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/interfaces/tasks.py +0 -0
  24. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/models.py +0 -0
  25. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/oauth_app.py +0 -0
  26. {ee_client-2.2.0 → ee_client-2.3.0}/eeclient/tasks.py +0 -0
  27. {ee_client-2.2.0 → ee_client-2.3.0}/setup.cfg +0 -0
  28. {ee_client-2.2.0 → ee_client-2.3.0}/tests/test_client.py +0 -0
  29. {ee_client-2.2.0 → ee_client-2.3.0}/tests/test_data.py +0 -0
  30. {ee_client-2.2.0 → ee_client-2.3.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.2.0
3
+ Version: 2.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
@@ -15,7 +15,7 @@ Requires-Python: >=3.9
15
15
  Description-Content-Type: text/x-rst
16
16
  License-File: LICENSE
17
17
  Requires-Dist: earthengine-api
18
- Requires-Dist: httpx
18
+ Requires-Dist: httpx[http2]
19
19
  Requires-Dist: aiogoogle
20
20
  Requires-Dist: tenacity
21
21
  Requires-Dist: pydantic
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ee-client
3
- Version: 2.2.0
3
+ Version: 2.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
@@ -15,7 +15,7 @@ Requires-Python: >=3.9
15
15
  Description-Content-Type: text/x-rst
16
16
  License-File: LICENSE
17
17
  Requires-Dist: earthengine-api
18
- Requires-Dist: httpx
18
+ Requires-Dist: httpx[http2]
19
19
  Requires-Dist: aiogoogle
20
20
  Requires-Dist: tenacity
21
21
  Requires-Dist: pydantic
@@ -1,5 +1,5 @@
1
1
  earthengine-api
2
- httpx
2
+ httpx[http2]
3
3
  aiogoogle
4
4
  tenacity
5
5
  pydantic
@@ -1,6 +1,6 @@
1
1
  __title__ = "eeclient"
2
2
  __summary__ = "A client for Google Earth Engine"
3
- __version__ = "2.2.0"
3
+ __version__ = "2.3.0"
4
4
 
5
5
  __author__ = "Daniel Guerrero"
6
6
  __email__ = "dfgm2006@gmail.com"
@@ -33,6 +33,23 @@ SEPAL_API_DOWNLOAD_URL = None
33
33
  VERIFY_SSL = True
34
34
 
35
35
 
36
+ class SimpleRateLimiter:
37
+ def __init__(self, qps: float | None):
38
+ self.qps = qps
39
+ self._lock = asyncio.Lock()
40
+ self._next = 0.0
41
+
42
+ async def acquire(self):
43
+ if not self.qps or self.qps <= 0:
44
+ return
45
+ async with self._lock:
46
+ now = asyncio.get_running_loop().time()
47
+ wait = max(0.0, self._next - now)
48
+ if wait:
49
+ await asyncio.sleep(wait)
50
+ self._next = max(now, self._next) + 1.0 / self.qps
51
+
52
+
36
53
  class EESession(SepalCredentialMixin):
37
54
  def __init__(
38
55
  self,
@@ -52,12 +69,18 @@ class EESession(SepalCredentialMixin):
52
69
  Raises:
53
70
  ValueError: If SEPAL_HOST environment variable is not set
54
71
  """
72
+ self._inflight = asyncio.BoundedSemaphore(30)
73
+ self._rate = SimpleRateLimiter(60)
74
+
75
+ self._auth_refresh_lock = asyncio.Lock()
76
+
77
+ self._client: httpx.AsyncClient | None = None
78
+ self._client_lock = asyncio.Lock()
79
+
55
80
  self.enforce_project_id = enforce_project_id
56
81
  super().__init__(sepal_headers)
57
82
 
58
- # Create user-specific logger
59
83
  self.logger = logging.getLogger(f"eeclient.{self.user}")
60
-
61
84
  self.logger.debug(
62
85
  "EESession initialized"
63
86
  if self._credentials
@@ -127,29 +150,37 @@ class EESession(SepalCredentialMixin):
127
150
 
128
151
  return GEEHeaders.model_validate(data)
129
152
 
130
- async def get_headers(self) -> GEEHeaders:
131
- """Async method to get headers, refreshing credentials if needed"""
153
+ async def get_headers(self):
154
+ # Only one task refreshes the token; others wait briefly.
132
155
  if self.needs_credentials_refresh():
133
- await self.set_credentials()
156
+ async with self._auth_refresh_lock:
157
+ if self.needs_credentials_refresh(): # double-check after lock
158
+ await self.set_credentials()
134
159
  return self.get_current_headers()
135
160
 
161
+ async def _ensure_client(self) -> httpx.AsyncClient:
162
+ if self._client is None:
163
+ async with self._client_lock:
164
+ if self._client is None:
165
+ self._client = httpx.AsyncClient(
166
+ http2=True,
167
+ timeout=httpx.Timeout(connect=60, read=360, write=60, pool=60),
168
+ limits=httpx.Limits(
169
+ max_connections=40, max_keepalive_connections=20
170
+ ),
171
+ verify=getattr(self, "verify_ssl", True),
172
+ )
173
+ return self._client
174
+
136
175
  @asynccontextmanager
137
176
  async def get_client(self):
138
- """Context manager for an HTTP client using the current headers.
139
- A new client is created each time to ensure fresh headers."""
140
-
141
- timeout = httpx.Timeout(connect=60.0, read=360.0, write=60.0, pool=60.0)
142
- headers = await self.get_headers()
143
- # Use the model_dump with by_alias=True to keep the original HTTP header names
144
- headers = headers.model_dump(by_alias=True) # type: ignore
145
- limits = httpx.Limits(max_connections=100, max_keepalive_connections=50)
146
- async_client = httpx.AsyncClient(
147
- headers=headers, timeout=timeout, limits=limits
148
- )
149
- try:
150
- yield async_client
151
- finally:
152
- await async_client.aclose()
177
+ client = await self._ensure_client()
178
+ yield client
179
+
180
+ async def aclose(self):
181
+ if self._client is not None:
182
+ await self._client.aclose()
183
+ self._client = None
153
184
 
154
185
  async def rest_call(
155
186
  self,
@@ -168,51 +199,61 @@ class EESession(SepalCredentialMixin):
168
199
 
169
200
  while attempt < max_attempts:
170
201
  try:
171
- async with self.get_client() as client:
172
- url_with_project = self.set_url_project(url)
202
+ headers = (await self.get_headers()).model_dump(by_alias=True)
203
+ url_with_project = self.set_url_project(url)
204
+
205
+ async with self._inflight:
206
+ await self._rate.acquire()
207
+ async with self.get_client() as client:
173
208
 
174
- if "assets" not in url_with_project:
175
- # Do not log assets requests
176
- self.logger.debug(
177
- f"Making async {method} request to {url_with_project}"
209
+ if "assets" not in url_with_project:
210
+ # Do not log assets requests
211
+ self.logger.debug(
212
+ f"Making async {method} request to {url_with_project}"
213
+ )
214
+ response = await client.request(
215
+ method,
216
+ url_with_project,
217
+ json=data,
218
+ params=params,
219
+ headers=headers,
178
220
  )
179
- response = await client.request(
180
- method, url_with_project, json=data, params=params
181
- )
182
221
 
183
- if response.status_code >= 400:
184
- if "application/json" in response.headers.get(
185
- "Content-Type", ""
186
- ):
187
- error_data = response.json().get("error", {})
188
- self.logger.error(
189
- f"Request failed with error: {error_data}"
222
+ if response.status_code >= 400:
223
+ if "application/json" in response.headers.get(
224
+ "Content-Type", ""
225
+ ):
226
+ error_data = response.json().get("error", {})
227
+ self.logger.error(
228
+ f"Request failed with error: {error_data}"
229
+ )
230
+ raise EERestException(error_data)
231
+ else:
232
+ error_data = {
233
+ "code": response.status_code,
234
+ "message": response.reason_phrase
235
+ or "Unknown HTTP error",
236
+ "status": response.status_code,
237
+ }
238
+ self.logger.error(
239
+ f"Request failed with HTTP error: {error_data}"
240
+ )
241
+ raise EERestException(error_data)
242
+
243
+ try:
244
+ return response.json()
245
+ except Exception as e:
246
+ self.logger.error(f"Error parsing JSON response: {str(e)}")
247
+ self.logger.debug(
248
+ f"Response content: {response.text[:500]}..."
190
249
  )
191
- raise EERestException(error_data)
192
- else:
193
- error_data = {
194
- "code": response.status_code,
195
- "message": response.reason_phrase
196
- or "Unknown HTTP error",
197
- "status": response.status_code,
198
- }
199
- self.logger.error(
200
- f"Request failed with HTTP error: {error_data}"
250
+ raise EERestException(
251
+ {
252
+ "code": 500,
253
+ "message": f"Invalid JSON response: {str(e)}",
254
+ "status": response.status_code,
255
+ }
201
256
  )
202
- raise EERestException(error_data)
203
-
204
- try:
205
- return response.json()
206
- except Exception as e:
207
- self.logger.error(f"Error parsing JSON response: {str(e)}")
208
- self.logger.debug(f"Response content: {response.text[:500]}...")
209
- raise EERestException(
210
- {
211
- "code": 500,
212
- "message": f"Invalid JSON response: {str(e)}",
213
- "status": response.status_code,
214
- }
215
- )
216
257
 
217
258
  except EERestException as e:
218
259
  last_error = e
@@ -0,0 +1,100 @@
1
+ from ee.ee_exception import EEException
2
+
3
+
4
+ class EERestException(EEException):
5
+ def __init__(self, error):
6
+ self.message = error.get("message", "EE responded with an error")
7
+ super().__init__(self.message)
8
+ self.code = error.get("code", -1)
9
+ self.status = error.get("status", "UNDEFINED")
10
+ self.details = error.get("details")
11
+
12
+
13
+ class EEClientError(Exception):
14
+ """Custom exception class for EEClient errors."""
15
+
16
+ def __init__(self, error):
17
+ if isinstance(error, str):
18
+ self.message = error
19
+ self.code = -1
20
+ self.status = "UNDEFINED"
21
+ self.details = None
22
+ else:
23
+ self.message = error.get("message", "EEClient responded with an error")
24
+ self.code = error.get("code", -1)
25
+ self.status = error.get("status", "UNDEFINED")
26
+ self.details = error.get("details")
27
+ super().__init__(self.message)
28
+
29
+
30
+ class CredentialsFileNotFoundError(EEClientError):
31
+ """Raised when local credentials file is not found."""
32
+
33
+ def __init__(self, file_path: str):
34
+ self.file_path = file_path
35
+ error = {
36
+ "code": 404,
37
+ "message": (
38
+ "There was an error when trying to authenticate with Google "
39
+ "Earth Engine. Make sure your GEE account is properly "
40
+ "connected to SEPAL. For more information, please visit "
41
+ "<a href='https://docs.sepal.io/en/latest/setup/gee.html' "
42
+ "target='_blank'> the documentation </a>."
43
+ ),
44
+ "status": "CREDENTIALS_NOT_FOUND",
45
+ "details": f"Credentials file not found at {file_path}",
46
+ }
47
+ super().__init__(error)
48
+
49
+
50
+ class CredentialsFileUnknownError(EEClientError):
51
+ """Raised when unknown error occurred while accessing credentials file."""
52
+
53
+ def __init__(self):
54
+ error = {
55
+ "code": 500,
56
+ "message": (
57
+ "There was an error when trying to authenticate with Google "
58
+ "Earth Engine. Please re-authenticate your GEE account with "
59
+ "SEPAL. For more information, visit "
60
+ "<a href='https://docs.sepal.io/en/latest/setup/gee.html' "
61
+ "target='_blank'> the documentation </a>."
62
+ ),
63
+ "status": "CREDENTIALS_ERROR",
64
+ "details": "Unknown error occurred while accessing credentials file",
65
+ }
66
+ super().__init__(error)
67
+
68
+
69
+ class SepalCredentialsUnavailableError(EEClientError):
70
+ """Raised when SEPAL API cannot provide credentials (e.g., 500 error)."""
71
+
72
+ def __init__(self, status_code: int | None = None):
73
+ self.status_code = status_code
74
+ error = {
75
+ "code": status_code or 500,
76
+ "message": (
77
+ "There was an error when trying to authenticate with Google "
78
+ "Earth Engine. Make sure your GEE account is properly "
79
+ "connected to SEPAL. Please visit "
80
+ "<a href='https://docs.sepal.io/en/latest/setup/gee.html' "
81
+ "target='_blank'> the documentation </a> for more information."
82
+ ),
83
+ "status": "SEPAL_CREDENTIALS_UNAVAILABLE",
84
+ "details": (
85
+ f"SEPAL API returned error {status_code}"
86
+ if status_code
87
+ else "SEPAL API is unable to provide credentials"
88
+ ),
89
+ }
90
+ super().__init__(error)
91
+
92
+
93
+ # {'code': 401, 'message': 'Request had invalid authentication credentials.
94
+ # Expected OAuth 2 access token, login cookie or other valid authentication
95
+ # credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.',
96
+ # 'status': 'UNAUTHENTICATED'}
97
+ # Exception in _run_task: Request had invalid authentication credentials.
98
+ # Expected OAuth 2 access token, login cookie or other valid authentication
99
+ # credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.
100
+ # when that error happens, I need to re-set the credentials
@@ -1,4 +1,9 @@
1
1
  from eeclient.models import SepalHeaders, GoogleTokens
2
+ from eeclient.exceptions import (
3
+ CredentialsFileNotFoundError,
4
+ CredentialsFileUnknownError,
5
+ SepalCredentialsUnavailableError,
6
+ )
2
7
  import os
3
8
  import logging
4
9
  import json
@@ -84,10 +89,14 @@ class SepalCredentialMixin:
84
89
  def _load_credentials_from_file(self):
85
90
  """Load credentials from file and update internal state"""
86
91
  if not self.credentials_path.exists():
87
- raise ValueError(f"Credentials file not found at {self.credentials_path}")
92
+ raise CredentialsFileNotFoundError(str(self.credentials_path))
88
93
 
89
94
  try:
90
- credentials_data = json.loads(self.credentials_path.read_text())
95
+ file_content = self.credentials_path.read_text().strip()
96
+ if not file_content:
97
+ raise CredentialsFileNotFoundError(str(self.credentials_path))
98
+
99
+ credentials_data = json.loads(file_content)
91
100
  self._credentials = GoogleTokens.model_validate(credentials_data)
92
101
  self.access_token = self._credentials.access_token
93
102
  self.project_id = self._credentials.project_id
@@ -166,6 +175,12 @@ class SepalCredentialMixin:
166
175
  f"Project: {self.project_id}"
167
176
  )
168
177
  return
178
+ elif response.status_code == 500:
179
+ self.logger.error(
180
+ "SEPAL API returned 500 error - "
181
+ "credentials not available on server"
182
+ )
183
+ raise SepalCredentialsUnavailableError(500)
169
184
  else:
170
185
  self.logger.debug(
171
186
  f"Attempt {attempt}/{self.max_retries} failed with "
@@ -191,6 +206,8 @@ class SepalCredentialMixin:
191
206
  )
192
207
  attempt = 0
193
208
 
209
+ log.debug(f"Credentials path: {self.credentials_path}")
210
+
194
211
  while attempt < self.max_retries:
195
212
  attempt += 1
196
213
  try:
@@ -214,11 +231,17 @@ class SepalCredentialMixin:
214
231
  f"Credentials from file are still expired."
215
232
  )
216
233
 
234
+ except CredentialsFileNotFoundError:
235
+ self.logger.error(
236
+ f"Credentials file not found: {self.credentials_path}"
237
+ )
238
+ raise
217
239
  except Exception as e:
218
240
  self.logger.error(
219
241
  f"Attempt {attempt}/{self.max_retries} "
220
242
  f"encountered an exception while reading file: {e}"
221
243
  )
244
+ raise
222
245
 
223
246
  # Wait before retrying (the file might be updated externally)
224
247
  if attempt < self.max_retries:
@@ -226,11 +249,7 @@ class SepalCredentialMixin:
226
249
  self.logger.debug(f"Waiting {wait_time} seconds before retry...")
227
250
  await asyncio.sleep(wait_time)
228
251
 
229
- raise ValueError(
230
- f"Failed to retrieve valid credentials from file after "
231
- f"{self.max_retries} attempts. File may not be automatically "
232
- f"updated or credentials are permanently expired."
233
- )
252
+ raise CredentialsFileUnknownError()
234
253
 
235
254
  def set_credentials_sync(self) -> None:
236
255
  """
@@ -282,6 +301,13 @@ class SepalCredentialMixin:
282
301
  f"Project: {self.project_id}"
283
302
  )
284
303
  return
304
+ elif response.status_code == 500:
305
+ self.logger.error(
306
+ "SEPAL API returned 500 error - "
307
+ "credentials not available on server"
308
+ )
309
+ session.close()
310
+ raise SepalCredentialsUnavailableError(500)
285
311
  else:
286
312
  self.logger.debug(
287
313
  f"Attempt {attempt}/{self.max_retries} failed with "
@@ -336,6 +362,9 @@ class SepalCredentialMixin:
336
362
  f"Credentials from file are still expired."
337
363
  )
338
364
 
365
+ except CredentialsFileNotFoundError:
366
+ # Re-raise immediately - no point in retrying if file doesn't exist
367
+ raise
339
368
  except Exception as e:
340
369
  self.logger.error(
341
370
  f"Attempt {attempt}/{self.max_retries} "
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ee-client"
7
- version = "2.2.0"
7
+ version = "2.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 = [
@@ -12,7 +12,7 @@ authors = [
12
12
  ]
13
13
  dependencies = [
14
14
  "earthengine-api",
15
- "httpx",
15
+ "httpx[http2]",
16
16
  "aiogoogle",
17
17
  "tenacity",
18
18
  "pydantic",
@@ -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.2.0"
69
+ version = "2.3.0"
70
70
  version_files = [
71
71
  "pyproject.toml:version",
72
72
  "eeclient/__init__.py:__version__",
@@ -1,26 +0,0 @@
1
- from ee.ee_exception import EEException
2
-
3
-
4
- class EERestException(EEException):
5
- def __init__(self, error):
6
- self.message = error.get("message", "EE responded with an error")
7
- super().__init__(self.message)
8
- self.code = error.get("code", -1)
9
- self.status = error.get("status", "UNDEFINED")
10
- self.details = error.get("details")
11
-
12
-
13
- class EEClientError(Exception):
14
- """Custom exception class for EEClient errors."""
15
-
16
- pass
17
-
18
-
19
- # {'code': 401, 'message': 'Request had invalid authentication credentials.
20
- # Expected OAuth 2 access token, login cookie or other valid authentication
21
- # credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.',
22
- # 'status': 'UNAUTHENTICATED'}
23
- # Exception in _run_task: Request had invalid authentication credentials.
24
- # Expected OAuth 2 access token, login cookie or other valid authentication
25
- # credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.
26
- # when that error happens, I need to re-set the credentials
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes