ee-client 2.0.3__tar.gz → 2.2.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-2.0.3 → ee_client-2.2.0}/PKG-INFO +10 -3
- {ee_client-2.0.3 → ee_client-2.2.0}/README.rst +9 -2
- {ee_client-2.0.3 → ee_client-2.2.0}/ee_client.egg-info/PKG-INFO +10 -3
- {ee_client-2.0.3 → ee_client-2.2.0}/ee_client.egg-info/SOURCES.txt +1 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/__init__.py +1 -1
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/client.py +39 -112
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/export/image.py +20 -7
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/export/table.py +19 -6
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/helpers.py +11 -5
- ee_client-2.2.0/eeclient/sepal_credential_mixin.py +355 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/tasks.py +16 -5
- {ee_client-2.0.3 → ee_client-2.2.0}/pyproject.toml +2 -2
- {ee_client-2.0.3 → ee_client-2.2.0}/LICENSE +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/ee_client.egg-info/dependency_links.txt +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/ee_client.egg-info/requires.txt +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/ee_client.egg-info/top_level.txt +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/data.py +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/exceptions.py +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/export/__init__.py +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/interfaces/__init__.py +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/interfaces/export.py +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/interfaces/operations.py +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/interfaces/tasks.py +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/models.py +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/oauth_app.py +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/setup.cfg +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/tests/test_client.py +0 -0
- {ee_client-2.0.3 → ee_client-2.2.0}/tests/test_data.py +0 -0
- {ee_client-2.0.3 → ee_client-2.2.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.0
|
|
3
|
+
Version: 2.2.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
|
|
@@ -32,15 +32,22 @@ Dynamic: license-file
|
|
|
32
32
|
Earth Engine Client 🌎
|
|
33
33
|
==============================
|
|
34
34
|
|
|
35
|
-
The **Earth Engine Session Client** is a Python package that extends the Google Earth Engine (GEE) API by introducing multi-user session management through
|
|
35
|
+
The **Earth Engine Session Client** is a Python package that extends the Google Earth Engine (GEE) API by introducing multi-user session management through custom authentication.
|
|
36
|
+
|
|
37
|
+
**Why This Package?**
|
|
38
|
+
|
|
39
|
+
While Google Earth Engine applications can be created using a global service account, this approach has significant limitations: users cannot access their private GEE assets without making them public. This package solves this problem by handling custom authentication, allowing each user to access their own private assets securely.
|
|
40
|
+
|
|
41
|
+
Unlike the standard GEE API—which relies on a global session object and does not support multi-user environments—this client ensures that each session is authenticated and managed independently with user-specific credentials.
|
|
36
42
|
|
|
37
43
|
Each session is instantiated via the ``EESession`` class, currently only accepts SEPAL headers as its only parameter. **A valid ``sepal-session-id`` cookie must be present in these headers**, as it is used to retrieve the corresponding GEE credentials. Once authenticated, the session exposes an ``operations`` property that provides easy access to key API methods.
|
|
38
44
|
|
|
39
45
|
Key Features
|
|
40
46
|
------------
|
|
41
47
|
|
|
48
|
+
- **Custom User Authentication**: Enable users to access their private GEE assets without requiring them to be public, solving the limitation of global service account approaches.
|
|
42
49
|
- **SEPAL-based Initialization**: Create sessions using SEPAL headers. The required ``sepal-session-id`` cookie is automatically used to retrieve GEE credentials.
|
|
43
|
-
- **
|
|
50
|
+
- **Multi-User Session Management**: Encapsulate user-specific credentials and project data in independent ``EESession`` objects.
|
|
44
51
|
- **Enhanced API Operations**: Access GEE functionalities via the ``operations`` property, which includes methods such as:
|
|
45
52
|
- ``get_info``: Retrieve detailed information about an Earth Engine object.
|
|
46
53
|
- ``get_map_id``: Generate a map ID for an Earth Engine image.
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
Earth Engine Client 🌎
|
|
2
2
|
==============================
|
|
3
3
|
|
|
4
|
-
The **Earth Engine Session Client** is a Python package that extends the Google Earth Engine (GEE) API by introducing multi-user session management through
|
|
4
|
+
The **Earth Engine Session Client** is a Python package that extends the Google Earth Engine (GEE) API by introducing multi-user session management through custom authentication.
|
|
5
|
+
|
|
6
|
+
**Why This Package?**
|
|
7
|
+
|
|
8
|
+
While Google Earth Engine applications can be created using a global service account, this approach has significant limitations: users cannot access their private GEE assets without making them public. This package solves this problem by handling custom authentication, allowing each user to access their own private assets securely.
|
|
9
|
+
|
|
10
|
+
Unlike the standard GEE API—which relies on a global session object and does not support multi-user environments—this client ensures that each session is authenticated and managed independently with user-specific credentials.
|
|
5
11
|
|
|
6
12
|
Each session is instantiated via the ``EESession`` class, currently only accepts SEPAL headers as its only parameter. **A valid ``sepal-session-id`` cookie must be present in these headers**, as it is used to retrieve the corresponding GEE credentials. Once authenticated, the session exposes an ``operations`` property that provides easy access to key API methods.
|
|
7
13
|
|
|
8
14
|
Key Features
|
|
9
15
|
------------
|
|
10
16
|
|
|
17
|
+
- **Custom User Authentication**: Enable users to access their private GEE assets without requiring them to be public, solving the limitation of global service account approaches.
|
|
11
18
|
- **SEPAL-based Initialization**: Create sessions using SEPAL headers. The required ``sepal-session-id`` cookie is automatically used to retrieve GEE credentials.
|
|
12
|
-
- **
|
|
19
|
+
- **Multi-User Session Management**: Encapsulate user-specific credentials and project data in independent ``EESession`` objects.
|
|
13
20
|
- **Enhanced API Operations**: Access GEE functionalities via the ``operations`` property, which includes methods such as:
|
|
14
21
|
- ``get_info``: Retrieve detailed information about an Earth Engine object.
|
|
15
22
|
- ``get_map_id``: Generate a map ID for an Earth Engine image.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ee-client
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.2.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
|
|
@@ -32,15 +32,22 @@ Dynamic: license-file
|
|
|
32
32
|
Earth Engine Client 🌎
|
|
33
33
|
==============================
|
|
34
34
|
|
|
35
|
-
The **Earth Engine Session Client** is a Python package that extends the Google Earth Engine (GEE) API by introducing multi-user session management through
|
|
35
|
+
The **Earth Engine Session Client** is a Python package that extends the Google Earth Engine (GEE) API by introducing multi-user session management through custom authentication.
|
|
36
|
+
|
|
37
|
+
**Why This Package?**
|
|
38
|
+
|
|
39
|
+
While Google Earth Engine applications can be created using a global service account, this approach has significant limitations: users cannot access their private GEE assets without making them public. This package solves this problem by handling custom authentication, allowing each user to access their own private assets securely.
|
|
40
|
+
|
|
41
|
+
Unlike the standard GEE API—which relies on a global session object and does not support multi-user environments—this client ensures that each session is authenticated and managed independently with user-specific credentials.
|
|
36
42
|
|
|
37
43
|
Each session is instantiated via the ``EESession`` class, currently only accepts SEPAL headers as its only parameter. **A valid ``sepal-session-id`` cookie must be present in these headers**, as it is used to retrieve the corresponding GEE credentials. Once authenticated, the session exposes an ``operations`` property that provides easy access to key API methods.
|
|
38
44
|
|
|
39
45
|
Key Features
|
|
40
46
|
------------
|
|
41
47
|
|
|
48
|
+
- **Custom User Authentication**: Enable users to access their private GEE assets without requiring them to be public, solving the limitation of global service account approaches.
|
|
42
49
|
- **SEPAL-based Initialization**: Create sessions using SEPAL headers. The required ``sepal-session-id`` cookie is automatically used to retrieve GEE credentials.
|
|
43
|
-
- **
|
|
50
|
+
- **Multi-User Session Management**: Encapsulate user-specific credentials and project data in independent ``EESession`` objects.
|
|
44
51
|
- **Enhanced API Operations**: Access GEE functionalities via the ``operations`` property, which includes methods such as:
|
|
45
52
|
- ``get_info``: Retrieve detailed information about an Earth Engine object.
|
|
46
53
|
- ``get_map_id``: Generate a map ID for an Earth Engine image.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
from typing import Any, Dict, Literal, Optional
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
import time
|
|
5
4
|
import asyncio
|
|
6
5
|
import httpx
|
|
7
6
|
import logging
|
|
8
7
|
from contextlib import asynccontextmanager
|
|
9
8
|
|
|
10
9
|
from eeclient.exceptions import EEClientError, EERestException
|
|
11
|
-
from eeclient.models import GEEHeaders,
|
|
10
|
+
from eeclient.models import GEEHeaders, SepalHeaders
|
|
11
|
+
from eeclient.sepal_credential_mixin import SepalCredentialMixin
|
|
12
12
|
|
|
13
13
|
import eeclient.export as _export_module
|
|
14
14
|
import eeclient.data as _operations_module
|
|
@@ -33,8 +33,12 @@ SEPAL_API_DOWNLOAD_URL = None
|
|
|
33
33
|
VERIFY_SSL = True
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
class EESession:
|
|
37
|
-
def __init__(
|
|
36
|
+
class EESession(SepalCredentialMixin):
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
sepal_headers: Optional[SepalHeaders] = None,
|
|
40
|
+
enforce_project_id: bool = True,
|
|
41
|
+
):
|
|
38
42
|
"""Session that handles two scenarios to set the headers for Earth Engine API
|
|
39
43
|
|
|
40
44
|
It can be initialized with the headers sent by SEPAL or with the
|
|
@@ -48,46 +52,20 @@ class EESession:
|
|
|
48
52
|
Raises:
|
|
49
53
|
ValueError: If SEPAL_HOST environment variable is not set
|
|
50
54
|
"""
|
|
51
|
-
# Get and validate environment variables that are required for the session
|
|
52
|
-
self.sepal_host = os.getenv("SEPAL_HOST")
|
|
53
|
-
if not self.sepal_host:
|
|
54
|
-
raise ValueError("SEPAL_HOST environment variable not set")
|
|
55
|
-
|
|
56
|
-
self.sepal_api_download_url = (
|
|
57
|
-
f"https://{self.sepal_host}/api/user-files/download/"
|
|
58
|
-
"?path=%2F.config%2Fearthengine%2Fcredentials"
|
|
59
|
-
)
|
|
60
|
-
self.verify_ssl = not (
|
|
61
|
-
self.sepal_host == "host.docker.internal"
|
|
62
|
-
or self.sepal_host == "danielg.sepal.io"
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
self.expiry_date = 0
|
|
66
|
-
self.max_retries = 3
|
|
67
|
-
self._credentials = None
|
|
68
|
-
|
|
69
55
|
self.enforce_project_id = enforce_project_id
|
|
70
|
-
|
|
71
|
-
self.sepal_session_id = self.sepal_headers.cookies["SEPAL-SESSIONID"]
|
|
72
|
-
self.sepal_user_data = self.sepal_headers.sepal_user
|
|
73
|
-
self.user = self.sepal_user_data.username
|
|
56
|
+
super().__init__(sepal_headers)
|
|
74
57
|
|
|
75
58
|
# Create user-specific logger
|
|
76
59
|
self.logger = logging.getLogger(f"eeclient.{self.user}")
|
|
77
60
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
self._credentials = self._google_tokens
|
|
84
|
-
else:
|
|
85
|
-
self._credentials = None
|
|
86
|
-
self.project_id = None
|
|
87
|
-
self.logger.debug(
|
|
88
|
-
"No credentials found in initial headers. "
|
|
61
|
+
self.logger.debug(
|
|
62
|
+
"EESession initialized"
|
|
63
|
+
if self._credentials
|
|
64
|
+
else (
|
|
65
|
+
"EESession created without credentials. "
|
|
89
66
|
"Call initialize() or use create() to fetch credentials."
|
|
90
67
|
)
|
|
68
|
+
)
|
|
91
69
|
|
|
92
70
|
async def initialize(self) -> "EESession":
|
|
93
71
|
"""Asynchronously initialize the session by fetching credentials if needed.
|
|
@@ -103,11 +81,16 @@ class EESession:
|
|
|
103
81
|
return self
|
|
104
82
|
|
|
105
83
|
@classmethod
|
|
106
|
-
async def create(
|
|
84
|
+
async def create(
|
|
85
|
+
cls,
|
|
86
|
+
sepal_headers: Optional[SepalHeaders] = None,
|
|
87
|
+
enforce_project_id: bool = True,
|
|
88
|
+
):
|
|
107
89
|
"""Asynchronously create an EESession instance.
|
|
108
90
|
|
|
109
91
|
Args:
|
|
110
|
-
sepal_headers (SepalHeaders): The headers sent by SEPAL
|
|
92
|
+
sepal_headers (Optional[SepalHeaders]): The headers sent by SEPAL.
|
|
93
|
+
If None, will use file-based authentication.
|
|
111
94
|
enforce_project_id (bool, optional): If set, it cannot be changed.
|
|
112
95
|
Defaults to True.
|
|
113
96
|
|
|
@@ -118,14 +101,10 @@ class EESession:
|
|
|
118
101
|
return await session.initialize()
|
|
119
102
|
|
|
120
103
|
async def get_assets_folder(self) -> str:
|
|
121
|
-
if self.
|
|
104
|
+
if self.needs_credentials_refresh():
|
|
122
105
|
await self.set_credentials()
|
|
123
106
|
return f"projects/{self.project_id}/assets/"
|
|
124
107
|
|
|
125
|
-
def is_expired(self) -> bool:
|
|
126
|
-
"""Returns if a token is about to expire"""
|
|
127
|
-
return (self.expiry_date / 1000) - time.time() < 60
|
|
128
|
-
|
|
129
108
|
def get_current_headers(self) -> GEEHeaders:
|
|
130
109
|
"""Get current headers without refreshing credentials"""
|
|
131
110
|
if not self._credentials:
|
|
@@ -133,17 +112,24 @@ class EESession:
|
|
|
133
112
|
|
|
134
113
|
self.logger.debug(f"Getting headers with project id: {self.project_id}")
|
|
135
114
|
|
|
115
|
+
# Get username based on authentication mode
|
|
116
|
+
username = (
|
|
117
|
+
self.sepal_user_data.username
|
|
118
|
+
if hasattr(self, "sepal_user_data") and self.sepal_user_data
|
|
119
|
+
else self.user
|
|
120
|
+
)
|
|
121
|
+
|
|
136
122
|
data = {
|
|
137
123
|
"x-goog-user-project": self.project_id,
|
|
138
124
|
"Authorization": f"Bearer {self._credentials.access_token}",
|
|
139
|
-
"Username":
|
|
125
|
+
"Username": username,
|
|
140
126
|
}
|
|
141
127
|
|
|
142
128
|
return GEEHeaders.model_validate(data)
|
|
143
129
|
|
|
144
130
|
async def get_headers(self) -> GEEHeaders:
|
|
145
131
|
"""Async method to get headers, refreshing credentials if needed"""
|
|
146
|
-
if self.
|
|
132
|
+
if self.needs_credentials_refresh():
|
|
147
133
|
await self.set_credentials()
|
|
148
134
|
return self.get_current_headers()
|
|
149
135
|
|
|
@@ -165,68 +151,6 @@ class EESession:
|
|
|
165
151
|
finally:
|
|
166
152
|
await async_client.aclose()
|
|
167
153
|
|
|
168
|
-
async def set_credentials(self) -> None:
|
|
169
|
-
"""
|
|
170
|
-
Refresh credentials asynchronously.
|
|
171
|
-
Uses its own HTTP client (thus bypassing get_headers) to avoid recursion.
|
|
172
|
-
"""
|
|
173
|
-
self.logger.debug(
|
|
174
|
-
"Token is expired or about to expire; attempting to refresh credentials."
|
|
175
|
-
)
|
|
176
|
-
attempt = 0
|
|
177
|
-
credentials_url = self.sepal_api_download_url
|
|
178
|
-
|
|
179
|
-
# Prepare cookies for authentication.
|
|
180
|
-
sepal_cookies = httpx.Cookies()
|
|
181
|
-
sepal_cookies.set("SEPAL-SESSIONID", self.sepal_session_id)
|
|
182
|
-
|
|
183
|
-
last_status = None
|
|
184
|
-
|
|
185
|
-
while attempt < self.max_retries:
|
|
186
|
-
attempt += 1
|
|
187
|
-
try:
|
|
188
|
-
async with httpx.AsyncClient(
|
|
189
|
-
cookies=sepal_cookies,
|
|
190
|
-
verify=self.verify_ssl,
|
|
191
|
-
limits=httpx.Limits(
|
|
192
|
-
max_connections=100, max_keepalive_connections=50
|
|
193
|
-
),
|
|
194
|
-
) as client:
|
|
195
|
-
self.logger.debug(f"Attempt {attempt} to refresh credentials.")
|
|
196
|
-
response = await client.get(credentials_url)
|
|
197
|
-
|
|
198
|
-
last_status = response.status_code
|
|
199
|
-
|
|
200
|
-
if response.status_code == 200:
|
|
201
|
-
self._credentials = GoogleTokens.model_validate(response.json())
|
|
202
|
-
self.expiry_date = self._credentials.access_token_expiry_date
|
|
203
|
-
self.project_id = (
|
|
204
|
-
self._credentials.project_id
|
|
205
|
-
if self.enforce_project_id
|
|
206
|
-
else self.project_id
|
|
207
|
-
)
|
|
208
|
-
self.logger.debug(
|
|
209
|
-
f"Successfully refreshed credentials "
|
|
210
|
-
f"!{self._credentials}==================. {self.project_id}"
|
|
211
|
-
)
|
|
212
|
-
return
|
|
213
|
-
else:
|
|
214
|
-
self.logger.debug(
|
|
215
|
-
f"Attempt {attempt}/{self.max_retries} failed with "
|
|
216
|
-
f"status code: {response.status_code}."
|
|
217
|
-
)
|
|
218
|
-
except Exception as e:
|
|
219
|
-
self.logger.error(
|
|
220
|
-
f"Attempt {attempt}/{self.max_retries} "
|
|
221
|
-
f"encountered an exception: {e}"
|
|
222
|
-
)
|
|
223
|
-
await asyncio.sleep(2**attempt) # Exponential backoff
|
|
224
|
-
|
|
225
|
-
raise ValueError(
|
|
226
|
-
f"Failed to retrieve credentials after {self.max_retries} attempts, "
|
|
227
|
-
f"last status code: {last_status}"
|
|
228
|
-
)
|
|
229
|
-
|
|
230
154
|
async def rest_call(
|
|
231
155
|
self,
|
|
232
156
|
method: Literal["GET", "POST", "DELETE"],
|
|
@@ -246,9 +170,12 @@ class EESession:
|
|
|
246
170
|
try:
|
|
247
171
|
async with self.get_client() as client:
|
|
248
172
|
url_with_project = self.set_url_project(url)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
173
|
+
|
|
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}"
|
|
178
|
+
)
|
|
252
179
|
response = await client.request(
|
|
253
180
|
method, url_with_project, json=data, params=params
|
|
254
181
|
)
|
|
@@ -356,7 +283,7 @@ class EESession:
|
|
|
356
283
|
last_error = e
|
|
357
284
|
error_type = type(e).__name__
|
|
358
285
|
self.logger.error(
|
|
359
|
-
f"Unexpected error in rest_call ({error_type}):
|
|
286
|
+
f"Unexpected error in rest_call ({error_type}): {str(e)}"
|
|
360
287
|
)
|
|
361
288
|
|
|
362
289
|
import traceback
|
|
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
|
|
|
9
9
|
from ee import serializer
|
|
10
10
|
import ee
|
|
11
11
|
import logging
|
|
12
|
+
from eeclient.tasks import Task
|
|
12
13
|
|
|
13
14
|
log = logging.getLogger("eeclient.export.image")
|
|
14
15
|
|
|
@@ -127,11 +128,14 @@ async def _export_image(
|
|
|
127
128
|
scale: Optional[float] = None,
|
|
128
129
|
crs: Optional[str] = None,
|
|
129
130
|
crs_transform: Optional[AffineTransform] = None,
|
|
130
|
-
) ->
|
|
131
|
+
) -> Task:
|
|
131
132
|
"""
|
|
132
|
-
Export
|
|
133
|
+
Export an image to either Google Drive or Earth Engine Asset.
|
|
133
134
|
|
|
134
135
|
Exactly one of drive_options or asset_options must be provided.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Task: The export task response from the API
|
|
135
139
|
"""
|
|
136
140
|
if (drive_options is None and asset_options is None) or (
|
|
137
141
|
drive_options is not None and asset_options is not None
|
|
@@ -177,7 +181,8 @@ async def _export_image(
|
|
|
177
181
|
log.debug(f"Exporting image with params: {request_params}")
|
|
178
182
|
|
|
179
183
|
url = "{earth_engine_api_url}/projects/{project}/image:export"
|
|
180
|
-
|
|
184
|
+
response_data = await client.rest_call("POST", url, data=request_params)
|
|
185
|
+
return Task.model_validate(response_data)
|
|
181
186
|
|
|
182
187
|
|
|
183
188
|
async def image_to_drive_async(
|
|
@@ -198,8 +203,12 @@ async def image_to_drive_async(
|
|
|
198
203
|
scale: Optional[float] = None,
|
|
199
204
|
crs: Optional[str] = None,
|
|
200
205
|
crs_transform: Optional[AffineTransform] = None,
|
|
201
|
-
) ->
|
|
202
|
-
"""Abstracts the export of an image to Google Drive.
|
|
206
|
+
) -> Task:
|
|
207
|
+
"""Abstracts the export of an image to Google Drive.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Task: The export task response from the API
|
|
211
|
+
"""
|
|
203
212
|
drive_options = DriveOptions(
|
|
204
213
|
file_format=file_format,
|
|
205
214
|
drive_destination=DriveDestination(
|
|
@@ -240,8 +249,12 @@ async def image_to_asset_async(
|
|
|
240
249
|
scale: Optional[float] = None,
|
|
241
250
|
crs: Optional[str] = None,
|
|
242
251
|
crs_transform: Optional[AffineTransform] = None,
|
|
243
|
-
) ->
|
|
244
|
-
"""Abstracts the export of an image to Earth Engine Asset.
|
|
252
|
+
) -> Task:
|
|
253
|
+
"""Abstracts the export of an image to Earth Engine Asset.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Task: The export task response from the API
|
|
257
|
+
"""
|
|
245
258
|
asset_options = AssetOptions(
|
|
246
259
|
earth_engine_destination=EarthEngineDestination(name=asset_id),
|
|
247
260
|
)
|
|
@@ -7,6 +7,7 @@ if TYPE_CHECKING:
|
|
|
7
7
|
from eeclient.client import EESession
|
|
8
8
|
|
|
9
9
|
from ee import serializer, encodable
|
|
10
|
+
from eeclient.tasks import Task
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class TableFileFormat(str, Enum):
|
|
@@ -75,11 +76,14 @@ async def _export_table(
|
|
|
75
76
|
max_vertices: Optional[int] = None,
|
|
76
77
|
workload_tag: Optional[str] = None,
|
|
77
78
|
priority: Optional[int] = None,
|
|
78
|
-
) ->
|
|
79
|
+
) -> Task:
|
|
79
80
|
"""
|
|
80
81
|
Export a table to either Google Drive or Earth Engine Asset.
|
|
81
82
|
|
|
82
83
|
Exactly one of drive_options or asset_options must be provided.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Task: The export task response from the API
|
|
83
87
|
"""
|
|
84
88
|
if (drive_options is None and asset_options is None) or (
|
|
85
89
|
drive_options is not None and asset_options is not None
|
|
@@ -107,24 +111,30 @@ async def _export_table(
|
|
|
107
111
|
params = export_options.model_dump(by_alias=True, exclude_none=True)
|
|
108
112
|
|
|
109
113
|
url = "{earth_engine_api_url}/projects/{project}/table:export"
|
|
110
|
-
|
|
114
|
+
response_data = await client.rest_call("POST", url, data=params)
|
|
115
|
+
return Task.model_validate(response_data)
|
|
111
116
|
|
|
112
117
|
|
|
113
118
|
async def table_to_drive_async(
|
|
114
119
|
client: "EESession",
|
|
115
120
|
collection,
|
|
116
|
-
filename_prefix: str,
|
|
117
121
|
file_format: TableFileFormat,
|
|
122
|
+
filename_prefix: str = "",
|
|
118
123
|
folder: Optional[str] = None,
|
|
119
124
|
description: str = "myExportTableTask",
|
|
120
125
|
selectors: Optional[list] = None,
|
|
121
126
|
max_vertices: Optional[int] = None,
|
|
122
127
|
priority: Optional[int] = None,
|
|
123
|
-
) ->
|
|
128
|
+
) -> Task:
|
|
124
129
|
"""
|
|
125
130
|
Export a table to Google Drive.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Task: The export task response from the API
|
|
126
134
|
"""
|
|
127
|
-
drive_destination = DriveDestination(
|
|
135
|
+
drive_destination = DriveDestination(
|
|
136
|
+
filename_prefix=filename_prefix or description, folder=folder
|
|
137
|
+
)
|
|
128
138
|
drive_options = DriveOptions(
|
|
129
139
|
file_format=file_format, drive_destination=drive_destination
|
|
130
140
|
)
|
|
@@ -148,9 +158,12 @@ async def table_to_asset_async(
|
|
|
148
158
|
selectors: Optional[list] = None,
|
|
149
159
|
max_vertices: Optional[int] = None,
|
|
150
160
|
priority: Optional[int] = None,
|
|
151
|
-
) ->
|
|
161
|
+
) -> Task:
|
|
152
162
|
"""
|
|
153
163
|
Export a table to Earth Engine Asset.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Task: The export task response from the API
|
|
154
167
|
"""
|
|
155
168
|
asset_options = AssetOptions(
|
|
156
169
|
earth_engine_destination=EarthEngineDestination(name=asset_id)
|
|
@@ -2,7 +2,7 @@ import os
|
|
|
2
2
|
import requests
|
|
3
3
|
import logging
|
|
4
4
|
from eeclient.models import SepalHeaders, SepalUser
|
|
5
|
-
from typing import Union
|
|
5
|
+
from typing import Optional, Union
|
|
6
6
|
|
|
7
7
|
from ee.imagecollection import ImageCollection
|
|
8
8
|
from ee.feature import Feature
|
|
@@ -59,11 +59,17 @@ def parse_cookie_string(cookie_string):
|
|
|
59
59
|
return cookies
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
def get_sepal_headers_from_auth(
|
|
62
|
+
def get_sepal_headers_from_auth(
|
|
63
|
+
sepal_user: Optional[str] = None,
|
|
64
|
+
sepal_password: Optional[str] = None,
|
|
65
|
+
sepal_host: Optional[str] = None,
|
|
66
|
+
) -> SepalHeaders:
|
|
67
|
+
|
|
63
68
|
log.debug("Getting SEPAL headers from authentication")
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
|
|
70
|
+
sepal_user = sepal_user or os.getenv("LOCAL_SEPAL_USER")
|
|
71
|
+
sepal_password = sepal_password or os.getenv("LOCAL_SEPAL_PASSWORD")
|
|
72
|
+
sepal_host = sepal_host or os.getenv("SEPAL_HOST")
|
|
67
73
|
|
|
68
74
|
if not sepal_user or not sepal_password or not sepal_host:
|
|
69
75
|
raise ValueError(
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
from eeclient.models import SepalHeaders, GoogleTokens
|
|
2
|
+
import os
|
|
3
|
+
import logging
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import asyncio
|
|
7
|
+
import httpx
|
|
8
|
+
import requests
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("eeclient")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SepalCredentialMixin:
|
|
16
|
+
def __init__(self, sepal_headers: Optional[SepalHeaders] = None):
|
|
17
|
+
log.debug("Initializing SepalCredentialMixin")
|
|
18
|
+
|
|
19
|
+
self.max_retries = 3
|
|
20
|
+
self._credentials = None
|
|
21
|
+
self.auth_mode = "sepal" if sepal_headers else "file"
|
|
22
|
+
|
|
23
|
+
if sepal_headers:
|
|
24
|
+
self._init_sepal_mode(sepal_headers)
|
|
25
|
+
else:
|
|
26
|
+
self._init_file_mode()
|
|
27
|
+
|
|
28
|
+
self.logger = logging.getLogger(f"eeclient.{self.user}")
|
|
29
|
+
|
|
30
|
+
# For backward compatibility (some code might expect this attribute)
|
|
31
|
+
self._service = None
|
|
32
|
+
|
|
33
|
+
def _init_sepal_mode(self, sepal_headers: SepalHeaders):
|
|
34
|
+
"""Initialize SEPAL authentication mode"""
|
|
35
|
+
self.sepal_host = os.getenv("SEPAL_HOST")
|
|
36
|
+
if not self.sepal_host:
|
|
37
|
+
raise ValueError("SEPAL_HOST environment variable not set")
|
|
38
|
+
|
|
39
|
+
self.sepal_headers = SepalHeaders.model_validate(sepal_headers)
|
|
40
|
+
self.sepal_session_id = self.sepal_headers.cookies["SEPAL-SESSIONID"]
|
|
41
|
+
self.sepal_user_data = self.sepal_headers.sepal_user
|
|
42
|
+
self.user = self.sepal_user_data.username
|
|
43
|
+
|
|
44
|
+
self.sepal_api_download_url = (
|
|
45
|
+
f"https://{self.sepal_host}/api/user-files/download/"
|
|
46
|
+
"?path=%2F.config%2Fearthengine%2Fcredentials"
|
|
47
|
+
)
|
|
48
|
+
self.verify_ssl = not (
|
|
49
|
+
self.sepal_host == "host.docker.internal"
|
|
50
|
+
or self.sepal_host == "danielg.sepal.io"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self._google_tokens = self.sepal_user_data.google_tokens
|
|
54
|
+
if self._google_tokens:
|
|
55
|
+
self._credentials = self._google_tokens
|
|
56
|
+
self.access_token = self._google_tokens.access_token
|
|
57
|
+
self.project_id = self._google_tokens.project_id
|
|
58
|
+
self.expiry_date = self._google_tokens.access_token_expiry_date
|
|
59
|
+
else:
|
|
60
|
+
self.access_token = None
|
|
61
|
+
self.project_id = None
|
|
62
|
+
self.expiry_date = 0
|
|
63
|
+
|
|
64
|
+
def _init_file_mode(self):
|
|
65
|
+
"""Initialize file-based authentication mode"""
|
|
66
|
+
home_path = Path.home()
|
|
67
|
+
credentials_file = (
|
|
68
|
+
".config/earthengine/credentials"
|
|
69
|
+
if "sepal-user" in home_path.name
|
|
70
|
+
else ".config/earthengine/sepal_credentials"
|
|
71
|
+
)
|
|
72
|
+
self.credentials_path = home_path / credentials_file
|
|
73
|
+
self.user = "local_user"
|
|
74
|
+
|
|
75
|
+
# No SEPAL session info for file-based credentials
|
|
76
|
+
self.sepal_session_id = None
|
|
77
|
+
self.sepal_host = None
|
|
78
|
+
self.sepal_api_download_url = None
|
|
79
|
+
self.verify_ssl = True
|
|
80
|
+
|
|
81
|
+
# Load initial credentials
|
|
82
|
+
self._load_credentials_from_file()
|
|
83
|
+
|
|
84
|
+
def _load_credentials_from_file(self):
|
|
85
|
+
"""Load credentials from file and update internal state"""
|
|
86
|
+
if not self.credentials_path.exists():
|
|
87
|
+
raise ValueError(f"Credentials file not found at {self.credentials_path}")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
credentials_data = json.loads(self.credentials_path.read_text())
|
|
91
|
+
self._credentials = GoogleTokens.model_validate(credentials_data)
|
|
92
|
+
self.access_token = self._credentials.access_token
|
|
93
|
+
self.project_id = self._credentials.project_id
|
|
94
|
+
self.expiry_date = self._credentials.access_token_expiry_date
|
|
95
|
+
|
|
96
|
+
if not self.access_token:
|
|
97
|
+
raise ValueError("No access token available in credentials file")
|
|
98
|
+
|
|
99
|
+
except json.JSONDecodeError as e:
|
|
100
|
+
raise ValueError(f"Invalid JSON in credentials file: {e}")
|
|
101
|
+
except Exception as e:
|
|
102
|
+
raise ValueError(f"Error loading credentials from file: {e}")
|
|
103
|
+
|
|
104
|
+
def is_expired(self) -> bool:
|
|
105
|
+
"""Returns if a token is about to expire"""
|
|
106
|
+
return (self.expiry_date / 1000) - time.time() < 60
|
|
107
|
+
|
|
108
|
+
def needs_credentials_refresh(self) -> bool:
|
|
109
|
+
"""Returns if credentials need to be refreshed (missing or expired)"""
|
|
110
|
+
return not self._credentials or self.is_expired()
|
|
111
|
+
|
|
112
|
+
async def set_credentials(self) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Refresh credentials based on the authentication mode.
|
|
115
|
+
"""
|
|
116
|
+
if self.auth_mode == "sepal":
|
|
117
|
+
await self._refresh_credentials_from_sepal()
|
|
118
|
+
else:
|
|
119
|
+
await self._refresh_credentials_from_file()
|
|
120
|
+
|
|
121
|
+
async def _refresh_credentials_from_sepal(self) -> None:
|
|
122
|
+
"""Refresh credentials via SEPAL API"""
|
|
123
|
+
if not self.sepal_session_id or not self.sepal_api_download_url:
|
|
124
|
+
raise ValueError("SEPAL session information not available")
|
|
125
|
+
|
|
126
|
+
self.logger.debug(
|
|
127
|
+
"Token is expired or about to expire; "
|
|
128
|
+
"attempting to refresh credentials from SEPAL."
|
|
129
|
+
)
|
|
130
|
+
attempt = 0
|
|
131
|
+
credentials_url = self.sepal_api_download_url
|
|
132
|
+
last_status = None
|
|
133
|
+
|
|
134
|
+
# Prepare cookies for authentication
|
|
135
|
+
sepal_cookies = httpx.Cookies()
|
|
136
|
+
sepal_cookies.set("SEPAL-SESSIONID", self.sepal_session_id)
|
|
137
|
+
|
|
138
|
+
while attempt < self.max_retries:
|
|
139
|
+
attempt += 1
|
|
140
|
+
try:
|
|
141
|
+
async with httpx.AsyncClient(
|
|
142
|
+
cookies=sepal_cookies,
|
|
143
|
+
verify=self.verify_ssl,
|
|
144
|
+
limits=httpx.Limits(
|
|
145
|
+
max_connections=100, max_keepalive_connections=50
|
|
146
|
+
),
|
|
147
|
+
) as client:
|
|
148
|
+
self.logger.debug(
|
|
149
|
+
f"Attempt {attempt} to refresh credentials from SEPAL."
|
|
150
|
+
)
|
|
151
|
+
response = await client.get(credentials_url)
|
|
152
|
+
|
|
153
|
+
last_status = response.status_code
|
|
154
|
+
|
|
155
|
+
if response.status_code == 200:
|
|
156
|
+
self._credentials = GoogleTokens.model_validate(response.json())
|
|
157
|
+
self.expiry_date = self._credentials.access_token_expiry_date
|
|
158
|
+
self.access_token = self._credentials.access_token
|
|
159
|
+
# Don't override project_id if enforce_project_id is set
|
|
160
|
+
if not hasattr(self, "enforce_project_id") or not getattr(
|
|
161
|
+
self, "enforce_project_id", False
|
|
162
|
+
):
|
|
163
|
+
self.project_id = self._credentials.project_id
|
|
164
|
+
self.logger.debug(
|
|
165
|
+
f"Successfully refreshed credentials from SEPAL. "
|
|
166
|
+
f"Project: {self.project_id}"
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
else:
|
|
170
|
+
self.logger.debug(
|
|
171
|
+
f"Attempt {attempt}/{self.max_retries} failed with "
|
|
172
|
+
f"status code: {response.status_code}."
|
|
173
|
+
)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
self.logger.error(
|
|
176
|
+
f"Attempt {attempt}/{self.max_retries} "
|
|
177
|
+
f"encountered an exception: {e}"
|
|
178
|
+
)
|
|
179
|
+
await asyncio.sleep(2**attempt) # Exponential backoff
|
|
180
|
+
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"Failed to retrieve credentials from SEPAL after "
|
|
183
|
+
f"{self.max_retries} attempts, last status code: {last_status}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async def _refresh_credentials_from_file(self) -> None:
|
|
187
|
+
"""Refresh credentials by re-reading from file"""
|
|
188
|
+
self.logger.debug(
|
|
189
|
+
"Token is expired or about to expire; "
|
|
190
|
+
"attempting to refresh credentials from file."
|
|
191
|
+
)
|
|
192
|
+
attempt = 0
|
|
193
|
+
|
|
194
|
+
while attempt < self.max_retries:
|
|
195
|
+
attempt += 1
|
|
196
|
+
try:
|
|
197
|
+
self.logger.debug(
|
|
198
|
+
f"Attempt {attempt} to refresh credentials from file."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Re-read credentials from file
|
|
202
|
+
self._load_credentials_from_file()
|
|
203
|
+
|
|
204
|
+
# Check if the new credentials are still expired
|
|
205
|
+
if not self.is_expired():
|
|
206
|
+
self.logger.debug(
|
|
207
|
+
f"Successfully refreshed credentials from file. "
|
|
208
|
+
f"Project: {self.project_id}"
|
|
209
|
+
)
|
|
210
|
+
return
|
|
211
|
+
else:
|
|
212
|
+
self.logger.debug(
|
|
213
|
+
f"Attempt {attempt}/{self.max_retries}: "
|
|
214
|
+
f"Credentials from file are still expired."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
except Exception as e:
|
|
218
|
+
self.logger.error(
|
|
219
|
+
f"Attempt {attempt}/{self.max_retries} "
|
|
220
|
+
f"encountered an exception while reading file: {e}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Wait before retrying (the file might be updated externally)
|
|
224
|
+
if attempt < self.max_retries:
|
|
225
|
+
wait_time = 2**attempt
|
|
226
|
+
self.logger.debug(f"Waiting {wait_time} seconds before retry...")
|
|
227
|
+
await asyncio.sleep(wait_time)
|
|
228
|
+
|
|
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
|
+
)
|
|
234
|
+
|
|
235
|
+
def set_credentials_sync(self) -> None:
|
|
236
|
+
"""
|
|
237
|
+
Refresh credentials synchronously based on the authentication mode.
|
|
238
|
+
"""
|
|
239
|
+
if self.auth_mode == "sepal":
|
|
240
|
+
self._refresh_credentials_from_sepal_sync()
|
|
241
|
+
else:
|
|
242
|
+
self._refresh_credentials_from_file_sync()
|
|
243
|
+
|
|
244
|
+
def _refresh_credentials_from_sepal_sync(self) -> None:
|
|
245
|
+
"""Refresh credentials via SEPAL API synchronously"""
|
|
246
|
+
if not self.sepal_session_id or not self.sepal_api_download_url:
|
|
247
|
+
raise ValueError("SEPAL session information not available")
|
|
248
|
+
|
|
249
|
+
self.logger.debug(
|
|
250
|
+
"Token is expired or about to expire; "
|
|
251
|
+
"attempting to refresh credentials from SEPAL (sync)."
|
|
252
|
+
)
|
|
253
|
+
attempt = 0
|
|
254
|
+
credentials_url = self.sepal_api_download_url
|
|
255
|
+
last_status = None
|
|
256
|
+
|
|
257
|
+
# Prepare session with cookies for authentication
|
|
258
|
+
session = requests.Session()
|
|
259
|
+
session.cookies.set("SEPAL-SESSIONID", self.sepal_session_id)
|
|
260
|
+
session.verify = self.verify_ssl
|
|
261
|
+
|
|
262
|
+
while attempt < self.max_retries:
|
|
263
|
+
attempt += 1
|
|
264
|
+
try:
|
|
265
|
+
self.logger.debug(
|
|
266
|
+
f"Attempt {attempt} to refresh credentials from SEPAL (sync)."
|
|
267
|
+
)
|
|
268
|
+
response = session.get(credentials_url)
|
|
269
|
+
last_status = response.status_code
|
|
270
|
+
|
|
271
|
+
if response.status_code == 200:
|
|
272
|
+
self._credentials = GoogleTokens.model_validate(response.json())
|
|
273
|
+
self.expiry_date = self._credentials.access_token_expiry_date
|
|
274
|
+
self.access_token = self._credentials.access_token
|
|
275
|
+
# Don't override project_id if enforce_project_id is set
|
|
276
|
+
if not hasattr(self, "enforce_project_id") or not getattr(
|
|
277
|
+
self, "enforce_project_id", False
|
|
278
|
+
):
|
|
279
|
+
self.project_id = self._credentials.project_id
|
|
280
|
+
self.logger.debug(
|
|
281
|
+
f"Successfully refreshed credentials from SEPAL (sync). "
|
|
282
|
+
f"Project: {self.project_id}"
|
|
283
|
+
)
|
|
284
|
+
return
|
|
285
|
+
else:
|
|
286
|
+
self.logger.debug(
|
|
287
|
+
f"Attempt {attempt}/{self.max_retries} failed with "
|
|
288
|
+
f"status code: {response.status_code}."
|
|
289
|
+
)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
self.logger.error(
|
|
292
|
+
f"Attempt {attempt}/{self.max_retries} "
|
|
293
|
+
f"encountered an exception: {e}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Wait before retrying (exponential backoff)
|
|
297
|
+
if attempt < self.max_retries:
|
|
298
|
+
wait_time = 2**attempt
|
|
299
|
+
self.logger.debug(f"Waiting {wait_time} seconds before retry...")
|
|
300
|
+
time.sleep(wait_time)
|
|
301
|
+
|
|
302
|
+
session.close()
|
|
303
|
+
raise ValueError(
|
|
304
|
+
f"Failed to retrieve credentials from SEPAL after "
|
|
305
|
+
f"{self.max_retries} attempts, last status code: {last_status}"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _refresh_credentials_from_file_sync(self) -> None:
|
|
309
|
+
"""Refresh credentials by re-reading from file synchronously"""
|
|
310
|
+
self.logger.debug(
|
|
311
|
+
"Token is expired or about to expire; "
|
|
312
|
+
"attempting to refresh credentials from file (sync)."
|
|
313
|
+
)
|
|
314
|
+
attempt = 0
|
|
315
|
+
|
|
316
|
+
while attempt < self.max_retries:
|
|
317
|
+
attempt += 1
|
|
318
|
+
try:
|
|
319
|
+
self.logger.debug(
|
|
320
|
+
f"Attempt {attempt} to refresh credentials from file (sync)."
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Re-read credentials from file
|
|
324
|
+
self._load_credentials_from_file()
|
|
325
|
+
|
|
326
|
+
# Check if the new credentials are still expired
|
|
327
|
+
if not self.is_expired():
|
|
328
|
+
self.logger.debug(
|
|
329
|
+
f"Successfully refreshed credentials from file (sync). "
|
|
330
|
+
f"Project: {self.project_id}"
|
|
331
|
+
)
|
|
332
|
+
return
|
|
333
|
+
else:
|
|
334
|
+
self.logger.debug(
|
|
335
|
+
f"Attempt {attempt}/{self.max_retries}: "
|
|
336
|
+
f"Credentials from file are still expired."
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
except Exception as e:
|
|
340
|
+
self.logger.error(
|
|
341
|
+
f"Attempt {attempt}/{self.max_retries} "
|
|
342
|
+
f"encountered an exception while reading file: {e}"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Wait before retrying (the file might be updated externally)
|
|
346
|
+
if attempt < self.max_retries:
|
|
347
|
+
wait_time = 2**attempt
|
|
348
|
+
self.logger.debug(f"Waiting {wait_time} seconds before retry...")
|
|
349
|
+
time.sleep(wait_time)
|
|
350
|
+
|
|
351
|
+
raise ValueError(
|
|
352
|
+
f"Failed to retrieve valid credentials from file after "
|
|
353
|
+
f"{self.max_retries} attempts. File may not be automatically "
|
|
354
|
+
f"updated or credentials are permanently expired."
|
|
355
|
+
)
|
|
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
|
|
|
3
3
|
|
|
4
4
|
if TYPE_CHECKING:
|
|
5
5
|
from eeclient.client import EESession
|
|
6
|
-
from pydantic import BaseModel, Field
|
|
6
|
+
from pydantic import BaseModel, Field, computed_field
|
|
7
7
|
from typing import List, Optional, Any
|
|
8
8
|
from datetime import datetime
|
|
9
9
|
|
|
@@ -31,7 +31,7 @@ class Stage(CamelCaseModel):
|
|
|
31
31
|
|
|
32
32
|
class TaskMetadata(CamelCaseModel):
|
|
33
33
|
type_info: str = Field(alias="@type")
|
|
34
|
-
state: str
|
|
34
|
+
state: Optional[str] = None
|
|
35
35
|
description: str
|
|
36
36
|
priority: int
|
|
37
37
|
create_time: datetime
|
|
@@ -40,7 +40,7 @@ class TaskMetadata(CamelCaseModel):
|
|
|
40
40
|
end_time: Optional[datetime] = None
|
|
41
41
|
type: str
|
|
42
42
|
destination_uris: Optional[List[str]] = None
|
|
43
|
-
attempt: int
|
|
43
|
+
attempt: Optional[int] = None
|
|
44
44
|
progress: Optional[float] = None
|
|
45
45
|
stages: Optional[List[Stage]] = None
|
|
46
46
|
batch_eecu_usage_seconds: Optional[float] = None
|
|
@@ -55,9 +55,20 @@ class Task(CamelCaseModel):
|
|
|
55
55
|
metadata: TaskMetadata
|
|
56
56
|
done: Optional[bool] = None
|
|
57
57
|
response: Optional[Response] = None
|
|
58
|
-
# Add error field which might be present instead of response
|
|
59
58
|
error: Optional[Any] = None
|
|
60
59
|
|
|
60
|
+
@computed_field
|
|
61
|
+
@property
|
|
62
|
+
def id(self) -> str:
|
|
63
|
+
"""Extract the task ID from the name field.
|
|
64
|
+
|
|
65
|
+
The name field has format: 'projects/{project}/operations/{task_id}'
|
|
66
|
+
This property extracts just the {task_id} part.
|
|
67
|
+
"""
|
|
68
|
+
if "/operations/" in self.name:
|
|
69
|
+
return self.name.split("/operations/")[-1]
|
|
70
|
+
return self.name
|
|
71
|
+
|
|
61
72
|
|
|
62
73
|
class TasksResponse(CamelCaseModel):
|
|
63
74
|
operations: List[Task]
|
|
@@ -79,7 +90,7 @@ async def get_tasks_async(client: "EESession") -> TasksResponse:
|
|
|
79
90
|
return operations_response
|
|
80
91
|
|
|
81
92
|
|
|
82
|
-
async def get_task_async(client: "EESession", task_id: str):
|
|
93
|
+
async def get_task_async(client: "EESession", task_id: str) -> Optional[Task]:
|
|
83
94
|
"""Search for the described task in the user Task list return None if
|
|
84
95
|
nothing is found.
|
|
85
96
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ee-client"
|
|
7
|
-
version = "2.0
|
|
7
|
+
version = "2.2.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 = "2.0
|
|
69
|
+
version = "2.2.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
|
|
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
|