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 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
@@ -1,3 +1,3 @@
1
1
  """Runtime version shared by the public package and HTTP transport."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.0"
@@ -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",
@@ -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
- async def ensure_active(self) -> ETradeCredentials:
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.load()
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._renewal_lock:
69
- credentials = await self.load()
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._renewal_lock:
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
- return await self._credential_store.load(self._storage_key)
118
+ async with self._transaction():
119
+ return await self._load()
92
120
 
93
121
  async def delete(self) -> None:
94
- async with self._renewal_lock:
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._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
- ):
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__ = ["CredentialStore", "KeyringCredentialStore", "MemoryCredentialStore"]
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 typing import Protocol
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.exceptions import ETradeValidationError
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__(self, *, service_name: str = "etrade-python") -> None:
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 asyncio.to_thread(keyring.get_password, self._service_name, profile)
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 asyncio.to_thread(keyring.set_password, self._service_name, profile, payload)
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 asyncio.to_thread(keyring.delete_password, self._service_name, profile)
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
- credentials = await client.session.load()
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())
@@ -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
 
@@ -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.0.0
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
- # etrade-python
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
- From a source checkout, install into an activated Python 3.11+ virtual environment:
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
- Once published on PyPI:
77
+ Alternatively, install from a source checkout:
70
78
 
71
79
  ```sh
72
- python -m pip install etrade-python
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 once the publisher is configured. See
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=YeTxdrXY3kz_W_njtE5_z4td7wCpUVFavZA2kZyxdsY,5963
1
+ etrade_python/__init__.py,sha256=a1Yr3dxfsESf60KViI-vPlxTuEDZ87QwVyViRo9Z4Ko,6097
2
2
  etrade_python/_dates.py,sha256=6I7QmElS3e5jipJSVkY0lrBNed1i9eCIjsMhFPicTlU,4614
3
- etrade_python/_version.py,sha256=wVzvdZEqZCvsp3GrIZI-Oh8FDnb35FkUIcgt3r4K1U4,94
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=2qIZExdQj1VlGCEzUrLCGD6kTwM5Yb02khMiFJiwyg4,1896
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=vtXjjZw8rAP2MBLhBj0csw422DLMUVo-k8YqK3TPiNU,750
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=2WdfWkHt6UGgKO6Jn4teDkXwTQJ4fp2dK_kC5258oA8,6226
19
- etrade_python/auth/stores/__init__.py,sha256=d2cCDnDxeXsBOqmLa0A1lDaQWr_GEzXrTlLqDuVYvmM,318
20
- etrade_python/auth/stores/base.py,sha256=XXbWAoYUf-MumiPybpAd-jv_8pMZMYxPRlu-FwdQctA,591
21
- etrade_python/auth/stores/keyring.py,sha256=QWEgEKg-41fbTYMu9RtrdsEFmgO9jAxkMRMCb12LfXI,1909
22
- etrade_python/auth/stores/memory.py,sha256=lnzjQe-rJgJIL4t-6_QHSizVhFJQv1DFilC8EVKNO3g,645
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=Hxs1iDI4hMgC6hWyEplgYdKulB1tLpHG_NUfbvpdag0,64833
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=O56r0q589CE-AoHlOJcykrzq9cvfxtVhg5Ri7R4nwV8,10204
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.0.0.dist-info/METADATA,sha256=U3dh0vFjg9wN05QACd1P8nQyMIo4_2OipsYEJzgkyL4,21795
46
- etrade_python-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
47
- etrade_python-1.0.0.dist-info/entry_points.txt,sha256=f4i6DI09N1yZdkyEiE48FA9ErB_FcvHCZd4Pm6TKukQ,53
48
- etrade_python-1.0.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
49
- etrade_python-1.0.0.dist-info/RECORD,,
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,,