earthscope-sdk 0.2.1__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.
- earthscope_sdk/__init__.py +5 -1
- earthscope_sdk/auth/auth_flow.py +240 -346
- earthscope_sdk/auth/client_credentials_flow.py +42 -162
- earthscope_sdk/auth/device_code_flow.py +169 -213
- earthscope_sdk/auth/error.py +46 -0
- earthscope_sdk/client/__init__.py +3 -0
- earthscope_sdk/client/_client.py +35 -0
- earthscope_sdk/client/user/_base.py +39 -0
- earthscope_sdk/client/user/_service.py +94 -0
- earthscope_sdk/client/user/models.py +53 -0
- earthscope_sdk/common/__init__.py +0 -0
- earthscope_sdk/common/_sync_runner.py +141 -0
- earthscope_sdk/common/client.py +99 -0
- earthscope_sdk/common/context.py +174 -0
- earthscope_sdk/common/service.py +59 -0
- earthscope_sdk/config/__init__.py +0 -0
- earthscope_sdk/config/_bootstrap.py +42 -0
- earthscope_sdk/config/_compat.py +148 -0
- earthscope_sdk/config/_util.py +48 -0
- earthscope_sdk/config/error.py +4 -0
- earthscope_sdk/config/models.py +310 -0
- earthscope_sdk/config/settings.py +295 -0
- earthscope_sdk/model/secret.py +29 -0
- {earthscope_sdk-0.2.1.dist-info → earthscope_sdk-1.0.0.dist-info}/METADATA +147 -123
- earthscope_sdk-1.0.0.dist-info/RECORD +30 -0
- {earthscope_sdk-0.2.1.dist-info → earthscope_sdk-1.0.0.dist-info}/WHEEL +1 -1
- earthscope_sdk/user/user.py +0 -24
- earthscope_sdk-0.2.1.dist-info/RECORD +0 -12
- /earthscope_sdk/{user → client/user}/__init__.py +0 -0
- {earthscope_sdk-0.2.1.dist-info → earthscope_sdk-1.0.0.dist-info/licenses}/LICENSE +0 -0
- {earthscope_sdk-0.2.1.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,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()
|