earthscope-sdk 0.2.1__py3-none-any.whl → 1.0.0b0__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- 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.1.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.1.dist-info → earthscope_sdk-1.0.0b0.dist-info}/WHEEL +1 -1
- earthscope_sdk/user/user.py +0 -24
- earthscope_sdk-0.2.1.dist-info/RECORD +0 -12
- /earthscope_sdk/{user → client/user}/__init__.py +0 -0
- {earthscope_sdk-0.2.1.dist-info → earthscope_sdk-1.0.0b0.dist-info}/LICENSE +0 -0
- {earthscope_sdk-0.2.1.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()
|