etrade-python 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- etrade_python/__init__.py +264 -0
- etrade_python/_dates.py +152 -0
- etrade_python/_version.py +3 -0
- etrade_python/accounts/__init__.py +31 -0
- etrade_python/accounts/models.py +204 -0
- etrade_python/accounts/service.py +136 -0
- etrade_python/alerts/__init__.py +23 -0
- etrade_python/alerts/models.py +180 -0
- etrade_python/alerts/service.py +128 -0
- etrade_python/auth/__init__.py +31 -0
- etrade_python/auth/credentials.py +85 -0
- etrade_python/auth/oauth.py +268 -0
- etrade_python/auth/session.py +154 -0
- etrade_python/auth/stores/__init__.py +7 -0
- etrade_python/auth/stores/base.py +18 -0
- etrade_python/auth/stores/keyring.py +49 -0
- etrade_python/auth/stores/memory.py +19 -0
- etrade_python/cli/__init__.py +1 -0
- etrade_python/cli/app.py +1623 -0
- etrade_python/client.py +84 -0
- etrade_python/config.py +43 -0
- etrade_python/enums.py +19 -0
- etrade_python/exceptions.py +68 -0
- etrade_python/market/__init__.py +59 -0
- etrade_python/market/models.py +811 -0
- etrade_python/market/service.py +168 -0
- etrade_python/orders/__init__.py +107 -0
- etrade_python/orders/builders.py +617 -0
- etrade_python/orders/models.py +1009 -0
- etrade_python/orders/service.py +259 -0
- etrade_python/orders/values.py +67 -0
- etrade_python/portfolio/__init__.py +35 -0
- etrade_python/portfolio/models.py +429 -0
- etrade_python/portfolio/service.py +99 -0
- etrade_python/py.typed +0 -0
- etrade_python/transactions/__init__.py +27 -0
- etrade_python/transactions/models.py +201 -0
- etrade_python/transactions/service.py +138 -0
- etrade_python/transport/__init__.py +7 -0
- etrade_python/transport/auth.py +17 -0
- etrade_python/transport/http.py +240 -0
- etrade_python/transport/logging.py +38 -0
- etrade_python/transport/response.py +243 -0
- etrade_python/transport/retry.py +75 -0
- etrade_python-1.0.0.dist-info/METADATA +513 -0
- etrade_python-1.0.0.dist-info/RECORD +49 -0
- etrade_python-1.0.0.dist-info/WHEEL +4 -0
- etrade_python-1.0.0.dist-info/entry_points.txt +2 -0
- etrade_python-1.0.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Typed transaction request and response models."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
from etrade_python._dates import parse_broker_datetime
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BrokerModel(BaseModel):
|
|
13
|
+
"""Base response model that discards unmodeled broker fields."""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(extra="ignore", frozen=True, populate_by_name=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TransactionsRequest(BaseModel):
|
|
19
|
+
"""Query parameters for list transactions."""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(frozen=True)
|
|
22
|
+
|
|
23
|
+
marker: str | None = None
|
|
24
|
+
count: int | None = Field(default=None, ge=1, le=50)
|
|
25
|
+
start_date: str | None = None
|
|
26
|
+
end_date: str | None = None
|
|
27
|
+
sort_order: str | None = "DESC"
|
|
28
|
+
|
|
29
|
+
@field_validator("marker", "start_date", "end_date", "sort_order")
|
|
30
|
+
@classmethod
|
|
31
|
+
def nonempty_string(cls, value: str | None) -> str | None:
|
|
32
|
+
if value is not None and not value.strip():
|
|
33
|
+
raise ValueError("A nonempty value is required")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="after")
|
|
37
|
+
def validate_date_range(self) -> "TransactionsRequest":
|
|
38
|
+
if (self.start_date is None) != (self.end_date is None):
|
|
39
|
+
raise ValueError("start_date and end_date must be supplied together")
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def query_params(self) -> dict[str, str | int | None]:
|
|
43
|
+
return {
|
|
44
|
+
"marker": self.marker,
|
|
45
|
+
"count": self.count,
|
|
46
|
+
"startDate": self.start_date,
|
|
47
|
+
"endDate": self.end_date,
|
|
48
|
+
"sortOrder": self.sort_order,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TransactionDetailsRequest(BaseModel):
|
|
53
|
+
"""Query parameters for transaction detail lookup."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(frozen=True)
|
|
56
|
+
|
|
57
|
+
store_id: str | None = None
|
|
58
|
+
|
|
59
|
+
@field_validator("store_id")
|
|
60
|
+
@classmethod
|
|
61
|
+
def nonempty_string(cls, value: str | None) -> str | None:
|
|
62
|
+
if value is not None and not value.strip():
|
|
63
|
+
raise ValueError("A nonempty value is required")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
def query_params(self) -> dict[str, str | None]:
|
|
67
|
+
return {"storeId": self.store_id}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ProductId(BrokerModel):
|
|
71
|
+
symbol: str | None = None
|
|
72
|
+
type_code: str | None = Field(default=None, alias="typeCode")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TransactionProduct(BrokerModel):
|
|
76
|
+
symbol: str | None = None
|
|
77
|
+
security_type: str | None = Field(default=None, alias="securityType")
|
|
78
|
+
security_sub_type: str | None = Field(default=None, alias="securitySubType")
|
|
79
|
+
call_put: str | None = Field(default=None, alias="callPut")
|
|
80
|
+
expiry_year: int | None = Field(default=None, alias="expiryYear")
|
|
81
|
+
expiry_month: int | None = Field(default=None, alias="expiryMonth")
|
|
82
|
+
expiry_day: int | None = Field(default=None, alias="expiryDay")
|
|
83
|
+
strike_price: Decimal | None = Field(default=None, alias="strikePrice")
|
|
84
|
+
expiry_type: str | None = Field(default=None, alias="expiryType")
|
|
85
|
+
product_id: ProductId | None = Field(default=None, alias="productId")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TransactionCategory(BrokerModel):
|
|
89
|
+
category_id: int | str | None = Field(default=None, alias="categoryId")
|
|
90
|
+
parent_id: int | str | None = Field(default=None, alias="parentId")
|
|
91
|
+
category_name: str | None = Field(default=None, alias="categoryName")
|
|
92
|
+
parent_name: str | None = Field(default=None, alias="parentName")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TransactionBrokerage(BrokerModel):
|
|
96
|
+
transaction_type: str | None = Field(default=None, alias="transactionType")
|
|
97
|
+
product: TransactionProduct | None = None
|
|
98
|
+
quantity: Decimal | None = None
|
|
99
|
+
price: Decimal | None = None
|
|
100
|
+
settlement_currency: str | None = Field(default=None, alias="settlementCurrency")
|
|
101
|
+
payment_currency: str | None = Field(default=None, alias="paymentCurrency")
|
|
102
|
+
fee: Decimal | None = None
|
|
103
|
+
memo: str | None = None
|
|
104
|
+
check_no: int | str | None = Field(default=None, alias="checkNo")
|
|
105
|
+
order_no: int | str | None = Field(default=None, alias="orderNo")
|
|
106
|
+
|
|
107
|
+
@model_validator(mode="before")
|
|
108
|
+
@classmethod
|
|
109
|
+
def normalize_product(cls, value: object) -> object:
|
|
110
|
+
if not isinstance(value, dict):
|
|
111
|
+
return value
|
|
112
|
+
data = dict(cast(dict[str, Any], value))
|
|
113
|
+
if "Product" in data and "product" not in data:
|
|
114
|
+
data["product"] = data["Product"]
|
|
115
|
+
data.pop("Product", None)
|
|
116
|
+
if data.get("product") == {}:
|
|
117
|
+
data["product"] = None
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Transaction(BrokerModel):
|
|
122
|
+
transaction_id: int | str | None = Field(default=None, alias="transactionId")
|
|
123
|
+
account_id: int | str | None = Field(default=None, alias="accountId")
|
|
124
|
+
transaction_date: datetime | None = Field(default=None, alias="transactionDate")
|
|
125
|
+
post_date: datetime | None = Field(default=None, alias="postDate")
|
|
126
|
+
amount: Decimal | None = None
|
|
127
|
+
description: str | None = None
|
|
128
|
+
transaction_type: str | None = Field(default=None, alias="transactionType")
|
|
129
|
+
inst_type: str | None = Field(default=None, alias="instType")
|
|
130
|
+
store_id: int | str | None = Field(default=None, alias="storeId")
|
|
131
|
+
category: TransactionCategory | None = None
|
|
132
|
+
brokerage: TransactionBrokerage | None = None
|
|
133
|
+
|
|
134
|
+
@field_validator("transaction_date", "post_date", mode="before")
|
|
135
|
+
@classmethod
|
|
136
|
+
def parse_transaction_dates(cls, value: object) -> datetime | None:
|
|
137
|
+
return parse_broker_datetime(value)
|
|
138
|
+
|
|
139
|
+
@model_validator(mode="before")
|
|
140
|
+
@classmethod
|
|
141
|
+
def normalize_nested_aliases(cls, value: object) -> object:
|
|
142
|
+
if not isinstance(value, dict):
|
|
143
|
+
return value
|
|
144
|
+
data = dict(cast(dict[str, Any], value))
|
|
145
|
+
for source, target in {"Category": "category", "Brokerage": "brokerage"}.items():
|
|
146
|
+
if source in data and target not in data:
|
|
147
|
+
data[target] = data[source]
|
|
148
|
+
data.pop(source, None)
|
|
149
|
+
if data.get(target) == {}:
|
|
150
|
+
data[target] = None
|
|
151
|
+
return data
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _empty_transactions() -> list[Transaction]:
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TransactionsResponse(BrokerModel):
|
|
159
|
+
transactions: list[Transaction] = Field(
|
|
160
|
+
default_factory=_empty_transactions, alias="transaction"
|
|
161
|
+
)
|
|
162
|
+
marker: str | None = None
|
|
163
|
+
next: str | None = None
|
|
164
|
+
page_markers: str | None = Field(default=None, alias="pageMarkers")
|
|
165
|
+
more_transactions: bool | str | None = Field(default=None, alias="moreTransactions")
|
|
166
|
+
transaction_count: int | str | None = Field(default=None, alias="transactionCount")
|
|
167
|
+
total_count: int | str | None = Field(default=None, alias="totalCount")
|
|
168
|
+
|
|
169
|
+
@model_validator(mode="before")
|
|
170
|
+
@classmethod
|
|
171
|
+
def normalize_response(cls, value: object) -> object:
|
|
172
|
+
if not isinstance(value, dict):
|
|
173
|
+
return value
|
|
174
|
+
data = dict(cast(dict[str, Any], value))
|
|
175
|
+
if "Transaction" in data and "transaction" not in data:
|
|
176
|
+
data["transaction"] = data["Transaction"]
|
|
177
|
+
data.pop("Transaction", None)
|
|
178
|
+
return data
|
|
179
|
+
|
|
180
|
+
@field_validator("transactions", mode="before")
|
|
181
|
+
@classmethod
|
|
182
|
+
def normalize_transactions(cls, value: object) -> object:
|
|
183
|
+
if value is None:
|
|
184
|
+
return []
|
|
185
|
+
if isinstance(value, dict):
|
|
186
|
+
return [cast(dict[str, Any], value)]
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TransactionDetailsResponse(BrokerModel):
|
|
191
|
+
transaction: Transaction
|
|
192
|
+
|
|
193
|
+
@model_validator(mode="before")
|
|
194
|
+
@classmethod
|
|
195
|
+
def normalize_response(cls, value: object) -> object:
|
|
196
|
+
if not isinstance(value, dict):
|
|
197
|
+
return value
|
|
198
|
+
data = cast(dict[str, Any], value)
|
|
199
|
+
if "transaction" in data:
|
|
200
|
+
return data
|
|
201
|
+
return {"transaction": data}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Transactions API service."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from etrade_python.exceptions import ETradeResponseError, ETradeValidationError
|
|
9
|
+
from etrade_python.transactions.models import (
|
|
10
|
+
Transaction,
|
|
11
|
+
TransactionDetailsRequest,
|
|
12
|
+
TransactionDetailsResponse,
|
|
13
|
+
TransactionsRequest,
|
|
14
|
+
TransactionsResponse,
|
|
15
|
+
)
|
|
16
|
+
from etrade_python.transport.http import ApiTransport
|
|
17
|
+
from etrade_python.transport.response import (
|
|
18
|
+
JsonValue,
|
|
19
|
+
raise_response_validation_error,
|
|
20
|
+
validate_response_shape,
|
|
21
|
+
)
|
|
22
|
+
from etrade_python.transport.retry import RetrySafety
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransactionsService:
|
|
26
|
+
"""Access E*TRADE transaction endpoints."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, transport: ApiTransport) -> None:
|
|
29
|
+
self._transport = transport
|
|
30
|
+
|
|
31
|
+
async def list(
|
|
32
|
+
self, account_id_key: str, request: TransactionsRequest | None = None
|
|
33
|
+
) -> TransactionsResponse:
|
|
34
|
+
if not account_id_key.strip():
|
|
35
|
+
raise ETradeValidationError("account_id_key is required")
|
|
36
|
+
|
|
37
|
+
transactions_request = request or TransactionsRequest()
|
|
38
|
+
response = await self._transport.request(
|
|
39
|
+
"GET",
|
|
40
|
+
f"/v1/accounts/{account_id_key}/transactions.json",
|
|
41
|
+
params=transactions_request.query_params(),
|
|
42
|
+
safety=RetrySafety.SAFE_READ,
|
|
43
|
+
operation="transactions.list",
|
|
44
|
+
)
|
|
45
|
+
if response.status_code == 204 and response.data is None:
|
|
46
|
+
return TransactionsResponse(transaction=[])
|
|
47
|
+
data = _require_mapping(response.data)
|
|
48
|
+
transactions_data = _unwrap(data, "TransactionListResponse")
|
|
49
|
+
try:
|
|
50
|
+
return validate_response_shape(
|
|
51
|
+
TransactionsResponse.model_validate(transactions_data),
|
|
52
|
+
"transactions",
|
|
53
|
+
collection_field="transactions",
|
|
54
|
+
)
|
|
55
|
+
except ValidationError as error:
|
|
56
|
+
raise_response_validation_error("Invalid transactions response", error)
|
|
57
|
+
|
|
58
|
+
async def get(
|
|
59
|
+
self,
|
|
60
|
+
account_id_key: str,
|
|
61
|
+
transaction_id: str,
|
|
62
|
+
request: TransactionDetailsRequest | None = None,
|
|
63
|
+
) -> TransactionDetailsResponse:
|
|
64
|
+
if not account_id_key.strip():
|
|
65
|
+
raise ETradeValidationError("account_id_key is required")
|
|
66
|
+
if not transaction_id.strip():
|
|
67
|
+
raise ETradeValidationError("transaction_id is required")
|
|
68
|
+
|
|
69
|
+
details_request = request or TransactionDetailsRequest()
|
|
70
|
+
response = await self._transport.request(
|
|
71
|
+
"GET",
|
|
72
|
+
f"/v1/accounts/{account_id_key}/transactions/{transaction_id}.json",
|
|
73
|
+
params=details_request.query_params(),
|
|
74
|
+
safety=RetrySafety.SAFE_READ,
|
|
75
|
+
operation="transactions.get",
|
|
76
|
+
)
|
|
77
|
+
data = _require_mapping(response.data)
|
|
78
|
+
transaction_data = _unwrap(data, "TransactionDetailsResponse")
|
|
79
|
+
try:
|
|
80
|
+
return validate_response_shape(
|
|
81
|
+
TransactionDetailsResponse.model_validate(transaction_data), "transaction details"
|
|
82
|
+
)
|
|
83
|
+
except ValidationError as error:
|
|
84
|
+
raise_response_validation_error("Invalid transaction details response", error)
|
|
85
|
+
|
|
86
|
+
async def list_all(
|
|
87
|
+
self, account_id_key: str, request: TransactionsRequest | None = None
|
|
88
|
+
) -> AsyncIterator[Transaction]:
|
|
89
|
+
"""Iterate pages, omitting repeated boundary transactions.
|
|
90
|
+
|
|
91
|
+
Automatic pagination requires count >= 2 because an inclusive marker
|
|
92
|
+
cannot advance a one-record page. Single-page list() permits count=1.
|
|
93
|
+
"""
|
|
94
|
+
base_request = request or TransactionsRequest()
|
|
95
|
+
count = base_request.count or 50
|
|
96
|
+
if count == 1:
|
|
97
|
+
raise ETradeValidationError("Automatic transaction pagination requires count >= 2")
|
|
98
|
+
current_request = base_request
|
|
99
|
+
previous_last_id: str | None = None
|
|
100
|
+
seen_markers: set[str] = {base_request.marker} if base_request.marker else set()
|
|
101
|
+
while True:
|
|
102
|
+
page = await self.list(account_id_key, current_request)
|
|
103
|
+
marker = page.marker
|
|
104
|
+
has_next_page = bool(marker) and len(page.transactions) >= count
|
|
105
|
+
if has_next_page and marker in seen_markers:
|
|
106
|
+
raise ETradeResponseError("Transaction pagination did not advance")
|
|
107
|
+
|
|
108
|
+
transactions = page.transactions
|
|
109
|
+
if (
|
|
110
|
+
transactions
|
|
111
|
+
and previous_last_id is not None
|
|
112
|
+
and transactions[0].transaction_id is not None
|
|
113
|
+
and str(transactions[0].transaction_id) == previous_last_id
|
|
114
|
+
):
|
|
115
|
+
transactions = transactions[1:]
|
|
116
|
+
for transaction in transactions:
|
|
117
|
+
yield transaction
|
|
118
|
+
|
|
119
|
+
if not has_next_page:
|
|
120
|
+
return
|
|
121
|
+
last_id = page.transactions[-1].transaction_id
|
|
122
|
+
previous_last_id = str(last_id) if last_id is not None else None
|
|
123
|
+
if marker is not None:
|
|
124
|
+
seen_markers.add(marker)
|
|
125
|
+
current_request = base_request.model_copy(update={"marker": marker})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _require_mapping(data: JsonValue) -> dict[str, Any]:
|
|
129
|
+
if not isinstance(data, dict):
|
|
130
|
+
raise ETradeResponseError("Invalid transaction response")
|
|
131
|
+
return cast(dict[str, Any], data)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _unwrap(data: dict[str, Any], envelope: str) -> dict[str, Any]:
|
|
135
|
+
value = data.get(envelope, data)
|
|
136
|
+
if not isinstance(value, dict):
|
|
137
|
+
raise ETradeResponseError("Invalid transaction response")
|
|
138
|
+
return cast(dict[str, Any], value)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Transport extension points, usable with HTTPX MockTransport in tests."""
|
|
2
|
+
|
|
3
|
+
from etrade_python.transport.auth import RequestAuthenticator
|
|
4
|
+
from etrade_python.transport.http import ApiTransport
|
|
5
|
+
from etrade_python.transport.retry import RetryPolicy, RetrySafety
|
|
6
|
+
|
|
7
|
+
__all__ = ["ApiTransport", "RequestAuthenticator", "RetryPolicy", "RetrySafety"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Request authentication and optional confirmed-usage notification."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RequestAuthenticator(Protocol):
|
|
10
|
+
async def authenticate(self, request: httpx.Request) -> Callable[[], Awaitable[None]] | None:
|
|
11
|
+
"""Attach auth headers in place, using fresh signing state for each attempt.
|
|
12
|
+
|
|
13
|
+
Must not change the method, URL, or body. Implementations must mask their own repr
|
|
14
|
+
and must not log secrets. May return a request-local callback to record usage
|
|
15
|
+
after a completed 2xx exchange. Returning None requires no notification.
|
|
16
|
+
"""
|
|
17
|
+
...
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Shared async request pipeline. No business endpoint or OAuth logic lives here."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from typing import Literal
|
|
11
|
+
from urllib.parse import unquote
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from etrade_python._version import __version__
|
|
16
|
+
from etrade_python.config import ETradeSettings
|
|
17
|
+
from etrade_python.exceptions import (
|
|
18
|
+
AuthenticationRequired,
|
|
19
|
+
AuthorizationExpired,
|
|
20
|
+
ETradeAuthenticationError,
|
|
21
|
+
ETradeTransportError,
|
|
22
|
+
ETradeValidationError,
|
|
23
|
+
)
|
|
24
|
+
from etrade_python.transport.auth import RequestAuthenticator
|
|
25
|
+
from etrade_python.transport.logging import private_http_exchange
|
|
26
|
+
from etrade_python.transport.response import JsonValue, Redactor, TransportResponse, parse_response
|
|
27
|
+
from etrade_python.transport.retry import RetryPolicy, RetrySafety
|
|
28
|
+
|
|
29
|
+
LOGGER = logging.getLogger("etrade_python.transport")
|
|
30
|
+
HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]
|
|
31
|
+
QueryParameters = Mapping[str, str | int | bool | None]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _decimal_json(value: object) -> str:
|
|
35
|
+
if isinstance(value, Decimal) and value.is_finite():
|
|
36
|
+
return str(value)
|
|
37
|
+
raise ValueError("Unsupported JSON value")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ApiTransport:
|
|
41
|
+
"""One shared HTTP client, with per-request authentication and retry state.
|
|
42
|
+
|
|
43
|
+
Injected clients remain caller-owned. Their hooks and logging configuration
|
|
44
|
+
are also caller-owned and must not record credentials or account information.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
settings: ETradeSettings,
|
|
50
|
+
*,
|
|
51
|
+
authenticator: RequestAuthenticator | None = None,
|
|
52
|
+
http_client: httpx.AsyncClient | None = None,
|
|
53
|
+
http_transport: httpx.AsyncBaseTransport | None = None,
|
|
54
|
+
retry_policy: RetryPolicy | None = None,
|
|
55
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
56
|
+
) -> None:
|
|
57
|
+
if http_client is not None and http_transport is not None:
|
|
58
|
+
raise ETradeValidationError("Supply either http_client or http_transport, not both")
|
|
59
|
+
self._settings = settings
|
|
60
|
+
self._authenticator = authenticator
|
|
61
|
+
self._owns_client = http_client is None
|
|
62
|
+
self._client = http_client or httpx.AsyncClient(
|
|
63
|
+
transport=http_transport,
|
|
64
|
+
timeout=settings.request_timeout_seconds,
|
|
65
|
+
follow_redirects=False,
|
|
66
|
+
trust_env=False,
|
|
67
|
+
)
|
|
68
|
+
self._retry_policy = retry_policy or RetryPolicy()
|
|
69
|
+
self._sleep = sleep
|
|
70
|
+
self._closed = False
|
|
71
|
+
|
|
72
|
+
async def __aenter__(self) -> "ApiTransport":
|
|
73
|
+
if self._closed:
|
|
74
|
+
raise ETradeTransportError("Transport is closed")
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
async def __aexit__(self, *_: object) -> None:
|
|
78
|
+
await self.aclose()
|
|
79
|
+
|
|
80
|
+
async def aclose(self) -> None:
|
|
81
|
+
if not self._closed:
|
|
82
|
+
self._closed = True
|
|
83
|
+
if self._owns_client:
|
|
84
|
+
await self._client.aclose()
|
|
85
|
+
|
|
86
|
+
async def request(
|
|
87
|
+
self,
|
|
88
|
+
method: HttpMethod,
|
|
89
|
+
path: str,
|
|
90
|
+
*,
|
|
91
|
+
params: QueryParameters | None = None,
|
|
92
|
+
body: JsonValue = None,
|
|
93
|
+
safety: RetrySafety = RetrySafety.NEVER,
|
|
94
|
+
operation: str = "request",
|
|
95
|
+
) -> TransportResponse:
|
|
96
|
+
"""Request an API-relative JSON endpoint.
|
|
97
|
+
|
|
98
|
+
`operation` is a static label such as `accounts.list`, never user data.
|
|
99
|
+
`path` includes /v1 and any desired .json suffix. Query parameters must be
|
|
100
|
+
passed separately. Decimal request values serialize as JSON strings.
|
|
101
|
+
"""
|
|
102
|
+
if self._closed:
|
|
103
|
+
raise ETradeTransportError("Transport is closed")
|
|
104
|
+
self._validate_path(path)
|
|
105
|
+
if not re.fullmatch(r"[a-z_]+(?:\.[a-z_]+)*", operation):
|
|
106
|
+
raise ETradeValidationError("Invalid operation label")
|
|
107
|
+
if method not in {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"}:
|
|
108
|
+
raise ETradeValidationError("Invalid HTTP method")
|
|
109
|
+
if safety is RetrySafety.SAFE_READ and method not in {"GET", "HEAD"}:
|
|
110
|
+
raise ETradeValidationError("Only read requests may be classified as safe reads")
|
|
111
|
+
if self._authenticator is None:
|
|
112
|
+
raise AuthenticationRequired(
|
|
113
|
+
"An authenticator is required; OAuth is not implemented yet"
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
content = (
|
|
117
|
+
json.dumps(body, default=_decimal_json, allow_nan=False).encode()
|
|
118
|
+
if body is not None
|
|
119
|
+
else None
|
|
120
|
+
)
|
|
121
|
+
except (ValueError, TypeError, RecursionError):
|
|
122
|
+
raise ETradeValidationError("Invalid JSON request body") from None
|
|
123
|
+
headers = {"Accept": "application/json", "User-Agent": f"etrade-python/{__version__}"}
|
|
124
|
+
if content is not None:
|
|
125
|
+
headers["Content-Type"] = "application/json"
|
|
126
|
+
url = self._settings.environment.api_base_url + path
|
|
127
|
+
attempt = 0
|
|
128
|
+
while True:
|
|
129
|
+
attempt += 1
|
|
130
|
+
try:
|
|
131
|
+
request = self._client.build_request(
|
|
132
|
+
method,
|
|
133
|
+
url,
|
|
134
|
+
params={k: v for k, v in (params or {}).items() if v is not None},
|
|
135
|
+
content=content,
|
|
136
|
+
headers=headers,
|
|
137
|
+
timeout=self._settings.request_timeout_seconds,
|
|
138
|
+
)
|
|
139
|
+
except (httpx.HTTPError, ValueError, TypeError):
|
|
140
|
+
raise ETradeValidationError("Could not construct HTTP request") from None
|
|
141
|
+
original_url, original_content = request.url, request.content
|
|
142
|
+
try:
|
|
143
|
+
record_usage = await self._authenticator.authenticate(request)
|
|
144
|
+
except AuthenticationRequired:
|
|
145
|
+
raise AuthenticationRequired("Authorization is required") from None
|
|
146
|
+
except AuthorizationExpired:
|
|
147
|
+
raise AuthorizationExpired("Authorization has expired") from None
|
|
148
|
+
except Exception:
|
|
149
|
+
raise ETradeAuthenticationError("Request authentication failed") from None
|
|
150
|
+
if (
|
|
151
|
+
request.method != method
|
|
152
|
+
or request.url != original_url
|
|
153
|
+
or request.content != original_content
|
|
154
|
+
):
|
|
155
|
+
raise ETradeAuthenticationError(
|
|
156
|
+
"Authenticator changed the request method, URL, or body"
|
|
157
|
+
)
|
|
158
|
+
redactor = self._redactor(request)
|
|
159
|
+
started = time.monotonic()
|
|
160
|
+
try:
|
|
161
|
+
with private_http_exchange():
|
|
162
|
+
response = await self._client.send(request, follow_redirects=False, auth=None)
|
|
163
|
+
except httpx.TransportError as exc:
|
|
164
|
+
self._log(operation, attempt, started, None)
|
|
165
|
+
delay = self._retry_policy.delay(safety=safety, attempt=attempt, error=exc)
|
|
166
|
+
if delay is None:
|
|
167
|
+
raise ETradeTransportError(
|
|
168
|
+
"HTTP exchange failed; the outcome of a mutation may be unknown"
|
|
169
|
+
) from None
|
|
170
|
+
await self._sleep(delay)
|
|
171
|
+
continue
|
|
172
|
+
except httpx.HTTPError:
|
|
173
|
+
raise ETradeTransportError("HTTP exchange failed") from None
|
|
174
|
+
self._log(operation, attempt, started, response.status_code)
|
|
175
|
+
delay = self._retry_policy.delay(safety=safety, attempt=attempt, response=response)
|
|
176
|
+
if delay is not None:
|
|
177
|
+
await response.aclose()
|
|
178
|
+
await self._sleep(delay)
|
|
179
|
+
continue
|
|
180
|
+
try:
|
|
181
|
+
if 200 <= response.status_code < 300 and record_usage is not None:
|
|
182
|
+
try:
|
|
183
|
+
await record_usage()
|
|
184
|
+
except Exception:
|
|
185
|
+
# A local storage failure must not obscure a successful trade
|
|
186
|
+
# or cause callers to retry a confirmed broker exchange.
|
|
187
|
+
LOGGER.warning(
|
|
188
|
+
"Could not record authenticated request usage",
|
|
189
|
+
extra={"operation": operation},
|
|
190
|
+
)
|
|
191
|
+
return parse_response(response, redactor)
|
|
192
|
+
finally:
|
|
193
|
+
await response.aclose()
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _validate_path(path: str) -> None:
|
|
197
|
+
decoded = unquote(path)
|
|
198
|
+
if (
|
|
199
|
+
not path.startswith("/v1/")
|
|
200
|
+
or any(c.isspace() for c in path)
|
|
201
|
+
or any(c in decoded for c in ("?", "#", "\\", "%"))
|
|
202
|
+
or "//" in decoded
|
|
203
|
+
# Encoded ordinary spaces are valid in company-name lookup paths.
|
|
204
|
+
or any((c.isspace() and c != " ") or ord(c) < 32 or ord(c) == 127 for c in decoded)
|
|
205
|
+
or any(part in {".", ".."} for part in decoded.split("/"))
|
|
206
|
+
):
|
|
207
|
+
raise ETradeValidationError(
|
|
208
|
+
"Expected a relative /v1/ API path without query or traversal"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _redactor(self, request: httpx.Request) -> Redactor:
|
|
212
|
+
secrets = [
|
|
213
|
+
self._settings.consumer_key.get_secret_value(),
|
|
214
|
+
self._settings.consumer_secret.get_secret_value(),
|
|
215
|
+
]
|
|
216
|
+
authorization = request.headers.get("authorization", "")
|
|
217
|
+
secrets.append(authorization)
|
|
218
|
+
# OAuth header values may be percent encoded. Include both representations.
|
|
219
|
+
secrets.extend(re.findall(r'="([^"\r\n]*)"', authorization))
|
|
220
|
+
if authorization and not authorization.startswith("OAuth "):
|
|
221
|
+
secrets.append(authorization.split(" ", 1)[-1])
|
|
222
|
+
secrets.extend(str(value) for value in request.url.params.values())
|
|
223
|
+
segments = request.url.path.split("/")
|
|
224
|
+
if "accounts" in segments:
|
|
225
|
+
index = segments.index("accounts") + 1
|
|
226
|
+
if index < len(segments) and segments[index] != "list.json":
|
|
227
|
+
secrets.append(segments[index])
|
|
228
|
+
return Redactor(secrets)
|
|
229
|
+
|
|
230
|
+
def _log(self, operation: str, attempt: int, started: float, status: int | None) -> None:
|
|
231
|
+
LOGGER.info(
|
|
232
|
+
"E*TRADE HTTP attempt",
|
|
233
|
+
extra={
|
|
234
|
+
"operation": operation,
|
|
235
|
+
"environment": self._settings.environment.value,
|
|
236
|
+
"status_code": status,
|
|
237
|
+
"latency_seconds": time.monotonic() - started,
|
|
238
|
+
"attempt": attempt,
|
|
239
|
+
},
|
|
240
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Keep HTTPX's automatic URL/wire logs out of SDK request diagnostics."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
|
|
8
|
+
_SENSITIVE_EXCHANGE: ContextVar[bool] = ContextVar("etrade_sensitive_exchange", default=False)
|
|
9
|
+
_HTTP_LOGGERS = (
|
|
10
|
+
"httpx",
|
|
11
|
+
"httpcore.connection",
|
|
12
|
+
"httpcore.http11",
|
|
13
|
+
"httpcore.http2",
|
|
14
|
+
"httpcore.proxy",
|
|
15
|
+
"httpcore.socks",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _ExchangeFilter(logging.Filter):
|
|
20
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
21
|
+
# This affects only the current task's SDK exchange, not unrelated clients.
|
|
22
|
+
return not _SENSITIVE_EXCHANGE.get()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_FILTER = _ExchangeFilter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def private_http_exchange() -> Generator[None, None, None]:
|
|
30
|
+
for name in _HTTP_LOGGERS:
|
|
31
|
+
# addFilter is idempotent for the same instance. Do not change log levels
|
|
32
|
+
# or handlers, which are owned by the host application.
|
|
33
|
+
logging.getLogger(name).addFilter(_FILTER)
|
|
34
|
+
token = _SENSITIVE_EXCHANGE.set(True)
|
|
35
|
+
try:
|
|
36
|
+
yield
|
|
37
|
+
finally:
|
|
38
|
+
_SENSITIVE_EXCHANGE.reset(token)
|