etrade-python 1.0.0__tar.gz → 1.1.0__tar.gz

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.
Files changed (129) hide show
  1. {etrade_python-1.0.0 → etrade_python-1.1.0}/CHANGELOG.md +24 -1
  2. {etrade_python-1.0.0 → etrade_python-1.1.0}/CONTRIBUTING.md +8 -0
  3. {etrade_python-1.0.0 → etrade_python-1.1.0}/PKG-INFO +77 -10
  4. {etrade_python-1.0.0 → etrade_python-1.1.0}/README.md +74 -9
  5. {etrade_python-1.0.0 → etrade_python-1.1.0}/SECURITY.md +19 -5
  6. {etrade_python-1.0.0 → etrade_python-1.1.0}/pyproject.toml +3 -1
  7. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/__init__.py +4 -0
  8. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/_version.py +1 -1
  9. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/auth/__init__.py +2 -0
  10. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/auth/session.py +61 -17
  11. etrade_python-1.1.0/src/etrade_python/auth/stores/__init__.py +12 -0
  12. etrade_python-1.1.0/src/etrade_python/auth/stores/_workers.py +44 -0
  13. etrade_python-1.1.0/src/etrade_python/auth/stores/base.py +33 -0
  14. etrade_python-1.1.0/src/etrade_python/auth/stores/keyring.py +125 -0
  15. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/auth/stores/memory.py +8 -0
  16. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/cli/app.py +1 -4
  17. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/exceptions.py +4 -0
  18. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/http.py +3 -0
  19. etrade_python-1.1.0/tests/credential_worker.py +127 -0
  20. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_cli.py +19 -0
  21. etrade_python-1.1.0/tests/unit/test_credential_coordination.py +533 -0
  22. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_session.py +1 -1
  23. etrade_python-1.0.0/src/etrade_python/auth/stores/__init__.py +0 -7
  24. etrade_python-1.0.0/src/etrade_python/auth/stores/base.py +0 -18
  25. etrade_python-1.0.0/src/etrade_python/auth/stores/keyring.py +0 -49
  26. {etrade_python-1.0.0 → etrade_python-1.1.0}/.env.example +0 -0
  27. {etrade_python-1.0.0 → etrade_python-1.1.0}/.gitignore +0 -0
  28. {etrade_python-1.0.0 → etrade_python-1.1.0}/LICENSE +0 -0
  29. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/_dates.py +0 -0
  30. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/accounts/__init__.py +0 -0
  31. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/accounts/models.py +0 -0
  32. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/accounts/service.py +0 -0
  33. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/alerts/__init__.py +0 -0
  34. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/alerts/models.py +0 -0
  35. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/alerts/service.py +0 -0
  36. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/auth/credentials.py +0 -0
  37. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/auth/oauth.py +0 -0
  38. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/cli/__init__.py +0 -0
  39. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/client.py +0 -0
  40. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/config.py +0 -0
  41. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/enums.py +0 -0
  42. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/market/__init__.py +0 -0
  43. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/market/models.py +0 -0
  44. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/market/service.py +0 -0
  45. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/orders/__init__.py +0 -0
  46. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/orders/builders.py +0 -0
  47. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/orders/models.py +0 -0
  48. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/orders/service.py +0 -0
  49. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/orders/values.py +0 -0
  50. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/portfolio/__init__.py +0 -0
  51. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/portfolio/models.py +0 -0
  52. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/portfolio/service.py +0 -0
  53. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/py.typed +0 -0
  54. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transactions/__init__.py +0 -0
  55. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transactions/models.py +0 -0
  56. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transactions/service.py +0 -0
  57. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/__init__.py +0 -0
  58. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/auth.py +0 -0
  59. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/logging.py +0 -0
  60. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/response.py +0 -0
  61. {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/retry.py +0 -0
  62. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/__init__.py +0 -0
  63. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/conftest.py +0 -0
  64. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_accounts.py +0 -0
  65. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_alerts.py +0 -0
  66. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_http.py +0 -0
  67. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_market.py +0 -0
  68. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_orders.py +0 -0
  69. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_portfolio_transactions.py +0 -0
  70. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_request_id_validation.py +0 -0
  71. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_response.py +0 -0
  72. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_response_models.py +0 -0
  73. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_response_shapes.py +0 -0
  74. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_session_usage.py +0 -0
  75. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/README.md +0 -0
  76. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/documented_response_fields.json +0 -0
  77. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/error.json +0 -0
  78. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/error.xml +0 -0
  79. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/account_balance_response.json +0 -0
  80. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/account_list_response.json +0 -0
  81. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/alert_details_response.json +0 -0
  82. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/alerts_response.json +0 -0
  83. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/cancel_order_response.json +0 -0
  84. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/change_preview_order_response.json +0 -0
  85. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/delete_alerts_failed_response.json +0 -0
  86. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/delete_alerts_response.json +0 -0
  87. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/documented_model_samples.json +0 -0
  88. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/option_chain_response.json +0 -0
  89. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/option_expirations_response.json +0 -0
  90. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/orders_pages.json +0 -0
  91. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/orders_response.json +0 -0
  92. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/orders_response_full.json +0 -0
  93. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/orders_response_page2.json +0 -0
  94. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/place_change_order_response.json +0 -0
  95. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/place_order_request_equity.json +0 -0
  96. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/place_order_response_equity.json +0 -0
  97. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/place_order_response_full.json +0 -0
  98. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/portfolio_complete_response.json +0 -0
  99. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/portfolio_pages.json +0 -0
  100. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/portfolio_response.json +0 -0
  101. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/preview_order_request_equity.json +0 -0
  102. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/preview_order_response_equity.json +0 -0
  103. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/preview_order_response_full.json +0 -0
  104. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/product_lookup_response.json +0 -0
  105. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/quote_details_response.json +0 -0
  106. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/quote_response.json +0 -0
  107. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/transaction_details_response.json +0 -0
  108. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/transactions_inclusive_pages.json +0 -0
  109. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/transactions_response.json +0 -0
  110. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/transactions_response_page2.json +0 -0
  111. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/integration/.gitkeep +0 -0
  112. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/recorded/.gitkeep +0 -0
  113. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_accounts_models.py +0 -0
  114. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_alerts_models.py +0 -0
  115. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_client.py +0 -0
  116. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_config.py +0 -0
  117. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_credential_stores.py +0 -0
  118. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_dates.py +0 -0
  119. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_logging.py +0 -0
  120. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_market_models.py +0 -0
  121. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_oauth.py +0 -0
  122. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_order_builder.py +0 -0
  123. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_order_request_validation.py +0 -0
  124. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_orders_models.py +0 -0
  125. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_portfolio_models.py +0 -0
  126. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_response_model_coverage.py +0 -0
  127. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_response_unknown_fields.py +0 -0
  128. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_retry.py +0 -0
  129. {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_transactions_models.py +0 -0
@@ -1,6 +1,29 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased — planned 1.0.0
3
+ ## 1.1.0 — 2026-10-17
4
+
5
+ ### Added
6
+
7
+ - Optional public `CoordinatedCredentialStore` transaction-lock protocol and
8
+ `CredentialCoordinationError` for sanitized coordination failures.
9
+ - Cross-process OS-backed namespace locks in `KeyringCredentialStore`, with a
10
+ configurable directory and acquisition timeout, and shared-instance locks in
11
+ `MemoryCredentialStore`. Credentials and namespace formats remain compatible.
12
+ - Public `SessionManager.revoke()` coordinating broker revocation and local
13
+ deletion; the existing auth CLI now uses it.
14
+ - Offline subprocess tests using fake shared keyring storage and mocked OAuth.
15
+
16
+ ### Fixed
17
+
18
+ - Serialize complete lifecycle transactions across cooperating processes, reload
19
+ credentials under the lock, and preserve newer usage/renewal timestamps when
20
+ saving the same credentials. Late usage callbacks cannot restore deleted or
21
+ replaced credentials.
22
+ - Wait for cancelled keyring workers before releasing transaction ownership,
23
+ including cancellation of pending tasks during event-loop shutdown.
24
+ Retain credential-free lock files to prevent lock identity replacement races.
25
+
26
+ ## 1.0.0 — 2026-10-11
4
27
 
5
28
  ### Added
6
29
 
@@ -48,6 +48,14 @@ in ordinary CI.
48
48
  Test pagination, validation, credential redaction, and retry behavior when changing
49
49
  those features. Order mutations must not acquire automatic retries.
50
50
 
51
+ Credential lifecycle changes must also test independent session managers and
52
+ actual subprocess contention. Use synthetic keyring backends and HTTPX
53
+ MockTransport; subprocess tests must not access the system keyring or broker.
54
+ Cover replacement/deletion, monotonic timestamps, renewal/revocation, cancellation
55
+ during keyring workers, lock timeouts, namespace isolation, and process-exit lock
56
+ release. A lock must cover the full read/check/update transaction. Report offline
57
+ verification separately from authenticated broker validation.
58
+
51
59
  ## Package verification and releases
52
60
 
53
61
  After `make package-check`, verify installation outside the source checkout in a
@@ -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,4 +1,13 @@
1
- # etrade-python
1
+ <div align="center">
2
+ <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">
3
+ </div>
4
+
5
+ <div align="center">
6
+ <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>
7
+ <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>
8
+ <a href="https://github.com/kevinrider/etrade-python/issues"><img src="https://img.shields.io/github/issues/kevinrider/etrade-python" alt="GitHub issues"></a>
9
+ <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>
10
+ </div>
2
11
 
3
12
  An unofficial, typed, async-first Python client for the E*TRADE REST API.
4
13
 
@@ -6,9 +15,6 @@ An unofficial, typed, async-first Python client for the E*TRADE REST API.
6
15
  management, and shared async networking for Python applications, CLIs, background
7
16
  workers, web services, workflow engines, and notebooks. Requires Python 3.11+.
8
17
 
9
- The package is prepared for its first stable release, **1.0.0**. The PyPI
10
- installation instructions below apply once the release is published.
11
-
12
18
  ## Features
13
19
 
14
20
  - Namespaced Accounts, Portfolio, Transactions, Market, Orders, and Alerts services.
@@ -26,16 +32,16 @@ inspection through Pydantic.
26
32
 
27
33
  ## Installation
28
34
 
29
- From a source checkout, install into an activated Python 3.11+ virtual environment:
35
+ Install from PyPI into an activated Python 3.11+ virtual environment:
30
36
 
31
37
  ```sh
32
- python -m pip install .
38
+ python -m pip install etrade-python
33
39
  ```
34
40
 
35
- Once published on PyPI:
41
+ Alternatively, install from a source checkout:
36
42
 
37
43
  ```sh
38
- python -m pip install etrade-python
44
+ python -m pip install .
39
45
  ```
40
46
 
41
47
  The distribution name is `etrade-python`; the import package is `etrade_python`.
@@ -107,6 +113,11 @@ contacts the broker even when the token appears active locally.
107
113
 
108
114
  `auth status` reports a local lifecycle estimate, not broker verification.
109
115
 
116
+ `await client.session.revoke()` coordinates broker revocation and local deletion.
117
+ The CLI's `etrade auth revoke` uses this method. Missing credentials are a no-op;
118
+ broker failure preserves the local credentials. Revocation cannot recall an
119
+ already signed or dispatched brokerage request.
120
+
110
121
  ## Accounts and Portfolio
111
122
 
112
123
  After logging in with the CLI, save this as a Python script and run it with the
@@ -341,6 +352,60 @@ application-managed authorization, then `await client.session.save(credentials)`
341
352
  Always close the client after use. Applications can implement the async
342
353
  `CredentialStore` protocol for other persistence systems.
343
354
 
355
+ ### Sharing a profile across local processes
356
+
357
+ Starting in 1.1.0, session lifecycle operations use namespace transaction locks
358
+ when the store implements `CoordinatedCredentialStore`. The built-in keyring store
359
+ coordinates cooperating SDK/CLI processes using OS-backed file locks. The keyring
360
+ credential format and existing environment/consumer-key/profile namespaces are
361
+ unchanged. Ordinary brokerage HTTP requests remain concurrent; only lifecycle
362
+ transactions are serialized. Automatic inactivity renewal reloads the stored
363
+ credentials after locking, avoiding duplicate renewal by waiting processes.
364
+ Explicit `session.renew()` still contacts the broker on every invocation.
365
+
366
+ Lock files contain no credentials. They use hashed keyring-service and namespace
367
+ identities in the per-user cache directory `etrade-python/credential-locks` selected
368
+ by platformdirs. Files remain after release to preserve lock identity; OS ownership
369
+ is released when a process exits. Do not delete lock files or clear this cache
370
+ while cooperating processes are running. Use a local filesystem supporting OS
371
+ locks, the same OS user, and the same lock directory for all cooperating processes.
372
+ Older clients and direct uncoordinated store access do not participate.
373
+
374
+ The keyring lock acquisition timeout defaults to 60 seconds. A timeout, unsupported
375
+ locking, or filesystem failure raises `CredentialCoordinationError` without
376
+ credential/namespace/path details. To configure storage coordination explicitly:
377
+
378
+ ```python
379
+ from pathlib import Path
380
+ from etrade_python import ETradeClient, ETradeSettings, KeyringCredentialStore
381
+
382
+ store = KeyringCredentialStore(
383
+ lock_directory=Path("/private/local/path/credential-locks"),
384
+ lock_timeout_seconds=30,
385
+ )
386
+ client = ETradeClient(ETradeSettings(), credential_store=store, profile="personal")
387
+ ```
388
+
389
+ Every process sharing those credentials must use that same configuration. The
390
+ existing CLI uses the default lock directory. The directory is created on first
391
+ use, with private permissions on POSIX; provision custom directories with
392
+ appropriate access controls. No plaintext credential fallback is introduced.
393
+
394
+ `MemoryCredentialStore` coordinates managers sharing one store instance within
395
+ one event loop. Existing custom stores implementing only `CredentialStore` remain
396
+ compatible with the earlier per-manager locking guarantee. Custom stores can add
397
+ `lock(namespace) -> AbstractAsyncContextManager[None]` to implement the exported
398
+ `CoordinatedCredentialStore` protocol. Hold that lock across the complete
399
+ read/check/update operation; its `load`, `save`, and `delete` methods must not
400
+ reacquire the non-reentrant lock. Direct store users must explicitly acquire the
401
+ lock themselves. Distributed stores must provide their own appropriate locking.
402
+
403
+ Cancelled keyring operations finish their worker before releasing transaction
404
+ ownership. This can delay cancellation if the keyring backend is blocked. A process
405
+ crash releases OS lock ownership but does not prove the outcome of an interrupted
406
+ broker renewal or revocation. Recheck/re-authorize as necessary; this mechanism
407
+ does not journal OAuth operations or prevent duplicate trading submissions.
408
+
344
409
  ## Diagnostic CLI and Orders Demo
345
410
 
346
411
  Use `etrade --help` or `etrade COMMAND --help` to inspect available commands.
@@ -421,7 +486,7 @@ uv run python -m twine check dist/*
421
486
  CI checks Python 3.11–3.14, enforces at least 90% combined statement/branch coverage,
422
487
  and verifies wheel installation outside the checkout. Push and pull-request CI
423
488
  does not publish. A separate release workflow publishes stable GitHub releases
424
- using PyPI Trusted Publishing once the publisher is configured. See
489
+ using PyPI Trusted Publishing. See
425
490
  [CONTRIBUTING.md](CONTRIBUTING.md) for contribution and package verification instructions.
426
491
 
427
492
  ## API Coverage
@@ -2,8 +2,7 @@
2
2
 
3
3
  `etrade-python` implements OAuth authentication and brokerage operations, including
4
4
  order placement, changes, and cancellation. Production mutations affect real
5
- brokerage accounts. The first stable release is planned as 1.0.0; until it is
6
- published, security fixes target the development branch.
5
+ brokerage accounts. The supported release series is 1.1.x. Use the latest available patch release.
7
6
 
8
7
  ## Reporting vulnerabilities
9
8
 
@@ -31,9 +30,24 @@ A dedicated security contact has not yet been published.
31
30
  Completed HTTP 2xx exchanges do, even if later payload parsing fails. Failed or
32
31
  cancelled exchanges do not. Explicit renewal contacts the broker, and timestamps
33
32
  are persisted only after successful renewal.
34
- - Updates within a session manager are serialized and preserve newer timestamps.
35
- Late responses do not restore deleted or replaced credentials. Custom stores
36
- used across multiple processes must provide their own concurrency guarantees.
33
+ - In 1.1.0, built-in keyring session updates are coordinated across cooperating
34
+ processes and preserve newer timestamps. Late usage updates do not restore
35
+ deleted or replaced credentials. Namespace locks cover complete lifecycle
36
+ transactions, including broker renewal and revocation, rather than brokerage
37
+ HTTP calls. Existing custom stores remain compatible but must implement the
38
+ optional `CoordinatedCredentialStore` contract for shared-store coordination.
39
+ Old clients and direct store writes bypass these guarantees.
40
+ - Lock files contain no credentials and use hashed namespace/service identities.
41
+ All cooperating processes must use the same per-user lock directory on a local
42
+ filesystem supporting OS locks. Private directories and files are used; existing
43
+ custom directory permissions are the caller's responsibility. Do not delete
44
+ lock files or clear the directory while clients are running. No soft-lock or
45
+ unlocked fallback is allowed. OS lock ownership is released on process exit.
46
+ - Keyring worker cancellation is deferred until the worker completes so a late
47
+ write cannot run after transaction ownership is released. A blocked keyring can
48
+ delay cancellation. A process crash or lost OAuth response still leaves broker
49
+ outcomes uncertain; locks do not provide crash-atomic broker/keyring changes.
50
+ Revocation cannot recall already signed or dispatched requests.
37
51
  If usage persistence fails after a successful API exchange, the library logs a
38
52
  sanitized warning and preserves the broker response without retrying it.
39
53
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "etrade-python"
7
- version = "1.0.0"
7
+ version = "1.1.0"
8
8
  description = "An unofficial, typed, async-first E*TRADE API client"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -22,6 +22,8 @@ classifiers = [
22
22
  "Typing :: Typed",
23
23
  ]
24
24
  dependencies = [
25
+ "filelock>=3.32.7,<4",
26
+ "platformdirs>=4,<5",
25
27
  "httpx>=0.28,<1",
26
28
  "keyring>=25,<26",
27
29
  "pydantic>=2.10,<3",
@@ -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",
@@ -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
 
@@ -0,0 +1,12 @@
1
+ """Credential store implementations."""
2
+
3
+ from etrade_python.auth.stores.base import CoordinatedCredentialStore, CredentialStore
4
+ from etrade_python.auth.stores.keyring import KeyringCredentialStore
5
+ from etrade_python.auth.stores.memory import MemoryCredentialStore
6
+
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
@@ -0,0 +1,33 @@
1
+ """Credential store protocol."""
2
+
3
+ from contextlib import AbstractAsyncContextManager
4
+ from typing import Protocol, runtime_checkable
5
+
6
+ from etrade_python.auth.credentials import ETradeCredentials
7
+
8
+
9
+ class CredentialStore(Protocol):
10
+ """Async persistence boundary for OAuth credentials."""
11
+
12
+ async def load(self, profile: str) -> ETradeCredentials | None:
13
+ """Load credentials for a profile, returning None when absent."""
14
+
15
+ async def save(self, profile: str, credentials: ETradeCredentials) -> None:
16
+ """Persist credentials for a profile."""
17
+
18
+ async def delete(self, profile: str) -> None:
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
+ ...