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.
@@ -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()