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.
@@ -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,4 @@
1
+ class ProfileDoesNotExistError(KeyError):
2
+ """
3
+ The named profile does not exist
4
+ """
@@ -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()