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.
Files changed (29) hide show
  1. {ee_client-2.0.3 → ee_client-2.2.0}/PKG-INFO +10 -3
  2. {ee_client-2.0.3 → ee_client-2.2.0}/README.rst +9 -2
  3. {ee_client-2.0.3 → ee_client-2.2.0}/ee_client.egg-info/PKG-INFO +10 -3
  4. {ee_client-2.0.3 → ee_client-2.2.0}/ee_client.egg-info/SOURCES.txt +1 -0
  5. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/__init__.py +1 -1
  6. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/client.py +39 -112
  7. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/export/image.py +20 -7
  8. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/export/table.py +19 -6
  9. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/helpers.py +11 -5
  10. ee_client-2.2.0/eeclient/sepal_credential_mixin.py +355 -0
  11. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/tasks.py +16 -5
  12. {ee_client-2.0.3 → ee_client-2.2.0}/pyproject.toml +2 -2
  13. {ee_client-2.0.3 → ee_client-2.2.0}/LICENSE +0 -0
  14. {ee_client-2.0.3 → ee_client-2.2.0}/ee_client.egg-info/dependency_links.txt +0 -0
  15. {ee_client-2.0.3 → ee_client-2.2.0}/ee_client.egg-info/requires.txt +0 -0
  16. {ee_client-2.0.3 → ee_client-2.2.0}/ee_client.egg-info/top_level.txt +0 -0
  17. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/data.py +0 -0
  18. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/exceptions.py +0 -0
  19. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/export/__init__.py +0 -0
  20. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/interfaces/__init__.py +0 -0
  21. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/interfaces/export.py +0 -0
  22. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/interfaces/operations.py +0 -0
  23. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/interfaces/tasks.py +0 -0
  24. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/models.py +0 -0
  25. {ee_client-2.0.3 → ee_client-2.2.0}/eeclient/oauth_app.py +0 -0
  26. {ee_client-2.0.3 → ee_client-2.2.0}/setup.cfg +0 -0
  27. {ee_client-2.0.3 → ee_client-2.2.0}/tests/test_client.py +0 -0
  28. {ee_client-2.0.3 → ee_client-2.2.0}/tests/test_data.py +0 -0
  29. {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
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 a custom authentication. 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.
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
- - **Custom Session Management**: Encapsulate user-specific credentials and project data in an ``EESession`` object.
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 a custom authentication. 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.
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
- - **Custom Session Management**: Encapsulate user-specific credentials and project data in an ``EESession`` object.
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
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 a custom authentication. 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.
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
- - **Custom Session Management**: Encapsulate user-specific credentials and project data in an ``EESession`` object.
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.
@@ -13,6 +13,7 @@ eeclient/exceptions.py
13
13
  eeclient/helpers.py
14
14
  eeclient/models.py
15
15
  eeclient/oauth_app.py
16
+ eeclient/sepal_credential_mixin.py
16
17
  eeclient/tasks.py
17
18
  eeclient/export/__init__.py
18
19
  eeclient/export/image.py
@@ -1,6 +1,6 @@
1
1
  __title__ = "eeclient"
2
2
  __summary__ = "A client for Google Earth Engine"
3
- __version__ = "2.0.3"
3
+ __version__ = "2.2.0"
4
4
 
5
5
  __author__ = "Daniel Guerrero"
6
6
  __email__ = "dfgm2006@gmail.com"
@@ -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, GoogleTokens, SepalHeaders
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__(self, sepal_headers: SepalHeaders, enforce_project_id: bool = True):
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
- self.sepal_headers = SepalHeaders.model_validate(sepal_headers)
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
- # Initialize credentials from the initial tokens
79
- self._google_tokens = self.sepal_user_data.google_tokens
80
- if self._google_tokens:
81
- self.expiry_date = self._google_tokens.access_token_expiry_date
82
- self.project_id = self._google_tokens.project_id
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(cls, sepal_headers: SepalHeaders, enforce_project_id: bool = True):
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.is_expired():
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": self.sepal_user_data.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.is_expired():
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
- self.logger.debug(
250
- f"Making async (asdf) {method} request to {url_with_project}"
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}): " f"{str(e)}"
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
- ) -> dict:
131
+ ) -> Task:
131
132
  """
132
- Export a table to either Google Drive or Earth Engine Asset.
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
- return await client.rest_call("POST", url, data=request_params)
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
- ) -> dict:
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
- ) -> dict:
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
- ) -> dict:
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
- return await client.rest_call("POST", url, data=params)
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
- ) -> dict:
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(filename_prefix=filename_prefix, folder=folder)
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
- ) -> dict:
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
- sepal_user = os.getenv("LOCAL_SEPAL_USER")
65
- sepal_password = os.getenv("LOCAL_SEPAL_PASSWORD")
66
- sepal_host = os.getenv("SEPAL_HOST")
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.3"
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.3"
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