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.
- earthscope_sdk/__init__.py +5 -1
- earthscope_sdk/auth/auth_flow.py +224 -347
- earthscope_sdk/auth/client_credentials_flow.py +46 -156
- earthscope_sdk/auth/device_code_flow.py +154 -207
- 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 +54 -0
- earthscope_sdk/config/__init__.py +0 -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 +208 -0
- earthscope_sdk/config/settings.py +284 -0
- {earthscope_sdk-0.2.1.dist-info → earthscope_sdk-1.0.0b0.dist-info}/METADATA +144 -123
- earthscope_sdk-1.0.0b0.dist-info/RECORD +28 -0
- {earthscope_sdk-0.2.1.dist-info → earthscope_sdk-1.0.0b0.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.0b0.dist-info}/LICENSE +0 -0
- {earthscope_sdk-0.2.1.dist-info → earthscope_sdk-1.0.0b0.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(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()
|