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.
Files changed (64) hide show
  1. {datacosmos-0.0.13 → datacosmos-0.0.14}/PKG-INFO +1 -1
  2. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/datacosmos_client.py +67 -41
  3. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos.egg-info/PKG-INFO +1 -1
  4. {datacosmos-0.0.13 → datacosmos-0.0.14}/pyproject.toml +1 -1
  5. {datacosmos-0.0.13 → datacosmos-0.0.14}/LICENSE.md +0 -0
  6. {datacosmos-0.0.13 → datacosmos-0.0.14}/README.md +0 -0
  7. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/__init__.py +0 -0
  8. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/auth/__init__.py +0 -0
  9. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/auth/local_token_fetcher.py +0 -0
  10. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/auth/token.py +0 -0
  11. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/__init__.py +0 -0
  12. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/auth/__init__.py +0 -0
  13. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/auth/factory.py +0 -0
  14. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/config.py +0 -0
  15. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/constants.py +0 -0
  16. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/loaders/yaml_source.py +0 -0
  17. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/__init__.py +0 -0
  18. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/authentication_config.py +0 -0
  19. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/local_user_account_authentication_config.py +0 -0
  20. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/m2m_authentication_config.py +0 -0
  21. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/no_authentication_config.py +0 -0
  22. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/config/models/url.py +0 -0
  23. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/exceptions/__init__.py +0 -0
  24. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/exceptions/datacosmos_exception.py +0 -0
  25. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/__init__.py +0 -0
  26. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/collection/__init__.py +0 -0
  27. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/collection/collection_client.py +0 -0
  28. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/collection/models/__init__.py +0 -0
  29. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/collection/models/collection_update.py +0 -0
  30. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/constants/__init__.py +0 -0
  31. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/constants/satellite_name_mapping.py +0 -0
  32. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/enums/__init__.py +0 -0
  33. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/enums/processing_level.py +0 -0
  34. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/enums/product_type.py +0 -0
  35. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/enums/season.py +0 -0
  36. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/__init__.py +0 -0
  37. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/item_client.py +0 -0
  38. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/__init__.py +0 -0
  39. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/asset.py +0 -0
  40. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/catalog_search_parameters.py +0 -0
  41. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/datacosmos_item.py +0 -0
  42. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/eo_band.py +0 -0
  43. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/item_update.py +0 -0
  44. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/item/models/raster_band.py +0 -0
  45. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/stac_client.py +0 -0
  46. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/__init__.py +0 -0
  47. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/dataclasses/__init__.py +0 -0
  48. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/dataclasses/upload_path.py +0 -0
  49. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/storage_base.py +0 -0
  50. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/storage_client.py +0 -0
  51. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/stac/storage/uploader.py +0 -0
  52. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/__init__.py +0 -0
  53. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/__init__.py +0 -0
  54. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/check_api_response.py +0 -0
  55. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/models/__init__.py +0 -0
  56. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/models/datacosmos_error.py +0 -0
  57. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/http_response/models/datacosmos_response.py +0 -0
  58. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos/utils/url.py +0 -0
  59. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos.egg-info/SOURCES.txt +0 -0
  60. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos.egg-info/dependency_links.txt +0 -0
  61. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos.egg-info/requires.txt +0 -0
  62. {datacosmos-0.0.13 → datacosmos-0.0.14}/datacosmos.egg-info/top_level.txt +0 -0
  63. {datacosmos-0.0.13 → datacosmos-0.0.14}/setup.cfg +0 -0
  64. {datacosmos-0.0.13 → datacosmos-0.0.14}/tests/test_pass.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datacosmos
3
- Version: 0.0.13
3
+ Version: 0.0.14
4
4
  Summary: A library for interacting with DataCosmos from Python code
5
5
  Author-email: Open Cosmos <support@open-cosmos.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -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: SDK configuration (if omitted, Config() loads YAML + env).
28
- http_session: Pre-authenticated session (OAuth2Session or requests.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) # pydantic v2
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.response,
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}: {str(e)}"
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}: {str(e)}"
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}: {str(e)}"
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datacosmos
3
- Version: 0.0.13
3
+ Version: 0.0.14
4
4
  Summary: A library for interacting with DataCosmos from Python code
5
5
  Author-email: Open Cosmos <support@open-cosmos.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "datacosmos"
7
- version = "0.0.13"
7
+ version = "0.0.14"
8
8
  authors = [
9
9
  { name="Open Cosmos", email="support@open-cosmos.com" },
10
10
  ]
File without changes
File without changes
File without changes