earthscope-sdk 1.0.0b1__py3-none-any.whl → 1.1.0__py3-none-any.whl

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.
@@ -1,4 +1,4 @@
1
- __version__ = "1.0.0b1"
1
+ __version__ = "1.1.0"
2
2
 
3
3
  from earthscope_sdk.client import AsyncEarthScopeClient, EarthScopeClient
4
4
 
@@ -311,9 +311,10 @@ class AuthFlow(httpx.Auth):
311
311
  """
312
312
  super().async_auth_flow
313
313
  if request.headers.get("authorization") is None:
314
- await self.async_refresh_if_necessary()
315
- access_token = self.access_token
316
- request.headers["authorization"] = f"Bearer {access_token}"
314
+ if self._settings.is_host_allowed(request.url.host):
315
+ await self.async_refresh_if_necessary()
316
+ access_token = self.access_token
317
+ request.headers["authorization"] = f"Bearer {access_token}"
317
318
 
318
319
  yield request
319
320
 
@@ -326,8 +327,9 @@ class AuthFlow(httpx.Auth):
326
327
  # NOTE: we explicitly redefine this sync method because ctx.syncify()
327
328
  # does not support generators
328
329
  if request.headers.get("authorization") is None:
329
- self.refresh_if_necessary()
330
- access_token = self.access_token
331
- request.headers["authorization"] = f"Bearer {access_token}"
330
+ if self._settings.is_host_allowed(request.url.host):
331
+ self.refresh_if_necessary()
332
+ access_token = self.access_token
333
+ request.headers["authorization"] = f"Bearer {access_token}"
332
334
 
333
335
  yield request
@@ -1,5 +1,4 @@
1
1
  import logging
2
- from dataclasses import dataclass
3
2
  from json import JSONDecodeError
4
3
 
5
4
  from earthscope_sdk.auth.auth_flow import AuthFlow
@@ -9,12 +8,6 @@ from earthscope_sdk.common.context import SdkContext
9
8
  logger = logging.getLogger(__name__)
10
9
 
11
10
 
12
- @dataclass
13
- class GetTokensErrorResponse:
14
- error: str
15
- error_description: str
16
-
17
-
18
11
  class ClientCredentialsFlow(AuthFlow):
19
12
  """
20
13
  Implements the oauth2 Client Credentials "machine-to-machine" (M2M) flow.
@@ -69,12 +62,9 @@ class ClientCredentialsFlow(AuthFlow):
69
62
 
70
63
  # Unauthorized
71
64
  if r.status_code == 401:
72
- err = GetTokensErrorResponse(**resp)
73
- if err.error == "access_denied":
74
- if err.error_description == "Unauthorized":
75
- raise UnauthorizedError(
76
- f"m2m client '{self._settings.client_id}' is not authorized"
77
- )
65
+ raise UnauthorizedError(
66
+ f"m2m client '{self._settings.client_id}' is not authorized. IdP response: {resp}"
67
+ )
78
68
 
79
69
  # Unhandled
80
70
  raise ClientCredentialsFlowError("client credentials flow failed", r.content)
@@ -1,11 +1,12 @@
1
1
  import logging
2
+ from asyncio import sleep
2
3
  from contextlib import asynccontextmanager, contextmanager
3
- from dataclasses import dataclass
4
4
  from enum import Enum
5
5
  from json import JSONDecodeError
6
- from time import sleep
7
6
  from typing import Optional
8
7
 
8
+ from pydantic import BaseModel, ValidationError
9
+
9
10
  from earthscope_sdk.auth.auth_flow import AuthFlow
