datacosmos 0.0.13__tar.gz → 0.0.14__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.
- {datacosmos-0.0.13 → datacosmos-0.0.14}/PKG-INFO +1 -1
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/datacosmos_client.py +67 -41
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos.egg-info/PKG-INFO +1 -1
- {datacosmos-0.0.13 → datacosmos-0.0.14}/pyproject.toml +1 -1
- {datacosmos-0.0.13 → datacosmos-0.0.14}/LICENSE.md +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/README.md +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/auth/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/auth/local_token_fetcher.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/auth/token.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/auth/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/auth/factory.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/config.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/constants.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/loaders/yaml_source.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/authentication_config.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/local_user_account_authentication_config.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/m2m_authentication_config.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/no_authentication_config.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/url.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/exceptions/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/exceptions/datacosmos_exception.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/collection/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/collection/collection_client.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/collection/models/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/collection/models/collection_update.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/constants/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/constants/satellite_name_mapping.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/enums/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/enums/processing_level.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/enums/product_type.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/enums/season.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/item_client.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/asset.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/catalog_search_parameters.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/datacosmos_item.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/eo_band.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/item_update.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/raster_band.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/stac_client.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/dataclasses/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/dataclasses/upload_path.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/storage_base.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/storage_client.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/uploader.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/check_api_response.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/models/__init__.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/models/datacosmos_error.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/models/datacosmos_response.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/url.py +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos.egg-info/SOURCES.txt +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos.egg-info/dependency_links.txt +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos.egg-info/requires.txt +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos.egg-info/top_level.txt +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/setup.cfg +0 -0
- {datacosmos-0.0.13 → datacosmos-0.0.14}/tests/test_pass.py +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Client to interact with the Datacosmos API with authentication and request handling."""
|
|
2
2
|
|
|
3
|
+
import threading
|
|
3
4
|
from datetime import datetime, timedelta, timezone
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Any, Optional
|
|
@@ -16,6 +17,8 @@ from datacosmos.exceptions.datacosmos_exception import DatacosmosException
|
|
|
16
17
|
class DatacosmosClient:
|
|
17
18
|
"""Client to interact with the Datacosmos API with authentication and request handling."""
|
|
18
19
|
|
|
20
|
+
TOKEN_EXPIRY_SKEW_SECONDS = 60
|
|
21
|
+
|
|
19
22
|
def __init__(
|
|
20
23
|
self,
|
|
21
24
|
config: Optional[Config | Any] = None,
|
|
@@ -24,13 +27,13 @@ class DatacosmosClient:
|
|
|
24
27
|
"""Initialize the DatacosmosClient.
|
|
25
28
|
|
|
26
29
|
Args:
|
|
27
|
-
config:
|
|
28
|
-
http_session: Pre-authenticated session
|
|
29
|
-
with 'Authorization: Bearer ...').
|
|
30
|
+
config (Optional[Config]): Configuration object (only needed when SDK creates its own session).
|
|
31
|
+
http_session (Optional[requests.Session]): Pre-authenticated session.
|
|
30
32
|
"""
|
|
31
33
|
self.config = self._coerce_config(config)
|
|
32
34
|
self.token: Optional[str] = None
|
|
33
35
|
self.token_expiry: Optional[datetime] = None
|
|
36
|
+
self._refresh_lock = threading.Lock()
|
|
34
37
|
|
|
35
38
|
if http_session is not None:
|
|
36
39
|
self._init_with_injected_session(http_session)
|
|
@@ -42,7 +45,6 @@ class DatacosmosClient:
|
|
|
42
45
|
# --------------------------- init helpers ---------------------------
|
|
43
46
|
|
|
44
47
|
def _coerce_config(self, cfg: Optional[Config | Any]) -> Config:
|
|
45
|
-
"""Normalize various config inputs into a Config instance."""
|
|
46
48
|
if cfg is None:
|
|
47
49
|
return Config()
|
|
48
50
|
if isinstance(cfg, Config):
|
|
@@ -50,7 +52,7 @@ class DatacosmosClient:
|
|
|
50
52
|
if isinstance(cfg, dict):
|
|
51
53
|
return Config(**cfg)
|
|
52
54
|
try:
|
|
53
|
-
return Config.model_validate(cfg)
|
|
55
|
+
return Config.model_validate(cfg)
|
|
54
56
|
except Exception as e:
|
|
55
57
|
raise DatacosmosException(
|
|
56
58
|
"Invalid config provided to DatacosmosClient"
|
|
@@ -59,7 +61,6 @@ class DatacosmosClient:
|
|
|
59
61
|
def _init_with_injected_session(
|
|
60
62
|
self, http_session: requests.Session | OAuth2Session
|
|
61
63
|
) -> None:
|
|
62
|
-
"""Adopt a caller-provided session and extract token/expiry."""
|
|
63
64
|
self._http_client = http_session
|
|
64
65
|
self._owns_session = False
|
|
65
66
|
|
|
@@ -69,19 +70,15 @@ class DatacosmosClient:
|
|
|
69
70
|
raise DatacosmosException(
|
|
70
71
|
"Failed to extract access token from injected session"
|
|
71
72
|
)
|
|
72
|
-
|
|
73
73
|
self.token_expiry = self._compute_expiry(
|
|
74
|
-
token_data.get("expires_at"),
|
|
75
|
-
token_data.get("expires_in"),
|
|
74
|
+
token_data.get("expires_at"), token_data.get("expires_in")
|
|
76
75
|
)
|
|
77
76
|
|
|
78
77
|
def _extract_token_data(
|
|
79
78
|
self, http_session: requests.Session | OAuth2Session
|
|
80
79
|
) -> dict:
|
|
81
|
-
"""Return {'access_token', 'expires_at'?, 'expires_in'?} from the session."""
|
|
82
80
|
if isinstance(http_session, OAuth2Session):
|
|
83
81
|
return getattr(http_session, "token", {}) or {}
|
|
84
|
-
|
|
85
82
|
if isinstance(http_session, requests.Session):
|
|
86
83
|
auth_header = http_session.headers.get("Authorization", "")
|
|
87
84
|
if not auth_header.startswith("Bearer "):
|
|
@@ -89,7 +86,6 @@ class DatacosmosClient:
|
|
|
89
86
|
"Injected requests.Session must include a 'Bearer' token in its headers"
|
|
90
87
|
)
|
|
91
88
|
return {"access_token": auth_header.split(" ", 1)[1]}
|
|
92
|
-
|
|
93
89
|
raise DatacosmosException(f"Unsupported session type: {type(http_session)}")
|
|
94
90
|
|
|
95
91
|
def _compute_expiry(
|
|
@@ -97,7 +93,6 @@ class DatacosmosClient:
|
|
|
97
93
|
expires_at: Optional[datetime | int | float],
|
|
98
94
|
expires_in: Optional[int | float],
|
|
99
95
|
) -> Optional[datetime]:
|
|
100
|
-
"""Normalize expiry inputs to an absolute UTC datetime (or None)."""
|
|
101
96
|
if isinstance(expires_at, datetime):
|
|
102
97
|
return expires_at
|
|
103
98
|
if isinstance(expires_at, (int, float)):
|
|
@@ -106,51 +101,33 @@ class DatacosmosClient:
|
|
|
106
101
|
try:
|
|
107
102
|
return datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
|
|
108
103
|
except (TypeError, ValueError):
|
|
104
|
+
# Unknown/invalid expiry -> mark as unknown so refresh logic kicks in
|
|
109
105
|
return None
|
|
110
106
|
return None
|
|
111
107
|
|
|
112
108
|
# --------------------------- auth/session ---------------------------
|
|
113
109
|
|
|
114
110
|
def _authenticate_and_initialize_client(self) -> requests.Session:
|
|
115
|
-
"""Authenticate and initialize the HTTP client with a valid token."""
|
|
116
111
|
auth = self.config.authentication
|
|
117
112
|
auth_type = getattr(auth, "type", "m2m")
|
|
118
|
-
|
|
119
113
|
if auth_type == "m2m":
|
|
120
114
|
return self.__build_m2m_session()
|
|
121
|
-
|
|
122
115
|
if auth_type == "local":
|
|
123
116
|
return self.__build_local_session()
|
|
124
|
-
|
|
125
117
|
raise DatacosmosException(f"Unsupported authentication type: {auth_type}")
|
|
126
118
|
|
|
127
|
-
def _refresh_token_if_needed(self):
|
|
128
|
-
"""Refresh the token if it has expired (only if SDK created it)."""
|
|
129
|
-
if not getattr(self, "_owns_session", False):
|
|
130
|
-
return
|
|
131
|
-
now = datetime.now(timezone.utc)
|
|
132
|
-
# Treat missing token or missing expiry as 'needs refresh'
|
|
133
|
-
if (
|
|
134
|
-
(not self.token)
|
|
135
|
-
or (self.token_expiry is None)
|
|
136
|
-
or (self.token_expiry <= now)
|
|
137
|
-
):
|
|
138
|
-
self._http_client = self._authenticate_and_initialize_client()
|
|
139
|
-
|
|
140
119
|
def __build_m2m_session(self) -> requests.Session:
|
|
141
120
|
"""Client Credentials (M2M) flow using requests-oauthlib."""
|
|
142
121
|
auth = self.config.authentication
|
|
143
122
|
try:
|
|
144
123
|
client = BackendApplicationClient(client_id=auth.client_id)
|
|
145
124
|
oauth_session = OAuth2Session(client=client)
|
|
146
|
-
|
|
147
125
|
token_response = oauth_session.fetch_token(
|
|
148
126
|
token_url=auth.token_url,
|
|
149
127
|
client_id=auth.client_id,
|
|
150
128
|
client_secret=auth.client_secret,
|
|
151
129
|
audience=auth.audience,
|
|
152
130
|
)
|
|
153
|
-
|
|
154
131
|
self.token = token_response["access_token"]
|
|
155
132
|
expires_at = token_response.get("expires_at")
|
|
156
133
|
if isinstance(expires_at, (int, float)):
|
|
@@ -159,11 +136,9 @@ class DatacosmosClient:
|
|
|
159
136
|
self.token_expiry = datetime.now(timezone.utc) + timedelta(
|
|
160
137
|
seconds=int(token_response.get("expires_in", 3600))
|
|
161
138
|
)
|
|
162
|
-
|
|
163
139
|
http_client = requests.Session()
|
|
164
140
|
http_client.headers.update({"Authorization": f"Bearer {self.token}"})
|
|
165
141
|
return http_client
|
|
166
|
-
|
|
167
142
|
except (HTTPError, ConnectionError, Timeout) as e:
|
|
168
143
|
raise DatacosmosException(f"Authentication failed: {e}") from e
|
|
169
144
|
except RequestException as e:
|
|
@@ -195,38 +170,89 @@ class DatacosmosClient:
|
|
|
195
170
|
|
|
196
171
|
http_client = requests.Session()
|
|
197
172
|
http_client.headers.update({"Authorization": f"Bearer {self.token}"})
|
|
198
|
-
|
|
199
|
-
# keep for potential reuse in refresh path (optional)
|
|
200
173
|
self._local_token_fetcher = fetcher
|
|
201
174
|
return http_client
|
|
202
175
|
|
|
176
|
+
# --------------------------- refresh logic ---------------------------
|
|
177
|
+
|
|
178
|
+
def _needs_refresh(self) -> bool:
|
|
179
|
+
if not getattr(self, "_owns_session", False):
|
|
180
|
+
return False
|
|
181
|
+
if not self.token or self.token_expiry is None:
|
|
182
|
+
return True
|
|
183
|
+
return (self.token_expiry - datetime.now(timezone.utc)) <= timedelta(
|
|
184
|
+
seconds=self.TOKEN_EXPIRY_SKEW_SECONDS
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def _refresh_now(self) -> None:
|
|
188
|
+
"""Force refresh.
|
|
189
|
+
|
|
190
|
+
In case of local auth it uses LocalTokenFetcher (non-interactive refresh/cached token).
|
|
191
|
+
In case of m2m auth it re-runs client-credentials flow.
|
|
192
|
+
"""
|
|
193
|
+
with self._refresh_lock:
|
|
194
|
+
if not self._needs_refresh():
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
auth_type = getattr(self.config.authentication, "type", "m2m")
|
|
198
|
+
if auth_type == "local" and hasattr(self, "_local_token_fetcher"):
|
|
199
|
+
tok = self._local_token_fetcher.get_token()
|
|
200
|
+
self.token = tok.access_token
|
|
201
|
+
self.token_expiry = datetime.fromtimestamp(
|
|
202
|
+
tok.expires_at, tz=timezone.utc
|
|
203
|
+
)
|
|
204
|
+
self._http_client.headers.update(
|
|
205
|
+
{"Authorization": f"Bearer {self.token}"}
|
|
206
|
+
)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# default/m2m:
|
|
210
|
+
self._http_client = self.__build_m2m_session()
|
|
211
|
+
|
|
212
|
+
def _refresh_token_if_needed(self) -> None:
|
|
213
|
+
if self._needs_refresh():
|
|
214
|
+
self._refresh_now()
|
|
215
|
+
|
|
203
216
|
# --------------------------- request API ---------------------------
|
|
204
217
|
|
|
205
218
|
def request(
|
|
206
219
|
self, method: str, url: str, *args: Any, **kwargs: Any
|
|
207
220
|
) -> requests.Response:
|
|
208
|
-
"""Send an HTTP request using the authenticated session."""
|
|
221
|
+
"""Send an HTTP request using the authenticated session (with auto-refresh)."""
|
|
209
222
|
self._refresh_token_if_needed()
|
|
210
223
|
try:
|
|
211
224
|
response = self._http_client.request(method, url, *args, **kwargs)
|
|
212
225
|
response.raise_for_status()
|
|
213
226
|
return response
|
|
214
227
|
except HTTPError as e:
|
|
228
|
+
status = getattr(e.response, "status_code", None)
|
|
229
|
+
if status in (401, 403) and getattr(self, "_owns_session", False):
|
|
230
|
+
# token likely expired/invalid — refresh once and retry
|
|
231
|
+
self._refresh_now()
|
|
232
|
+
retry_response = self._http_client.request(method, url, *args, **kwargs)
|
|
233
|
+
try:
|
|
234
|
+
retry_response.raise_for_status()
|
|
235
|
+
return retry_response
|
|
236
|
+
except HTTPError as e:
|
|
237
|
+
raise DatacosmosException(
|
|
238
|
+
f"HTTP error during {method.upper()} request to {url} after refresh",
|
|
239
|
+
response=e.response,
|
|
240
|
+
) from e
|
|
215
241
|
raise DatacosmosException(
|
|
216
242
|
f"HTTP error during {method.upper()} request to {url}",
|
|
217
|
-
response=e
|
|
243
|
+
response=getattr(e, "response", None),
|
|
218
244
|
) from e
|
|
219
245
|
except ConnectionError as e:
|
|
220
246
|
raise DatacosmosException(
|
|
221
|
-
f"Connection error during {method.upper()} request to {url}: {
|
|
247
|
+
f"Connection error during {method.upper()} request to {url}: {e}"
|
|
222
248
|
) from e
|
|
223
249
|
except Timeout as e:
|
|
224
250
|
raise DatacosmosException(
|
|
225
|
-
f"Request timeout during {method.upper()} request to {url}: {
|
|
251
|
+
f"Request timeout during {method.upper()} request to {url}: {e}"
|
|
226
252
|
) from e
|
|
227
253
|
except RequestException as e:
|
|
228
254
|
raise DatacosmosException(
|
|
229
|
-
f"Unexpected request failure during {method.upper()} request to {url}: {
|
|
255
|
+
f"Unexpected request failure during {method.upper()} request to {url}: {e}"
|
|
230
256
|
) from e
|
|
231
257
|
|
|
232
258
|
def get(self, url: str, *args: Any, **kwargs: Any) -> requests.Response:
|
|
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
|
{datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/m2m_authentication_config.py
RENAMED
|
File without changes
|
{datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/no_authentication_config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/collection/models/collection_update.py
RENAMED
|
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
|
{datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/catalog_search_parameters.py
RENAMED
|
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
|
{datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/check_api_response.py
RENAMED
|
File without changes
|
|
File without changes
|
{datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/models/datacosmos_error.py
RENAMED
|
File without changes
|
{datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/models/datacosmos_response.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|