earthscope-sdk 0.2.1__py3-none-any.whl → 1.0.0b1__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.
@@ -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,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,4 @@
1
+ class ProfileDoesNotExistError(KeyError):
2
+ """
3
+ The named profile does not exist
4
+ """
@@ -0,0 +1,291 @@
1
+ import base64
2
+ import binascii
3
+ import datetime as dt
4
+ import functools
5
+ from contextlib import suppress
6
+ from enum import Enum
7
+ from functools import cached_property
8
+ from typing import Annotated, Any, Optional, Type, Union
9
+
10
+ from annotated_types import Ge, Gt
11
+ from pydantic import (
12
+ AliasChoices,
13
+ BaseModel,
14
+ BeforeValidator,
15
+ ConfigDict,
16
+ Field,
17
+ HttpUrl,
18
+ SecretStr,
19
+ SerializationInfo,
20
+ ValidationError,
21
+ field_serializer,
22
+ model_validator,
23
+ )
24
+
25
+ from earthscope_sdk import __version__
26
+
27
+
28
+ def _try_float(v: Any):
29
+ try:
30
+ return float(v)
31
+ except (TypeError, ValueError):
32
+ return v
33
+
34
+
35
+ Timedelta = Annotated[dt.timedelta, BeforeValidator(_try_float)]
36
+
37
+
38
+ class AccessTokenBody(BaseModel):
39
+ """
40
+ Access token payload
41
+
42
+ [See Auth0 docs](https://auth0.com/docs/secure/tokens/access-tokens/access-token-profiles)
43
+ """
44
+
45
+ audience: Annotated[Union[str, list[str]], Field(alias="aud")]
46
+ issuer: Annotated[str, Field(alias="iss")]
47
+ issued_at: Annotated[dt.datetime, Field(alias="iat")]
48
+ expires_at: Annotated[dt.datetime, Field(alias="exp")]
49
+ scope: Annotated[str, Field(alias="scope")] = ""
50
+ subject: Annotated[str, Field(alias="sub")]
51
+ grant_type: Annotated[Optional[str], Field(alias="gty")] = None
52
+ token_id: Annotated[Optional[str], Field(alias="jti")] = None
53
+ client_id: Annotated[
54
+ str,
55
+ Field(
56
+ validation_alias=AliasChoices("client_id", "azp"),
57
+ serialization_alias="client_id",
58
+ ),
59
+ ]
60
+
61
+ @cached_property
62
+ def ttl(self) -> dt.timedelta:
63
+ """time to live (TTL) until expiration"""
64
+ return self.expires_at - dt.datetime.now(dt.timezone.utc)
65
+
66
+ model_config = ConfigDict(extra="allow", frozen=True, populate_by_name=True)
67
+
68
+
69
+ class AuthFlowType(Enum):
70
+ DeviceCode = "device_code"
71
+ MachineToMachine = "m2m"
72
+
73
+
74
+ class Tokens(BaseModel):
75
+ """
76
+ EarthScope SDK oauth2 tokens
77
+ """
78
+
79
+ access_token: Optional[SecretStr] = None
80
+ id_token: Optional[SecretStr] = None
81
+ refresh_token: Optional[SecretStr] = None
82
+
83
+ model_config = ConfigDict(frozen=True)
84
+
85
+ @cached_property
86
+ def access_token_body(self):
87
+ if self.access_token is None:
88
+ return None
89
+
90
+ with suppress(IndexError, binascii.Error, ValidationError):
91
+ payload_b64 = self.access_token.get_secret_value().split(".", 2)[1]
92
+ payload = base64.b64decode(payload_b64 + "==") # extra padding
93
+ return AccessTokenBody.model_validate_json(payload)
94
+
95
+ raise ValueError("Unable to decode access token body")
96
+
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
+ @model_validator(mode="after")
115
+ def ensure_one_of(self):
116
+ # allow all fields to be optional in subclasses
117
+ if self.__class__ != Tokens:
118
+ return self
119
+
120
+ if self.access_token or self.refresh_token:
121
+ return self
122
+
123
+ raise ValueError("At least one of access token and refresh token is required.")
124
+
125
+
126
+ class RetrySettings(BaseModel):
127
+ """
128
+ Retry configuration for the [Stamina library](https://stamina.hynek.me/en/stable/index.html)
129
+ """
130
+
131
+ # same defaults as AWS SDK "standard" mode:
132
+ # https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html#standard-retry-mode
133
+
134
+ attempts: Annotated[int, Ge(0)] = 3
135
+ timeout: Timedelta = dt.timedelta(seconds=20)
136
+
137
+ wait_initial: Timedelta = dt.timedelta(milliseconds=100)
138
+ wait_max: Timedelta = dt.timedelta(seconds=5)
139
+ wait_jitter: Timedelta = dt.timedelta(seconds=1)
140
+ wait_exp_base: Annotated[float, Gt(0)] = 2
141
+
142
+ async def retry_context(self, *retry_exc: Type[Exception]):
143
+ """
144
+ Obtain a [Stamina](https://stamina.hynek.me/en/stable/index.html) retry iterator.
145
+ """
146
+ from stamina import retry_context
147
+
148
+ retry_on = functools.partial(self.is_retriable, retry_exc=retry_exc)
149
+
150
+ ctx = retry_context(
151
+ on=retry_on,
152
+ attempts=self.attempts,
153
+ timeout=self.timeout,
154
+ wait_initial=self.wait_initial,
155
+ wait_jitter=self.wait_jitter,
156
+ wait_max=self.wait_max,
157
+ wait_exp_base=self.wait_exp_base,
158
+ )
159
+ async for attempt in ctx:
160
+ yield attempt
161
+
162
+ def is_retriable(
163
+ self,
164
+ exc: Exception,
165
+ *args,
166
+ retry_exc: tuple[Type[Exception]] = (),
167
+ **kwargs,
168
+ ) -> bool:
169
+ """
170
+ Check if the given exception can be retried
171
+ """
172
+ if retry_exc and isinstance(exc, retry_exc):
173
+ return True
174
+
175
+ return False
176
+
177
+
178
+ class HttpRetrySettings(RetrySettings):
179
+ status_codes: set[int] = {429, 500, 502, 503, 504}
180
+
181
+ def is_retriable(
182
+ self,
183
+ exc: Exception,
184
+ *args,
185
+ **kwargs,
186
+ ) -> bool:
187
+ from httpx import HTTPStatusError
188
+
189
+ if isinstance(exc, HTTPStatusError):
190
+ if exc.response.status_code in self.status_codes:
191
+ return True
192
+
193
+ return super().is_retriable(exc, *args, **kwargs)
194
+
195
+
196
+ class AuthFlowSettings(Tokens):
197
+ """
198
+ Auth flow configuration
199
+
200
+ Not for direct use.
201
+ """
202
+
203
+ # Auth parameters
204
+ audience: str = "https://api.earthscope.org"
205
+ domain: HttpUrl = HttpUrl("https://login.earthscope.org")
206
+ client_id: str = "b9DtAFBd6QvMg761vI3YhYquNZbJX5G0"
207
+ scope: str = "offline_access"
208
+ client_secret: Optional[SecretStr] = None
209
+
210
+ # Auth exchange retries
211
+ retry: HttpRetrySettings = HttpRetrySettings(
212
+ attempts=5,
213
+ timeout=dt.timedelta(seconds=30),
214
+ wait_initial=dt.timedelta(seconds=1),
215
+ wait_jitter=dt.timedelta(seconds=3),
216
+ )
217
+
218
+ @cached_property
219
+ def auth_flow_type(self) -> AuthFlowType:
220
+ if self.client_secret is not None:
221
+ return AuthFlowType.MachineToMachine
222
+
223
+ return AuthFlowType.DeviceCode
224
+
225
+
226
+ class HttpSettings(BaseModel):
227
+ """
228
+ HTTP client configuration
229
+ """
230
+
231
+ # httpx limits
232
+ keepalive_expiry: Timedelta = dt.timedelta(seconds=5)
233
+ max_connections: Optional[int] = None
234
+ max_keepalive_connections: Optional[int] = None
235
+
236
+ # httpx timeouts
237
+ timeout_connect: Timedelta = dt.timedelta(seconds=5)
238
+ timeout_read: Timedelta = dt.timedelta(seconds=5)
239
+
240
+ # automatically retry requests
241
+ retry: HttpRetrySettings = HttpRetrySettings()
242
+
243
+ # Other
244
+ user_agent: str = f"earthscope-sdk py/{__version__}"
245
+
246
+ @cached_property
247
+ def limits(self):
248
+ """httpx Limits on client connection pool"""
249
+ # lazy import
250
+ import httpx
251
+
252
+ return httpx.Limits(
253
+ max_connections=self.max_connections,
254
+ max_keepalive_connections=self.max_keepalive_connections,
255
+ keepalive_expiry=self.keepalive_expiry.total_seconds(),
256
+ )
257
+
258
+ @cached_property
259
+ def timeouts(self):
260
+ """httpx Timeouts default behavior"""
261
+ # lazy import
262
+ import httpx
263
+
264
+ return httpx.Timeout(
265
+ connect=self.timeout_connect.total_seconds(),
266
+ # reuse read timeout for others
267
+ read=self.timeout_read.total_seconds(),
268
+ write=self.timeout_read.total_seconds(),
269
+ pool=self.timeout_read.total_seconds(),
270
+ )
271
+
272
+
273
+ class ResourceRefs(BaseModel):
274
+ """
275
+ References to EarthScope resources
276
+ """
277
+
278
+ api_url: HttpUrl = HttpUrl("https://api.earthscope.org")
279
+ """Base URL for api.earthscope.org"""
280
+
281
+
282
+ class SdkBaseSettings(BaseModel):
283
+ """
284
+ Common base class for SDK settings
285
+
286
+ Not for direct use.
287
+ """
288
+
289
+ http: HttpSettings = HttpSettings()
290
+ oauth2: AuthFlowSettings = AuthFlowSettings()
291
+ resources: ResourceRefs = ResourceRefs()