ee-client 2.2.1__tar.gz → 2.4.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 (29) hide show
  1. {ee_client-2.2.1 → ee_client-2.4.0}/PKG-INFO +2 -2
  2. {ee_client-2.2.1 → ee_client-2.4.0}/ee_client.egg-info/PKG-INFO +2 -2
  3. {ee_client-2.2.1 → ee_client-2.4.0}/ee_client.egg-info/requires.txt +1 -1
  4. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/__init__.py +1 -1
  5. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/client.py +101 -60
  6. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/models.py +7 -0
  7. {ee_client-2.2.1 → ee_client-2.4.0}/pyproject.toml +3 -3
  8. {ee_client-2.2.1 → ee_client-2.4.0}/LICENSE +0 -0
  9. {ee_client-2.2.1 → ee_client-2.4.0}/README.rst +0 -0
  10. {ee_client-2.2.1 → ee_client-2.4.0}/ee_client.egg-info/SOURCES.txt +0 -0
  11. {ee_client-2.2.1 → ee_client-2.4.0}/ee_client.egg-info/dependency_links.txt +0 -0
  12. {ee_client-2.2.1 → ee_client-2.4.0}/ee_client.egg-info/top_level.txt +0 -0
  13. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/data.py +0 -0
  14. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/exceptions.py +0 -0
  15. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/export/__init__.py +0 -0
  16. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/export/image.py +0 -0
  17. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/export/table.py +0 -0
  18. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/helpers.py +0 -0
  19. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/interfaces/__init__.py +0 -0
  20. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/interfaces/export.py +0 -0
  21. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/interfaces/operations.py +0 -0
  22. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/interfaces/tasks.py +0 -0
  23. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/oauth_app.py +0 -0
  24. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/sepal_credential_mixin.py +0 -0
  25. {ee_client-2.2.1 → ee_client-2.4.0}/eeclient/tasks.py +0 -0
  26. {ee_client-2.2.1 → ee_client-2.4.0}/setup.cfg +0 -0
  27. {ee_client-2.2.1 → ee_client-2.4.0}/tests/test_client.py +0 -0
  28. {ee_client-2.2.1 → ee_client-2.4.0}/tests/test_data.py +0 -0
  29. {ee_client-2.2.1 → ee_client-2.4.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.1
3
+ Version: 2.4.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.1
3
+ Version: 2.4.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.1"
3
+ __version__ = "2.4.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
@@ -141,6 +141,13 @@ class SepalHeaders(BaseModel):
141
141
  raise ValueError("Invalid JSON string in sepal-user field") from e
142
142
  return v
143
143
 
144
+ @property
145
+ def session_id(self) -> Optional[str]:
146
+ """
147
+ Returns the SEPAL-SESSIONID cookie value if present.
148
+ """
149
+ return self.cookies.get("SEPAL-SESSIONID")
150
+
144
151
  model_config = ConfigDict(
145
152
  alias_generator=to_camel,
146
153
  populate_by_name=True,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ee-client"
7
- version = "2.2.1"
7
+ version = "2.4.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.1"
69
+ version = "2.4.0"
70
70
  version_files = [
71
71
  "pyproject.toml:version",
72
72
  "eeclient/__init__.py:__version__",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes