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.
- {etrade_python-1.0.0 → etrade_python-1.1.0}/CHANGELOG.md +24 -1
- {etrade_python-1.0.0 → etrade_python-1.1.0}/CONTRIBUTING.md +8 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/PKG-INFO +77 -10
- {etrade_python-1.0.0 → etrade_python-1.1.0}/README.md +74 -9
- {etrade_python-1.0.0 → etrade_python-1.1.0}/SECURITY.md +19 -5
- {etrade_python-1.0.0 → etrade_python-1.1.0}/pyproject.toml +3 -1
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/__init__.py +4 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/_version.py +1 -1
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/auth/__init__.py +2 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/auth/session.py +61 -17
- etrade_python-1.1.0/src/etrade_python/auth/stores/__init__.py +12 -0
- etrade_python-1.1.0/src/etrade_python/auth/stores/_workers.py +44 -0
- etrade_python-1.1.0/src/etrade_python/auth/stores/base.py +33 -0
- etrade_python-1.1.0/src/etrade_python/auth/stores/keyring.py +125 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/auth/stores/memory.py +8 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/cli/app.py +1 -4
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/exceptions.py +4 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/http.py +3 -0
- etrade_python-1.1.0/tests/credential_worker.py +127 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_cli.py +19 -0
- etrade_python-1.1.0/tests/unit/test_credential_coordination.py +533 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_session.py +1 -1
- etrade_python-1.0.0/src/etrade_python/auth/stores/__init__.py +0 -7
- etrade_python-1.0.0/src/etrade_python/auth/stores/base.py +0 -18
- etrade_python-1.0.0/src/etrade_python/auth/stores/keyring.py +0 -49
- {etrade_python-1.0.0 → etrade_python-1.1.0}/.env.example +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/.gitignore +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/LICENSE +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/_dates.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/accounts/__init__.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/accounts/models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/accounts/service.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/alerts/__init__.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/alerts/models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/alerts/service.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/auth/credentials.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/auth/oauth.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/cli/__init__.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/client.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/config.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/enums.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/market/__init__.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/market/models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/market/service.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/orders/__init__.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/orders/builders.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/orders/models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/orders/service.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/orders/values.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/portfolio/__init__.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/portfolio/models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/portfolio/service.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/py.typed +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transactions/__init__.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transactions/models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transactions/service.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/__init__.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/auth.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/logging.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/response.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/src/etrade_python/transport/retry.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/__init__.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/conftest.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_accounts.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_alerts.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_http.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_market.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_orders.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_portfolio_transactions.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_request_id_validation.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_response.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_response_models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_response_shapes.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/contract/test_session_usage.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/README.md +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/documented_response_fields.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/error.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/error.xml +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/account_balance_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/account_list_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/alert_details_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/alerts_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/cancel_order_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/change_preview_order_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/delete_alerts_failed_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/delete_alerts_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/documented_model_samples.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/option_chain_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/option_expirations_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/orders_pages.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/orders_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/orders_response_full.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/orders_response_page2.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/place_change_order_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/place_order_request_equity.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/place_order_response_equity.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/place_order_response_full.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/portfolio_complete_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/portfolio_pages.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/portfolio_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/preview_order_request_equity.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/preview_order_response_equity.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/preview_order_response_full.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/product_lookup_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/quote_details_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/quote_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/transaction_details_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/transactions_inclusive_pages.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/transactions_response.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/fixtures/responses/transactions_response_page2.json +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/integration/.gitkeep +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/recorded/.gitkeep +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_accounts_models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_alerts_models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_client.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_config.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_credential_stores.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_dates.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_logging.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_market_models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_oauth.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_order_builder.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_order_request_validation.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_orders_models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_portfolio_models.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_response_model_coverage.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_response_unknown_fields.py +0 -0
- {etrade_python-1.0.0 → etrade_python-1.1.0}/tests/unit/test_retry.py +0 -0
- {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
|
-
##
|
|
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.
|
|
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,4 +1,13 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
Alternatively, install from a source checkout:
|
|
36
42
|
|
|
37
43
|
```sh
|
|
38
|
-
python -m pip install
|
|
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
|
|
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
|
|
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
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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",
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
+
...
|