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.
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/PKG-INFO +5 -4
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/README.md +3 -3
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/pyproject.toml +3 -2
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/__init__.py +1 -1
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/auth/auth_flow.py +8 -6
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/auth/client_credentials_flow.py +3 -13
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/auth/device_code_flow.py +19 -10
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/common/context.py +2 -0
- earthscope_sdk-1.1.0/src/earthscope_sdk/config/_bootstrap.py +42 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/models.py +40 -20
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/settings.py +11 -0
- earthscope_sdk-1.1.0/src/earthscope_sdk/model/secret.py +29 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk.egg-info/PKG-INFO +5 -4
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk.egg-info/SOURCES.txt +2 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk.egg-info/requires.txt +1 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/tests/test_auth.py +76 -2
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/tests/test_context.py +63 -3
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/tests/test_settings.py +178 -4
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/LICENSE +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/setup.cfg +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/setup.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/auth/__init__.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/auth/error.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/__init__.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/_client.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/user/__init__.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/user/_base.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/user/_service.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/client/user/models.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/common/__init__.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/common/_sync_runner.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/common/client.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/common/service.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/__init__.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/_compat.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/_util.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/config/error.py +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk.egg-info/dependency_links.txt +0 -0
- {earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk.egg-info/top_level.txt +0 -0
- {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
|
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
|
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](
|
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](
|
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
|
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](
|
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](
|
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
|
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
|
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
|
@@ -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
|
-
|
315
|
-
|
316
|
-
|
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.
|
330
|
-
|
331
|
-
|
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
|
{earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk/auth/client_credentials_flow.py
RENAMED
@@ -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
|
-
|
73
|
-
|
74
|
-
|
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
|
-
|
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:
|
35
|
+
interval: float
|
36
36
|
|
37
37
|
|
38
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
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](
|
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](
|
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
|
@@ -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
|
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
|
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"{
|
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
|
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
|
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(
|
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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{earthscope_sdk-1.0.0b1 → earthscope_sdk-1.1.0}/src/earthscope_sdk.egg-info/dependency_links.txt
RENAMED
File without changes
|
File without changes
|
File without changes
|