datacosmos 0.0.13__tar.gz → 0.0.15__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.

Potentially problematic release.


This version of datacosmos might be problematic. Click here for more details.

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