etrade-python 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.
- etrade_python/__init__.py +264 -0
- etrade_python/_dates.py +152 -0
- etrade_python/_version.py +3 -0
- etrade_python/accounts/__init__.py +31 -0
- etrade_python/accounts/models.py +204 -0
- etrade_python/accounts/service.py +136 -0
- etrade_python/alerts/__init__.py +23 -0
- etrade_python/alerts/models.py +180 -0
- etrade_python/alerts/service.py +128 -0
- etrade_python/auth/__init__.py +31 -0
- etrade_python/auth/credentials.py +85 -0
- etrade_python/auth/oauth.py +268 -0
- etrade_python/auth/session.py +154 -0
- etrade_python/auth/stores/__init__.py +7 -0
- etrade_python/auth/stores/base.py +18 -0
- etrade_python/auth/stores/keyring.py +49 -0
- etrade_python/auth/stores/memory.py +19 -0
- etrade_python/cli/__init__.py +1 -0
- etrade_python/cli/app.py +1623 -0
- etrade_python/client.py +84 -0
- etrade_python/config.py +43 -0
- etrade_python/enums.py +19 -0
- etrade_python/exceptions.py +68 -0
- etrade_python/market/__init__.py +59 -0
- etrade_python/market/models.py +811 -0
- etrade_python/market/service.py +168 -0
- etrade_python/orders/__init__.py +107 -0
- etrade_python/orders/builders.py +617 -0
- etrade_python/orders/models.py +1009 -0
- etrade_python/orders/service.py +259 -0
- etrade_python/orders/values.py +67 -0
- etrade_python/portfolio/__init__.py +35 -0
- etrade_python/portfolio/models.py +429 -0
- etrade_python/portfolio/service.py +99 -0
- etrade_python/py.typed +0 -0
- etrade_python/transactions/__init__.py +27 -0
- etrade_python/transactions/models.py +201 -0
- etrade_python/transactions/service.py +138 -0
- etrade_python/transport/__init__.py +7 -0
- etrade_python/transport/auth.py +17 -0
- etrade_python/transport/http.py +240 -0
- etrade_python/transport/logging.py +38 -0
- etrade_python/transport/response.py +243 -0
- etrade_python/transport/retry.py +75 -0
- etrade_python-1.0.0.dist-info/METADATA +513 -0
- etrade_python-1.0.0.dist-info/RECORD +49 -0
- etrade_python-1.0.0.dist-info/WHEEL +4 -0
- etrade_python-1.0.0.dist-info/entry_points.txt +2 -0
- etrade_python-1.0.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""OAuth 1.0a protocol operations for E*TRADE."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Final
|
|
12
|
+
from urllib.parse import parse_qsl, quote, urlencode
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import SecretStr
|
|
16
|
+
|
|
17
|
+
from etrade_python.auth.credentials import (
|
|
18
|
+
AuthorizationUrl,
|
|
19
|
+
ETradeCredentials,
|
|
20
|
+
RenewalResult,
|
|
21
|
+
RequestToken,
|
|
22
|
+
RevocationResult,
|
|
23
|
+
)
|
|
24
|
+
from etrade_python.config import ETradeSettings
|
|
25
|
+
from etrade_python.enums import AUTHORIZATION_URL, OAUTH_BASE_URL
|
|
26
|
+
from etrade_python.exceptions import (
|
|
27
|
+
ETradeResponseError,
|
|
28
|
+
ETradeTransportError,
|
|
29
|
+
ETradeValidationError,
|
|
30
|
+
)
|
|
31
|
+
from etrade_python.transport.logging import private_http_exchange
|
|
32
|
+
from etrade_python.transport.response import Redactor, parse_response
|
|
33
|
+
|
|
34
|
+
LOGGER = logging.getLogger("etrade_python.auth.oauth")
|
|
35
|
+
SIGNATURE_METHOD: Final[str] = "HMAC-SHA1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _encode(value: str) -> str:
|
|
39
|
+
return quote(value, safe="~-._")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _base_url(url: httpx.URL) -> str:
|
|
43
|
+
scheme = url.scheme.lower()
|
|
44
|
+
host = (url.host or "").lower()
|
|
45
|
+
port = url.port
|
|
46
|
+
authority = host
|
|
47
|
+
if port is not None and not (
|
|
48
|
+
(scheme == "http" and port == 80) or (scheme == "https" and port == 443)
|
|
49
|
+
):
|
|
50
|
+
authority = f"{authority}:{port}"
|
|
51
|
+
path = url.raw_path.decode("ascii").split("?", 1)[0] or "/"
|
|
52
|
+
return f"{scheme}://{authority}{path}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _authorization_header(parameters: dict[str, str]) -> str:
|
|
56
|
+
header = ",".join(
|
|
57
|
+
f'{_encode(key)}="{_encode(value)}"' for key, value in sorted(parameters.items())
|
|
58
|
+
)
|
|
59
|
+
return f'OAuth realm="",{header}'
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OAuthClient:
|
|
63
|
+
"""Protocol-level OAuth operations and request signing."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
settings: ETradeSettings,
|
|
68
|
+
*,
|
|
69
|
+
http_client: httpx.AsyncClient | None = None,
|
|
70
|
+
http_transport: httpx.AsyncBaseTransport | None = None,
|
|
71
|
+
nonce_factory: Callable[[], str] | None = None,
|
|
72
|
+
timestamp_factory: Callable[[], int] | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
if http_client is not None and http_transport is not None:
|
|
75
|
+
raise ETradeValidationError("Supply either http_client or http_transport, not both")
|
|
76
|
+
self._settings = settings
|
|
77
|
+
self._owns_client = http_client is None
|
|
78
|
+
self._client = http_client or httpx.AsyncClient(
|
|
79
|
+
transport=http_transport,
|
|
80
|
+
timeout=settings.request_timeout_seconds,
|
|
81
|
+
follow_redirects=False,
|
|
82
|
+
trust_env=False,
|
|
83
|
+
)
|
|
84
|
+
self._nonce_factory = nonce_factory or (lambda: secrets.token_hex(16))
|
|
85
|
+
self._timestamp_factory = timestamp_factory or (lambda: int(time.time()))
|
|
86
|
+
self._closed = False
|
|
87
|
+
|
|
88
|
+
async def __aenter__(self) -> "OAuthClient":
|
|
89
|
+
if self._closed:
|
|
90
|
+
raise ETradeTransportError("OAuth client is closed")
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
async def __aexit__(self, *_: object) -> None:
|
|
94
|
+
await self.aclose()
|
|
95
|
+
|
|
96
|
+
async def aclose(self) -> None:
|
|
97
|
+
if not self._closed:
|
|
98
|
+
self._closed = True
|
|
99
|
+
if self._owns_client:
|
|
100
|
+
await self._client.aclose()
|
|
101
|
+
|
|
102
|
+
async def get_request_token(self) -> RequestToken:
|
|
103
|
+
request = self._signed_get(
|
|
104
|
+
f"{OAUTH_BASE_URL}/oauth/request_token",
|
|
105
|
+
oauth_parameters={"oauth_callback": "oob"},
|
|
106
|
+
)
|
|
107
|
+
text = await self._send(request, operation="oauth.request_token")
|
|
108
|
+
values = self._parse_form(text)
|
|
109
|
+
try:
|
|
110
|
+
return RequestToken(
|
|
111
|
+
oauth_token=SecretStr(values["oauth_token"]),
|
|
112
|
+
oauth_token_secret=SecretStr(values["oauth_token_secret"]),
|
|
113
|
+
oauth_callback_confirmed=values.get("oauth_callback_confirmed", "").lower()
|
|
114
|
+
== "true",
|
|
115
|
+
)
|
|
116
|
+
except KeyError:
|
|
117
|
+
raise ETradeResponseError("Invalid OAuth request-token response") from None
|
|
118
|
+
|
|
119
|
+
def get_authorization_url(self, request_token: RequestToken) -> AuthorizationUrl:
|
|
120
|
+
query = urlencode(
|
|
121
|
+
{
|
|
122
|
+
"key": self._settings.consumer_key.get_secret_value(),
|
|
123
|
+
"token": request_token.oauth_token.get_secret_value(),
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
return AuthorizationUrl(url=f"{AUTHORIZATION_URL}?{query}", request_token=request_token)
|
|
127
|
+
|
|
128
|
+
async def exchange_verifier(
|
|
129
|
+
self, request_token: RequestToken, verifier: str
|
|
130
|
+
) -> ETradeCredentials:
|
|
131
|
+
if not verifier.strip():
|
|
132
|
+
raise ETradeValidationError("OAuth verifier is required")
|
|
133
|
+
request = self._signed_get(
|
|
134
|
+
f"{OAUTH_BASE_URL}/oauth/access_token",
|
|
135
|
+
token=request_token.oauth_token,
|
|
136
|
+
token_secret=request_token.oauth_token_secret,
|
|
137
|
+
oauth_parameters={"oauth_verifier": verifier},
|
|
138
|
+
)
|
|
139
|
+
text = await self._send(request, operation="oauth.access_token")
|
|
140
|
+
values = self._parse_form(text)
|
|
141
|
+
try:
|
|
142
|
+
return ETradeCredentials(
|
|
143
|
+
access_token=SecretStr(values["oauth_token"]),
|
|
144
|
+
access_token_secret=SecretStr(values["oauth_token_secret"]),
|
|
145
|
+
)
|
|
146
|
+
except KeyError:
|
|
147
|
+
raise ETradeResponseError("Invalid OAuth access-token response") from None
|
|
148
|
+
|
|
149
|
+
async def renew_access_token(self, credentials: ETradeCredentials) -> RenewalResult:
|
|
150
|
+
request = self._signed_get(
|
|
151
|
+
f"{OAUTH_BASE_URL}/oauth/renew_access_token",
|
|
152
|
+
token=credentials.access_token,
|
|
153
|
+
token_secret=credentials.access_token_secret,
|
|
154
|
+
)
|
|
155
|
+
text = await self._send(request, operation="oauth.renew_access_token")
|
|
156
|
+
return RenewalResult(credentials=credentials, message=text.strip())
|
|
157
|
+
|
|
158
|
+
async def revoke_access_token(self, credentials: ETradeCredentials) -> RevocationResult:
|
|
159
|
+
request = self._signed_get(
|
|
160
|
+
f"{OAUTH_BASE_URL}/oauth/revoke_access_token",
|
|
161
|
+
token=credentials.access_token,
|
|
162
|
+
token_secret=credentials.access_token_secret,
|
|
163
|
+
)
|
|
164
|
+
text = await self._send(request, operation="oauth.revoke_access_token")
|
|
165
|
+
return RevocationResult(revoked=True, message=text.strip())
|
|
166
|
+
|
|
167
|
+
def sign_request(self, request: httpx.Request, credentials: ETradeCredentials) -> None:
|
|
168
|
+
parameters = self._oauth_parameters(token=credentials.access_token)
|
|
169
|
+
signature = self._signature(
|
|
170
|
+
request.method,
|
|
171
|
+
request.url,
|
|
172
|
+
parameters,
|
|
173
|
+
token_secret=credentials.access_token_secret,
|
|
174
|
+
)
|
|
175
|
+
parameters["oauth_signature"] = signature
|
|
176
|
+
request.headers["Authorization"] = _authorization_header(parameters)
|
|
177
|
+
|
|
178
|
+
def _signed_get(
|
|
179
|
+
self,
|
|
180
|
+
url: str,
|
|
181
|
+
*,
|
|
182
|
+
token: SecretStr | None = None,
|
|
183
|
+
token_secret: SecretStr | None = None,
|
|
184
|
+
oauth_parameters: dict[str, str] | None = None,
|
|
185
|
+
) -> httpx.Request:
|
|
186
|
+
request = self._client.build_request(
|
|
187
|
+
"GET", url, timeout=self._settings.request_timeout_seconds
|
|
188
|
+
)
|
|
189
|
+
parameters = self._oauth_parameters(token=token)
|
|
190
|
+
parameters.update(oauth_parameters or {})
|
|
191
|
+
signature = self._signature("GET", request.url, parameters, token_secret=token_secret)
|
|
192
|
+
parameters["oauth_signature"] = signature
|
|
193
|
+
request.headers["Authorization"] = _authorization_header(parameters)
|
|
194
|
+
return request
|
|
195
|
+
|
|
196
|
+
def _oauth_parameters(self, *, token: SecretStr | None = None) -> dict[str, str]:
|
|
197
|
+
parameters = {
|
|
198
|
+
"oauth_consumer_key": self._settings.consumer_key.get_secret_value(),
|
|
199
|
+
"oauth_nonce": self._nonce_factory(),
|
|
200
|
+
"oauth_signature_method": SIGNATURE_METHOD,
|
|
201
|
+
"oauth_timestamp": str(self._timestamp_factory()),
|
|
202
|
+
}
|
|
203
|
+
if token is not None:
|
|
204
|
+
parameters["oauth_token"] = token.get_secret_value()
|
|
205
|
+
return parameters
|
|
206
|
+
|
|
207
|
+
def _signature(
|
|
208
|
+
self,
|
|
209
|
+
method: str,
|
|
210
|
+
url: httpx.URL,
|
|
211
|
+
oauth_parameters: dict[str, str],
|
|
212
|
+
*,
|
|
213
|
+
token_secret: SecretStr | None,
|
|
214
|
+
) -> str:
|
|
215
|
+
parameters = [
|
|
216
|
+
(key, value) for key, value in oauth_parameters.items() if key != "oauth_signature"
|
|
217
|
+
]
|
|
218
|
+
parameters.extend((key, value) for key, value in url.params.multi_items())
|
|
219
|
+
parameter_string = "&".join(
|
|
220
|
+
f"{_encode(key)}={_encode(value)}" for key, value in sorted(parameters)
|
|
221
|
+
)
|
|
222
|
+
base_string = "&".join(
|
|
223
|
+
[_encode(method.upper()), _encode(_base_url(url)), _encode(parameter_string)]
|
|
224
|
+
)
|
|
225
|
+
secret = self._settings.consumer_secret.get_secret_value()
|
|
226
|
+
token_secret_value = "" if token_secret is None else token_secret.get_secret_value()
|
|
227
|
+
signing_key = f"{_encode(secret)}&{_encode(token_secret_value)}"
|
|
228
|
+
digest = hmac.new(
|
|
229
|
+
signing_key.encode("utf-8"), base_string.encode("utf-8"), hashlib.sha1
|
|
230
|
+
).digest()
|
|
231
|
+
return base64.b64encode(digest).decode("ascii")
|
|
232
|
+
|
|
233
|
+
async def _send(self, request: httpx.Request, *, operation: str) -> str:
|
|
234
|
+
if self._closed:
|
|
235
|
+
raise ETradeTransportError("OAuth client is closed")
|
|
236
|
+
redactor = self._redactor(request)
|
|
237
|
+
try:
|
|
238
|
+
with private_http_exchange():
|
|
239
|
+
response = await self._client.send(request, follow_redirects=False, auth=None)
|
|
240
|
+
except httpx.TransportError:
|
|
241
|
+
raise ETradeTransportError("OAuth HTTP exchange failed") from None
|
|
242
|
+
except httpx.HTTPError:
|
|
243
|
+
raise ETradeTransportError("OAuth HTTP exchange failed") from None
|
|
244
|
+
try:
|
|
245
|
+
if not 200 <= response.status_code < 300:
|
|
246
|
+
parse_response(response, redactor)
|
|
247
|
+
LOGGER.info("E*TRADE OAuth request", extra={"operation": operation})
|
|
248
|
+
return response.text
|
|
249
|
+
finally:
|
|
250
|
+
await response.aclose()
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _parse_form(text: str) -> dict[str, str]:
|
|
254
|
+
values = dict(parse_qsl(text, keep_blank_values=True, strict_parsing=False))
|
|
255
|
+
if not values:
|
|
256
|
+
raise ETradeResponseError("Invalid OAuth form response")
|
|
257
|
+
return values
|
|
258
|
+
|
|
259
|
+
def _redactor(self, request: httpx.Request) -> Redactor:
|
|
260
|
+
authorization = request.headers.get("authorization", "")
|
|
261
|
+
secrets_to_mask = [
|
|
262
|
+
self._settings.consumer_key.get_secret_value(),
|
|
263
|
+
self._settings.consumer_secret.get_secret_value(),
|
|
264
|
+
authorization,
|
|
265
|
+
]
|
|
266
|
+
# Redactor also masks decoded forms of percent-encoded OAuth values.
|
|
267
|
+
secrets_to_mask.extend(re.findall(r'="([^"\r\n]*)"', authorization))
|
|
268
|
+
return Redactor(secrets_to_mask)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""OAuth access-token lifecycle management."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from zoneinfo import ZoneInfo
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from etrade_python.auth.credentials import ETradeCredentials
|
|
13
|
+
from etrade_python.auth.oauth import OAuthClient
|
|
14
|
+
from etrade_python.auth.stores.base import CredentialStore
|
|
15
|
+
from etrade_python.config import ETradeSettings
|
|
16
|
+
from etrade_python.exceptions import AuthenticationRequired, AuthorizationExpired
|
|
17
|
+
|
|
18
|
+
EASTERN = ZoneInfo("America/New_York")
|
|
19
|
+
INACTIVITY_SECONDS = 7200
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TokenStatus(StrEnum):
|
|
23
|
+
ACTIVE = "active"
|
|
24
|
+
INACTIVE_RENEWABLE = "inactive_renewable"
|
|
25
|
+
EXPIRED = "expired"
|
|
26
|
+
MISSING = "missing"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SessionManager:
|
|
30
|
+
"""Decide whether persisted credentials are usable, renewable, or expired."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
settings: ETradeSettings,
|
|
36
|
+
oauth_client: OAuthClient,
|
|
37
|
+
credential_store: CredentialStore,
|
|
38
|
+
profile: str = "default",
|
|
39
|
+
clock: Callable[[], datetime] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._settings = settings
|
|
42
|
+
self._oauth_client = oauth_client
|
|
43
|
+
self._credential_store = credential_store
|
|
44
|
+
consumer_fingerprint = hashlib.sha256(
|
|
45
|
+
settings.consumer_key.get_secret_value().encode("utf-8")
|
|
46
|
+
).hexdigest()
|
|
47
|
+
self._storage_key = f"{settings.environment.value}:{consumer_fingerprint}:{profile}"
|
|
48
|
+
self._clock = clock or (lambda: datetime.now(UTC))
|
|
49
|
+
self._renewal_lock = asyncio.Lock()
|
|
50
|
+
|
|
51
|
+
async def ensure_active(self) -> ETradeCredentials:
|
|
52
|
+
async with self._renewal_lock:
|
|
53
|
+
now = self._now()
|
|
54
|
+
credentials = await self.load()
|
|
55
|
+
status = self.status(credentials, now=now)
|
|
56
|
+
if status is TokenStatus.MISSING:
|
|
57
|
+
raise AuthenticationRequired("E*TRADE authorization is required")
|
|
58
|
+
if credentials is None:
|
|
59
|
+
raise AuthenticationRequired("E*TRADE authorization is required")
|
|
60
|
+
if status is TokenStatus.EXPIRED:
|
|
61
|
+
raise AuthorizationExpired("E*TRADE authorization has expired")
|
|
62
|
+
if status is TokenStatus.ACTIVE:
|
|
63
|
+
return credentials
|
|
64
|
+
return await self._renew(credentials)
|
|
65
|
+
|
|
66
|
+
async def renew(self) -> ETradeCredentials:
|
|
67
|
+
"""Renew same-day credentials with the broker, even when locally active."""
|
|
68
|
+
async with self._renewal_lock:
|
|
69
|
+
credentials = await self.load()
|
|
70
|
+
if credentials is None:
|
|
71
|
+
raise AuthenticationRequired("E*TRADE authorization is required")
|
|
72
|
+
if self.status(credentials) is TokenStatus.EXPIRED:
|
|
73
|
+
raise AuthorizationExpired("E*TRADE authorization has expired")
|
|
74
|
+
return await self._renew(credentials)
|
|
75
|
+
|
|
76
|
+
async def _renew(self, credentials: ETradeCredentials) -> ETradeCredentials:
|
|
77
|
+
"""Perform renewal while the caller holds the renewal lock."""
|
|
78
|
+
result = await self._oauth_client.renew_access_token(credentials)
|
|
79
|
+
renewed = result.credentials.with_renewal(self._now())
|
|
80
|
+
await self._save(renewed)
|
|
81
|
+
return renewed
|
|
82
|
+
|
|
83
|
+
async def save(self, credentials: ETradeCredentials) -> None:
|
|
84
|
+
async with self._renewal_lock:
|
|
85
|
+
await self._save(credentials)
|
|
86
|
+
|
|
87
|
+
async def _save(self, credentials: ETradeCredentials) -> None:
|
|
88
|
+
await self._credential_store.save(self._storage_key, credentials)
|
|
89
|
+
|
|
90
|
+
async def load(self) -> ETradeCredentials | None:
|
|
91
|
+
return await self._credential_store.load(self._storage_key)
|
|
92
|
+
|
|
93
|
+
async def delete(self) -> None:
|
|
94
|
+
async with self._renewal_lock:
|
|
95
|
+
await self._credential_store.delete(self._storage_key)
|
|
96
|
+
|
|
97
|
+
async def record_usage(self, credentials: ETradeCredentials) -> None:
|
|
98
|
+
"""Record confirmed usage without overwriting newer or replaced credentials."""
|
|
99
|
+
used_at = self._now()
|
|
100
|
+
async with self._renewal_lock:
|
|
101
|
+
current = await self.load()
|
|
102
|
+
if current is None or (
|
|
103
|
+
current.access_token != credentials.access_token
|
|
104
|
+
or current.access_token_secret != credentials.access_token_secret
|
|
105
|
+
or current.acquired_at != credentials.acquired_at
|
|
106
|
+
):
|
|
107
|
+
return
|
|
108
|
+
if used_at > current.last_used_at:
|
|
109
|
+
await self._save(current.with_last_used(used_at))
|
|
110
|
+
|
|
111
|
+
def status(
|
|
112
|
+
self, credentials: ETradeCredentials | None, *, now: datetime | None = None
|
|
113
|
+
) -> TokenStatus:
|
|
114
|
+
if credentials is None:
|
|
115
|
+
return TokenStatus.MISSING
|
|
116
|
+
current = (now or self._now()).astimezone(UTC)
|
|
117
|
+
if self._is_expired(credentials, now=current):
|
|
118
|
+
return TokenStatus.EXPIRED
|
|
119
|
+
inactive_after = timedelta(
|
|
120
|
+
seconds=INACTIVITY_SECONDS - self._settings.inactivity_buffer_seconds
|
|
121
|
+
)
|
|
122
|
+
if current - credentials.last_used_at >= inactive_after:
|
|
123
|
+
return TokenStatus.INACTIVE_RENEWABLE
|
|
124
|
+
return TokenStatus.ACTIVE
|
|
125
|
+
|
|
126
|
+
def _is_expired(self, credentials: ETradeCredentials, *, now: datetime) -> bool:
|
|
127
|
+
acquired_eastern = credentials.acquired_at.astimezone(EASTERN)
|
|
128
|
+
next_midnight = (acquired_eastern + timedelta(days=1)).replace(
|
|
129
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
130
|
+
)
|
|
131
|
+
return now.astimezone(EASTERN) >= next_midnight
|
|
132
|
+
|
|
133
|
+
def _now(self) -> datetime:
|
|
134
|
+
value = self._clock()
|
|
135
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
136
|
+
return value.replace(tzinfo=UTC)
|
|
137
|
+
return value.astimezone(UTC)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class SessionAuthenticator:
|
|
141
|
+
"""Transport authenticator backed by SessionManager credentials."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, session_manager: SessionManager, oauth_client: OAuthClient) -> None:
|
|
144
|
+
self._session_manager = session_manager
|
|
145
|
+
self._oauth_client = oauth_client
|
|
146
|
+
|
|
147
|
+
async def authenticate(self, request: httpx.Request) -> Callable[[], Awaitable[None]]:
|
|
148
|
+
credentials = await self._session_manager.ensure_active()
|
|
149
|
+
self._oauth_client.sign_request(request, credentials)
|
|
150
|
+
|
|
151
|
+
async def record_usage() -> None:
|
|
152
|
+
await self._session_manager.record_usage(credentials)
|
|
153
|
+
|
|
154
|
+
return record_usage
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Credential store implementations."""
|
|
2
|
+
|
|
3
|
+
from etrade_python.auth.stores.base import CredentialStore
|
|
4
|
+
from etrade_python.auth.stores.keyring import KeyringCredentialStore
|
|
5
|
+
from etrade_python.auth.stores.memory import MemoryCredentialStore
|
|
6
|
+
|
|
7
|
+
__all__ = ["CredentialStore", "KeyringCredentialStore", "MemoryCredentialStore"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Credential store protocol."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from etrade_python.auth.credentials import ETradeCredentials
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CredentialStore(Protocol):
|
|
9
|
+
"""Async persistence boundary for OAuth credentials."""
|
|
10
|
+
|
|
11
|
+
async def load(self, profile: str) -> ETradeCredentials | None:
|
|
12
|
+
"""Load credentials for a profile, returning None when absent."""
|
|
13
|
+
|
|
14
|
+
async def save(self, profile: str, credentials: ETradeCredentials) -> None:
|
|
15
|
+
"""Persist credentials for a profile."""
|
|
16
|
+
|
|
17
|
+
async def delete(self, profile: str) -> None:
|
|
18
|
+
"""Delete credentials for a profile."""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Keyring-backed credential store."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import keyring
|
|
7
|
+
from keyring.errors import PasswordDeleteError
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from etrade_python.auth.credentials import ETradeCredentials
|
|
11
|
+
from etrade_python.exceptions import ETradeValidationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KeyringCredentialStore:
|
|
15
|
+
"""Store credentials in the user's configured system keyring."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *, service_name: str = "etrade-python") -> None:
|
|
18
|
+
self._service_name = service_name
|
|
19
|
+
|
|
20
|
+
async def load(self, profile: str) -> ETradeCredentials | None:
|
|
21
|
+
raw = await asyncio.to_thread(keyring.get_password, self._service_name, profile)
|
|
22
|
+
if raw is None:
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
data = json.loads(raw)
|
|
26
|
+
return ETradeCredentials.model_validate(data)
|
|
27
|
+
except (json.JSONDecodeError, TypeError, ValidationError):
|
|
28
|
+
raise ETradeValidationError("Stored E*TRADE credentials are invalid") from None
|
|
29
|
+
|
|
30
|
+
async def save(self, profile: str, credentials: ETradeCredentials) -> None:
|
|
31
|
+
payload = json.dumps(
|
|
32
|
+
{
|
|
33
|
+
"access_token": credentials.access_token.get_secret_value(),
|
|
34
|
+
"access_token_secret": credentials.access_token_secret.get_secret_value(),
|
|
35
|
+
"acquired_at": credentials.acquired_at.isoformat(),
|
|
36
|
+
"last_used_at": credentials.last_used_at.isoformat(),
|
|
37
|
+
"renewed_at": None
|
|
38
|
+
if credentials.renewed_at is None
|
|
39
|
+
else credentials.renewed_at.isoformat(),
|
|
40
|
+
},
|
|
41
|
+
separators=(",", ":"),
|
|
42
|
+
)
|
|
43
|
+
await asyncio.to_thread(keyring.set_password, self._service_name, profile, payload)
|
|
44
|
+
|
|
45
|
+
async def delete(self, profile: str) -> None:
|
|
46
|
+
try:
|
|
47
|
+
await asyncio.to_thread(keyring.delete_password, self._service_name, profile)
|
|
48
|
+
except PasswordDeleteError:
|
|
49
|
+
return
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""In-memory credential store."""
|
|
2
|
+
|
|
3
|
+
from etrade_python.auth.credentials import ETradeCredentials
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MemoryCredentialStore:
|
|
7
|
+
"""Process-local credential store, mainly for tests and temporary sessions."""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._credentials: dict[str, ETradeCredentials] = {}
|
|
11
|
+
|
|
12
|
+
async def load(self, profile: str) -> ETradeCredentials | None:
|
|
13
|
+
return self._credentials.get(profile)
|
|
14
|
+
|
|
15
|
+
async def save(self, profile: str, credentials: ETradeCredentials) -> None:
|
|
16
|
+
self._credentials[profile] = credentials
|
|
17
|
+
|
|
18
|
+
async def delete(self, profile: str) -> None:
|
|
19
|
+
self._credentials.pop(profile, None)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface package."""
|