etrade-python 1.0.0__py3-none-any.whl → 1.1.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 +4 -0
- etrade_python/_version.py +1 -1
- etrade_python/auth/__init__.py +2 -0
- etrade_python/auth/session.py +61 -17
- etrade_python/auth/stores/__init__.py +7 -2
- etrade_python/auth/stores/_workers.py +44 -0
- etrade_python/auth/stores/base.py +16 -1
- etrade_python/auth/stores/keyring.py +81 -5
- etrade_python/auth/stores/memory.py +8 -0
- etrade_python/cli/app.py +1 -4
- etrade_python/exceptions.py +4 -0
- etrade_python/transport/http.py +3 -0
- {etrade_python-1.0.0.dist-info → etrade_python-1.1.0.dist-info}/METADATA +77 -10
- {etrade_python-1.0.0.dist-info → etrade_python-1.1.0.dist-info}/RECORD +17 -16
- {etrade_python-1.0.0.dist-info → etrade_python-1.1.0.dist-info}/WHEEL +0 -0
- {etrade_python-1.0.0.dist-info → etrade_python-1.1.0.dist-info}/entry_points.txt +0 -0
- {etrade_python-1.0.0.dist-info → etrade_python-1.1.0.dist-info}/licenses/LICENSE +0 -0
etrade_python/__init__.py
CHANGED
|
@@ -20,6 +20,7 @@ from etrade_python.alerts import (
|
|
|
20
20
|
)
|
|
21
21
|
from etrade_python.auth import (
|
|
22
22
|
AuthorizationUrl,
|
|
23
|
+
CoordinatedCredentialStore,
|
|
23
24
|
CredentialStore,
|
|
24
25
|
ETradeCredentials,
|
|
25
26
|
KeyringCredentialStore,
|
|
@@ -38,6 +39,7 @@ from etrade_python.enums import Environment
|
|
|
38
39
|
from etrade_python.exceptions import (
|
|
39
40
|
AuthenticationRequired,
|
|
40
41
|
AuthorizationExpired,
|
|
42
|
+
CredentialCoordinationError,
|
|
41
43
|
ETradeApiError,
|
|
42
44
|
ETradeAuthenticationError,
|
|
43
45
|
ETradeError,
|
|
@@ -201,6 +203,7 @@ __all__ = [
|
|
|
201
203
|
"CancelOrderResponse",
|
|
202
204
|
"CancelOrderRequest",
|
|
203
205
|
"AuthenticationRequired",
|
|
206
|
+
"CredentialCoordinationError",
|
|
204
207
|
"Account",
|
|
205
208
|
"AccountBalanceRequest",
|
|
206
209
|
"AccountBalanceResponse",
|
|
@@ -208,6 +211,7 @@ __all__ = [
|
|
|
208
211
|
"AccountsService",
|
|
209
212
|
"AuthorizationUrl",
|
|
210
213
|
"AuthorizationExpired",
|
|
214
|
+
"CoordinatedCredentialStore",
|
|
211
215
|
"CredentialStore",
|
|
212
216
|
"ETradeApiError",
|
|
213
217
|
"ETradeAuthenticationError",
|
etrade_python/_version.py
CHANGED
etrade_python/auth/__init__.py
CHANGED
|
@@ -10,6 +10,7 @@ from etrade_python.auth.credentials import (
|
|
|
10
10
|
from etrade_python.auth.oauth import OAuthClient
|
|
11
11
|
from etrade_python.auth.session import SessionAuthenticator, SessionManager, TokenStatus
|
|
12
12
|
from etrade_python.auth.stores import (
|
|
13
|
+
CoordinatedCredentialStore,
|
|
13
14
|
CredentialStore,
|
|
14
15
|
KeyringCredentialStore,
|
|
15
16
|
MemoryCredentialStore,
|
|
@@ -17,6 +18,7 @@ from etrade_python.auth.stores import (
|
|
|
17
18
|
|
|
18
19
|
__all__ = [
|
|
19
20
|
"AuthorizationUrl",
|
|
21
|
+
"CoordinatedCredentialStore",
|
|
20
22
|
"CredentialStore",
|
|
21
23
|
"ETradeCredentials",
|
|
22
24
|
"KeyringCredentialStore",
|
etrade_python/auth/session.py
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import hashlib
|
|
5
|
-
from collections.abc import Awaitable, Callable
|
|
5
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
6
7
|
from datetime import UTC, datetime, timedelta
|
|
7
8
|
from enum import StrEnum
|
|
8
9
|
from zoneinfo import ZoneInfo
|
|
@@ -11,7 +12,7 @@ import httpx
|
|
|
11
12
|
|
|
12
13
|
from etrade_python.auth.credentials import ETradeCredentials
|
|
13
14
|
from etrade_python.auth.oauth import OAuthClient
|
|
14
|
-
from etrade_python.auth.stores.base import CredentialStore
|
|
15
|
+
from etrade_python.auth.stores.base import CoordinatedCredentialStore, CredentialStore
|
|
15
16
|
from etrade_python.config import ETradeSettings
|
|
16
17
|
from etrade_python.exceptions import AuthenticationRequired, AuthorizationExpired
|
|
17
18
|
|
|
@@ -48,10 +49,23 @@ class SessionManager:
|
|
|
48
49
|
self._clock = clock or (lambda: datetime.now(UTC))
|
|
49
50
|
self._renewal_lock = asyncio.Lock()
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
@asynccontextmanager
|
|
53
|
+
async def _transaction(self) -> AsyncGenerator[None]:
|
|
52
54
|
async with self._renewal_lock:
|
|
55
|
+
if isinstance(self._credential_store, CoordinatedCredentialStore):
|
|
56
|
+
async with self._credential_store.lock(self._storage_key):
|
|
57
|
+
yield
|
|
58
|
+
else:
|
|
59
|
+
# Preserve the original contract for existing custom stores.
|
|
60
|
+
yield
|
|
61
|
+
|
|
62
|
+
async def _load(self) -> ETradeCredentials | None:
|
|
63
|
+
return await self._credential_store.load(self._storage_key)
|
|
64
|
+
|
|
65
|
+
async def ensure_active(self) -> ETradeCredentials:
|
|
66
|
+
async with self._transaction():
|
|
53
67
|
now = self._now()
|
|
54
|
-
credentials = await self.
|
|
68
|
+
credentials = await self._load()
|
|
55
69
|
status = self.status(credentials, now=now)
|
|
56
70
|
if status is TokenStatus.MISSING:
|
|
57
71
|
raise AuthenticationRequired("E*TRADE authorization is required")
|
|
@@ -65,8 +79,8 @@ class SessionManager:
|
|
|
65
79
|
|
|
66
80
|
async def renew(self) -> ETradeCredentials:
|
|
67
81
|
"""Renew same-day credentials with the broker, even when locally active."""
|
|
68
|
-
async with self.
|
|
69
|
-
credentials = await self.
|
|
82
|
+
async with self._transaction():
|
|
83
|
+
credentials = await self._load()
|
|
70
84
|
if credentials is None:
|
|
71
85
|
raise AuthenticationRequired("E*TRADE authorization is required")
|
|
72
86
|
if self.status(credentials) is TokenStatus.EXPIRED:
|
|
@@ -76,34 +90,56 @@ class SessionManager:
|
|
|
76
90
|
async def _renew(self, credentials: ETradeCredentials) -> ETradeCredentials:
|
|
77
91
|
"""Perform renewal while the caller holds the renewal lock."""
|
|
78
92
|
result = await self._oauth_client.renew_access_token(credentials)
|
|
79
|
-
renewed = result.credentials.with_renewal(self._now())
|
|
93
|
+
renewed = result.credentials.with_renewal(max(self._now(), credentials.last_used_at))
|
|
94
|
+
if credentials.renewed_at is not None and (
|
|
95
|
+
renewed.renewed_at is None or credentials.renewed_at > renewed.renewed_at
|
|
96
|
+
):
|
|
97
|
+
renewed = renewed.model_copy(update={"renewed_at": credentials.renewed_at})
|
|
80
98
|
await self._save(renewed)
|
|
81
99
|
return renewed
|
|
82
100
|
|
|
83
101
|
async def save(self, credentials: ETradeCredentials) -> None:
|
|
84
|
-
async with self.
|
|
102
|
+
async with self._transaction():
|
|
103
|
+
current = await self._load()
|
|
104
|
+
if current is not None and _same_credentials(current, credentials):
|
|
105
|
+
renewed = [t for t in (current.renewed_at, credentials.renewed_at) if t is not None]
|
|
106
|
+
credentials = credentials.model_copy(
|
|
107
|
+
update={
|
|
108
|
+
"last_used_at": max(current.last_used_at, credentials.last_used_at),
|
|
109
|
+
"renewed_at": max(renewed) if renewed else None,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
85
112
|
await self._save(credentials)
|
|
86
113
|
|
|
87
114
|
async def _save(self, credentials: ETradeCredentials) -> None:
|
|
88
115
|
await self._credential_store.save(self._storage_key, credentials)
|
|
89
116
|
|
|
90
117
|
async def load(self) -> ETradeCredentials | None:
|
|
91
|
-
|
|
118
|
+
async with self._transaction():
|
|
119
|
+
return await self._load()
|
|
92
120
|
|
|
93
121
|
async def delete(self) -> None:
|
|
94
|
-
async with self.
|
|
122
|
+
async with self._transaction():
|
|
95
123
|
await self._credential_store.delete(self._storage_key)
|
|
96
124
|
|
|
125
|
+
async def revoke(self) -> None:
|
|
126
|
+
"""Revoke current credentials and delete locally only after broker success.
|
|
127
|
+
|
|
128
|
+
This coordinates lifecycle updates, not already-dispatched API requests.
|
|
129
|
+
Missing credentials are a no-op. Broker errors preserve local credentials.
|
|
130
|
+
"""
|
|
131
|
+
async with self._transaction():
|
|
132
|
+
credentials = await self._load()
|
|
133
|
+
if credentials is not None:
|
|
134
|
+
await self._oauth_client.revoke_access_token(credentials)
|
|
135
|
+
await self._credential_store.delete(self._storage_key)
|
|
136
|
+
|
|
97
137
|
async def record_usage(self, credentials: ETradeCredentials) -> None:
|
|
98
138
|
"""Record confirmed usage without overwriting newer or replaced credentials."""
|
|
99
139
|
used_at = self._now()
|
|
100
|
-
async with self.
|
|
101
|
-
current = await self.
|
|
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
|
-
):
|
|
140
|
+
async with self._transaction():
|
|
141
|
+
current = await self._load()
|
|
142
|
+
if current is None or not _same_credentials(current, credentials):
|
|
107
143
|
return
|
|
108
144
|
if used_at > current.last_used_at:
|
|
109
145
|
await self._save(current.with_last_used(used_at))
|
|
@@ -137,6 +173,14 @@ class SessionManager:
|
|
|
137
173
|
return value.astimezone(UTC)
|
|
138
174
|
|
|
139
175
|
|
|
176
|
+
def _same_credentials(left: ETradeCredentials, right: ETradeCredentials) -> bool:
|
|
177
|
+
return (
|
|
178
|
+
left.access_token == right.access_token
|
|
179
|
+
and left.access_token_secret == right.access_token_secret
|
|
180
|
+
and left.acquired_at == right.acquired_at
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
140
184
|
class SessionAuthenticator:
|
|
141
185
|
"""Transport authenticator backed by SessionManager credentials."""
|
|
142
186
|
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
"""Credential store implementations."""
|
|
2
2
|
|
|
3
|
-
from etrade_python.auth.stores.base import CredentialStore
|
|
3
|
+
from etrade_python.auth.stores.base import CoordinatedCredentialStore, CredentialStore
|
|
4
4
|
from etrade_python.auth.stores.keyring import KeyringCredentialStore
|
|
5
5
|
from etrade_python.auth.stores.memory import MemoryCredentialStore
|
|
6
6
|
|
|
7
|
-
__all__ = [
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CoordinatedCredentialStore",
|
|
9
|
+
"CredentialStore",
|
|
10
|
+
"KeyringCredentialStore",
|
|
11
|
+
"MemoryCredentialStore",
|
|
12
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Wait for keyring and lock workers before relinquishing transaction ownership."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextvars
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import ParamSpec, TypeVar
|
|
7
|
+
|
|
8
|
+
P = ParamSpec("P")
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def run_worker(function: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
|
|
13
|
+
"""Defer cancellation until a worker finishes, including repeated cancellation.
|
|
14
|
+
|
|
15
|
+
A worker thread cannot be stopped by cancelling its awaiter. Releasing a
|
|
16
|
+
namespace lock first would allow its late write to race with the next owner.
|
|
17
|
+
"""
|
|
18
|
+
context = contextvars.copy_context()
|
|
19
|
+
# Use an executor Future, not a Task: asyncio.run() cancels all Tasks during
|
|
20
|
+
# shutdown, including a shielded to_thread Task. This Future stays alive until
|
|
21
|
+
# the actual thread finishes, so the transaction cannot unlock prematurely.
|
|
22
|
+
worker = asyncio.get_running_loop().run_in_executor(
|
|
23
|
+
None, lambda: context.run(function, *args, **kwargs)
|
|
24
|
+
)
|
|
25
|
+
cancelled = False
|
|
26
|
+
while True:
|
|
27
|
+
try:
|
|
28
|
+
result = await asyncio.shield(worker)
|
|
29
|
+
except asyncio.CancelledError:
|
|
30
|
+
cancelled = True
|
|
31
|
+
if not worker.done():
|
|
32
|
+
continue
|
|
33
|
+
# Retrieve worker exceptions even when cancellation wins.
|
|
34
|
+
if not worker.cancelled():
|
|
35
|
+
worker.exception()
|
|
36
|
+
raise
|
|
37
|
+
except Exception:
|
|
38
|
+
if cancelled:
|
|
39
|
+
raise asyncio.CancelledError from None
|
|
40
|
+
raise
|
|
41
|
+
else:
|
|
42
|
+
if cancelled:
|
|
43
|
+
raise asyncio.CancelledError
|
|
44
|
+
return result
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Credential store protocol."""
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from contextlib import AbstractAsyncContextManager
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
4
5
|
|
|
5
6
|
from etrade_python.auth.credentials import ETradeCredentials
|
|
6
7
|
|
|
@@ -16,3 +17,17 @@ class CredentialStore(Protocol):
|
|
|
16
17
|
|
|
17
18
|
async def delete(self, profile: str) -> None:
|
|
18
19
|
"""Delete credentials for a profile."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class CoordinatedCredentialStore(CredentialStore, Protocol):
|
|
24
|
+
"""Optional transaction lock shared by all users of a credential namespace.
|
|
25
|
+
|
|
26
|
+
SessionManager holds this lock across complete lifecycle operations. Store
|
|
27
|
+
methods must not reacquire it. Direct store users must explicitly acquire it
|
|
28
|
+
around read/modify/write sequences. Locks need not be reentrant.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def lock(self, namespace: str) -> AbstractAsyncContextManager[None]:
|
|
32
|
+
"""Exclusively coordinate a namespace, releasing ownership on exit."""
|
|
33
|
+
...
|
|
@@ -1,24 +1,100 @@
|
|
|
1
1
|
"""Keyring-backed credential store."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import hashlib
|
|
4
5
|
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import AsyncGenerator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import cast
|
|
5
11
|
|
|
6
12
|
import keyring
|
|
13
|
+
from filelock import BaseFileLock, FileLock, SoftFileLock, Timeout
|
|
7
14
|
from keyring.errors import PasswordDeleteError
|
|
15
|
+
from platformdirs import user_cache_path
|
|
8
16
|
from pydantic import ValidationError
|
|
9
17
|
|
|
10
18
|
from etrade_python.auth.credentials import ETradeCredentials
|
|
11
|
-
from etrade_python.
|
|
19
|
+
from etrade_python.auth.stores._workers import run_worker
|
|
20
|
+
from etrade_python.exceptions import CredentialCoordinationError, ETradeValidationError
|
|
12
21
|
|
|
13
22
|
|
|
14
23
|
class KeyringCredentialStore:
|
|
15
24
|
"""Store credentials in the user's configured system keyring."""
|
|
16
25
|
|
|
17
|
-
def __init__(
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
service_name: str = "etrade-python",
|
|
30
|
+
lock_directory: Path | None = None,
|
|
31
|
+
lock_timeout_seconds: float = 60.0,
|
|
32
|
+
) -> None:
|
|
33
|
+
if (
|
|
34
|
+
isinstance(lock_timeout_seconds, bool)
|
|
35
|
+
or not math.isfinite(lock_timeout_seconds)
|
|
36
|
+
or lock_timeout_seconds < 0
|
|
37
|
+
):
|
|
38
|
+
raise ETradeValidationError("Credential lock timeout must be finite and nonnegative")
|
|
18
39
|
self._service_name = service_name
|
|
40
|
+
self._lock_directory = (
|
|
41
|
+
lock_directory
|
|
42
|
+
if lock_directory is not None
|
|
43
|
+
else user_cache_path("etrade-python") / "credential-locks"
|
|
44
|
+
)
|
|
45
|
+
self._lock_timeout_seconds = lock_timeout_seconds
|
|
46
|
+
|
|
47
|
+
@asynccontextmanager
|
|
48
|
+
async def lock(self, namespace: str) -> AsyncGenerator[None]:
|
|
49
|
+
"""Hold an OS-backed namespace lock; no credential values are written to disk."""
|
|
50
|
+
digest = hashlib.sha256(
|
|
51
|
+
json.dumps([self._service_name, namespace]).encode("utf-8")
|
|
52
|
+
).hexdigest()
|
|
53
|
+
# FileLock's runtime alias may select a soft lock on unsupported systems.
|
|
54
|
+
lock_class = cast(type[BaseFileLock], FileLock)
|
|
55
|
+
if issubclass(lock_class, SoftFileLock):
|
|
56
|
+
raise CredentialCoordinationError("OS credential locks are unavailable")
|
|
57
|
+
lock = lock_class(
|
|
58
|
+
self._lock_directory / f"{digest}.lock",
|
|
59
|
+
thread_local=False,
|
|
60
|
+
mode=0o600,
|
|
61
|
+
fallback_to_soft=False,
|
|
62
|
+
preserve_lock_file=True,
|
|
63
|
+
)
|
|
64
|
+
deadline = asyncio.get_running_loop().time() + self._lock_timeout_seconds
|
|
65
|
+
try:
|
|
66
|
+
try:
|
|
67
|
+
await run_worker(
|
|
68
|
+
self._lock_directory.mkdir, mode=0o700, parents=True, exist_ok=True
|
|
69
|
+
)
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
await run_worker(lock.acquire, timeout=0)
|
|
73
|
+
break
|
|
74
|
+
except Timeout:
|
|
75
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
76
|
+
if remaining <= 0:
|
|
77
|
+
raise CredentialCoordinationError(
|
|
78
|
+
"Timed out coordinating credential access"
|
|
79
|
+
) from None
|
|
80
|
+
await asyncio.sleep(min(0.05, remaining))
|
|
81
|
+
except (OSError, NotImplementedError):
|
|
82
|
+
raise CredentialCoordinationError(
|
|
83
|
+
"Could not coordinate credential access"
|
|
84
|
+
) from None
|
|
85
|
+
yield
|
|
86
|
+
finally:
|
|
87
|
+
# Acquisition may finish in a worker after its caller is cancelled.
|
|
88
|
+
if lock.is_locked:
|
|
89
|
+
try:
|
|
90
|
+
await run_worker(lock.release)
|
|
91
|
+
except OSError:
|
|
92
|
+
raise CredentialCoordinationError(
|
|
93
|
+
"Could not release credential coordination lock"
|
|
94
|
+
) from None
|
|
19
95
|
|
|
20
96
|
async def load(self, profile: str) -> ETradeCredentials | None:
|
|
21
|
-
raw = await
|
|
97
|
+
raw = await run_worker(keyring.get_password, self._service_name, profile)
|
|
22
98
|
if raw is None:
|
|
23
99
|
return None
|
|
24
100
|
try:
|
|
@@ -40,10 +116,10 @@ class KeyringCredentialStore:
|
|
|
40
116
|
},
|
|
41
117
|
separators=(",", ":"),
|
|
42
118
|
)
|
|
43
|
-
await
|
|
119
|
+
await run_worker(keyring.set_password, self._service_name, profile, payload)
|
|
44
120
|
|
|
45
121
|
async def delete(self, profile: str) -> None:
|
|
46
122
|
try:
|
|
47
|
-
await
|
|
123
|
+
await run_worker(keyring.delete_password, self._service_name, profile)
|
|
48
124
|
except PasswordDeleteError:
|
|
49
125
|
return
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""In-memory credential store."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextlib import AbstractAsyncContextManager
|
|
5
|
+
|
|
3
6
|
from etrade_python.auth.credentials import ETradeCredentials
|
|
4
7
|
|
|
5
8
|
|
|
@@ -8,6 +11,11 @@ class MemoryCredentialStore:
|
|
|
8
11
|
|
|
9
12
|
def __init__(self) -> None:
|
|
10
13
|
self._credentials: dict[str, ETradeCredentials] = {}
|
|
14
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
15
|
+
|
|
16
|
+
def lock(self, namespace: str) -> AbstractAsyncContextManager[None]:
|
|
17
|
+
"""Coordinate managers sharing this store instance and event loop."""
|
|
18
|
+
return self._locks.setdefault(namespace, asyncio.Lock())
|
|
11
19
|
|
|
12
20
|
async def load(self, profile: str) -> ETradeCredentials | None:
|
|
13
21
|
return self._credentials.get(profile)
|
etrade_python/cli/app.py
CHANGED
|
@@ -142,10 +142,7 @@ def revoke(profile: str = typer.Option("default", "--profile", "-p")) -> None:
|
|
|
142
142
|
|
|
143
143
|
async def command() -> None:
|
|
144
144
|
async with ETradeClient(_settings(), profile=profile) as client:
|
|
145
|
-
|
|
146
|
-
if credentials is not None:
|
|
147
|
-
await client.oauth.revoke_access_token(credentials)
|
|
148
|
-
await client.session.delete()
|
|
145
|
+
await client.session.revoke()
|
|
149
146
|
typer.echo(f"Credentials removed for profile '{profile}'.")
|
|
150
147
|
|
|
151
148
|
_handle(command())
|
etrade_python/exceptions.py
CHANGED
|
@@ -9,6 +9,10 @@ class ETradeAuthenticationError(ETradeError):
|
|
|
9
9
|
"""Authentication failed or is unavailable."""
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class CredentialCoordinationError(ETradeAuthenticationError):
|
|
13
|
+
"""Credential transaction ownership could not be obtained or released."""
|
|
14
|
+
|
|
15
|
+
|
|
12
16
|
class AuthenticationRequired(ETradeAuthenticationError):
|
|
13
17
|
"""Human authorization or an authentication provider is required."""
|
|
14
18
|
|
etrade_python/transport/http.py
CHANGED
|
@@ -17,6 +17,7 @@ from etrade_python.config import ETradeSettings
|
|
|
17
17
|
from etrade_python.exceptions import (
|
|
18
18
|
AuthenticationRequired,
|
|
19
19
|
AuthorizationExpired,
|
|
20
|
+
CredentialCoordinationError,
|
|
20
21
|
ETradeAuthenticationError,
|
|
21
22
|
ETradeTransportError,
|
|
22
23
|
ETradeValidationError,
|
|
@@ -145,6 +146,8 @@ class ApiTransport:
|
|
|
145
146
|
raise AuthenticationRequired("Authorization is required") from None
|
|
146
147
|
except AuthorizationExpired:
|
|
147
148
|
raise AuthorizationExpired("Authorization has expired") from None
|
|
149
|
+
except CredentialCoordinationError:
|
|
150
|
+
raise
|
|
148
151
|
except Exception:
|
|
149
152
|
raise ETradeAuthenticationError("Request authentication failed") from None
|
|
150
153
|
if (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: etrade-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: An unofficial, typed, async-first E*TRADE API client
|
|
5
5
|
Project-URL: Repository, https://github.com/kevinrider/etrade-python
|
|
6
6
|
Project-URL: Issues, https://github.com/kevinrider/etrade-python/issues
|
|
@@ -17,8 +17,10 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
19
|
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: filelock<4,>=3.32.7
|
|
20
21
|
Requires-Dist: httpx<1,>=0.28
|
|
21
22
|
Requires-Dist: keyring<26,>=25
|
|
23
|
+
Requires-Dist: platformdirs<5,>=4
|
|
22
24
|
Requires-Dist: pydantic-settings<3,>=2.7
|
|
23
25
|
Requires-Dist: pydantic<3,>=2.10
|
|
24
26
|
Requires-Dist: typer<1,>=0.20
|
|
@@ -32,7 +34,16 @@ Requires-Dist: ruff<1,>=0.11; extra == 'dev'
|
|
|
32
34
|
Requires-Dist: twine<7,>=6; extra == 'dev'
|
|
33
35
|
Description-Content-Type: text/markdown
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
<div align="center">
|
|
38
|
+
<img src="https://repository-images.githubusercontent.com/1391295794/76df490d-7ec2-48f4-87e8-d4945d37f722" alt="E*TRADE and Python logos above the etrade-python title" width="640">
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div align="center">
|
|
42
|
+
<a href="https://github.com/kevinrider/etrade-python/releases"><img src="https://img.shields.io/github/release/kevinrider/etrade-python.svg?style=flat-square" alt="Latest Version"></a>
|
|
43
|
+
<a href="https://github.com/kevinrider/etrade-python/actions/workflows/ci.yml"><img src="https://github.com/kevinrider/etrade-python/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
44
|
+
<a href="https://github.com/kevinrider/etrade-python/issues"><img src="https://img.shields.io/github/issues/kevinrider/etrade-python" alt="GitHub issues"></a>
|
|
45
|
+
<a href="https://github.com/kevinrider/etrade-python/blob/main/LICENSE"><img src="https://img.shields.io/github/license/kevinrider/etrade-python" alt="License"></a>
|
|
46
|
+
</div>
|
|
36
47
|
|
|
37
48
|
An unofficial, typed, async-first Python client for the E*TRADE REST API.
|
|
38
49
|
|
|
@@ -40,9 +51,6 @@ An unofficial, typed, async-first Python client for the E*TRADE REST API.
|
|
|
40
51
|
management, and shared async networking for Python applications, CLIs, background
|
|
41
52
|
workers, web services, workflow engines, and notebooks. Requires Python 3.11+.
|
|
42
53
|
|
|
43
|
-
The package is prepared for its first stable release, **1.0.0**. The PyPI
|
|
44
|
-
installation instructions below apply once the release is published.
|
|
45
|
-
|
|
46
54
|
## Features
|
|
47
55
|
|
|
48
56
|
- Namespaced Accounts, Portfolio, Transactions, Market, Orders, and Alerts services.
|
|
@@ -60,16 +68,16 @@ inspection through Pydantic.
|
|
|
60
68
|
|
|
61
69
|
## Installation
|
|
62
70
|
|
|
63
|
-
|
|
71
|
+
Install from PyPI into an activated Python 3.11+ virtual environment:
|
|
64
72
|
|
|
65
73
|
```sh
|
|
66
|
-
python -m pip install
|
|
74
|
+
python -m pip install etrade-python
|
|
67
75
|
```
|
|
68
76
|
|
|
69
|
-
|
|
77
|
+
Alternatively, install from a source checkout:
|
|
70
78
|
|
|
71
79
|
```sh
|
|
72
|
-
python -m pip install
|
|
80
|
+
python -m pip install .
|
|
73
81
|
```
|
|
74
82
|
|
|
75
83
|
The distribution name is `etrade-python`; the import package is `etrade_python`.
|
|
@@ -141,6 +149,11 @@ contacts the broker even when the token appears active locally.
|
|
|
141
149
|
|
|
142
150
|
`auth status` reports a local lifecycle estimate, not broker verification.
|
|
143
151
|
|
|
152
|
+
`await client.session.revoke()` coordinates broker revocation and local deletion.
|
|
153
|
+
The CLI's `etrade auth revoke` uses this method. Missing credentials are a no-op;
|
|
154
|
+
broker failure preserves the local credentials. Revocation cannot recall an
|
|
155
|
+
already signed or dispatched brokerage request.
|
|
156
|
+
|
|
144
157
|
## Accounts and Portfolio
|
|
145
158
|
|
|
146
159
|
After logging in with the CLI, save this as a Python script and run it with the
|
|
@@ -375,6 +388,60 @@ application-managed authorization, then `await client.session.save(credentials)`
|
|
|
375
388
|
Always close the client after use. Applications can implement the async
|
|
376
389
|
`CredentialStore` protocol for other persistence systems.
|
|
377
390
|
|
|
391
|
+
### Sharing a profile across local processes
|
|
392
|
+
|
|
393
|
+
Starting in 1.1.0, session lifecycle operations use namespace transaction locks
|
|
394
|
+
when the store implements `CoordinatedCredentialStore`. The built-in keyring store
|
|
395
|
+
coordinates cooperating SDK/CLI processes using OS-backed file locks. The keyring
|
|
396
|
+
credential format and existing environment/consumer-key/profile namespaces are
|
|
397
|
+
unchanged. Ordinary brokerage HTTP requests remain concurrent; only lifecycle
|
|
398
|
+
transactions are serialized. Automatic inactivity renewal reloads the stored
|
|
399
|
+
credentials after locking, avoiding duplicate renewal by waiting processes.
|
|
400
|
+
Explicit `session.renew()` still contacts the broker on every invocation.
|
|
401
|
+
|
|
402
|
+
Lock files contain no credentials. They use hashed keyring-service and namespace
|
|
403
|
+
identities in the per-user cache directory `etrade-python/credential-locks` selected
|
|
404
|
+
by platformdirs. Files remain after release to preserve lock identity; OS ownership
|
|
405
|
+
is released when a process exits. Do not delete lock files or clear this cache
|
|
406
|
+
while cooperating processes are running. Use a local filesystem supporting OS
|
|
407
|
+
locks, the same OS user, and the same lock directory for all cooperating processes.
|
|
408
|
+
Older clients and direct uncoordinated store access do not participate.
|
|
409
|
+
|
|
410
|
+
The keyring lock acquisition timeout defaults to 60 seconds. A timeout, unsupported
|
|
411
|
+
locking, or filesystem failure raises `CredentialCoordinationError` without
|
|
412
|
+
credential/namespace/path details. To configure storage coordination explicitly:
|
|
413
|
+
|
|
414
|
+
```python
|
|
415
|
+
from pathlib import Path
|
|
416
|
+
from etrade_python import ETradeClient, ETradeSettings, KeyringCredentialStore
|
|
417
|
+
|
|
418
|
+
store = KeyringCredentialStore(
|
|
419
|
+
lock_directory=Path("/private/local/path/credential-locks"),
|
|
420
|
+
lock_timeout_seconds=30,
|
|
421
|
+
)
|
|
422
|
+
client = ETradeClient(ETradeSettings(), credential_store=store, profile="personal")
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Every process sharing those credentials must use that same configuration. The
|
|
426
|
+
existing CLI uses the default lock directory. The directory is created on first
|
|
427
|
+
use, with private permissions on POSIX; provision custom directories with
|
|
428
|
+
appropriate access controls. No plaintext credential fallback is introduced.
|
|
429
|
+
|
|
430
|
+
`MemoryCredentialStore` coordinates managers sharing one store instance within
|
|
431
|
+
one event loop. Existing custom stores implementing only `CredentialStore` remain
|
|
432
|
+
compatible with the earlier per-manager locking guarantee. Custom stores can add
|
|
433
|
+
`lock(namespace) -> AbstractAsyncContextManager[None]` to implement the exported
|
|
434
|
+
`CoordinatedCredentialStore` protocol. Hold that lock across the complete
|
|
435
|
+
read/check/update operation; its `load`, `save`, and `delete` methods must not
|
|
436
|
+
reacquire the non-reentrant lock. Direct store users must explicitly acquire the
|
|
437
|
+
lock themselves. Distributed stores must provide their own appropriate locking.
|
|
438
|
+
|
|
439
|
+
Cancelled keyring operations finish their worker before releasing transaction
|
|
440
|
+
ownership. This can delay cancellation if the keyring backend is blocked. A process
|
|
441
|
+
crash releases OS lock ownership but does not prove the outcome of an interrupted
|
|
442
|
+
broker renewal or revocation. Recheck/re-authorize as necessary; this mechanism
|
|
443
|
+
does not journal OAuth operations or prevent duplicate trading submissions.
|
|
444
|
+
|
|
378
445
|
## Diagnostic CLI and Orders Demo
|
|
379
446
|
|
|
380
447
|
Use `etrade --help` or `etrade COMMAND --help` to inspect available commands.
|
|
@@ -455,7 +522,7 @@ uv run python -m twine check dist/*
|
|
|
455
522
|
CI checks Python 3.11–3.14, enforces at least 90% combined statement/branch coverage,
|
|
456
523
|
and verifies wheel installation outside the checkout. Push and pull-request CI
|
|
457
524
|
does not publish. A separate release workflow publishes stable GitHub releases
|
|
458
|
-
using PyPI Trusted Publishing
|
|
525
|
+
using PyPI Trusted Publishing. See
|
|
459
526
|
[CONTRIBUTING.md](CONTRIBUTING.md) for contribution and package verification instructions.
|
|
460
527
|
|
|
461
528
|
## API Coverage
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
etrade_python/__init__.py,sha256=
|
|
1
|
+
etrade_python/__init__.py,sha256=a1Yr3dxfsESf60KViI-vPlxTuEDZ87QwVyViRo9Z4Ko,6097
|
|
2
2
|
etrade_python/_dates.py,sha256=6I7QmElS3e5jipJSVkY0lrBNed1i9eCIjsMhFPicTlU,4614
|
|
3
|
-
etrade_python/_version.py,sha256=
|
|
3
|
+
etrade_python/_version.py,sha256=IPd7E5iUFG2-pfvcaBsXlVPOc6sKuVUkhpkhdQbEd0k,94
|
|
4
4
|
etrade_python/client.py,sha256=5I5frcyDDnPfIfmYRYMBnHxsQbqJh1mPufegbDHEe70,3439
|
|
5
5
|
etrade_python/config.py,sha256=JbuPHoGijKHO9eN37WIrWNZaGpxspNBcNj9wIab8oHY,1699
|
|
6
6
|
etrade_python/enums.py,sha256=ashvh8PXge21mUeGj7cJfYuQlxZp5V_vGuiIF4hCjiw,519
|
|
7
|
-
etrade_python/exceptions.py,sha256=
|
|
7
|
+
etrade_python/exceptions.py,sha256=mgU94Ip_zXl-tJnwKUb1T3NXuGfp2FLq7n50uzo-Ryg,2038
|
|
8
8
|
etrade_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
9
|
etrade_python/accounts/__init__.py,sha256=0KtBaDl7UVin-Gl-qnwKhjBaWpiKU2XsIoLakCv60JM,657
|
|
10
10
|
etrade_python/accounts/models.py,sha256=9gILGHLW6V_8TOoVtgBk-TMRblpog8aC09ma2vNNM8c,9753
|
|
@@ -12,16 +12,17 @@ etrade_python/accounts/service.py,sha256=dbs8C_qavZCtGhkPuwSkAa-bK1thwd3viYoA0r1
|
|
|
12
12
|
etrade_python/alerts/__init__.py,sha256=aZ3fwW4iKyWFFki6pq5BmSEHkD7he70KnyzI7W0bEuY,473
|
|
13
13
|
etrade_python/alerts/models.py,sha256=-hLI_0WO7wymOIWF3dWjq3royq6minN_Bx_32DM-Te8,5646
|
|
14
14
|
etrade_python/alerts/service.py,sha256=96y9KwMsyMJVipenicS0NlIQ6mgziQv9bnUuDbzcQ_I,5084
|
|
15
|
-
etrade_python/auth/__init__.py,sha256=
|
|
15
|
+
etrade_python/auth/__init__.py,sha256=ubYydeK8C5eQwW_wzQ8MuW9vH5d_EgIBdR7gzP5jUIg,816
|
|
16
16
|
etrade_python/auth/credentials.py,sha256=1yF2lr6Qj2xuBBBnWiZdn2QUIs3qOjSO4NNgC-N27Gc,2873
|
|
17
17
|
etrade_python/auth/oauth.py,sha256=kFrLH83uf-cDe8MJycKNHCaUw8dXpuAn8GJzLNTK4F0,10494
|
|
18
|
-
etrade_python/auth/session.py,sha256=
|
|
19
|
-
etrade_python/auth/stores/__init__.py,sha256=
|
|
20
|
-
etrade_python/auth/stores/
|
|
21
|
-
etrade_python/auth/stores/
|
|
22
|
-
etrade_python/auth/stores/
|
|
18
|
+
etrade_python/auth/session.py,sha256=6LqrQ6t2xEGKjCtjY8OOJYkbdS9_pg3KHFmtvmKSFGo,8304
|
|
19
|
+
etrade_python/auth/stores/__init__.py,sha256=ftUbEjP5tlsWPLPJwZy_NarveRqQ59AfeHhmMk-HR-w,395
|
|
20
|
+
etrade_python/auth/stores/_workers.py,sha256=MqJPqTLJpDeY8fAdOt--f984ZiDQrNQPCcbzcnu6Yh0,1607
|
|
21
|
+
etrade_python/auth/stores/base.py,sha256=z8GQinmFJO0lmPis3c-pgxE4Iva7hVN8ObNnEvROTFA,1226
|
|
22
|
+
etrade_python/auth/stores/keyring.py,sha256=hTOKlt-MQ2f7K9tU43BC12TsbiPUcllK_3bDfvVG_10,5079
|
|
23
|
+
etrade_python/auth/stores/memory.py,sha256=fPsgJ8JAeyRhumm2MvtbANsblhl9gEngSZ1No3aa3pU,979
|
|
23
24
|
etrade_python/cli/__init__.py,sha256=0ouz55BJFQtDyuOsMAIaI-RI0vCWqZfos6OF1lDc_js,38
|
|
24
|
-
etrade_python/cli/app.py,sha256=
|
|
25
|
+
etrade_python/cli/app.py,sha256=rYDhh0xxO0Rwa8Yv-XsnNEshPPKR4GlxK2LJXoiGnOo,64671
|
|
25
26
|
etrade_python/market/__init__.py,sha256=uEfAqe1ff4tkpbSk-YNMEhwLn-oVQUK6-_gSFkqM5iU,1296
|
|
26
27
|
etrade_python/market/models.py,sha256=Qumrafwxi5qDymdsU5_idJZHkL50YUVQPI-mlHmGc1g,32501
|
|
27
28
|
etrade_python/market/service.py,sha256=q3vQNzaVo4LG6TiIw_seexvPqJrhMI9HeNGTXZ3cph8,6683
|
|
@@ -38,12 +39,12 @@ etrade_python/transactions/models.py,sha256=u1mBMVBzPy4cvKOCsjRQaLWY2xnylWBZb1X_
|
|
|
38
39
|
etrade_python/transactions/service.py,sha256=A5k_LLAzioqgmrGR-OH228RBk21BKHUSKJBo3W7clLA,5495
|
|
39
40
|
etrade_python/transport/__init__.py,sha256=VaGqhPNrNnskVEwAgllywxW4pHE_9Kk0K8j5rf7o_T4,342
|
|
40
41
|
etrade_python/transport/auth.py,sha256=eLXwnwdD7K5URBuyf9cdV-I4o2s-EYM7PUXic5sFGXs,669
|
|
41
|
-
etrade_python/transport/http.py,sha256=
|
|
42
|
+
etrade_python/transport/http.py,sha256=jSs9U2PvCcDDIhMqsqYxTN_mb9cXGs1bjAnF3r7jVUA,10307
|
|
42
43
|
etrade_python/transport/logging.py,sha256=743RBiSJPxLVM1aacs-nGWP_bV8SvPsBfOoHXTwir28,1129
|
|
43
44
|
etrade_python/transport/response.py,sha256=BxOlKtphuwsdTEpjO-OkEL-Y-0GEaIShHtkMNNQKEPI,8905
|
|
44
45
|
etrade_python/transport/retry.py,sha256=L7KzV-AoLPrnnttfIRGzhSh3U5rpuU8a8mnB_p4v_Z0,2627
|
|
45
|
-
etrade_python-1.
|
|
46
|
-
etrade_python-1.
|
|
47
|
-
etrade_python-1.
|
|
48
|
-
etrade_python-1.
|
|
49
|
-
etrade_python-1.
|
|
46
|
+
etrade_python-1.1.0.dist-info/METADATA,sha256=guJIBSWElVQzh87e92P6flgZ0RXf-222JkW9moloJwI,26062
|
|
47
|
+
etrade_python-1.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
48
|
+
etrade_python-1.1.0.dist-info/entry_points.txt,sha256=f4i6DI09N1yZdkyEiE48FA9ErB_FcvHCZd4Pm6TKukQ,53
|
|
49
|
+
etrade_python-1.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
50
|
+
etrade_python-1.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|