earthscope-sdk 0.2.0__py3-none-any.whl → 1.0.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.
- earthscope_sdk/__init__.py +5 -1
- earthscope_sdk/auth/auth_flow.py +240 -346
- earthscope_sdk/auth/client_credentials_flow.py +42 -162
- earthscope_sdk/auth/device_code_flow.py +169 -213
- earthscope_sdk/auth/error.py +46 -0
- earthscope_sdk/client/__init__.py +3 -0
- earthscope_sdk/client/_client.py +35 -0
- earthscope_sdk/client/user/_base.py +39 -0
- earthscope_sdk/client/user/_service.py +94 -0
- earthscope_sdk/client/user/models.py +53 -0
- earthscope_sdk/common/__init__.py +0 -0
- earthscope_sdk/common/_sync_runner.py +141 -0
- earthscope_sdk/common/client.py +99 -0
- earthscope_sdk/common/context.py +174 -0
- earthscope_sdk/common/service.py +59 -0
- earthscope_sdk/config/__init__.py +0 -0
- earthscope_sdk/config/_bootstrap.py +42 -0
- earthscope_sdk/config/_compat.py +148 -0
- earthscope_sdk/config/_util.py +48 -0
- earthscope_sdk/config/error.py +4 -0
- earthscope_sdk/config/models.py +310 -0
- earthscope_sdk/config/settings.py +295 -0
- earthscope_sdk/model/secret.py +29 -0
- {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/METADATA +147 -123
- earthscope_sdk-1.0.0.dist-info/RECORD +30 -0
- {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/WHEEL +1 -1
- earthscope_sdk/user/user.py +0 -32
- earthscope_sdk-0.2.0.dist-info/RECORD +0 -12
- /earthscope_sdk/{user → client/user}/__init__.py +0 -0
- {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info/licenses}/LICENSE +0 -0
- {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,59 @@
|
|
1
|
+
from typing import TYPE_CHECKING
|
2
|
+
|
3
|
+
from earthscope_sdk.auth.error import UnauthenticatedError, UnauthorizedError
|
4
|
+
|
5
|
+
if TYPE_CHECKING:
|
6
|
+
from httpx import Request
|
7
|
+
|
8
|
+
from earthscope_sdk.common.context import SdkContext
|
9
|
+
|
10
|
+
|
11
|
+
class SdkService:
|
12
|
+
"""
|
13
|
+
Base class for EarthScope SDK services
|
14
|
+
"""
|
15
|
+
|
16
|
+
@property
|
17
|
+
def ctx(self):
|
18
|
+
"""
|
19
|
+
SDK Context
|
20
|
+
"""
|
21
|
+
return self._ctx
|
22
|
+
|
23
|
+
@property
|
24
|
+
def resources(self):
|
25
|
+
"""
|
26
|
+
References to EarthScope resources
|
27
|
+
"""
|
28
|
+
return self.ctx.settings.resources
|
29
|
+
|
30
|
+
def __init__(self, ctx: "SdkContext"):
|
31
|
+
self._ctx = ctx
|
32
|
+
|
33
|
+
async def _send(self, request: "Request"):
|
34
|
+
"""
|
35
|
+
Send an HTTP request.
|
36
|
+
|
37
|
+
Performs common response handling.
|
38
|
+
"""
|
39
|
+
resp = await self.ctx.httpx_client.send(request=request)
|
40
|
+
|
41
|
+
# Throw specific errors for certain status codes
|
42
|
+
|
43
|
+
if resp.status_code == 401:
|
44
|
+
await resp.aread() # must read body before using .text prop
|
45
|
+
raise UnauthenticatedError(resp.text)
|
46
|
+
|
47
|
+
if resp.status_code == 403:
|
48
|
+
await resp.aread() # must read body before using .text prop
|
49
|
+
raise UnauthorizedError(resp.text)
|
50
|
+
|
51
|
+
# Raise HTTP errors
|
52
|
+
resp.raise_for_status()
|
53
|
+
|
54
|
+
return resp
|
55
|
+
|
56
|
+
async def _send_with_retries(self, request: "Request"):
|
57
|
+
async for attempt in self.ctx.settings.http.retry.retry_context():
|
58
|
+
with attempt:
|
59
|
+
return await self._send(request=request)
|
File without changes
|
@@ -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
|
@@ -0,0 +1,148 @@
|
|
1
|
+
"""
|
2
|
+
This module facilitates compatibility with EarthScope CLI v0.x.x
|
3
|
+
"""
|
4
|
+
|
5
|
+
import json
|
6
|
+
import logging
|
7
|
+
import os
|
8
|
+
import sys
|
9
|
+
from pathlib import Path
|
10
|
+
from typing import Union
|
11
|
+
|
12
|
+
from pydantic_settings import PydanticBaseSettingsSource
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
####
|
17
|
+
# The following code is nearly verbatim from Click's source (which is what Typer uses underneath)
|
18
|
+
# https://github.com/pallets/click/blob/main/src/click/utils.py.
|
19
|
+
#
|
20
|
+
# The various functions and consts have been renamed to be "private"
|
21
|
+
#
|
22
|
+
# Click's get_app_dir() was used to determine the "application directory" in earthscope-cli v0.x.x.
|
23
|
+
# whereas this has moved to ~/.earthscope/ starting in 1.0.0
|
24
|
+
#
|
25
|
+
# We replicate this functionality here so that the SDK does not need a dependency on Click.
|
26
|
+
####
|
27
|
+
|
28
|
+
_WIN = sys.platform.startswith("win")
|
29
|
+
|
30
|
+
|
31
|
+
def _posixify(name: str) -> str:
|
32
|
+
return "-".join(name.split()).lower()
|
33
|
+
|
34
|
+
|
35
|
+
def _get_legacy_app_dir(
|
36
|
+
app_name: str,
|
37
|
+
roaming: bool = True,
|
38
|
+
force_posix: bool = False,
|
39
|
+
) -> str:
|
40
|
+
r"""Returns the config folder for the application. The default behavior
|
41
|
+
is to return whatever is most appropriate for the operating system.
|
42
|
+
|
43
|
+
To give you an idea, for an app called ``"Foo Bar"``, something like
|
44
|
+
the following folders could be returned:
|
45
|
+
|
46
|
+
Mac OS X:
|
47
|
+
``~/Library/Application Support/Foo Bar``
|
48
|
+
Mac OS X (POSIX):
|
49
|
+
``~/.foo-bar``
|
50
|
+
Unix:
|
51
|
+
``~/.config/foo-bar``
|
52
|
+
Unix (POSIX):
|
53
|
+
``~/.foo-bar``
|
54
|
+
Windows (roaming):
|
55
|
+
``C:\Users\<user>\AppData\Roaming\Foo Bar``
|
56
|
+
Windows (not roaming):
|
57
|
+
``C:\Users\<user>\AppData\Local\Foo Bar``
|
58
|
+
|
59
|
+
.. versionadded:: 2.0
|
60
|
+
|
61
|
+
:param app_name: the application name. This should be properly capitalized
|
62
|
+
and can contain whitespace.
|
63
|
+
:param roaming: controls if the folder should be roaming or not on Windows.
|
64
|
+
Has no effect otherwise.
|
65
|
+
:param force_posix: if this is set to `True` then on any POSIX system the
|
66
|
+
folder will be stored in the home folder with a leading
|
67
|
+
dot instead of the XDG config home or darwin's
|
68
|
+
application support folder.
|
69
|
+
"""
|
70
|
+
if _WIN:
|
71
|
+
key = "APPDATA" if roaming else "LOCALAPPDATA"
|
72
|
+
folder = os.environ.get(key)
|
73
|
+
if folder is None:
|
74
|
+
folder = os.path.expanduser("~")
|
75
|
+
return os.path.join(folder, app_name)
|
76
|
+
if force_posix:
|
77
|
+
return os.path.join(os.path.expanduser(f"~/.{_posixify(app_name)}"))
|
78
|
+
if sys.platform == "darwin":
|
79
|
+
return os.path.join(
|
80
|
+
os.path.expanduser("~/Library/Application Support"), app_name
|
81
|
+
)
|
82
|
+
return os.path.join(
|
83
|
+
os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
|
84
|
+
_posixify(app_name),
|
85
|
+
)
|
86
|
+
|
87
|
+
|
88
|
+
####
|
89
|
+
# end of Click code
|
90
|
+
####
|
91
|
+
|
92
|
+
# Legacy state was just the exact response body from Auth0 e.g.
|
93
|
+
# {
|
94
|
+
# "access_token": "<TOKEN>",
|
95
|
+
# "id_token": "<TOKEN>",
|
96
|
+
# "refresh_token": "<TOKEN>",
|
97
|
+
# "scope": "openid profile email offline_access",
|
98
|
+
# "expires_at": 1734742981,
|
99
|
+
# "issued_at": 1734714181
|
100
|
+
# }
|
101
|
+
|
102
|
+
# the following is how the local app directory was determined in v0.x.x
|
103
|
+
_LEGACY_APP_DIR = Path(
|
104
|
+
os.environ.get("APP_DIRECTORY", _get_legacy_app_dir("earthscope-cli"))
|
105
|
+
)
|
106
|
+
|
107
|
+
|
108
|
+
def _get_legacy_auth_state_path():
|
109
|
+
return _LEGACY_APP_DIR / "sso_tokens.json"
|
110
|
+
|
111
|
+
|
112
|
+
class LegacyEarthScopeCLISettingsSource(PydanticBaseSettingsSource):
|
113
|
+
"""
|
114
|
+
This SettingsSource facilitates migrating earthscope-cli v0.x.x local state into
|
115
|
+
earthscope-sdk v1.x.x state
|
116
|
+
"""
|
117
|
+
|
118
|
+
def __init__(self, settings_cls, *keys: str):
|
119
|
+
super().__init__(settings_cls)
|
120
|
+
self._keys = keys
|
121
|
+
|
122
|
+
def __call__(self):
|
123
|
+
# load path dynamically so we can override in tests
|
124
|
+
state_path = _get_legacy_auth_state_path()
|
125
|
+
|
126
|
+
# attempt to load from legacy sso_tokens.json
|
127
|
+
try:
|
128
|
+
with state_path.open() as f:
|
129
|
+
state: dict[str, Union[str, int]] = json.load(f)
|
130
|
+
|
131
|
+
# In either error case, just return an empty
|
132
|
+
except json.JSONDecodeError:
|
133
|
+
logger.warning(
|
134
|
+
f"Found legacy earthscope-cli state at '{state_path}', but unable to decode content as JSON"
|
135
|
+
)
|
136
|
+
return {}
|
137
|
+
|
138
|
+
except FileNotFoundError:
|
139
|
+
return {}
|
140
|
+
|
141
|
+
# only preserve specific keys if non-null
|
142
|
+
settings = {k: v for k in self._keys if (v := state.get(k)) is not None}
|
143
|
+
return {"oauth2": settings}
|
144
|
+
|
145
|
+
def __repr__(self) -> str:
|
146
|
+
return f"{self.__class__.__name__}()"
|
147
|
+
|
148
|
+
def get_field_value(self, *args, **kwargs): ... # unused abstract method
|
@@ -0,0 +1,48 @@
|
|
1
|
+
import pathlib
|
2
|
+
from functools import reduce
|
3
|
+
|
4
|
+
|
5
|
+
def _merge(a: dict, b: dict):
|
6
|
+
for key in b:
|
7
|
+
if key in a:
|
8
|
+
if isinstance(a[key], dict) and isinstance(b[key], dict):
|
9
|
+
_merge(a[key], b[key])
|
10
|
+
continue
|
11
|
+
|
12
|
+
a[key] = b[key]
|
13
|
+
|
14
|
+
return a
|
15
|
+
|
16
|
+
|
17
|
+
def deep_merge(*mappings: dict, **kwargs):
|
18
|
+
"""
|
19
|
+
Merge all of the dictionaries provided, reursing into nested dictionaries.
|
20
|
+
|
21
|
+
Behavior:
|
22
|
+
- The precedence increases from left to right, with keyword arguments as the highest precedence.
|
23
|
+
- The leftmost dictionary is modified in-place. Pass an empty dictionary first to avoid modification of inputs.
|
24
|
+
"""
|
25
|
+
return reduce(_merge, (*mappings, kwargs))
|
26
|
+
|
27
|
+
|
28
|
+
def get_config_dir(app_name: str):
|
29
|
+
r"""
|
30
|
+
Returns the hidden config folder in the user's home directory for the application.
|
31
|
+
|
32
|
+
Mac OS X:
|
33
|
+
`~/.app-name`
|
34
|
+
Unix:
|
35
|
+
`~/.app-name`
|
36
|
+
Windows:
|
37
|
+
`C:\Users\<user>\.app-name`
|
38
|
+
"""
|
39
|
+
app_slug = slugify(app_name)
|
40
|
+
config_dir = pathlib.Path.home() / f".{app_slug}"
|
41
|
+
return config_dir
|
42
|
+
|
43
|
+
|
44
|
+
def slugify(name: str) -> str:
|
45
|
+
"""
|
46
|
+
Convert the given name into a slug which can be used more easily in file names
|
47
|
+
"""
|
48
|
+
return "-".join(name.split()).lower()
|
@@ -0,0 +1,310 @@
|
|
1
|
+
import base64
|
2
|
+
import binascii
|
3
|
+
import datetime as dt
|
4
|
+
import fnmatch
|
5
|
+
import functools
|
6
|
+
from contextlib import suppress
|
7
|
+
from enum import Enum
|
8
|
+
from functools import cached_property
|
9
|
+
from typing import Annotated, Any, Optional, Type, Union
|
10
|
+
|
11
|
+
from annotated_types import Ge, Gt
|
12
|
+
from pydantic import (
|
13
|
+
AliasChoices,
|
14
|
+
BaseModel,
|
15
|
+
BeforeValidator,
|
16
|
+
ConfigDict,
|
17
|
+
Field,
|
18
|
+
HttpUrl,
|
19
|
+
ValidationError,
|
20
|
+
model_validator,
|
21
|
+
)
|
22
|
+
|
23
|
+
from earthscope_sdk import __version__
|
24
|
+
from earthscope_sdk.model.secret import SecretStr
|
25
|
+
|
26
|
+
|
27
|
+
def _try_float(v: Any):
|
28
|
+
try:
|
29
|
+
return float(v)
|
30
|
+
except (TypeError, ValueError):
|
31
|
+
return v
|
32
|
+
|
33
|
+
|
34
|
+
Timedelta = Annotated[dt.timedelta, BeforeValidator(_try_float)]
|
35
|
+
|
36
|
+
|
37
|
+
class AccessTokenBody(BaseModel):
|
38
|
+
"""
|
39
|
+
Access token payload
|
40
|
+
|
41
|
+
[See Auth0 docs](https://auth0.com/docs/secure/tokens/access-tokens/access-token-profiles)
|
42
|
+
"""
|
43
|
+
|
44
|
+
audience: Annotated[Union[str, list[str]], Field(alias="aud")]
|
45
|
+
issuer: Annotated[str, Field(alias="iss")]
|
46
|
+
issued_at: Annotated[dt.datetime, Field(alias="iat")]
|
47
|
+
expires_at: Annotated[dt.datetime, Field(alias="exp")]
|
48
|
+
scope: Annotated[str, Field(alias="scope")] = ""
|
49
|
+
subject: Annotated[str, Field(alias="sub")]
|
50
|
+
grant_type: Annotated[Optional[str], Field(alias="gty")] = None
|
51
|
+
token_id: Annotated[Optional[str], Field(alias="jti")] = None
|
52
|
+
client_id: Annotated[
|
53
|
+
str,
|
54
|
+
Field(
|
55
|
+
validation_alias=AliasChoices("client_id", "azp"),
|
56
|
+
serialization_alias="client_id",
|
57
|
+
),
|
58
|
+
]
|
59
|
+
|
60
|
+
@cached_property
|
61
|
+
def ttl(self) -> dt.timedelta:
|
62
|
+
"""time to live (TTL) until expiration"""
|
63
|
+
return self.expires_at - dt.datetime.now(dt.timezone.utc)
|
64
|
+
|
65
|
+
model_config = ConfigDict(extra="allow", frozen=True, populate_by_name=True)
|
66
|
+
|
67
|
+
|
68
|
+
class AuthFlowType(Enum):
|
69
|
+
DeviceCode = "device_code"
|
70
|
+
MachineToMachine = "m2m"
|
71
|
+
|
72
|
+
|
73
|
+
class Tokens(BaseModel):
|
74
|
+
"""
|
75
|
+
EarthScope SDK oauth2 tokens
|
76
|
+
"""
|
77
|
+
|
78
|
+
access_token: Optional[SecretStr] = None
|
79
|
+
id_token: Optional[SecretStr] = None
|
80
|
+
refresh_token: Optional[SecretStr] = None
|
81
|
+
|
82
|
+
model_config = ConfigDict(frozen=True)
|
83
|
+
|
84
|
+
@cached_property
|
85
|
+
def access_token_body(self):
|
86
|
+
if self.access_token is None:
|
87
|
+
return None
|
88
|
+
|
89
|
+
with suppress(IndexError, binascii.Error, ValidationError):
|
90
|
+
payload_b64 = self.access_token.get_secret_value().split(".", 2)[1]
|
91
|
+
payload = base64.b64decode(payload_b64 + "==") # extra padding
|
92
|
+
return AccessTokenBody.model_validate_json(payload)
|
93
|
+
|
94
|
+
raise ValueError("Unable to decode access token body")
|
95
|
+
|
96
|
+
@model_validator(mode="after")
|
97
|
+
def ensure_one_of(self):
|
98
|
+
# allow all fields to be optional in subclasses
|
99
|
+
if self.__class__ != Tokens:
|
100
|
+
return self
|
101
|
+
|
102
|
+
if self.access_token or self.refresh_token:
|
103
|
+
return self
|
104
|
+
|
105
|
+
raise ValueError("At least one of access token and refresh token is required.")
|
106
|
+
|
107
|
+
|
108
|
+
class RetrySettings(BaseModel):
|
109
|
+
"""
|
110
|
+
Retry configuration for the [Stamina library](https://stamina.hynek.me/en/stable/index.html)
|
111
|
+
"""
|
112
|
+
|
113
|
+
# same defaults as AWS SDK "standard" mode:
|
114
|
+
# https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html#standard-retry-mode
|
115
|
+
|
116
|
+
attempts: Annotated[int, Ge(0)] = 3
|
117
|
+
timeout: Timedelta = dt.timedelta(seconds=20)
|
118
|
+
|
119
|
+
wait_initial: Timedelta = dt.timedelta(milliseconds=100)
|
120
|
+
wait_max: Timedelta = dt.timedelta(seconds=5)
|
121
|
+
wait_jitter: Timedelta = dt.timedelta(seconds=1)
|
122
|
+
wait_exp_base: Annotated[float, Gt(0)] = 2
|
123
|
+
|
124
|
+
async def retry_context(self, *retry_exc: Type[Exception]):
|
125
|
+
"""
|
126
|
+
Obtain a [Stamina](https://stamina.hynek.me/en/stable/index.html) retry iterator.
|
127
|
+
"""
|
128
|
+
from stamina import retry_context
|
129
|
+
|
130
|
+
retry_on = functools.partial(self.is_retriable, retry_exc=retry_exc)
|
131
|
+
|
132
|
+
ctx = retry_context(
|
133
|
+
on=retry_on,
|
134
|
+
attempts=self.attempts,
|
135
|
+
timeout=self.timeout,
|
136
|
+
wait_initial=self.wait_initial,
|
137
|
+
wait_jitter=self.wait_jitter,
|
138
|
+
wait_max=self.wait_max,
|
139
|
+
wait_exp_base=self.wait_exp_base,
|
140
|
+
)
|
141
|
+
async for attempt in ctx:
|
142
|
+
yield attempt
|
143
|
+
|
144
|
+
def is_retriable(
|
145
|
+
self,
|
146
|
+
exc: Exception,
|
147
|
+
*args,
|
148
|
+
retry_exc: tuple[Type[Exception]] = (),
|
149
|
+
**kwargs,
|
150
|
+
) -> bool:
|
151
|
+
"""
|
152
|
+
Check if the given exception can be retried
|
153
|
+
"""
|
154
|
+
if retry_exc and isinstance(exc, retry_exc):
|
155
|
+
return True
|
156
|
+
|
157
|
+
return False
|
158
|
+
|
159
|
+
|
160
|
+
class HttpRetrySettings(RetrySettings):
|
161
|
+
status_codes: set[int] = {429, 500, 502, 503, 504}
|
162
|
+
|
163
|
+
def is_retriable(
|
164
|
+
self,
|
165
|
+
exc: Exception,
|
166
|
+
*args,
|
167
|
+
**kwargs,
|
168
|
+
) -> bool:
|
169
|
+
from httpx import HTTPStatusError
|
170
|
+
|
171
|
+
if isinstance(exc, HTTPStatusError):
|
172
|
+
if exc.response.status_code in self.status_codes:
|
173
|
+
return True
|
174
|
+
|
175
|
+
return super().is_retriable(exc, *args, **kwargs)
|
176
|
+
|
177
|
+
|
178
|
+
class AuthFlowSettings(Tokens):
|
179
|
+
"""
|
180
|
+
Auth flow configuration
|
181
|
+
|
182
|
+
Not for direct use.
|
183
|
+
"""
|
184
|
+
|
185
|
+
# Auth parameters
|
186
|
+
audience: str = "https://api.earthscope.org"
|
187
|
+
domain: HttpUrl = HttpUrl("https://login.earthscope.org")
|
188
|
+
client_id: str = "b9DtAFBd6QvMg761vI3YhYquNZbJX5G0"
|
189
|
+
scope: str = "offline_access"
|
190
|
+
client_secret: Optional[SecretStr] = None
|
191
|
+
|
192
|
+
# Only inject bearer token for requests to these hosts
|
193
|
+
allowed_hosts: set[str] = {
|
194
|
+
"earthscope.org",
|
195
|
+
"*.earthscope.org",
|
196
|
+
}
|
197
|
+
|
198
|
+
# Auth exchange retries
|
199
|
+
retry: HttpRetrySettings = HttpRetrySettings(
|
200
|
+
attempts=5,
|
201
|
+
timeout=dt.timedelta(seconds=30),
|
202
|
+
wait_initial=dt.timedelta(seconds=1),
|
203
|
+
wait_jitter=dt.timedelta(seconds=3),
|
204
|
+
)
|
205
|
+
|
206
|
+
@cached_property
|
207
|
+
def auth_flow_type(self) -> AuthFlowType:
|
208
|
+
if self.client_secret is not None:
|
209
|
+
return AuthFlowType.MachineToMachine
|
210
|
+
|
211
|
+
return AuthFlowType.DeviceCode
|
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
|
+
|
244
|
+
|
245
|
+
class HttpSettings(BaseModel):
|
246
|
+
"""
|
247
|
+
HTTP client configuration
|
248
|
+
"""
|
249
|
+
|
250
|
+
# httpx limits
|
251
|
+
keepalive_expiry: Timedelta = dt.timedelta(seconds=5)
|
252
|
+
max_connections: Optional[int] = None
|
253
|
+
max_keepalive_connections: Optional[int] = None
|
254
|
+
|
255
|
+
# httpx timeouts
|
256
|
+
timeout_connect: Timedelta = dt.timedelta(seconds=5)
|
257
|
+
timeout_read: Timedelta = dt.timedelta(seconds=5)
|
258
|
+
|
259
|
+
# automatically retry requests
|
260
|
+
retry: HttpRetrySettings = HttpRetrySettings()
|
261
|
+
|
262
|
+
# Other
|
263
|
+
user_agent: str = f"earthscope-sdk py/{__version__}"
|
264
|
+
|
265
|
+
@cached_property
|
266
|
+
def limits(self):
|
267
|
+
"""httpx Limits on client connection pool"""
|
268
|
+
# lazy import
|
269
|
+
import httpx
|
270
|
+
|
271
|
+
return httpx.Limits(
|
272
|
+
max_connections=self.max_connections,
|
273
|
+
max_keepalive_connections=self.max_keepalive_connections,
|
274
|
+
keepalive_expiry=self.keepalive_expiry.total_seconds(),
|
275
|
+
)
|
276
|
+
|
277
|
+
@cached_property
|
278
|
+
def timeouts(self):
|
279
|
+
"""httpx Timeouts default behavior"""
|
280
|
+
# lazy import
|
281
|
+
import httpx
|
282
|
+
|
283
|
+
return httpx.Timeout(
|
284
|
+
connect=self.timeout_connect.total_seconds(),
|
285
|
+
# reuse read timeout for others
|
286
|
+
read=self.timeout_read.total_seconds(),
|
287
|
+
write=self.timeout_read.total_seconds(),
|
288
|
+
pool=self.timeout_read.total_seconds(),
|
289
|
+
)
|
290
|
+
|
291
|
+
|
292
|
+
class ResourceRefs(BaseModel):
|
293
|
+
"""
|
294
|
+
References to EarthScope resources
|
295
|
+
"""
|
296
|
+
|
297
|
+
api_url: HttpUrl = HttpUrl("https://api.earthscope.org")
|
298
|
+
"""Base URL for api.earthscope.org"""
|
299
|
+
|
300
|
+
|
301
|
+
class SdkBaseSettings(BaseModel):
|
302
|
+
"""
|
303
|
+
Common base class for SDK settings
|
304
|
+
|
305
|
+
Not for direct use.
|
306
|
+
"""
|
307
|
+
|
308
|
+
http: HttpSettings = HttpSettings()
|
309
|
+
oauth2: AuthFlowSettings = AuthFlowSettings()
|
310
|
+
resources: ResourceRefs = ResourceRefs()
|