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,46 @@
1
+ class AuthFlowError(Exception):
2
+ """Generic authentication flow error"""
3
+
4
+
5
+ class UnauthenticatedError(AuthFlowError):
6
+ pass
7
+
8
+
9
+ class UnauthorizedError(AuthFlowError):
10
+ pass
11
+
12
+
13
+ class NoTokensError(AuthFlowError):
14
+ pass
15
+
16
+
17
+ class NoAccessTokenError(NoTokensError):
18
+ pass
19
+
20
+
21
+ class NoIdTokenError(NoTokensError):
22
+ pass
23
+
24
+
25
+ class NoRefreshTokenError(NoTokensError):
26
+ pass
27
+
28
+
29
+ class InvalidRefreshTokenError(AuthFlowError):
30
+ pass
31
+
32
+
33
+ class ClientCredentialsFlowError(AuthFlowError):
34
+ pass
35
+
36
+
37
+ class DeviceCodeRequestDeviceCodeError(AuthFlowError):
38
+ pass
39
+
40
+
41
+ class DeviceCodePollingError(AuthFlowError):
42
+ pass
43
+
44
+
45
+ class DeviceCodePollingExpiredError(DeviceCodePollingError):
46
+ pass
@@ -0,0 +1,3 @@
1
+ from earthscope_sdk.client._client import AsyncEarthScopeClient, EarthScopeClient
2
+
3
+ __all__ = ["EarthScopeClient", "AsyncEarthScopeClient"]
@@ -0,0 +1,35 @@
1
+ from functools import cached_property
2
+
3
+ from earthscope_sdk.common.client import AsyncSdkClient, SdkClient
4
+
5
+
6
+ class AsyncEarthScopeClient(AsyncSdkClient):
7
+ """
8
+ An async client for interacting with api.earthscope.org
9
+ """
10
+
11
+ @cached_property
12
+ def user(self):
13
+ """
14
+ User and Identity Management functionality
15
+ """
16
+ # lazy load
17
+ from earthscope_sdk.client.user._service import AsyncUserService
18
+
19
+ return AsyncUserService(self._ctx)
20
+
21
+
22
+ class EarthScopeClient(SdkClient):
23
+ """
24
+ A client for interacting with api.earthscope.org
25
+ """
26
+
27
+ @cached_property
28
+ def user(self):
29
+ """
30
+ User and Identity Management functionality
31
+ """
32
+ # lazy load
33
+ from earthscope_sdk.client.user._service import UserService
34
+
35
+ return UserService(self._ctx)
@@ -0,0 +1,39 @@
1
+ from earthscope_sdk.common.service import SdkService
2
+
3
+
4
+ class UserBaseService(SdkService):
5
+ """
6
+ L1 user/IDM endpoints
7
+ """
8
+
9
+ async def _get_aws_credentials(self, *, role: str = "s3-miniseed"):
10
+ """
11
+ Retrieve temporary AWS credentials
12
+ """
13
+
14
+ from earthscope_sdk.client.user.models import AwsTemporaryCredentials
15
+
16
+ req = self.ctx.httpx_client.build_request(
17
+ method="GET",
18
+ url=f"{self.resources.api_url}beta/user/credentials/aws/{role}",
19
+ )
20
+
21
+ resp = await self._send(req)
22
+
23
+ return AwsTemporaryCredentials.model_validate_json(resp.content)
24
+
25
+ async def _get_profile(self):
26
+ """
27
+ Retrieve your EarthScope user profile
28
+ """
29
+
30
+ from earthscope_sdk.client.user.models import UserProfile
31
+
32
+ req = self.ctx.httpx_client.build_request(
33
+ method="GET",
34
+ url=f"{self.resources.api_url}beta/user/profile",
35
+ )
36
+
37
+ resp = await self._send(req)
38
+
39
+ return UserProfile.model_validate_json(resp.content)
@@ -0,0 +1,94 @@
1
+ from contextlib import suppress
2
+ from datetime import timedelta
3
+ from typing import TYPE_CHECKING, Optional
4
+
5
+ from earthscope_sdk.client.user._base import UserBaseService
6
+
7
+ if TYPE_CHECKING:
8
+ from earthscope_sdk.client.user.models import AwsTemporaryCredentials
9
+ from earthscope_sdk.common.context import SdkContext
10
+
11
+
12
+ class _UserService(UserBaseService):
13
+ """
14
+ L2 user/IDM service functionality
15
+ """
16
+
17
+ def __init__(self, ctx: "SdkContext"):
18
+ super().__init__(ctx)
19
+
20
+ self._aws_creds_by_role = dict[str, "AwsTemporaryCredentials"]()
21
+
22
+ async def _get_aws_credentials(
23
+ self,
24
+ *,
25
+ role: str = "s3-miniseed",
26
+ force=False,
27
+ ttl_threshold: Optional[timedelta] = None,
28
+ ):
29
+ """
30
+ Retrieve temporary AWS credentials.
31
+
32
+ Leverages a memory cache and disk cache in this profile's state directory
33
+ (`~/.earthscope/<profile_name>/aws.<role>.json`)
34
+
35
+ Args:
36
+ role: Alias of the role to assume. Defaults to `s3-miniseed`.
37
+ force: Ignore cache and fetch new credentials. Defaults to `False`.
38
+ ttl_threshold: Time-to-live remaining on cached credentials after which new credentials are fetched. Defaults to 30s.
39
+
40
+ Returns: Temporary AWS credentials for the selected role.
41
+ """
42
+ # lazy imports
43
+ from pydantic import ValidationError
44
+
45
+ from earthscope_sdk.client.user.models import AwsTemporaryCredentials
46
+
47
+ aws_creds_file_path = self.ctx.settings.profile_dir / f"aws.{role}.json"
48
+
49
+ if not force:
50
+ # Check memory cache
51
+ if aws_creds := self._aws_creds_by_role.get(role):
52
+ if not aws_creds.is_expired(ttl_threshold):
53
+ return aws_creds
54
+
55
+ # Check disk cache
56
+ with suppress(FileNotFoundError, ValidationError):
57
+ aws_creds_bytes = aws_creds_file_path.read_bytes()
58
+ aws_creds = AwsTemporaryCredentials.model_validate_json(aws_creds_bytes)
59
+ if not aws_creds.is_expired(ttl_threshold):
60
+ return aws_creds
61
+
62
+ # Get new AWS creds
63
+ # NOTE: this method will implicitly refresh our access token if necessary
64
+ aws_creds = await super()._get_aws_credentials(role=role)
65
+
66
+ # persist in memory and on disk
67
+ self._aws_creds_by_role[role] = aws_creds
68
+ aws_creds_file_path.write_bytes(aws_creds.model_dump_json().encode())
69
+
70
+ return aws_creds
71
+
72
+
73
+ class AsyncUserService(_UserService):
74
+ """
75
+ User and Identity Management functionality
76
+ """
77
+
78
+ def __init__(self, ctx: "SdkContext"):
79
+ super().__init__(ctx)
80
+
81
+ self.get_aws_credentials = self._get_aws_credentials
82
+ self.get_profile = self._get_profile
83
+
84
+
85
+ class UserService(_UserService):
86
+ """
87
+ User and Identity Management functionality
88
+ """
89
+
90
+ def __init__(self, ctx: "SdkContext"):
91
+ super().__init__(ctx)
92
+
93
+ self.get_aws_credentials = ctx.syncify(self._get_aws_credentials)
94
+ self.get_profile = ctx.syncify(self._get_profile)
@@ -0,0 +1,53 @@
1
+ import datetime as dt
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, ConfigDict
5
+
6
+
7
+ class UserProfile(BaseModel):
8
+ """
9
+ EarthScope user profile
10
+ """
11
+
12
+ first_name: str
13
+ last_name: str
14
+ country_code: str
15
+ region_code: Optional[str]
16
+ institution: str
17
+ work_sector: str
18
+ user_id: str
19
+ primary_email: str
20
+ created_at: dt.datetime
21
+ updated_at: dt.datetime
22
+
23
+
24
+ class AwsTemporaryCredentials(BaseModel):
25
+ """
26
+ AWS temporary credentials
27
+ """
28
+
29
+ aws_access_key_id: str
30
+ aws_secret_access_key: str
31
+ aws_session_token: str
32
+ expiration: dt.datetime
33
+
34
+ model_config = ConfigDict(frozen=True)
35
+
36
+ @property
37
+ def ttl(self):
38
+ """
39
+ Time to live
40
+ """
41
+ return self.expiration - dt.datetime.now(dt.timezone.utc)
42
+
43
+ def is_expired(self, ttl_threshold: Optional[dt.timedelta] = None):
44
+ """
45
+ Check if these credentials are past or near expiration
46
+
47
+ Args:
48
+ ttl_threshold: remaining time-to-live before considering the temporary AWS creds expired (Default: 30s)
49
+ """
50
+ if ttl_threshold is None:
51
+ ttl_threshold = dt.timedelta(seconds=30)
52
+
53
+ return self.ttl <= ttl_threshold
File without changes
@@ -0,0 +1,141 @@
1
+ import abc
2
+ import asyncio
3
+ import functools
4
+ import sys
5
+ from typing import Any, Callable, Coroutine, TypeVar
6
+
7
+ if sys.version_info >= (3, 10):
8
+ from typing import ParamSpec
9
+ else:
10
+ from typing_extensions import ParamSpec
11
+
12
+ T_Retval = TypeVar("T_Retval")
13
+ T_ParamSpec = ParamSpec("T_ParamSpec")
14
+
15
+
16
+ class SyncRunner(abc.ABC):
17
+ """
18
+ Facilitates running async functions in a synchronous manner
19
+ """
20
+
21
+ @property
22
+ def is_closed(self) -> bool:
23
+ """
24
+ The async runner is closed and no more synchronous invocations may occur.
25
+ """
26
+ return False
27
+
28
+ @abc.abstractmethod
29
+ def stop(self):
30
+ """
31
+ Stop the async runner (i.e. clean up any allocated resources).
32
+
33
+ Methods created via `.syncify()` may not work after stopping this runner.
34
+ """
35
+
36
+ @abc.abstractmethod
37
+ def syncify(
38
+ self,
39
+ async_function: Callable[T_ParamSpec, Coroutine[Any, Any, T_Retval]],
40
+ ) -> Callable[T_ParamSpec, T_Retval]:
41
+ """
42
+ Create a synchronous version of an async function
43
+ """
44
+
45
+
46
+ class SimpleSyncRunner(SyncRunner):
47
+ """
48
+ Runs async functions in a dedicated event loop in the same thread as the caller.
49
+
50
+ This is a simple light-weight option, but does not work when an EventLoop already
51
+ exists in the calling thread.
52
+ """
53
+
54
+ def __init__(self):
55
+ self._loop = asyncio.new_event_loop()
56
+
57
+ @property
58
+ def is_closed(self):
59
+ return self._loop.is_closed()
60
+
61
+ def stop(self):
62
+ # implements abstract method
63
+ self._loop.stop()
64
+ self._loop.close()
65
+
66
+ def syncify(
67
+ self,
68
+ async_function: Callable[T_ParamSpec, Coroutine[Any, Any, T_Retval]],
69
+ ) -> Callable[T_ParamSpec, T_Retval]:
70
+ # implements abstract method
71
+
72
+ @functools.wraps(async_function)
73
+ def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval:
74
+ partial_f = functools.partial(async_function, *args, **kwargs)
75
+
76
+ # run_until_complete() only runs the event loop until partial_f() has finished.
77
+ # - The loop does not continue running in the background.
78
+ # - This method will throw a RuntimeError if there is already an event loop
79
+ # running in the calling thread.
80
+ return self._loop.run_until_complete(partial_f())
81
+
82
+ return wrapper
83
+
84
+
85
+ class BackgroundSyncRunner(SyncRunner):
86
+ """
87
+ Runs async functions in a dedicated event loop in a background thread.
88
+
89
+ This has the overhead of spawning a dedicated daemon thread.
90
+ """
91
+
92
+ def __init__(self):
93
+ # lazy import
94
+ import threading
95
+
96
+ self._lock = threading.Lock()
97
+ self._loop = asyncio.new_event_loop()
98
+ self._running = False
99
+ self._thread = threading.Thread(target=self._run_loop, daemon=True)
100
+
101
+ @property
102
+ def is_closed(self):
103
+ return self._loop.is_closed() and not self._thread.is_alive()
104
+
105
+ def start(self):
106
+ with self._lock:
107
+ if not self._thread.is_alive():
108
+ self._thread.start()
109
+
110
+ def stop(self):
111
+ # implements abstract method
112
+ with self._lock:
113
+ if self._running:
114
+ self._loop.call_soon_threadsafe(self._loop.stop)
115
+ self._thread.join()
116
+
117
+ def syncify(
118
+ self,
119
+ async_function: Callable[T_ParamSpec, Coroutine[Any, Any, T_Retval]],
120
+ ) -> Callable[T_ParamSpec, T_Retval]:
121
+ # implements abstract method
122
+
123
+ # start the thread (if necessary) for running sync functions created here
124
+ self.start()
125
+
126
+ @functools.wraps(async_function)
127
+ def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval:
128
+ partial_f = functools.partial(async_function, *args, **kwargs)
129
+ fut = asyncio.run_coroutine_threadsafe(partial_f(), self._loop)
130
+ return fut.result()
131
+
132
+ return wrapper
133
+
134
+ def _run_loop(self):
135
+ asyncio.set_event_loop(self._loop)
136
+ self._running = True
137
+ try:
138
+ self._loop.run_forever()
139
+ finally:
140
+ self._loop.close()
141
+ self._running = False
@@ -0,0 +1,99 @@
1
+ from typing import TYPE_CHECKING, Optional, overload
2
+
3
+ if TYPE_CHECKING:
4
+ from earthscope_sdk.common.context import SdkContext
5
+ from earthscope_sdk.config.settings import SdkSettings
6
+
7
+
8
+ class _SdkClient:
9
+ @property
10
+ def ctx(self):
11
+ """
12
+ SDK Context
13
+ """
14
+ return self._ctx
15
+
16
+ @property
17
+ def is_closed(self):
18
+ """
19
+ This client is closed
20
+ """
21
+ return self._ctx.is_closed
22
+
23
+ @property
24
+ def resources(self):
25
+ """
26
+ References to EarthScope resources
27
+ """
28
+ return self.ctx.settings.resources
29
+
30
+ @overload
31
+ def __init__(self): ...
32
+
33
+ @overload
34
+ def __init__(self, *, ctx: "SdkContext"): ...
35
+
36
+ @overload
37
+ def __init__(self, *, settings: "SdkSettings"): ...
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ ctx: Optional["SdkContext"] = None,
43
+ settings: Optional["SdkSettings"] = None,
44
+ ):
45
+ # lazy imports
46
+ from earthscope_sdk.common.context import SdkContext
47
+ from earthscope_sdk.config.settings import SdkSettings
48
+
49
+ if ctx:
50
+ self._created_ctx = False
51
+
52
+ else:
53
+ if settings is None:
54
+ settings = SdkSettings()
55
+
56
+ ctx = SdkContext(settings=settings)
57
+ self._created_ctx = True
58
+
59
+ self._ctx = ctx
60
+
61
+
62
+ class AsyncSdkClient(_SdkClient):
63
+ """
64
+ Base class for asynchronous EarthScope SDK clients
65
+ """
66
+
67
+ async def __aenter__(self):
68
+ return self
69
+
70
+ async def __aexit__(self, exc_t, exc_v, exc_tb):
71
+ await self.close()
72
+
73
+ async def close(self):
74
+ """
75
+ Close this client
76
+ """
77
+ # only close the context if we created it
78
+ if self._created_ctx:
79
+ await self.ctx.async_close()
80
+
81
+
82
+ class SdkClient(_SdkClient):
83
+ """
84
+ Base class for synchronous EarthScope SDK clients
85
+ """
86
+
87
+ def __enter__(self):
88
+ return self
89
+
90
+ def __exit__(self, exc_type, exc_val, exc_tb):
91
+ self.close()
92
+
93
+ def close(self):
94
+ """
95
+ Close this client
96
+ """
97
+ # only close the context if we created it
98
+ if self._created_ctx:
99
+ self.ctx.close()
@@ -0,0 +1,174 @@
1
+ import sys
2
+ from functools import cached_property
3
+ from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, TypeVar, cast
4
+
5
+ if sys.version_info >= (3, 10):
6
+ from typing import ParamSpec
7
+ else:
8
+ from typing_extensions import ParamSpec
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from httpx import AsyncClient
13
+
14
+ from earthscope_sdk.common._sync_runner import SyncRunner
15
+ from earthscope_sdk.config.settings import SdkSettings
16
+
17
+ T_Retval = TypeVar("T_Retval")
18
+ T_ParamSpec = ParamSpec("T_ParamSpec")
19
+
20
+
21
+ class SdkContext:
22
+ """
23
+ Shared context for the EarthScope SDK
24
+ """
25
+
26
+ @cached_property
27
+ def auth_flow(self):
28
+ """
29
+ Reusable AuthFlow instance for managing token lifecycle
30
+ """
31
+ # lazy import; avoid circular dependency
32
+ from earthscope_sdk.auth.auth_flow import AuthFlow
33
+ from earthscope_sdk.config.models import AuthFlowType
34
+
35
+ auth_flow_type = self.settings.oauth2.auth_flow_type
36
+
37
+ if auth_flow_type == AuthFlowType.DeviceCode:
38
+ return cast(AuthFlow, self.device_code_flow)
39
+
40
+ if auth_flow_type == AuthFlowType.MachineToMachine:
41
+ return cast(AuthFlow, self.client_credentials_flow)
42
+
43
+ # Fall back to base auth flow; if a refresh token is present, we can still
44
+ # facilitate automated renewal.
45
+ return AuthFlow(ctx=self)
46
+
47
+ @cached_property
48
+ def client_credentials_flow(self):
49
+ """
50
+ Reusable ClientCredentialsFlow instance for managing token lifecycle
51
+ """
52
+ # lazy import; avoid circular dependency
53
+ from earthscope_sdk.auth.client_credentials_flow import ClientCredentialsFlow
54
+
55
+ return ClientCredentialsFlow(ctx=self)
56
+
57
+ @cached_property
58
+ def device_code_flow(self):
59
+ """
60
+ Resusable DeviceCodeFlow instance for managing token lifecycle
61
+ """
62
+ # lazy import; avoid circular dependency
63
+ from earthscope_sdk.auth.device_code_flow import DeviceCodeFlow
64
+
65
+ return DeviceCodeFlow(ctx=self)
66
+
67
+ @cached_property
68
+ def httpx_client(self):
69
+ """
70
+ Reusable HTTPx client for shared session and connection pooling.
71
+
72
+ Automatically:
73
+ - injects common headers (e.g. authorization, user-agent)
74
+ - refreshes access token automatically
75
+ """
76
+ import httpx # lazy import
77
+
78
+ self._httpx_client = httpx.AsyncClient(
79
+ auth=self.auth_flow,
80
+ headers={
81
+ "user-agent": self.settings.http.user_agent,
82
+ },
83
+ limits=self.settings.http.limits,
84
+ timeout=self.settings.http.timeouts,
85
+ )
86
+
87
+ return self._httpx_client
88
+
89
+ @property
90
+ def is_closed(self):
91
+ if (c := self._httpx_client) and not c.is_closed:
92
+ return False
93
+
94
+ if (r := self._runner) and not r.is_closed:
95
+ return False
96
+
97
+ return True
98
+
99
+ @cached_property
100
+ def settings(self):
101
+ """
102
+ Profile-specific settings merged with any configured defaults
103
+ """
104
+ return self._settings
105
+
106
+ def __init__(
107
+ self,
108
+ settings: Optional["SdkSettings"] = None,
109
+ *,
110
+ runner: Optional["SyncRunner"] = None,
111
+ ):
112
+ # lazy import
113
+ from earthscope_sdk.config.settings import SdkSettings
114
+
115
+ # Local state
116
+ self._httpx_client: Optional["AsyncClient"] = None
117
+ self._runner: Optional["SyncRunner"] = runner
118
+ self._settings = settings or SdkSettings()
119
+
120
+ async def async_close(self):
121
+ """
122
+ Close this SdkContext to release underlying resources (e.g. connection pools)
123
+ """
124
+ if self._httpx_client:
125
+ await self._httpx_client.aclose()
126
+
127
+ if self._runner:
128
+ self._runner.stop()
129
+
130
+ def close(self):
131
+ """
132
+ Close this SdkContext to release underlying resources (e.g. connection pools)
133
+ """
134
+ # needs a different implementation than async_close()
135
+
136
+ # need to run aclose() in the event loop
137
+ if self._httpx_client:
138
+ self.syncify(self._httpx_client.aclose)()
139
+
140
+ # closing the event loop should be done *outside* the event loop *after* all async cleanup
141
+ if self._runner:
142
+ self._runner.stop()
143
+
144
+ def syncify(
145
+ self,
146
+ async_function: Callable[T_ParamSpec, Coroutine[Any, Any, T_Retval]],
147
+ ) -> Callable[T_ParamSpec, T_Retval]:
148
+ """
149
+ Create a synchronous version of an async function
150
+ """
151
+ if not self._runner:
152
+ self._runner = self._get_runner()
153
+
154
+ return self._runner.syncify(async_function)
155
+
156
+ def _get_runner(self) -> "SyncRunner":
157
+ # lazy imports
158
+ import asyncio
159
+
160
+ from earthscope_sdk.common._sync_runner import (
161
+ BackgroundSyncRunner,
162
+ SimpleSyncRunner,
163
+ )
164
+
165
+ try:
166
+ asyncio.get_running_loop()
167
+
168
+ except RuntimeError:
169
+ # No event loop exists in this thread; we can run in this thread
170
+ return SimpleSyncRunner()
171
+
172
+ else:
173
+ # An event loop already exists in this thread; create a background thread
174
+ return BackgroundSyncRunner()