ee-client 2.1.0__tar.gz → 2.2.1__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.1.0 → ee_client-2.2.1}/PKG-INFO +1 -1
- {ee_client-2.1.0 → ee_client-2.2.1}/ee_client.egg-info/PKG-INFO +1 -1
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/__init__.py +1 -1
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/client.py +29 -89
- ee_client-2.2.1/eeclient/exceptions.py +100 -0
- ee_client-2.2.1/eeclient/sepal_credential_mixin.py +384 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/pyproject.toml +2 -2
- ee_client-2.1.0/eeclient/exceptions.py +0 -26
- ee_client-2.1.0/eeclient/sepal_credential_mixin.py +0 -37
- {ee_client-2.1.0 → ee_client-2.2.1}/LICENSE +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/README.rst +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/ee_client.egg-info/SOURCES.txt +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/ee_client.egg-info/dependency_links.txt +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/ee_client.egg-info/requires.txt +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/ee_client.egg-info/top_level.txt +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/data.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/export/__init__.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/export/image.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/export/table.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/helpers.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/interfaces/__init__.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/interfaces/export.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/interfaces/operations.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/interfaces/tasks.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/models.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/oauth_app.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/eeclient/tasks.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/setup.cfg +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/tests/test_client.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/tests/test_data.py +0 -0
- {ee_client-2.1.0 → ee_client-2.2.1}/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ee-client
|
|
3
|
-
Version: 2.1
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: extends the capabilities of the earthengine-api by providing custom session management and client interactions
|
|
5
5
|
Author-email: Daniel Guerrero <dfgm2006@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/dfguerrerom/ee-client
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ee-client
|
|
3
|
-
Version: 2.1
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: extends the capabilities of the earthengine-api by providing custom session management and client interactions
|
|
5
5
|
Author-email: Daniel Guerrero <dfgm2006@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/dfguerrerom/ee-client
|
|
@@ -1,14 +1,13 @@
|
|
|
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
|
|
12
11
|
from eeclient.sepal_credential_mixin import SepalCredentialMixin
|
|
13
12
|
|
|
14
13
|
import eeclient.export as _export_module
|
|
@@ -35,7 +34,11 @@ VERIFY_SSL = True
|
|
|
35
34
|
|
|
36
35
|
|
|
37
36
|
class EESession(SepalCredentialMixin):
|
|
38
|
-
def __init__(
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
sepal_headers: Optional[SepalHeaders] = None,
|
|
40
|
+
enforce_project_id: bool = True,
|
|
41
|
+
):
|
|
39
42
|
"""Session that handles two scenarios to set the headers for Earth Engine API
|
|
40
43
|
|
|
41
44
|
It can be initialized with the headers sent by SEPAL or with the
|
|
@@ -49,25 +52,20 @@ class EESession(SepalCredentialMixin):
|
|
|
49
52
|
Raises:
|
|
50
53
|
ValueError: If SEPAL_HOST environment variable is not set
|
|
51
54
|
"""
|
|
52
|
-
super().__init__(sepal_headers)
|
|
53
|
-
|
|
54
|
-
self.max_retries = 3
|
|
55
|
-
self._credentials = None
|
|
56
55
|
self.enforce_project_id = enforce_project_id
|
|
56
|
+
super().__init__(sepal_headers)
|
|
57
57
|
|
|
58
58
|
# Create user-specific logger
|
|
59
59
|
self.logger = logging.getLogger(f"eeclient.{self.user}")
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
self._credentials
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
self.project_id = None
|
|
67
|
-
self.logger.debug(
|
|
68
|
-
"No credentials found in initial headers. "
|
|
61
|
+
self.logger.debug(
|
|
62
|
+
"EESession initialized"
|
|
63
|
+
if self._credentials
|
|
64
|
+
else (
|
|
65
|
+
"EESession created without credentials. "
|
|
69
66
|
"Call initialize() or use create() to fetch credentials."
|
|
70
67
|
)
|
|
68
|
+
)
|
|
71
69
|
|
|
72
70
|
async def initialize(self) -> "EESession":
|
|
73
71
|
"""Asynchronously initialize the session by fetching credentials if needed.
|
|
@@ -83,11 +81,16 @@ class EESession(SepalCredentialMixin):
|
|
|
83
81
|
return self
|
|
84
82
|
|
|
85
83
|
@classmethod
|
|
86
|
-
async def create(
|
|
84
|
+
async def create(
|
|
85
|
+
cls,
|
|
86
|
+
sepal_headers: Optional[SepalHeaders] = None,
|
|
87
|
+
enforce_project_id: bool = True,
|
|
88
|
+
):
|
|
87
89
|
"""Asynchronously create an EESession instance.
|
|
88
90
|
|
|
89
91
|
Args:
|
|
90
|
-
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.
|
|
91
94
|
enforce_project_id (bool, optional): If set, it cannot be changed.
|
|
92
95
|
Defaults to True.
|
|
93
96
|
|
|
@@ -102,14 +105,6 @@ class EESession(SepalCredentialMixin):
|
|
|
102
105
|
await self.set_credentials()
|
|
103
106
|
return f"projects/{self.project_id}/assets/"
|
|
104
107
|
|
|
105
|
-
def is_expired(self) -> bool:
|
|
106
|
-
"""Returns if a token is about to expire"""
|
|
107
|
-
return (self.expiry_date / 1000) - time.time() < 60
|
|
108
|
-
|
|
109
|
-
def needs_credentials_refresh(self) -> bool:
|
|
110
|
-
"""Returns if credentials need to be refreshed (missing or expired)"""
|
|
111
|
-
return not self._credentials or self.is_expired()
|
|
112
|
-
|
|
113
108
|
def get_current_headers(self) -> GEEHeaders:
|
|
114
109
|
"""Get current headers without refreshing credentials"""
|
|
115
110
|
if not self._credentials:
|
|
@@ -117,10 +112,17 @@ class EESession(SepalCredentialMixin):
|
|
|
117
112
|
|
|
118
113
|
self.logger.debug(f"Getting headers with project id: {self.project_id}")
|
|
119
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
|
+
|
|
120
122
|
data = {
|
|
121
123
|
"x-goog-user-project": self.project_id,
|
|
122
124
|
"Authorization": f"Bearer {self._credentials.access_token}",
|
|
123
|
-
"Username":
|
|
125
|
+
"Username": username,
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
return GEEHeaders.model_validate(data)
|
|
@@ -149,68 +151,6 @@ class EESession(SepalCredentialMixin):
|
|
|
149
151
|
finally:
|
|
150
152
|
await async_client.aclose()
|
|
151
153
|
|
|
152
|
-
async def set_credentials(self) -> None:
|
|
153
|
-
"""
|
|
154
|
-
Refresh credentials asynchronously.
|
|
155
|
-
Uses its own HTTP client (thus bypassing get_headers) to avoid recursion.
|
|
156
|
-
"""
|
|
157
|
-
self.logger.debug(
|
|
158
|
-
"Token is expired or about to expire; attempting to refresh credentials."
|
|
159
|
-
)
|
|
160
|
-
attempt = 0
|
|
161
|
-
credentials_url = self.sepal_api_download_url
|
|
162
|
-
|
|
163
|
-
# Prepare cookies for authentication.
|
|
164
|
-
sepal_cookies = httpx.Cookies()
|
|
165
|
-
sepal_cookies.set("SEPAL-SESSIONID", self.sepal_session_id)
|
|
166
|
-
|
|
167
|
-
last_status = None
|
|
168
|
-
|
|
169
|
-
while attempt < self.max_retries:
|
|
170
|
-
attempt += 1
|
|
171
|
-
try:
|
|
172
|
-
async with httpx.AsyncClient(
|
|
173
|
-
cookies=sepal_cookies,
|
|
174
|
-
verify=self.verify_ssl,
|
|
175
|
-
limits=httpx.Limits(
|
|
176
|
-
max_connections=100, max_keepalive_connections=50
|
|
177
|
-
),
|
|
178
|
-
) as client:
|
|
179
|
-
self.logger.debug(f"Attempt {attempt} to refresh credentials.")
|
|
180
|
-
response = await client.get(credentials_url)
|
|
181
|
-
|
|
182
|
-
last_status = response.status_code
|
|
183
|
-
|
|
184
|
-
if response.status_code == 200:
|
|
185
|
-
self._credentials = GoogleTokens.model_validate(response.json())
|
|
186
|
-
self.expiry_date = self._credentials.access_token_expiry_date
|
|
187
|
-
self.project_id = (
|
|
188
|
-
self._credentials.project_id
|
|
189
|
-
if self.enforce_project_id
|
|
190
|
-
else self.project_id
|
|
191
|
-
)
|
|
192
|
-
self.logger.debug(
|
|
193
|
-
f"Successfully refreshed credentials "
|
|
194
|
-
f"!{self._credentials}==================. {self.project_id}"
|
|
195
|
-
)
|
|
196
|
-
return
|
|
197
|
-
else:
|
|
198
|
-
self.logger.debug(
|
|
199
|
-
f"Attempt {attempt}/{self.max_retries} failed with "
|
|
200
|
-
f"status code: {response.status_code}."
|
|
201
|
-
)
|
|
202
|
-
except Exception as e:
|
|
203
|
-
self.logger.error(
|
|
204
|
-
f"Attempt {attempt}/{self.max_retries} "
|
|
205
|
-
f"encountered an exception: {e}"
|
|
206
|
-
)
|
|
207
|
-
await asyncio.sleep(2**attempt) # Exponential backoff
|
|
208
|
-
|
|
209
|
-
raise ValueError(
|
|
210
|
-
f"Failed to retrieve credentials after {self.max_retries} attempts, "
|
|
211
|
-
f"last status code: {last_status}"
|
|
212
|
-
)
|
|
213
|
-
|
|
214
154
|
async def rest_call(
|
|
215
155
|
self,
|
|
216
156
|
method: Literal["GET", "POST", "DELETE"],
|
|
@@ -343,7 +283,7 @@ class EESession(SepalCredentialMixin):
|
|
|
343
283
|
last_error = e
|
|
344
284
|
error_type = type(e).__name__
|
|
345
285
|
self.logger.error(
|
|
346
|
-
f"Unexpected error in rest_call ({error_type}):
|
|
286
|
+
f"Unexpected error in rest_call ({error_type}): {str(e)}"
|
|
347
287
|
)
|
|
348
288
|
|
|
349
289
|
import traceback
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from ee.ee_exception import EEException
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EERestException(EEException):
|
|
5
|
+
def __init__(self, error):
|
|
6
|
+
self.message = error.get("message", "EE responded with an error")
|
|
7
|
+
super().__init__(self.message)
|
|
8
|
+
self.code = error.get("code", -1)
|
|
9
|
+
self.status = error.get("status", "UNDEFINED")
|
|
10
|
+
self.details = error.get("details")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EEClientError(Exception):
|
|
14
|
+
"""Custom exception class for EEClient errors."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, error):
|
|
17
|
+
if isinstance(error, str):
|
|
18
|
+
self.message = error
|
|
19
|
+
self.code = -1
|
|
20
|
+
self.status = "UNDEFINED"
|
|
21
|
+
self.details = None
|
|
22
|
+
else:
|
|
23
|
+
self.message = error.get("message", "EEClient responded with an error")
|
|
24
|
+
self.code = error.get("code", -1)
|
|
25
|
+
self.status = error.get("status", "UNDEFINED")
|
|
26
|
+
self.details = error.get("details")
|
|
27
|
+
super().__init__(self.message)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CredentialsFileNotFoundError(EEClientError):
|
|
31
|
+
"""Raised when local credentials file is not found."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, file_path: str):
|
|
34
|
+
self.file_path = file_path
|
|
35
|
+
error = {
|
|
36
|
+
"code": 404,
|
|
37
|
+
"message": (
|
|
38
|
+
"There was an error when trying to authenticate with Google "
|
|
39
|
+
"Earth Engine. Make sure your GEE account is properly "
|
|
40
|
+
"connected to SEPAL. For more information, please visit "
|
|
41
|
+
"<a href='https://docs.sepal.io/en/latest/setup/gee.html' "
|
|
42
|
+
"target='_blank'> the documentation </a>."
|
|
43
|
+
),
|
|
44
|
+
"status": "CREDENTIALS_NOT_FOUND",
|
|
45
|
+
"details": f"Credentials file not found at {file_path}",
|
|
46
|
+
}
|
|
47
|
+
super().__init__(error)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CredentialsFileUnknownError(EEClientError):
|
|
51
|
+
"""Raised when unknown error occurred while accessing credentials file."""
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
error = {
|
|
55
|
+
"code": 500,
|
|
56
|
+
"message": (
|
|
57
|
+
"There was an error when trying to authenticate with Google "
|
|
58
|
+
"Earth Engine. Please re-authenticate your GEE account with "
|
|
59
|
+
"SEPAL. For more information, visit "
|
|
60
|
+
"<a href='https://docs.sepal.io/en/latest/setup/gee.html' "
|
|
61
|
+
"target='_blank'> the documentation </a>."
|
|
62
|
+
),
|
|
63
|
+
"status": "CREDENTIALS_ERROR",
|
|
64
|
+
"details": "Unknown error occurred while accessing credentials file",
|
|
65
|
+
}
|
|
66
|
+
super().__init__(error)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SepalCredentialsUnavailableError(EEClientError):
|
|
70
|
+
"""Raised when SEPAL API cannot provide credentials (e.g., 500 error)."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, status_code: int | None = None):
|
|
73
|
+
self.status_code = status_code
|
|
74
|
+
error = {
|
|
75
|
+
"code": status_code or 500,
|
|
76
|
+
"message": (
|
|
77
|
+
"There was an error when trying to authenticate with Google "
|
|
78
|
+
"Earth Engine. Make sure your GEE account is properly "
|
|
79
|
+
"connected to SEPAL. Please visit "
|
|
80
|
+
"<a href='https://docs.sepal.io/en/latest/setup/gee.html' "
|
|
81
|
+
"target='_blank'> the documentation </a> for more information."
|
|
82
|
+
),
|
|
83
|
+
"status": "SEPAL_CREDENTIALS_UNAVAILABLE",
|
|
84
|
+
"details": (
|
|
85
|
+
f"SEPAL API returned error {status_code}"
|
|
86
|
+
if status_code
|
|
87
|
+
else "SEPAL API is unable to provide credentials"
|
|
88
|
+
),
|
|
89
|
+
}
|
|
90
|
+
super().__init__(error)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# {'code': 401, 'message': 'Request had invalid authentication credentials.
|
|
94
|
+
# Expected OAuth 2 access token, login cookie or other valid authentication
|
|
95
|
+
# credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.',
|
|
96
|
+
# 'status': 'UNAUTHENTICATED'}
|
|
97
|
+
# Exception in _run_task: Request had invalid authentication credentials.
|
|
98
|
+
# Expected OAuth 2 access token, login cookie or other valid authentication
|
|
99
|
+
# credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.
|
|
100
|
+
# when that error happens, I need to re-set the credentials
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
from eeclient.models import SepalHeaders, GoogleTokens
|
|
2
|
+
from eeclient.exceptions import (
|
|
3
|
+
CredentialsFileNotFoundError,
|
|
4
|
+
CredentialsFileUnknownError,
|
|
5
|
+
SepalCredentialsUnavailableError,
|
|
6
|
+
)
|
|
7
|
+
import os
|
|
8
|
+
import logging
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import asyncio
|
|
12
|
+
import httpx
|
|
13
|
+
import requests
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("eeclient")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SepalCredentialMixin:
|
|
21
|
+
def __init__(self, sepal_headers: Optional[SepalHeaders] = None):
|
|
22
|
+
log.debug("Initializing SepalCredentialMixin")
|
|
23
|
+
|
|
24
|
+
self.max_retries = 3
|
|
25
|
+
self._credentials = None
|
|
26
|
+
self.auth_mode = "sepal" if sepal_headers else "file"
|
|
27
|
+
|
|
28
|
+
if sepal_headers:
|
|
29
|
+
self._init_sepal_mode(sepal_headers)
|
|
30
|
+
else:
|
|
31
|
+
self._init_file_mode()
|
|
32
|
+
|
|
33
|
+
self.logger = logging.getLogger(f"eeclient.{self.user}")
|
|
34
|
+
|
|
35
|
+
# For backward compatibility (some code might expect this attribute)
|
|
36
|
+
self._service = None
|
|
37
|
+
|
|
38
|
+
def _init_sepal_mode(self, sepal_headers: SepalHeaders):
|
|
39
|
+
"""Initialize SEPAL authentication mode"""
|
|
40
|
+
self.sepal_host = os.getenv("SEPAL_HOST")
|
|
41
|
+
if not self.sepal_host:
|
|
42
|
+
raise ValueError("SEPAL_HOST environment variable not set")
|
|
43
|
+
|
|
44
|
+
self.sepal_headers = SepalHeaders.model_validate(sepal_headers)
|
|
45
|
+
self.sepal_session_id = self.sepal_headers.cookies["SEPAL-SESSIONID"]
|
|
46
|
+
self.sepal_user_data = self.sepal_headers.sepal_user
|
|
47
|
+
self.user = self.sepal_user_data.username
|
|
48
|
+
|
|
49
|
+
self.sepal_api_download_url = (
|
|
50
|
+
f"https://{self.sepal_host}/api/user-files/download/"
|
|
51
|
+
"?path=%2F.config%2Fearthengine%2Fcredentials"
|
|
52
|
+
)
|
|
53
|
+
self.verify_ssl = not (
|
|
54
|
+
self.sepal_host == "host.docker.internal"
|
|
55
|
+
or self.sepal_host == "danielg.sepal.io"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
self._google_tokens = self.sepal_user_data.google_tokens
|
|
59
|
+
if self._google_tokens:
|
|
60
|
+
self._credentials = self._google_tokens
|
|
61
|
+
self.access_token = self._google_tokens.access_token
|
|
62
|
+
self.project_id = self._google_tokens.project_id
|
|
63
|
+
self.expiry_date = self._google_tokens.access_token_expiry_date
|
|
64
|
+
else:
|
|
65
|
+
self.access_token = None
|
|
66
|
+
self.project_id = None
|
|
67
|
+
self.expiry_date = 0
|
|
68
|
+
|
|
69
|
+
def _init_file_mode(self):
|
|
70
|
+
"""Initialize file-based authentication mode"""
|
|
71
|
+
home_path = Path.home()
|
|
72
|
+
credentials_file = (
|
|
73
|
+
".config/earthengine/credentials"
|
|
74
|
+
if "sepal-user" in home_path.name
|
|
75
|
+
else ".config/earthengine/sepal_credentials"
|
|
76
|
+
)
|
|
77
|
+
self.credentials_path = home_path / credentials_file
|
|
78
|
+
self.user = "local_user"
|
|
79
|
+
|
|
80
|
+
# No SEPAL session info for file-based credentials
|
|
81
|
+
self.sepal_session_id = None
|
|
82
|
+
self.sepal_host = None
|
|
83
|
+
self.sepal_api_download_url = None
|
|
84
|
+
self.verify_ssl = True
|
|
85
|
+
|
|
86
|
+
# Load initial credentials
|
|
87
|
+
self._load_credentials_from_file()
|
|
88
|
+
|
|
89
|
+
def _load_credentials_from_file(self):
|
|
90
|
+
"""Load credentials from file and update internal state"""
|
|
91
|
+
if not self.credentials_path.exists():
|
|
92
|
+
raise CredentialsFileNotFoundError(str(self.credentials_path))
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
file_content = self.credentials_path.read_text().strip()
|
|
96
|
+
if not file_content:
|
|
97
|
+
raise CredentialsFileNotFoundError(str(self.credentials_path))
|
|
98
|
+
|
|
99
|
+
credentials_data = json.loads(file_content)
|
|
100
|
+
self._credentials = GoogleTokens.model_validate(credentials_data)
|
|
101
|
+
self.access_token = self._credentials.access_token
|
|
102
|
+
self.project_id = self._credentials.project_id
|
|
103
|
+
self.expiry_date = self._credentials.access_token_expiry_date
|
|
104
|
+
|
|
105
|
+
if not self.access_token:
|
|
106
|
+
raise ValueError("No access token available in credentials file")
|
|
107
|
+
|
|
108
|
+
except json.JSONDecodeError as e:
|
|
109
|
+
raise ValueError(f"Invalid JSON in credentials file: {e}")
|
|
110
|
+
except Exception as e:
|
|
111
|
+
raise ValueError(f"Error loading credentials from file: {e}")
|
|
112
|
+
|
|
113
|
+
def is_expired(self) -> bool:
|
|
114
|
+
"""Returns if a token is about to expire"""
|
|
115
|
+
return (self.expiry_date / 1000) - time.time() < 60
|
|
116
|
+
|
|
117
|
+
def needs_credentials_refresh(self) -> bool:
|
|
118
|
+
"""Returns if credentials need to be refreshed (missing or expired)"""
|
|
119
|
+
return not self._credentials or self.is_expired()
|
|
120
|
+
|
|
121
|
+
async def set_credentials(self) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Refresh credentials based on the authentication mode.
|
|
124
|
+
"""
|
|
125
|
+
if self.auth_mode == "sepal":
|
|
126
|
+
await self._refresh_credentials_from_sepal()
|
|
127
|
+
else:
|
|
128
|
+
await self._refresh_credentials_from_file()
|
|
129
|
+
|
|
130
|
+
async def _refresh_credentials_from_sepal(self) -> None:
|
|
131
|
+
"""Refresh credentials via SEPAL API"""
|
|
132
|
+
if not self.sepal_session_id or not self.sepal_api_download_url:
|
|
133
|
+
raise ValueError("SEPAL session information not available")
|
|
134
|
+
|
|
135
|
+
self.logger.debug(
|
|
136
|
+
"Token is expired or about to expire; "
|
|
137
|
+
"attempting to refresh credentials from SEPAL."
|
|
138
|
+
)
|
|
139
|
+
attempt = 0
|
|
140
|
+
credentials_url = self.sepal_api_download_url
|
|
141
|
+
last_status = None
|
|
142
|
+
|
|
143
|
+
# Prepare cookies for authentication
|
|
144
|
+
sepal_cookies = httpx.Cookies()
|
|
145
|
+
sepal_cookies.set("SEPAL-SESSIONID", self.sepal_session_id)
|
|
146
|
+
|
|
147
|
+
while attempt < self.max_retries:
|
|
148
|
+
attempt += 1
|
|
149
|
+
try:
|
|
150
|
+
async with httpx.AsyncClient(
|
|
151
|
+
cookies=sepal_cookies,
|
|
152
|
+
verify=self.verify_ssl,
|
|
153
|
+
limits=httpx.Limits(
|
|
154
|
+
max_connections=100, max_keepalive_connections=50
|
|
155
|
+
),
|
|
156
|
+
) as client:
|
|
157
|
+
self.logger.debug(
|
|
158
|
+
f"Attempt {attempt} to refresh credentials from SEPAL."
|
|
159
|
+
)
|
|
160
|
+
response = await client.get(credentials_url)
|
|
161
|
+
|
|
162
|
+
last_status = response.status_code
|
|
163
|
+
|
|
164
|
+
if response.status_code == 200:
|
|
165
|
+
self._credentials = GoogleTokens.model_validate(response.json())
|
|
166
|
+
self.expiry_date = self._credentials.access_token_expiry_date
|
|
167
|
+
self.access_token = self._credentials.access_token
|
|
168
|
+
# Don't override project_id if enforce_project_id is set
|
|
169
|
+
if not hasattr(self, "enforce_project_id") or not getattr(
|
|
170
|
+
self, "enforce_project_id", False
|
|
171
|
+
):
|
|
172
|
+
self.project_id = self._credentials.project_id
|
|
173
|
+
self.logger.debug(
|
|
174
|
+
f"Successfully refreshed credentials from SEPAL. "
|
|
175
|
+
f"Project: {self.project_id}"
|
|
176
|
+
)
|
|
177
|
+
return
|
|
178
|
+
elif response.status_code == 500:
|
|
179
|
+
self.logger.error(
|
|
180
|
+
"SEPAL API returned 500 error - "
|
|
181
|
+
"credentials not available on server"
|
|
182
|
+
)
|
|
183
|
+
raise SepalCredentialsUnavailableError(500)
|
|
184
|
+
else:
|
|
185
|
+
self.logger.debug(
|
|
186
|
+
f"Attempt {attempt}/{self.max_retries} failed with "
|
|
187
|
+
f"status code: {response.status_code}."
|
|
188
|
+
)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
self.logger.error(
|
|
191
|
+
f"Attempt {attempt}/{self.max_retries} "
|
|
192
|
+
f"encountered an exception: {e}"
|
|
193
|
+
)
|
|
194
|
+
await asyncio.sleep(2**attempt) # Exponential backoff
|
|
195
|
+
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"Failed to retrieve credentials from SEPAL after "
|
|
198
|
+
f"{self.max_retries} attempts, last status code: {last_status}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def _refresh_credentials_from_file(self) -> None:
|
|
202
|
+
"""Refresh credentials by re-reading from file"""
|
|
203
|
+
self.logger.debug(
|
|
204
|
+
"Token is expired or about to expire; "
|
|
205
|
+
"attempting to refresh credentials from file."
|
|
206
|
+
)
|
|
207
|
+
attempt = 0
|
|
208
|
+
|
|
209
|
+
log.debug(f"Credentials path: {self.credentials_path}")
|
|
210
|
+
|
|
211
|
+
while attempt < self.max_retries:
|
|
212
|
+
attempt += 1
|
|
213
|
+
try:
|
|
214
|
+
self.logger.debug(
|
|
215
|
+
f"Attempt {attempt} to refresh credentials from file."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Re-read credentials from file
|
|
219
|
+
self._load_credentials_from_file()
|
|
220
|
+
|
|
221
|
+
# Check if the new credentials are still expired
|
|
222
|
+
if not self.is_expired():
|
|
223
|
+
self.logger.debug(
|
|
224
|
+
f"Successfully refreshed credentials from file. "
|
|
225
|
+
f"Project: {self.project_id}"
|
|
226
|
+
)
|
|
227
|
+
return
|
|
228
|
+
else:
|
|
229
|
+
self.logger.debug(
|
|
230
|
+
f"Attempt {attempt}/{self.max_retries}: "
|
|
231
|
+
f"Credentials from file are still expired."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
except CredentialsFileNotFoundError:
|
|
235
|
+
self.logger.error(
|
|
236
|
+
f"Credentials file not found: {self.credentials_path}"
|
|
237
|
+
)
|
|
238
|
+
raise
|
|
239
|
+
except Exception as e:
|
|
240
|
+
self.logger.error(
|
|
241
|
+
f"Attempt {attempt}/{self.max_retries} "
|
|
242
|
+
f"encountered an exception while reading file: {e}"
|
|
243
|
+
)
|
|
244
|
+
raise
|
|
245
|
+
|
|
246
|
+
# Wait before retrying (the file might be updated externally)
|
|
247
|
+
if attempt < self.max_retries:
|
|
248
|
+
wait_time = 2**attempt
|
|
249
|
+
self.logger.debug(f"Waiting {wait_time} seconds before retry...")
|
|
250
|
+
await asyncio.sleep(wait_time)
|
|
251
|
+
|
|
252
|
+
raise CredentialsFileUnknownError()
|
|
253
|
+
|
|
254
|
+
def set_credentials_sync(self) -> None:
|
|
255
|
+
"""
|
|
256
|
+
Refresh credentials synchronously based on the authentication mode.
|
|
257
|
+
"""
|
|
258
|
+
if self.auth_mode == "sepal":
|
|
259
|
+
self._refresh_credentials_from_sepal_sync()
|
|
260
|
+
else:
|
|
261
|
+
self._refresh_credentials_from_file_sync()
|
|
262
|
+
|
|
263
|
+
def _refresh_credentials_from_sepal_sync(self) -> None:
|
|
264
|
+
"""Refresh credentials via SEPAL API synchronously"""
|
|
265
|
+
if not self.sepal_session_id or not self.sepal_api_download_url:
|
|
266
|
+
raise ValueError("SEPAL session information not available")
|
|
267
|
+
|
|
268
|
+
self.logger.debug(
|
|
269
|
+
"Token is expired or about to expire; "
|
|
270
|
+
"attempting to refresh credentials from SEPAL (sync)."
|
|
271
|
+
)
|
|
272
|
+
attempt = 0
|
|
273
|
+
credentials_url = self.sepal_api_download_url
|
|
274
|
+
last_status = None
|
|
275
|
+
|
|
276
|
+
# Prepare session with cookies for authentication
|
|
277
|
+
session = requests.Session()
|
|
278
|
+
session.cookies.set("SEPAL-SESSIONID", self.sepal_session_id)
|
|
279
|
+
session.verify = self.verify_ssl
|
|
280
|
+
|
|
281
|
+
while attempt < self.max_retries:
|
|
282
|
+
attempt += 1
|
|
283
|
+
try:
|
|
284
|
+
self.logger.debug(
|
|
285
|
+
f"Attempt {attempt} to refresh credentials from SEPAL (sync)."
|
|
286
|
+
)
|
|
287
|
+
response = session.get(credentials_url)
|
|
288
|
+
last_status = response.status_code
|
|
289
|
+
|
|
290
|
+
if response.status_code == 200:
|
|
291
|
+
self._credentials = GoogleTokens.model_validate(response.json())
|
|
292
|
+
self.expiry_date = self._credentials.access_token_expiry_date
|
|
293
|
+
self.access_token = self._credentials.access_token
|
|
294
|
+
# Don't override project_id if enforce_project_id is set
|
|
295
|
+
if not hasattr(self, "enforce_project_id") or not getattr(
|
|
296
|
+
self, "enforce_project_id", False
|
|
297
|
+
):
|
|
298
|
+
self.project_id = self._credentials.project_id
|
|
299
|
+
self.logger.debug(
|
|
300
|
+
f"Successfully refreshed credentials from SEPAL (sync). "
|
|
301
|
+
f"Project: {self.project_id}"
|
|
302
|
+
)
|
|
303
|
+
return
|
|
304
|
+
elif response.status_code == 500:
|
|
305
|
+
self.logger.error(
|
|
306
|
+
"SEPAL API returned 500 error - "
|
|
307
|
+
"credentials not available on server"
|
|
308
|
+
)
|
|
309
|
+
session.close()
|
|
310
|
+
raise SepalCredentialsUnavailableError(500)
|
|
311
|
+
else:
|
|
312
|
+
self.logger.debug(
|
|
313
|
+
f"Attempt {attempt}/{self.max_retries} failed with "
|
|
314
|
+
f"status code: {response.status_code}."
|
|
315
|
+
)
|
|
316
|
+
except Exception as e:
|
|
317
|
+
self.logger.error(
|
|
318
|
+
f"Attempt {attempt}/{self.max_retries} "
|
|
319
|
+
f"encountered an exception: {e}"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Wait before retrying (exponential backoff)
|
|
323
|
+
if attempt < self.max_retries:
|
|
324
|
+
wait_time = 2**attempt
|
|
325
|
+
self.logger.debug(f"Waiting {wait_time} seconds before retry...")
|
|
326
|
+
time.sleep(wait_time)
|
|
327
|
+
|
|
328
|
+
session.close()
|
|
329
|
+
raise ValueError(
|
|
330
|
+
f"Failed to retrieve credentials from SEPAL after "
|
|
331
|
+
f"{self.max_retries} attempts, last status code: {last_status}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _refresh_credentials_from_file_sync(self) -> None:
|
|
335
|
+
"""Refresh credentials by re-reading from file synchronously"""
|
|
336
|
+
self.logger.debug(
|
|
337
|
+
"Token is expired or about to expire; "
|
|
338
|
+
"attempting to refresh credentials from file (sync)."
|
|
339
|
+
)
|
|
340
|
+
attempt = 0
|
|
341
|
+
|
|
342
|
+
while attempt < self.max_retries:
|
|
343
|
+
attempt += 1
|
|
344
|
+
try:
|
|
345
|
+
self.logger.debug(
|
|
346
|
+
f"Attempt {attempt} to refresh credentials from file (sync)."
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Re-read credentials from file
|
|
350
|
+
self._load_credentials_from_file()
|
|
351
|
+
|
|
352
|
+
# Check if the new credentials are still expired
|
|
353
|
+
if not self.is_expired():
|
|
354
|
+
self.logger.debug(
|
|
355
|
+
f"Successfully refreshed credentials from file (sync). "
|
|
356
|
+
f"Project: {self.project_id}"
|
|
357
|
+
)
|
|
358
|
+
return
|
|
359
|
+
else:
|
|
360
|
+
self.logger.debug(
|
|
361
|
+
f"Attempt {attempt}/{self.max_retries}: "
|
|
362
|
+
f"Credentials from file are still expired."
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
except CredentialsFileNotFoundError:
|
|
366
|
+
# Re-raise immediately - no point in retrying if file doesn't exist
|
|
367
|
+
raise
|
|
368
|
+
except Exception as e:
|
|
369
|
+
self.logger.error(
|
|
370
|
+
f"Attempt {attempt}/{self.max_retries} "
|
|
371
|
+
f"encountered an exception while reading file: {e}"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Wait before retrying (the file might be updated externally)
|
|
375
|
+
if attempt < self.max_retries:
|
|
376
|
+
wait_time = 2**attempt
|
|
377
|
+
self.logger.debug(f"Waiting {wait_time} seconds before retry...")
|
|
378
|
+
time.sleep(wait_time)
|
|
379
|
+
|
|
380
|
+
raise ValueError(
|
|
381
|
+
f"Failed to retrieve valid credentials from file after "
|
|
382
|
+
f"{self.max_retries} attempts. File may not be automatically "
|
|
383
|
+
f"updated or credentials are permanently expired."
|
|
384
|
+
)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ee-client"
|
|
7
|
-
version = "2.1
|
|
7
|
+
version = "2.2.1"
|
|
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.1
|
|
69
|
+
version = "2.2.1"
|
|
70
70
|
version_files = [
|
|
71
71
|
"pyproject.toml:version",
|
|
72
72
|
"eeclient/__init__.py:__version__",
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
from ee.ee_exception import EEException
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class EERestException(EEException):
|
|
5
|
-
def __init__(self, error):
|
|
6
|
-
self.message = error.get("message", "EE responded with an error")
|
|
7
|
-
super().__init__(self.message)
|
|
8
|
-
self.code = error.get("code", -1)
|
|
9
|
-
self.status = error.get("status", "UNDEFINED")
|
|
10
|
-
self.details = error.get("details")
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class EEClientError(Exception):
|
|
14
|
-
"""Custom exception class for EEClient errors."""
|
|
15
|
-
|
|
16
|
-
pass
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
# {'code': 401, 'message': 'Request had invalid authentication credentials.
|
|
20
|
-
# Expected OAuth 2 access token, login cookie or other valid authentication
|
|
21
|
-
# credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.',
|
|
22
|
-
# 'status': 'UNAUTHENTICATED'}
|
|
23
|
-
# Exception in _run_task: Request had invalid authentication credentials.
|
|
24
|
-
# Expected OAuth 2 access token, login cookie or other valid authentication
|
|
25
|
-
# credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.
|
|
26
|
-
# when that error happens, I need to re-set the credentials
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
from eeclient.models import SepalHeaders
|
|
2
|
-
import os
|
|
3
|
-
import logging
|
|
4
|
-
|
|
5
|
-
log = logging.getLogger("eeclient")
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class SepalCredentialMixin:
|
|
9
|
-
def __init__(self, sepal_headers: SepalHeaders):
|
|
10
|
-
log.debug("Initializing GDriveInterface with SEPAL headers")
|
|
11
|
-
self.sepal_host = os.getenv("SEPAL_HOST")
|
|
12
|
-
if not self.sepal_host:
|
|
13
|
-
raise ValueError("SEPAL_HOST environment variable not set")
|
|
14
|
-
|
|
15
|
-
self.sepal_headers = SepalHeaders.model_validate(sepal_headers)
|
|
16
|
-
self.sepal_session_id = self.sepal_headers.cookies["SEPAL-SESSIONID"]
|
|
17
|
-
self.sepal_user_data = self.sepal_headers.sepal_user
|
|
18
|
-
self.user = self.sepal_user_data.username
|
|
19
|
-
|
|
20
|
-
self.sepal_api_download_url = (
|
|
21
|
-
f"https://{self.sepal_host}/api/user-files/download/"
|
|
22
|
-
"?path=%2F.config%2Fearthengine%2Fcredentials"
|
|
23
|
-
)
|
|
24
|
-
self.verify_ssl = not (
|
|
25
|
-
self.sepal_host == "host.docker.internal"
|
|
26
|
-
or self.sepal_host == "danielg.sepal.io"
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
self._google_tokens = self.sepal_user_data.google_tokens
|
|
30
|
-
if self._google_tokens:
|
|
31
|
-
self.access_token = self._google_tokens.access_token
|
|
32
|
-
self.project_id = self._google_tokens.project_id
|
|
33
|
-
self.expiry_date = self._google_tokens.access_token_expiry_date
|
|
34
|
-
else:
|
|
35
|
-
self.access_token = None
|
|
36
|
-
self.project_id = None
|
|
37
|
-
self.expiry_date = 0
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|