10
11
  from earthscope_sdk.auth.error import (
11
12
  DeviceCodePollingError,
@@ -25,18 +26,16 @@ class PollingErrorType(str, Enum):
25
26
  ACCESS_DENIED = "access_denied"
26
27
 
27
28
 
28
- @dataclass
29
- class GetDeviceCodeResponse:
29
+ class GetDeviceCodeResponse(BaseModel):
30
30
  device_code: str
31
31
  user_code: str
32
32
  verification_uri: str
33
33
  verification_uri_complete: str
34
34
  expires_in: int
35
- interval: int
35
+ interval: float
36
36
 
37
37
 
38
- @dataclass
39
- class PollingErrorResponse:
38
+ class PollingErrorResponse(BaseModel):
40
39
  error: PollingErrorType
41
40
  error_description: str
42
41
 
@@ -157,7 +156,7 @@ class DeviceCodeFlow(AuthFlow):
157
156
  try:
158
157
  while True:
159
158
  # IdP-provided poll interval
160
- sleep(codes.interval)
159
+ await sleep(codes.interval)
161
160
 
162
161
  r = await self._ctx.httpx_client.post(
163
162
  f"{self._settings.domain}oauth/token",
@@ -185,7 +184,12 @@ class DeviceCodeFlow(AuthFlow):
185
184
  return self
186
185
 
187
186
  # Keep polling
188
- poll_err = PollingErrorResponse(**resp)
187
+ try:
188
+ poll_err = PollingErrorResponse.model_validate(resp)
189
+ except ValidationError as e:
190
+ raise DeviceCodePollingError(
191
+ f"Failed to unpack polling response: {r.text}"
192
+ ) from e
189
193
  if poll_err.error in [
190
194
  PollingErrorType.AUTHORIZATION_PENDING,
191
195
  PollingErrorType.SLOW_DOWN,
@@ -235,7 +239,12 @@ class DeviceCodeFlow(AuthFlow):
235
239
  f"Failed to get a device code: {r.content}"
236
240
  )
237
241
 
238
- codes = GetDeviceCodeResponse(**r.json())
242
+ try:
243
+ codes = GetDeviceCodeResponse.model_validate_json(r.content)
244
+ except ValidationError as e:
245
+ raise DeviceCodeRequestDeviceCodeError(
246
+ f"Failed to unpack device code response: {r.text}"
247
+ ) from e
239
248
 
240
249
  logger.debug(f"Got device code response: {codes}")
241
250
  return codes
@@ -78,6 +78,8 @@ class SdkContext:
78
78
  self._httpx_client = httpx.AsyncClient(
79
79
  auth=self.auth_flow,
80
80
  headers={
81
+ **self.settings.http.extra_headers,
82
+ # override anything specified via extra_headers
81
83
  "user-agent": self.settings.http.user_agent,
82
84
  },
83
85
  limits=self.settings.http.limits,
@@ -0,0 +1,42 @@
1
+ """
2
+ This module facilitates bootstrapping SDK settings from a JSON-encoded environment variable.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+
9
+ from pydantic_settings import PydanticBaseSettingsSource
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class BootstrapEnvironmentSettingsSource(PydanticBaseSettingsSource):
15
+ """
16
+ This SettingsSource facilitates bootstrapping the SDK from a special environment variable.
17
+
18
+ The environment variable should be a JSON string of the expected SDK settings and structure.
19
+ """
20
+
21
+ def __init__(self, settings_cls, env_var: str):
22
+ super().__init__(settings_cls)
23
+ self._env_var = env_var
24
+
25
+ def __call__(self):
26
+ try:
27
+ bootstrap_settings = os.environ[self._env_var]
28
+ except KeyError:
29
+ return {}
30
+
31
+ try:
32
+ return json.loads(bootstrap_settings)
33
+ except json.JSONDecodeError:
34
+ logger.warning(
35
+ f"Found bootstrap environment variable '{self._env_var}', but unable to decode content as JSON"
36
+ )
37
+ return {}
38
+
39
+ def __repr__(self) -> str:
40
+ return f"{self.__class__.__name__}(env_var='{self._env_var}')"
41
+
42
+ def get_field_value(self, *args, **kwargs): ... # unused abstract method
@@ -1,6 +1,7 @@
1
1
  import base64
2
2
  import binascii
3
3
  import datetime as dt
4
+ import fnmatch
4
5
  import functools
5
6
  from contextlib import suppress
6
7
  from enum import Enum
@@ -15,14 +16,12 @@ from pydantic import (
15
16
  ConfigDict,
16
17
  Field,
17
18
  HttpUrl,
18
- SecretStr,
19
- SerializationInfo,
20
19
  ValidationError,
21
- field_serializer,
22
20
  model_validator,
23
21
  )
24
22
 
25
23
  from earthscope_sdk import __version__
24
+ from earthscope_sdk.model.secret import SecretStr
26
25
 
27
26
 
28
27
  def _try_float(v: Any):
@@ -94,23 +93,6 @@ class Tokens(BaseModel):
94
93
 
95
94
  raise ValueError("Unable to decode access token body")
96
95
 
97
- @field_serializer("access_token", "id_token", "refresh_token", when_used="json")
98
- def dump_secret_json(self, secret: Optional[SecretStr], info: SerializationInfo):
99
- """
100
- A special field serializer to dump the actual secret value when writing to JSON.
101
-
102
- Only writes secret in plaintext when `info.context == "plaintext".
103
-
104
- See [Pydantic docs](https://docs.pydantic.dev/latest/concepts/serialization/#serialization-context)
105
- """
106
- if secret is None:
107
- return None
108
-
109
- if info.context == "plaintext":
110
- return secret.get_secret_value()
111
-
112
- return str(secret)
113
-
114
96
  @model_validator(mode="after")
115
97
  def ensure_one_of(self):
116
98
  # allow all fields to be optional in subclasses
@@ -207,6 +189,12 @@ class AuthFlowSettings(Tokens):
207
189
  scope: str = "offline_access"
208
190
  client_secret: Optional[SecretStr] = None
209
191
 
192
+ # Only inject bearer token for requests to these hosts
193
+ allowed_hosts: set[str] = {
194
+ "earthscope.org",
195
+ "*.earthscope.org",
196
+ }
197
+
210
198
  # Auth exchange retries
211
199
  retry: HttpRetrySettings = HttpRetrySettings(
212
200
  attempts=5,
@@ -222,6 +210,37 @@ class AuthFlowSettings(Tokens):
222
210
 
223
211
  return AuthFlowType.DeviceCode
224
212
 
213
+ @cached_property
214
+ def allowed_host_patterns(self) -> set[str]:
215
+ """
216
+ The subset of allowed hosts that are glob patterns.
217
+
218
+ Use `is_host_allowed` to check if a host is allowed by any of these patterns.
219
+ """
220
+ return {h for h in self.allowed_hosts if "*" in h or "?" in h}
221
+
222
+ def is_host_allowed(self, host: str) -> bool:
223
+ """
224
+ Check if a host matches any pattern in the allowed hosts set.
225
+
226
+ Supports glob patterns with '?' and '*' characters (e.g., *.earthscope.org).
227
+
228
+ Args:
229
+ host: The hostname to check
230
+
231
+ Returns:
232
+ True if the host matches any allowed pattern, False otherwise
233
+ """
234
+ if host in self.allowed_hosts:
235
+ return True
236
+
237
+ for allowed_pattern in self.allowed_host_patterns:
238
+ if fnmatch.fnmatch(host, allowed_pattern):
239
+ self.allowed_hosts.add(host)
240
+ return True
241
+
242
+ return False
243
+
225
244
 
226
245
  class HttpSettings(BaseModel):
227
246
  """
@@ -242,6 +261,7 @@ class HttpSettings(BaseModel):
242
261
 
243
262
  # Other
244
263
  user_agent: str = f"earthscope-sdk py/{__version__}"
264
+ extra_headers: dict[str, str] = {}
245
265
 
246
266
  @cached_property
247
267
  def limits(self):
@@ -11,11 +11,15 @@ from pydantic_settings import (
11
11
  TomlConfigSettingsSource,
12
12
  )
13
13
 
14
+ from earthscope_sdk.config._bootstrap import BootstrapEnvironmentSettingsSource
14
15
  from earthscope_sdk.config._compat import LegacyEarthScopeCLISettingsSource
15
16
  from earthscope_sdk.config._util import deep_merge, get_config_dir, slugify
16
17
  from earthscope_sdk.config.error import ProfileDoesNotExistError
17
18
  from earthscope_sdk.config.models import SdkBaseSettings, Tokens
18
19
 
20
+ _BOOTSTRAP_ENV_VAR = "ES_BOOTSTRAP_SETTINGS"
21
+ """Environment variable for bootstrapping the SDK"""
22
+
19
23
  _DEFAULT_PROFILE = "default"
20
24
  """Default profile name"""
21
25
 
@@ -269,6 +273,12 @@ class SdkSettings(SdkBaseSettings, BaseSettings):
269
273
  alias = SdkSettings.model_fields["profile_name"].validation_alias
270
274
  global_settings = _GlobalSettingsSource(settings_cls, "profile_name", alias)
271
275
 
276
+ # Check for bootstrapping configuration
277
+ bootstrap_settings = BootstrapEnvironmentSettingsSource(
278
+ settings_cls,
279
+ _BOOTSTRAP_ENV_VAR,
280
+ )
281
+
272
282
  # Compatibility with earthscope-cli v0.x.x state:
273
283
  # If we find this file, we only care about the access and refresh tokens
274
284
  keep_keys = {"access_token", "refresh_token"}
@@ -281,4 +291,5 @@ class SdkSettings(SdkBaseSettings, BaseSettings):
281
291
  dotenv_settings,
282
292
  global_settings,
283
293
  legacy_settings,
294
+ bootstrap_settings,
284
295
  )
@@ -0,0 +1,29 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import PlainSerializer, SerializationInfo
4
+ from pydantic import SecretStr as _SecretStr
5
+
6
+
7
+ def _dump_secret_plaintext(secret: _SecretStr, info: SerializationInfo):
8
+ """
9
+ A special field serializer to dump the actual secret value.
10
+
11
+ Only writes secret in plaintext when `info.context == "plaintext".
12
+
13
+ See [Pydantic docs](https://docs.pydantic.dev/latest/concepts/serialization/#serialization-context)
14
+ """
15
+
16
+ if info.context == "plaintext":
17
+ return secret.get_secret_value()
18
+
19
+ return str(secret)
20
+
21
+
22
+ SecretStr = Annotated[
23
+ _SecretStr,
24
+ PlainSerializer(
25
+ _dump_secret_plaintext,
26
+ return_type=str,
27
+ when_used="json-unless-none",
28
+ ),
29
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: earthscope-sdk
3
- Version: 1.0.0b1
3
+ Version: 1.1.0
4
4
  Summary: An SDK for EarthScope API
5
5
  Author-email: EarthScope <data-help@earthscope.org>
6
6
  License: Apache License
@@ -222,6 +222,7 @@ Requires-Dist: build; extra == "dev"
222
222
  Requires-Dist: pytest; extra == "dev"
223
223
  Requires-Dist: twine; extra == "dev"
224
224
  Requires-Dist: pip-tools; extra == "dev"
225
+ Requires-Dist: pre-commit; extra == "dev"
225
226
  Requires-Dist: pytest-httpx; extra == "dev"
226
227
  Requires-Dist: pytest-asyncio; extra == "dev"
227
228
  Requires-Dist: ruff; extra == "dev"
@@ -243,7 +244,7 @@ pip install earthscope-sdk
243
244
 
244
245
  ### Usage
245
246
 
246
- For detailed usage options and examples, visit [our usage docs](docs/usage.md).
247
+ For detailed usage info and examples, visit [our SDK docs](https://docs.earthscope.org/projects/SDK).
247
248
 
248
249
  ```py
249
250
  # Import and create a client
@@ -300,7 +301,7 @@ Once refreshable credentials are available to the SDK, it will transparently han
300
301
 
301
302
  ### Same host
302
303
 
303
- If you have the [EarthScope CLI](TODO) installed on the same host that is running your application which uses `earthscope-sdk`, you can simply log in using the CLI. The CLI shares credentials and configuration with this SDK (when running on the same host).
304
+ If you have the [EarthScope CLI](https://docs.earthscope.org/projects/CLI) installed on the same host that is running your application which uses `earthscope-sdk`, you can simply log in using the CLI. The CLI shares credentials and configuration with this SDK (when running on the same host).
304
305
 
305
306
  Running `es login` will open your browser and prompt you to log in to your EarthScope account.
306
307
 
@@ -320,7 +321,7 @@ Now when you run your application, `earthscope-sdk` will find your credentials.
320
321
 
321
322
  Sometimes your workload runs on different hosts than your main workstation and you cannot feasibly "log in" on all of them. For example, maybe you're running many containers in your workload.
322
323
 
323
- You can still use the [EarthScope CLI](TODO) to facilitate auth for applications on other machines.
324
+ You can still use the [EarthScope CLI](https://docs.earthscope.org/projects/CLI) to facilitate auth for applications on other machines.
324
325
 
325
326
  1. Use the CLI on your primary workstation [as described above](#same-host) to log in.
326
327
 
@@ -1,8 +1,8 @@
1
- earthscope_sdk/__init__.py,sha256=GUJAs2cToEo9f8MaCUP_VnRsyqZWNhsN-pId5vzPk4U,156
1
+ earthscope_sdk/__init__.py,sha256=bkZcCw9euoFA9FOzEZW4H6cpa-EPH1GUsAsUEwLclIY,154
2
2
  earthscope_sdk/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- earthscope_sdk/auth/auth_flow.py,sha256=6jBQxLJ2gAxXGgtZYXDH1yg5L_WUmgu7fRvW4JoCi4Q,10198
4
- earthscope_sdk/auth/client_credentials_flow.py,sha256=1GyDSIR1OgYP4u0xZoTov1u_YhY1AzHFpOcBCzY1h6E,2769
5
- earthscope_sdk/auth/device_code_flow.py,sha256=dC5Ffj3HzBguRxSHCZYvTe1MD3C-iKf2AlanGuRKNvI,7922
3
+ earthscope_sdk/auth/auth_flow.py,sha256=gxJmCr5TAhMEss7RskYFCv6-D3A5h0Zw2ejeLJJ_9aI,10352
4
+ earthscope_sdk/auth/client_credentials_flow.py,sha256=GAvskuoEd6qHe-BtfGwYQisMedmSkDhagy4aRPVPri4,2494
5
+ earthscope_sdk/auth/device_code_flow.py,sha256=p53pgRQraToFwONPBj3O3DHAa4x4rE6rvygo_Bbj5_U,8394
6
6
  earthscope_sdk/auth/error.py,sha256=eC33Bw1HaBEJE7-eI2krtE__5PxStc3EyiYO12v0kVw,693
7
7
  earthscope_sdk/client/__init__.py,sha256=JotTr5oTiiOsUc0RTg82EVCUSg_-u80Qu_R0-crCXkY,139
8
8
  earthscope_sdk/client/_client.py,sha256=ai7WdsTOYglA6bLkT-Wntvxlke6nSaGHwqrtg5PEy80,833
@@ -13,16 +13,18 @@ earthscope_sdk/client/user/models.py,sha256=drZAMwOYC1NVCzBZQhNL-pPTB28SURKfoZF8
13
13
  earthscope_sdk/common/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  earthscope_sdk/common/_sync_runner.py,sha256=h_A2pSEjZCLj7ov50M6cWHVoX6eXVmGzz5nX0MwLWDY,4131
15
15
  earthscope_sdk/common/client.py,sha256=g5ZTNhFm33H68J9pWD5fDu760Yd5cBdfQmsbU3t8D_4,2156
16
- earthscope_sdk/common/context.py,sha256=vrCB_Ez-98Ir7c0GrCe-g7DuRCgc9vPaoRWFYf5q8Ko,5138
16
+ earthscope_sdk/common/context.py,sha256=bt2UhSsIZGBaukA0QJiFsPhJaSUDdcT9kmAZrfTQsc4,5254
17
17
  earthscope_sdk/common/service.py,sha256=SCUZVJA3jFaEPeFrOf0v9osf2UpqldhlFmirOYWJjxM,1506
18
18
  earthscope_sdk/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
+ earthscope_sdk/config/_bootstrap.py,sha256=BvDSRccvFPFRnLS4MlE7OMLGYEgxY7znxFZ6AYgxvOE,1243
19
20
  earthscope_sdk/config/_compat.py,sha256=P3F5y_Kf5zp9m9uOhl1Bp3ke6expxq4Sm9AeVaBbAHk,4610
20
21
  earthscope_sdk/config/_util.py,sha256=RZ6zvKrvjUkO7i69s7AVoIDhamRg4x71CAZLnucr9QM,1249
21
22
  earthscope_sdk/config/error.py,sha256=jh25q-b317lAvp32WwQw0zdYoV-MxZtg-n5FgZOMymI,95
22
- earthscope_sdk/config/models.py,sha256=1334Rxzw4qDLSdQg9btxFQySBOCb8TEW6J95M-lyKEc,8198
23
- earthscope_sdk/config/settings.py,sha256=I2DwEvfmETcaYbSvUybs0EIih0yiJO9D46WnWzKPqbo,8812
24
- earthscope_sdk-1.0.0b1.dist-info/licenses/LICENSE,sha256=E_MrVXxRaMQNpvZhsDuz_J9N_ux7dlL_WpYSsE391HU,11349
25
- earthscope_sdk-1.0.0b1.dist-info/METADATA,sha256=BHeAUzZ882lmEExnoVKIywGSYvIbyNdnIh8BzRJs2Ng,17988
26
- earthscope_sdk-1.0.0b1.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
27
- earthscope_sdk-1.0.0b1.dist-info/top_level.txt,sha256=zTtIT9yN3JPJF7TqmTzqQcAvZZe4pAm907DLoGa5T_E,15
28
- earthscope_sdk-1.0.0b1.dist-info/RECORD,,
23
+ earthscope_sdk/config/models.py,sha256=MCN9fbJKqnKHnSeg-KhUOsnXsqMxmawcOiZ62689ZS0,8723
24
+ earthscope_sdk/config/settings.py,sha256=kGsoqAgoUS2xrI0rvdqbLEsI2M0Mpbm73oEDLpJG4_Q,9205
25
+ earthscope_sdk/model/secret.py,sha256=QTyWCqXvf9ZYWaVVQcGzdt3rGtyU3sx13AlzkNE3gaU,731
26
+ earthscope_sdk-1.1.0.dist-info/licenses/LICENSE,sha256=E_MrVXxRaMQNpvZhsDuz_J9N_ux7dlL_WpYSsE391HU,11349
27
+ earthscope_sdk-1.1.0.dist-info/METADATA,sha256=t9dLQDJbJeZBd-CiUncz2Hh4JnGazSsZg19MnIP6KQ8,18122
28
+ earthscope_sdk-1.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
29
+ earthscope_sdk-1.1.0.dist-info/top_level.txt,sha256=zTtIT9yN3JPJF7TqmTzqQcAvZZe4pAm907DLoGa5T_E,15
30
+ earthscope_sdk-1.1.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5