earthscope-sdk 0.2.0__py3-none-any.whl → 1.0.0__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.
Files changed (31) hide show
  1. earthscope_sdk/__init__.py +5 -1
  2. earthscope_sdk/auth/auth_flow.py +240 -346
  3. earthscope_sdk/auth/client_credentials_flow.py +42 -162
  4. earthscope_sdk/auth/device_code_flow.py +169 -213
  5. earthscope_sdk/auth/error.py +46 -0
  6. earthscope_sdk/client/__init__.py +3 -0
  7. earthscope_sdk/client/_client.py +35 -0
  8. earthscope_sdk/client/user/_base.py +39 -0
  9. earthscope_sdk/client/user/_service.py +94 -0
  10. earthscope_sdk/client/user/models.py +53 -0
  11. earthscope_sdk/common/__init__.py +0 -0
  12. earthscope_sdk/common/_sync_runner.py +141 -0
  13. earthscope_sdk/common/client.py +99 -0
  14. earthscope_sdk/common/context.py +174 -0
  15. earthscope_sdk/common/service.py +59 -0
  16. earthscope_sdk/config/__init__.py +0 -0
  17. earthscope_sdk/config/_bootstrap.py +42 -0
  18. earthscope_sdk/config/_compat.py +148 -0
  19. earthscope_sdk/config/_util.py +48 -0
  20. earthscope_sdk/config/error.py +4 -0
  21. earthscope_sdk/config/models.py +310 -0
  22. earthscope_sdk/config/settings.py +295 -0
  23. earthscope_sdk/model/secret.py +29 -0
  24. {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/METADATA +147 -123
  25. earthscope_sdk-1.0.0.dist-info/RECORD +30 -0
  26. {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/WHEEL +1 -1
  27. earthscope_sdk/user/user.py +0 -32
  28. earthscope_sdk-0.2.0.dist-info/RECORD +0 -12
  29. /earthscope_sdk/{user → client/user}/__init__.py +0 -0
  30. {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info/licenses}/LICENSE +0 -0
  31. {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/top_level.txt +0 -0
@@ -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_with_retries(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_with_retries(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()