earthscope-sdk 0.2.0__py3-none-any.whl → 1.0.0b0__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 +224 -347
- earthscope_sdk/auth/client_credentials_flow.py +46 -156
- earthscope_sdk/auth/device_code_flow.py +154 -207
- 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 +54 -0
- earthscope_sdk/config/__init__.py +0 -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 +208 -0
- earthscope_sdk/config/settings.py +284 -0
- {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0b0.dist-info}/METADATA +144 -123
- earthscope_sdk-1.0.0b0.dist-info/RECORD +28 -0
- {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0b0.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.0b0.dist-info}/LICENSE +0 -0
- {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0b0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,54 @@
|
|
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
|
File without changes
|
@@ -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,208 @@
|
|
1
|
+
import base64
|
2
|
+
import binascii
|
3
|
+
import datetime as dt
|
4
|
+
from contextlib import suppress
|
5
|
+
from enum import Enum
|
6
|
+
from functools import cached_property
|
7
|
+
from typing import Annotated, Any, Optional, Union
|
8
|
+
|
9
|
+
from pydantic import (
|
10
|
+
AliasChoices,
|
11
|
+
BaseModel,
|
12
|
+
BeforeValidator,
|
13
|
+
ConfigDict,
|
14
|
+
Field,
|
15
|
+
HttpUrl,
|
16
|
+
SecretStr,
|
17
|
+
SerializationInfo,
|
18
|
+
ValidationError,
|
19
|
+
field_serializer,
|
20
|
+
model_validator,
|
21
|
+
)
|
22
|
+
|
23
|
+
from earthscope_sdk import __version__
|
24
|
+
|
25
|
+
|
26
|
+
def _try_float(v: Any):
|
27
|
+
try:
|
28
|
+
return float(v)
|
29
|
+
except (TypeError, ValueError):
|
30
|
+
return v
|
31
|
+
|
32
|
+
|
33
|
+
Timedelta = Annotated[dt.timedelta, BeforeValidator(_try_float)]
|
34
|
+
|
35
|
+
|
36
|
+
class AccessTokenBody(BaseModel):
|
37
|
+
"""
|
38
|
+
Access token payload
|
39
|
+
|
40
|
+
[See Auth0 docs](https://auth0.com/docs/secure/tokens/access-tokens/access-token-profiles)
|
41
|
+
"""
|
42
|
+
|
43
|
+
audience: Annotated[Union[str, list[str]], Field(alias="aud")]
|
44
|
+
issuer: Annotated[str, Field(alias="iss")]
|
45
|
+
issued_at: Annotated[dt.datetime, Field(alias="iat")]
|
46
|
+
expires_at: Annotated[dt.datetime, Field(alias="exp")]
|
47
|
+
scope: Annotated[str, Field(alias="scope")] = ""
|
48
|
+
subject: Annotated[str, Field(alias="sub")]
|
49
|
+
grant_type: Annotated[Optional[str], Field(alias="gty")] = None
|
50
|
+
token_id: Annotated[Optional[str], Field(alias="jti")] = None
|
51
|
+
client_id: Annotated[
|
52
|
+
str,
|
53
|
+
Field(
|
54
|
+
validation_alias=AliasChoices("client_id", "azp"),
|
55
|
+
serialization_alias="client_id",
|
56
|
+
),
|
57
|
+
]
|
58
|
+
|
59
|
+
@cached_property
|
60
|
+
def ttl(self) -> dt.timedelta:
|
61
|
+
"""time to live (TTL) until expiration"""
|
62
|
+
return self.expires_at - dt.datetime.now(dt.timezone.utc)
|
63
|
+
|
64
|
+
model_config = ConfigDict(extra="allow", frozen=True, populate_by_name=True)
|
65
|
+
|
66
|
+
|
67
|
+
class AuthFlowType(Enum):
|
68
|
+
DeviceCode = "device_code"
|
69
|
+
MachineToMachine = "m2m"
|
70
|
+
|
71
|
+
|
72
|
+
class Tokens(BaseModel):
|
73
|
+
"""
|
74
|
+
EarthScope SDK oauth2 tokens
|
75
|
+
"""
|
76
|
+
|
77
|
+
access_token: Optional[SecretStr] = None
|
78
|
+
id_token: Optional[SecretStr] = None
|
79
|
+
refresh_token: Optional[SecretStr] = None
|
80
|
+
|
81
|
+
model_config = ConfigDict(frozen=True)
|
82
|
+
|
83
|
+
@cached_property
|
84
|
+
def access_token_body(self):
|
85
|
+
if self.access_token is None:
|
86
|
+
return None
|
87
|
+
|
88
|
+
with suppress(IndexError, binascii.Error, ValidationError):
|
89
|
+
payload_b64 = self.access_token.get_secret_value().split(".")[1]
|
90
|
+
payload = base64.b64decode(payload_b64 + "==") # extra padding
|
91
|
+
return AccessTokenBody.model_validate_json(payload)
|
92
|
+
|
93
|
+
raise ValueError("Unable to decode access token body")
|
94
|
+
|
95
|
+
@field_serializer("access_token", "id_token", "refresh_token", when_used="json")
|
96
|
+
def dump_secret_json(self, secret: Optional[SecretStr], info: SerializationInfo):
|
97
|
+
"""
|
98
|
+
A special field serializer to dump the actual secret value when writing to JSON.
|
99
|
+
|
100
|
+
Only writes secret in plaintext when `info.context == "plaintext".
|
101
|
+
|
102
|
+
See [Pydantic docs](https://docs.pydantic.dev/latest/concepts/serialization/#serialization-context)
|
103
|
+
"""
|
104
|
+
if secret is None:
|
105
|
+
return None
|
106
|
+
|
107
|
+
if info.context == "plaintext":
|
108
|
+
return secret.get_secret_value()
|
109
|
+
|
110
|
+
return str(secret)
|
111
|
+
|
112
|
+
@model_validator(mode="after")
|
113
|
+
def ensure_one_of(self):
|
114
|
+
# allow all fields to be optional in subclasses
|
115
|
+
if self.__class__ != Tokens:
|
116
|
+
return self
|
117
|
+
|
118
|
+
if self.access_token or self.refresh_token:
|
119
|
+
return self
|
120
|
+
|
121
|
+
raise ValueError("At least one of access token and refresh token is required.")
|
122
|
+
|
123
|
+
|
124
|
+
class AuthFlowSettings(Tokens):
|
125
|
+
"""
|
126
|
+
Auth flow configuration
|
127
|
+
|
128
|
+
Not for direct use.
|
129
|
+
"""
|
130
|
+
|
131
|
+
# Auth parameters
|
132
|
+
audience: str = "https://api.earthscope.org"
|
133
|
+
domain: HttpUrl = HttpUrl("https://login.earthscope.org")
|
134
|
+
client_id: str = "b9DtAFBd6QvMg761vI3YhYquNZbJX5G0"
|
135
|
+
scope: str = "offline_access"
|
136
|
+
client_secret: Optional[SecretStr] = None
|
137
|
+
|
138
|
+
@cached_property
|
139
|
+
def auth_flow_type(self) -> AuthFlowType:
|
140
|
+
if self.client_secret is not None:
|
141
|
+
return AuthFlowType.MachineToMachine
|
142
|
+
|
143
|
+
return AuthFlowType.DeviceCode
|
144
|
+
|
145
|
+
|
146
|
+
class HttpSettings(BaseModel):
|
147
|
+
"""
|
148
|
+
HTTP client configuration
|
149
|
+
"""
|
150
|
+
|
151
|
+
# httpx limits
|
152
|
+
keepalive_expiry: Timedelta = dt.timedelta(seconds=5)
|
153
|
+
max_connections: Optional[int] = None
|
154
|
+
max_keepalive_connections: Optional[int] = None
|
155
|
+
|
156
|
+
# httpx timeouts
|
157
|
+
timeout_connect: Timedelta = dt.timedelta(seconds=5)
|
158
|
+
timeout_read: Timedelta = dt.timedelta(seconds=5)
|
159
|
+
|
160
|
+
# Other
|
161
|
+
user_agent: str = f"earthscope-sdk py/{__version__}"
|
162
|
+
|
163
|
+
@cached_property
|
164
|
+
def limits(self):
|
165
|
+
"""httpx Limits on client connection pool"""
|
166
|
+
# lazy import
|
167
|
+
import httpx
|
168
|
+
|
169
|
+
return httpx.Limits(
|
170
|
+
max_connections=self.max_connections,
|
171
|
+
max_keepalive_connections=self.max_keepalive_connections,
|
172
|
+
keepalive_expiry=self.keepalive_expiry.total_seconds(),
|
173
|
+
)
|
174
|
+
|
175
|
+
@cached_property
|
176
|
+
def timeouts(self):
|
177
|
+
"""httpx Timeouts default behavior"""
|
178
|
+
# lazy import
|
179
|
+
import httpx
|
180
|
+
|
181
|
+
return httpx.Timeout(
|
182
|
+
connect=self.timeout_connect.total_seconds(),
|
183
|
+
# reuse read timeout for others
|
184
|
+
read=self.timeout_read.total_seconds(),
|
185
|
+
write=self.timeout_read.total_seconds(),
|
186
|
+
pool=self.timeout_read.total_seconds(),
|
187
|
+
)
|
188
|
+
|
189
|
+
|
190
|
+
class ResourceRefs(BaseModel):
|
191
|
+
"""
|
192
|
+
References to EarthScope resources
|
193
|
+
"""
|
194
|
+
|
195
|
+
api_url: HttpUrl = HttpUrl("https://api.earthscope.org")
|
196
|
+
"""Base URL for api.earthscope.org"""
|
197
|
+
|
198
|
+
|
199
|
+
class SdkBaseSettings(BaseModel):
|
200
|
+
"""
|
201
|
+
Common base class for SDK settings
|
202
|
+
|
203
|
+
Not for direct use.
|
204
|
+
"""
|
205
|
+
|
206
|
+
http: HttpSettings = HttpSettings()
|
207
|
+
oauth2: AuthFlowSettings = AuthFlowSettings()
|
208
|
+
resources: ResourceRefs = ResourceRefs()
|