earthscope-sdk 1.0.0b1__tar.gz → 1.1.0__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 (40) hide show
  1. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/PKG-INFO +5 -4
  2. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/README.md +3 -3
  3. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/pyproject.toml +3 -2
  4. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/__init__.py +1 -1
  5. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/auth/auth_flow.py +8 -6
  6. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/auth/client_credentials_flow.py +3 -13
  7. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/auth/device_code_flow.py +19 -10
  8. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/common/context.py +2 -0
  9. earthscope_sdk-1.1.0/src/earthscope_sdk/config/_bootstrap.py +42 -0
  10. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/models.py +40 -20
  11. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/settings.py +11 -0
  12. earthscope_sdk-1.1.0/src/earthscope_sdk/model/secret.py +29 -0
  13. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk.egg-info/PKG-INFO +5 -4
  14. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk.egg-info/SOURCES.txt +2 -0
  15. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk.egg-info/requires.txt +1 -0
  16. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/tests/test_auth.py +76 -2
  17. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/tests/test_context.py +63 -3
  18. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/tests/test_settings.py +178 -4
  19. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/LICENSE +0 -0
  20. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/setup.cfg +0 -0
  21. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/setup.py +0 -0
  22. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/auth/__init__.py +0 -0
  23. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/auth/error.py +0 -0
  24. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/__init__.py +0 -0
  25. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/_client.py +0 -0
  26. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/user/__init__.py +0 -0
  27. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/user/_base.py +0 -0
  28. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/user/_service.py +0 -0
  29. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/user/models.py +0 -0
  30. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/common/__init__.py +0 -0
  31. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/common/_sync_runner.py +0 -0
  32. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/common/client.py +0 -0
  33. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/common/service.py +0 -0
  34. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/__init__.py +0 -0
  35. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/_compat.py +0 -0
  36. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/_util.py +0 -0
  37. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/error.py +0 -0
  38. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk.egg-info/dependency_links.txt +0 -0
  39. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk.egg-info/top_level.txt +0 -0
  40. {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/tests/test_client.py +0 -0
@@ -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
 
@@ -14,7 +14,7 @@ pip install earthscope-sdk
14
14
 
15
15
  ### Usage
16
16
 
17
- For detailed usage options and examples, visit [our usage docs](docs/usage.md).
17
+ For detailed usage info and examples, visit [our SDK docs](https://docs.earthscope.org/projects/SDK).
18
18
 
19
19
  ```py
20
20
  # Import and create a client
@@ -71,7 +71,7 @@ Once refreshable credentials are available to the SDK, it will transparently han
71
71
 
72
72
  ### Same host
73
73
 
74
- 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).
74
+ 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).
75
75
 
76
76
  Running `es login` will open your browser and prompt you to log in to your EarthScope account.
77
77
 
@@ -91,7 +91,7 @@ Now when you run your application, `earthscope-sdk` will find your credentials.
91
91
 
92
92
  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.
93
93
 
94
- You can still use the [EarthScope CLI](TODO) to facilitate auth for applications on other machines.
94
+ You can still use the [EarthScope CLI](https://docs.earthscope.org/projects/CLI) to facilitate auth for applications on other machines.
95
95
 
96
96
  1. Use the CLI on your primary workstation [as described above](#same-host) to log in.
97
97
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "earthscope-sdk"
7
- version = "1.0.0b1"
7
+ version = "1.1.0"
8
8
  description = "An SDK for EarthScope API"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "EarthScope", email = "data-help@earthscope.org" }]
@@ -31,6 +31,7 @@ dev = [
31
31
  "pytest",
32
32
  "twine",
33
33
  "pip-tools",
34
+ "pre-commit",
34
35
  "pytest-httpx",
35
36
  "pytest-asyncio",
36
37
  "ruff",
@@ -40,7 +41,7 @@ dev = [
40
41
  Homepage = "https://gitlab.com/earthscope/public/earthscope-sdk"
41
42
 
42
43
  [tool.bumpver]
43
- current_version = "1.0.0b1"
44
+ current_version = "1.1.0"
44
45
  version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]"
45
46
  commit_message = "chore: bump version {old_version} -> {new_version}"
46
47
  commit = true
@@ -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
 
@@ -25,11 +25,13 @@ src/earthscope_sdk/common/client.py
25
25
  src/earthscope_sdk/common/context.py
26
26
  src/earthscope_sdk/common/service.py
27
27
  src/earthscope_sdk/config/__init__.py
28
+ src/earthscope_sdk/config/_bootstrap.py
28
29
  src/earthscope_sdk/config/_compat.py
29
30
  src/earthscope_sdk/config/_util.py
30
31
  src/earthscope_sdk/config/error.py
31
32
  src/earthscope_sdk/config/models.py
32
33
  src/earthscope_sdk/config/settings.py
34
+ src/earthscope_sdk/model/secret.py
33
35
  tests/test_auth.py
34
36
  tests/test_client.py
35
37
  tests/test_context.py
@@ -8,6 +8,7 @@ build
8
8
  pytest
9
9
  twine
10
10
  pip-tools
11
+ pre-commit
11
12
  pytest-httpx
12
13
  pytest-asyncio
13
14
  ruff
@@ -1,12 +1,20 @@
1
1
  import time
2
2
  import webbrowser
3
+ from typing import Type
3
4
 
4
5
  import pytest
5
6
  from pytest_httpx import HTTPXMock
6
7
 
7
8
  from earthscope_sdk.auth.client_credentials_flow import ClientCredentialsFlow
8
- from earthscope_sdk.auth.device_code_flow import DeviceCodeFlow
9
- from earthscope_sdk.auth.error import NoRefreshTokenError, NoTokensError
9
+ from earthscope_sdk.auth.device_code_flow import DeviceCodeFlow, PollingErrorType
10
+ from earthscope_sdk.auth.error import (
11
+ DeviceCodePollingExpiredError,
12
+ DeviceCodeRequestDeviceCodeError,
13
+ NoRefreshTokenError,
14
+ NoTokensError,
15
+ UnauthorizedError,
16
+ )
17
+ from earthscope_sdk.client import AsyncEarthScopeClient
10
18
  from earthscope_sdk.common.context import SdkContext
11
19
  from earthscope_sdk.config.models import AuthFlowSettings, Tokens
12
20
  from earthscope_sdk.config.settings import SdkSettings
@@ -76,6 +84,60 @@ class TestAuthDeviceCodeFlow:
76
84
  with pytest.raises(NoTokensError):
77
85
  flow.scope
78
86
 
87
+ @pytest.mark.asyncio
88
+ async def test_device_code_request_error(self, httpx_mock: HTTPXMock):
89
+ """Test handling of device code request errors."""
90
+ httpx_mock.add_response(
91
+ status_code=400,
92
+ json={"error": "invalid_request", "error_description": "Invalid client"},
93
+ )
94
+
95
+ async with AsyncEarthScopeClient() as client:
96
+ with pytest.raises(DeviceCodeRequestDeviceCodeError):
97
+ await client.ctx.device_code_flow._async_request_device_code()
98
+
99
+ @pytest.mark.parametrize(
100
+ "err_code, ErrType",
101
+ [
102
+ (PollingErrorType.ACCESS_DENIED, UnauthorizedError),
103
+ (PollingErrorType.EXPIRED_TOKEN, DeviceCodePollingExpiredError),
104
+ ],
105
+ )
106
+ @pytest.mark.asyncio
107
+ async def test_device_code_polling_error(
108
+ self,
109
+ httpx_mock: HTTPXMock,
110
+ err_code: str,
111
+ ErrType: Type[Exception],
112
+ ):
113
+ """Test handling of device code polling errors."""
114
+ # First response for device code request
115
+ httpx_mock.add_response(
116
+ status_code=200,
117
+ json={
118
+ "device_code": "test_code",
119
+ "user_code": "ABCD-EFGH",
120
+ "verification_uri": "activate",
121
+ "verification_uri_complete": "https://test.com/activate",
122
+ "expires_in": 900,
123
+ "interval": 0.001,
124
+ },
125
+ )
126
+ # Second response for polling with error
127
+ httpx_mock.add_response(
128
+ status_code=400,
129
+ json={
130
+ "error": err_code,
131
+ "error_description": "Pending",
132
+ },
133
+ )
134
+
135
+ async with AsyncEarthScopeClient() as client:
136
+ codes = await client.ctx.device_code_flow._async_request_device_code()
137
+
138
+ with pytest.raises(ErrType):
139
+ await client.ctx.device_code_flow._async_poll(codes=codes)
140
+
79
141
  @pytest.mark.skipif(
80
142
  is_pipeline(),
81
143
  reason="No user input in pipeline",
@@ -195,6 +257,18 @@ class TestAuthClientCredentialsFlow:
195
257
  with pytest.raises(NoTokensError):
196
258
  flow.scope
197
259
 
260
+ @pytest.mark.asyncio
261
+ async def test_client_credentials_unauthorized(self, httpx_mock: HTTPXMock):
262
+ settings = SdkSettings(
263
+ oauth2=AuthFlowSettings(client_id="foo", client_secret="bar")
264
+ )
265
+ flow = ClientCredentialsFlow(SdkContext(settings))
266
+
267
+ httpx_mock.add_response(status_code=401, json={})
268
+
269
+ with pytest.raises(UnauthorizedError):
270
+ await flow.async_request_tokens()
271
+
198
272
  @pytest.mark.skipif(
199
273
  missing_m2m_creds(),
200
274
  reason="Missing M2M credentials",
@@ -6,15 +6,25 @@ from pytest_httpx import HTTPXMock
6
6
 
7
7
  from earthscope_sdk.auth.error import NoAccessTokenError
8
8
  from earthscope_sdk.common.context import SdkContext
9
+ from earthscope_sdk.config.models import HttpSettings
9
10
  from earthscope_sdk.config.settings import SdkSettings
10
11
 
11
12
 
12
13
  class TestContext:
14
+ @pytest.mark.parametrize(
15
+ "host",
16
+ [
17
+ "earthscope.org",
18
+ "api.earthscope.org",
19
+ "data.earthscope.org",
20
+ ],
21
+ )
13
22
  @pytest.mark.asyncio
14
- async def test_context_async_refreshes_and_injects_auth_header(
23
+ async def test_context_async_refreshes_and_injects_auth_header_for_allowed_hosts(
15
24
  self,
16
25
  mock_settings: SdkSettings,
17
26
  httpx_mock: HTTPXMock,
27
+ host: str,
18
28
  ):
19
29
  httpx_mock.add_response()
20
30
 
@@ -22,15 +32,48 @@ class TestContext:
22
32
  with pytest.raises(NoAccessTokenError):
23
33
  ctx.auth_flow.access_token
24
34
 
25
- await ctx.httpx_client.get(f"{mock_settings.resources.api_url}foo")
35
+ await ctx.httpx_client.get(f"https://{host}/foo")
26
36
 
27
37
  at = ctx.auth_flow.access_token
28
38
  req = httpx_mock.get_requests()
29
39
 
30
- assert str(req[1].url).startswith(f"{mock_settings.resources.api_url}")
40
+ assert req[1].url.host == host
31
41
  assert req[1].headers["authorization"] == f"Bearer {at}"
32
42
  assert req[1].headers["user-agent"] == mock_settings.http.user_agent
33
43
 
44
+ @pytest.mark.parametrize(
45
+ "host",
46
+ [
47
+ "foo.org",
48
+ "earthscope.foo.org",
49
+ ],
50
+ )
51
+ @pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
52
+ @pytest.mark.asyncio
53
+ async def test_context_allowed_hosts_doesnt_inject_auth_header(
54
+ self,
55
+ mock_settings: SdkSettings,
56
+ httpx_mock: HTTPXMock,
57
+ host: str,
58
+ ):
59
+ httpx_mock.add_response()
60
+
61
+ ctx = SdkContext(mock_settings)
62
+ with pytest.raises(NoAccessTokenError):
63
+ ctx.auth_flow.access_token
64
+
65
+ await ctx.httpx_client.get(f"https://{host}/bar")
66
+
67
+ with pytest.raises(NoAccessTokenError):
68
+ ctx.auth_flow.access_token
69
+
70
+ req = httpx_mock.get_requests()
71
+
72
+ assert req[0].url.host == host
73
+ assert req[0].headers["user-agent"] == mock_settings.http.user_agent
74
+ with pytest.raises(KeyError):
75
+ req[0].headers["authorization"]
76
+
34
77
  @pytest.mark.asyncio
35
78
  async def test_context_async_close(
36
79
  self,
@@ -114,3 +157,20 @@ class TestContext:
114
157
 
115
158
  # note: in custom client, we don't expect our custom user-agent
116
159
  assert req[1].headers["user-agent"] != mock_settings.http.user_agent
160
+
161
+ def test_extra_headers_injected_into_requests(self):
162
+ settings = SdkSettings(
163
+ http=HttpSettings(
164
+ user_agent="aaa-user-agent",
165
+ extra_headers={
166
+ "x-test-header": "test",
167
+ "user-agent": "bbb-user-agent",
168
+ },
169
+ ),
170
+ )
171
+
172
+ ctx = SdkContext(settings)
173
+ req = ctx.httpx_client.build_request("GET", "https://www.foo.com")
174
+
175
+ assert req.headers["x-test-header"] == "test", "extra header injected"
176
+ assert req.headers["user-agent"] == "aaa-user-agent", "user-agent override"
@@ -1,3 +1,4 @@
1
+ import json
1
2
  from pathlib import Path
2
3
  from textwrap import dedent
3
4
 
@@ -14,7 +15,25 @@ from earthscope_sdk.config.models import (
14
15
  HttpSettings,
15
16
  Tokens,
16
17
  )
17
- from earthscope_sdk.config.settings import SdkSettings, _get_config_toml_path
18
+ from earthscope_sdk.config.settings import (
19
+ _BOOTSTRAP_ENV_VAR,
20
+ SdkSettings,
21
+ _get_config_toml_path,
22
+ )
23
+
24
+ _bootstrap_state_json = json.dumps(
25
+ {
26
+ "oauth2": {
27
+ "audience": "https://bootstrap-audience.earthscope.org",
28
+ "client_id": "bootstrap-client-id",
29
+ "domain": "https://bootstrap-domain.earthscope.org",
30
+ "scope": "bootstrap-scope",
31
+ "access_token": "bootstrap-at",
32
+ "refresh_token": "bootstrap-rt",
33
+ "id_token": "bootstrap-it",
34
+ }
35
+ }
36
+ )
18
37
 
19
38
 
20
39
  @pytest.fixture
@@ -75,9 +94,32 @@ class TestSdkSettings:
75
94
  s = SdkSettings(oauth2={"scope": "dict-scope"})
76
95
  assert s.oauth2.scope == "dict-scope"
77
96
 
97
+ def test_secret_serialization(self):
98
+ s = SdkSettings(
99
+ oauth2={
100
+ "access_token": "foo-at",
101
+ "id_token": "foo-it",
102
+ "refresh_token": "foo-rt",
103
+ "client_secret": "foo-secret",
104
+ }
105
+ )
106
+
107
+ dumped = s.model_dump(mode="json")
108
+ assert dumped["oauth2"]["access_token"] == "**********"
109
+ assert dumped["oauth2"]["id_token"] == "**********"
110
+ assert dumped["oauth2"]["refresh_token"] == "**********"
111
+ assert dumped["oauth2"]["client_secret"] == "**********"
112
+
113
+ dumped_plaintext = s.model_dump(mode="json", context="plaintext")
114
+ assert dumped_plaintext["oauth2"]["access_token"] == "foo-at"
115
+ assert dumped_plaintext["oauth2"]["id_token"] == "foo-it"
116
+ assert dumped_plaintext["oauth2"]["refresh_token"] == "foo-rt"
117
+ assert dumped_plaintext["oauth2"]["client_secret"] == "foo-secret"
118
+
78
119
 
79
120
  class TestSdkSettingsPrecedence:
80
121
  def test_precedence_all(self, config_toml: Path, monkeypatch: MonkeyPatch):
122
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
81
123
  config_toml.write_text(
82
124
  dedent("""
83
125
  [default]
@@ -93,6 +135,7 @@ class TestSdkSettingsPrecedence:
93
135
  assert s.oauth2.scope == "init-scope"
94
136
 
95
137
  def test_precedence_env(self, config_toml: Path, monkeypatch: MonkeyPatch):
138
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
96
139
  config_toml.write_text(
97
140
  dedent("""
98
141
  [default]
@@ -107,7 +150,8 @@ class TestSdkSettingsPrecedence:
107
150
 
108
151
  assert s.oauth2.scope == "env-scope"
109
152
 
110
- def test_precedence_profile(self, config_toml: Path):
153
+ def test_precedence_profile(self, config_toml: Path, monkeypatch: MonkeyPatch):
154
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
111
155
  config_toml.write_text(
112
156
  dedent("""
113
157
  [default]
@@ -121,7 +165,8 @@ class TestSdkSettingsPrecedence:
121
165
 
122
166
  assert s.oauth2.scope == "profile-scope"
123
167
 
124
- def test_precedence_defaults(self, config_toml: Path):
168
+ def test_precedence_defaults(self, config_toml: Path, monkeypatch: MonkeyPatch):
169
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
125
170
  config_toml.write_text(
126
171
  dedent("""
127
172
  [default]
@@ -132,6 +177,18 @@ class TestSdkSettingsPrecedence:
132
177
 
133
178
  assert s.oauth2.scope == "default-scope"
134
179
 
180
+ def test_precedence_bootstrap_state(self, monkeypatch: MonkeyPatch):
181
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
182
+
183
+ s = SdkSettings()
184
+ assert s.oauth2.audience == "https://bootstrap-audience.earthscope.org"
185
+ assert s.oauth2.client_id == "bootstrap-client-id"
186
+ assert str(s.oauth2.domain) == "https://bootstrap-domain.earthscope.org/"
187
+ assert s.oauth2.scope == "bootstrap-scope"
188
+ assert s.oauth2.access_token.get_secret_value() == "bootstrap-at"
189
+ assert s.oauth2.refresh_token.get_secret_value() == "bootstrap-rt"
190
+ assert s.oauth2.id_token.get_secret_value() == "bootstrap-it"
191
+
135
192
 
136
193
  class TestSdkSettingsProfiles:
137
194
  def test_profile_settings(self, config_toml: Path):
@@ -343,6 +400,7 @@ class TestTokensPrecedence:
343
400
  default_settings: SdkSettings,
344
401
  monkeypatch: MonkeyPatch,
345
402
  ):
403
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
346
404
  default_settings.write_tokens(Tokens(access_token="state-at"))
347
405
  config_toml.write_text(
348
406
  dedent("""
@@ -364,6 +422,7 @@ class TestTokensPrecedence:
364
422
  default_settings: SdkSettings,
365
423
  monkeypatch: MonkeyPatch,
366
424
  ):
425
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
367
426
  default_settings.write_tokens(Tokens(access_token="state-at"))
368
427
  config_toml.write_text(
369
428
  dedent("""
@@ -383,7 +442,9 @@ class TestTokensPrecedence:
383
442
  self,
384
443
  config_toml: Path,
385
444
  default_settings: SdkSettings,
445
+ monkeypatch: MonkeyPatch,
386
446
  ):
447
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
387
448
  default_settings.write_tokens(Tokens(access_token="state-at"))
388
449
  config_toml.write_text(
389
450
  dedent("""
@@ -402,7 +463,9 @@ class TestTokensPrecedence:
402
463
  self,
403
464
  config_toml: Path,
404
465
  default_settings: SdkSettings,
466
+ monkeypatch: MonkeyPatch,
405
467
  ):
468
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
406
469
  default_settings.write_tokens(Tokens(access_token="state-at"))
407
470
  config_toml.write_text(
408
471
  dedent("""
@@ -414,7 +477,12 @@ class TestTokensPrecedence:
414
477
  t = SdkSettings()
415
478
  assert t.oauth2.access_token.get_secret_value() == "default-at"
416
479
 
417
- def test_tokens_precedence_state(self, default_settings: SdkSettings):
480
+ def test_tokens_precedence_state(
481
+ self,
482
+ default_settings: SdkSettings,
483
+ monkeypatch: MonkeyPatch,
484
+ ):
485
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
418
486
  default_settings.write_tokens(Tokens(access_token="state-at"))
419
487
 
420
488
  t = SdkSettings()
@@ -463,3 +531,109 @@ class TestTokensPrecedence:
463
531
  s = SdkSettings()
464
532
  assert s.oauth2.refresh_token.get_secret_value() == "default-rt"
465
533
  assert s.oauth2.access_token.get_secret_value() == "profile-at"
534
+ config_toml.unlink()
535
+
536
+ # legacy > bootstrap
537
+ with monkeypatch.context() as m:
538
+ m.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
539
+
540
+ s = SdkSettings()
541
+ assert s.oauth2.refresh_token.get_secret_value() == "legacy-rt"
542
+ assert s.oauth2.access_token.get_secret_value() == "legacy-at"
543
+ assert s.oauth2.scope == "bootstrap-scope"
544
+
545
+
546
+ class TestBootstrapSettings:
547
+ def test_bootstrap_settings(self, monkeypatch: MonkeyPatch):
548
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, _bootstrap_state_json)
549
+
550
+ s = SdkSettings()
551
+ assert s.oauth2.audience == "https://bootstrap-audience.earthscope.org"
552
+ assert s.oauth2.client_id == "bootstrap-client-id"
553
+ assert str(s.oauth2.domain) == "https://bootstrap-domain.earthscope.org/"
554
+ assert s.oauth2.scope == "bootstrap-scope"
555
+ assert s.oauth2.access_token.get_secret_value() == "bootstrap-at"
556
+ assert s.oauth2.refresh_token.get_secret_value() == "bootstrap-rt"
557
+ assert s.oauth2.id_token.get_secret_value() == "bootstrap-it"
558
+
559
+ def test_bootstrap_settings_invalid_json(self, monkeypatch: MonkeyPatch):
560
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, "invalid-json")
561
+
562
+ s = SdkSettings()
563
+ assert s.oauth2.audience == "https://api.earthscope.org"
564
+ assert s.oauth2.client_id == "b9DtAFBd6QvMg761vI3YhYquNZbJX5G0"
565
+ assert str(s.oauth2.domain) == "https://login.earthscope.org/"
566
+ assert s.oauth2.scope == "offline_access"
567
+ assert s.oauth2.access_token is None
568
+ assert s.oauth2.refresh_token is None
569
+ assert s.oauth2.id_token is None
570
+
571
+ def test_bootstrap_settings_empty_json(self, monkeypatch: MonkeyPatch):
572
+ monkeypatch.setenv(_BOOTSTRAP_ENV_VAR, "{}")
573
+
574
+ s = SdkSettings()
575
+ assert s.oauth2.audience == "https://api.earthscope.org"
576
+ assert s.oauth2.client_id == "b9DtAFBd6QvMg761vI3YhYquNZbJX5G0"
577
+ assert str(s.oauth2.domain) == "https://login.earthscope.org/"
578
+ assert s.oauth2.scope == "offline_access"
579
+ assert s.oauth2.access_token is None
580
+ assert s.oauth2.refresh_token is None
581
+ assert s.oauth2.id_token is None
582
+
583
+ def test_bootstrap_settings_other_keys(self, monkeypatch: MonkeyPatch):
584
+ monkeypatch.setenv(
585
+ _BOOTSTRAP_ENV_VAR,
586
+ json.dumps(
587
+ {
588
+ "http": {
589
+ "timeout_read": 10.0,
590
+ "user_agent": "bootstrap-ua",
591
+ },
592
+ "oauth2": {
593
+ "scope": "bootstrap-scope",
594
+ },
595
+ }
596
+ ),
597
+ )
598
+
599
+ s = SdkSettings()
600
+ # bootstrap settings
601
+ assert s.http.timeout_read.total_seconds() == 10.0
602
+ assert s.http.user_agent == "bootstrap-ua"
603
+ assert s.oauth2.scope == "bootstrap-scope"
604
+
605
+ # defaults
606
+ assert s.http.timeout_connect.total_seconds() == 5.0
607
+ assert s.oauth2.audience == "https://api.earthscope.org"
608
+ assert s.oauth2.client_id == "b9DtAFBd6QvMg761vI3YhYquNZbJX5G0"
609
+ assert str(s.oauth2.domain) == "https://login.earthscope.org/"
610
+ assert s.oauth2.access_token is None
611
+ assert s.oauth2.refresh_token is None
612
+ assert s.oauth2.id_token is None
613
+
614
+
615
+ class TestAuthFlowSettings:
616
+ @pytest.mark.parametrize(
617
+ "host,allowed",
618
+ [
619
+ ("earthscope.org", True),
620
+ ("api.earthscope.org", True),
621
+ ("data.earthscope.org", True),
622
+ ("foo.earthscope.org", True),
623
+ ("foo.subdomain.earthscope.org", True),
624
+ ("earthscope.foo.org", False),
625
+ ("example.com", False),
626
+ ("foo.example.com", False),
627
+ ("earthscope.example.com", False),
628
+ ("foo.earthscope.example.com", False),
629
+ ],
630
+ )
631
+ def test_allowed_hosts_defaults(self, host: str, allowed: bool):
632
+ s = AuthFlowSettings()
633
+ assert s.is_host_allowed(host) == allowed
634
+
635
+ def test_allowed_hosts_cache(self):
636
+ s = AuthFlowSettings()
637
+ assert "foo.earthscope.org" not in s.allowed_hosts
638
+ assert s.is_host_allowed("foo.earthscope.org")
639
+ assert "foo.earthscope.org" in s.allowed_hosts
File without changes