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.
Files changed (49) hide show
  1. etrade_python/__init__.py +264 -0
  2. etrade_python/_dates.py +152 -0
  3. etrade_python/_version.py +3 -0
  4. etrade_python/accounts/__init__.py +31 -0
  5. etrade_python/accounts/models.py +204 -0
  6. etrade_python/accounts/service.py +136 -0
  7. etrade_python/alerts/__init__.py +23 -0
  8. etrade_python/alerts/models.py +180 -0
  9. etrade_python/alerts/service.py +128 -0
  10. etrade_python/auth/__init__.py +31 -0
  11. etrade_python/auth/credentials.py +85 -0
  12. etrade_python/auth/oauth.py +268 -0
  13. etrade_python/auth/session.py +154 -0
  14. etrade_python/auth/stores/__init__.py +7 -0
  15. etrade_python/auth/stores/base.py +18 -0
  16. etrade_python/auth/stores/keyring.py +49 -0
  17. etrade_python/auth/stores/memory.py +19 -0
  18. etrade_python/cli/__init__.py +1 -0
  19. etrade_python/cli/app.py +1623 -0
  20. etrade_python/client.py +84 -0
  21. etrade_python/config.py +43 -0
  22. etrade_python/enums.py +19 -0
  23. etrade_python/exceptions.py +68 -0
  24. etrade_python/market/__init__.py +59 -0
  25. etrade_python/market/models.py +811 -0
  26. etrade_python/market/service.py +168 -0
  27. etrade_python/orders/__init__.py +107 -0
  28. etrade_python/orders/builders.py +617 -0
  29. etrade_python/orders/models.py +1009 -0
  30. etrade_python/orders/service.py +259 -0
  31. etrade_python/orders/values.py +67 -0
  32. etrade_python/portfolio/__init__.py +35 -0
  33. etrade_python/portfolio/models.py +429 -0
  34. etrade_python/portfolio/service.py +99 -0
  35. etrade_python/py.typed +0 -0
  36. etrade_python/transactions/__init__.py +27 -0
  37. etrade_python/transactions/models.py +201 -0
  38. etrade_python/transactions/service.py +138 -0
  39. etrade_python/transport/__init__.py +7 -0
  40. etrade_python/transport/auth.py +17 -0
  41. etrade_python/transport/http.py +240 -0
  42. etrade_python/transport/logging.py +38 -0
  43. etrade_python/transport/response.py +243 -0
  44. etrade_python/transport/retry.py +75 -0
  45. etrade_python-1.0.0.dist-info/METADATA +513 -0
  46. etrade_python-1.0.0.dist-info/RECORD +49 -0
  47. etrade_python-1.0.0.dist-info/WHEEL +4 -0
  48. etrade_python-1.0.0.dist-info/entry_points.txt +2 -0
  49. 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)