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,243 @@
|
|
|
1
|
+
"""JSON decoding and deliberately small, defensive broker error parsing."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import NoReturn, TypeAlias, TypeVar, cast
|
|
8
|
+
from urllib.parse import quote, quote_plus, unquote
|
|
9
|
+
from xml.etree import ElementTree
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from etrade_python.exceptions import (
|
|
15
|
+
ETradeApiError,
|
|
16
|
+
ETradeHttpAuthenticationError,
|
|
17
|
+
ETradeNotFoundError,
|
|
18
|
+
ETradeRateLimitError,
|
|
19
|
+
ETradeResponseError,
|
|
20
|
+
ETradeUnavailableError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
JsonValue: TypeAlias = "None | bool | int | Decimal | str | list[JsonValue] | dict[str, JsonValue]"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ResponseModel = TypeVar("ResponseModel", bound=BaseModel)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_response_shape(
|
|
30
|
+
model: ResponseModel, response_name: str, *, collection_field: str | None = None
|
|
31
|
+
) -> ResponseModel:
|
|
32
|
+
"""Check parsed HTTP data without changing direct model construction."""
|
|
33
|
+
if collection_field is not None and collection_field not in model.model_fields_set:
|
|
34
|
+
raise ETradeResponseError(f"Invalid {response_name} response: missing collection")
|
|
35
|
+
_validate_populated_model(model, response_name, "")
|
|
36
|
+
return model
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_populated_model(model: BaseModel, response_name: str, path: str) -> None:
|
|
40
|
+
populated = [
|
|
41
|
+
name
|
|
42
|
+
for name in type(model).model_fields
|
|
43
|
+
if name in model.model_fields_set and getattr(model, name) is not None
|
|
44
|
+
]
|
|
45
|
+
if not populated:
|
|
46
|
+
location = f" at {path}" if path else ""
|
|
47
|
+
raise ETradeResponseError(
|
|
48
|
+
f"Invalid {response_name} response: unrecognized object{location}"
|
|
49
|
+
)
|
|
50
|
+
for name in populated:
|
|
51
|
+
value = getattr(model, name)
|
|
52
|
+
field_path = f"{path}.{name}" if path else name
|
|
53
|
+
if isinstance(value, BaseModel):
|
|
54
|
+
_validate_populated_model(value, response_name, field_path)
|
|
55
|
+
elif isinstance(value, list):
|
|
56
|
+
for index, item in enumerate(cast(list[object], value)):
|
|
57
|
+
if isinstance(item, BaseModel):
|
|
58
|
+
_validate_populated_model(item, response_name, f"{field_path}.{index}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class TransportResponse:
|
|
63
|
+
status_code: int
|
|
64
|
+
data: JsonValue = field(repr=False)
|
|
65
|
+
request_id: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Redactor:
|
|
69
|
+
"""Request-local redaction; never retain credentials in diagnostics."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, secrets: list[str]) -> None:
|
|
72
|
+
variants: set[str] = set()
|
|
73
|
+
for secret in secrets:
|
|
74
|
+
if secret:
|
|
75
|
+
variants.update(
|
|
76
|
+
(secret, quote(secret, safe=""), quote_plus(secret), unquote(secret))
|
|
77
|
+
)
|
|
78
|
+
self._secrets = sorted(variants, key=len, reverse=True)
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str:
|
|
81
|
+
return "Redactor()"
|
|
82
|
+
|
|
83
|
+
def clean(self, value: str) -> str:
|
|
84
|
+
for secret in self._secrets:
|
|
85
|
+
value = value.replace(secret, "[REDACTED]")
|
|
86
|
+
value = re.sub(r"(?i)\bOAuth\s+[^\r\n]+", "[REDACTED]", value)
|
|
87
|
+
value = re.sub(
|
|
88
|
+
r"(?i)((?:oauth_)?(?:token(?:_secret)?|consumer_secret|verifier|signature)"
|
|
89
|
+
r"\s*[=:]\s*)[^\s,;&]+",
|
|
90
|
+
r"\1[REDACTED]",
|
|
91
|
+
value,
|
|
92
|
+
)
|
|
93
|
+
return " ".join(value.split())[:1000]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _invalid_constant(value: str) -> None:
|
|
97
|
+
raise ValueError("Non-finite JSON number")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def decode_json(content: bytes) -> JsonValue:
|
|
101
|
+
return cast(
|
|
102
|
+
JsonValue, json.loads(content, parse_float=Decimal, parse_constant=_invalid_constant)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def correlation_id(response: httpx.Response, redactor: Redactor) -> str | None:
|
|
107
|
+
for name in ("x-request-id", "x-correlation-id"):
|
|
108
|
+
if value := response.headers.get(name):
|
|
109
|
+
return redactor.clean(value)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def response_diagnostic(response: httpx.Response, redactor: Redactor) -> str:
|
|
114
|
+
"""Return a short sanitized response summary for parser diagnostics."""
|
|
115
|
+
content_type = response.headers.get("content-type", "<missing>").split(";", 1)[0].strip()
|
|
116
|
+
content_length = len(response.content)
|
|
117
|
+
preview = _body_preview(response.content, redactor)
|
|
118
|
+
parts = [
|
|
119
|
+
f"status={response.status_code}",
|
|
120
|
+
f"content_type={content_type or '<missing>'}",
|
|
121
|
+
f"bytes={content_length}",
|
|
122
|
+
]
|
|
123
|
+
if preview:
|
|
124
|
+
parts.append(f"body_preview={preview}")
|
|
125
|
+
return ", ".join(parts)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def validation_diagnostic(error: object, *, prefix: str | None = None) -> str:
|
|
129
|
+
"""Return sanitized Pydantic validation locations without rejected values."""
|
|
130
|
+
errors = getattr(error, "errors", None)
|
|
131
|
+
if not callable(errors):
|
|
132
|
+
return "validation failed"
|
|
133
|
+
paths: list[str] = []
|
|
134
|
+
raw_errors = cast(list[dict[str, object]], errors())
|
|
135
|
+
for item in raw_errors:
|
|
136
|
+
loc = item.get("loc")
|
|
137
|
+
if not isinstance(loc, tuple) or not loc:
|
|
138
|
+
continue
|
|
139
|
+
location = cast(tuple[object, ...], loc)
|
|
140
|
+
rendered = ".".join(str(part) for part in location)
|
|
141
|
+
if prefix:
|
|
142
|
+
rendered = f"{prefix}.{rendered}"
|
|
143
|
+
paths.append(rendered)
|
|
144
|
+
if not paths:
|
|
145
|
+
return "validation failed"
|
|
146
|
+
unique_paths = list(dict.fromkeys(paths))
|
|
147
|
+
if len(unique_paths) > 5:
|
|
148
|
+
return "; ".join(unique_paths[:5]) + f"; +{len(unique_paths) - 5} more"
|
|
149
|
+
return "; ".join(unique_paths)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def raise_response_validation_error(
|
|
153
|
+
message: str, error: object, *, prefix: str | None = None
|
|
154
|
+
) -> NoReturn:
|
|
155
|
+
"""Raise a sanitized response validation error from a model parser failure."""
|
|
156
|
+
diagnostic = validation_diagnostic(error, prefix=prefix)
|
|
157
|
+
raise ETradeResponseError(f"{message}: {diagnostic}") from None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _body_preview(content: bytes, redactor: Redactor) -> str | None:
|
|
161
|
+
if not content:
|
|
162
|
+
return None
|
|
163
|
+
preview = content[:300].decode("utf-8", errors="replace")
|
|
164
|
+
cleaned = redactor.clean(preview)
|
|
165
|
+
if len(content) > 300:
|
|
166
|
+
cleaned = f"{cleaned}..."
|
|
167
|
+
return cleaned
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def parse_response(response: httpx.Response, redactor: Redactor) -> TransportResponse:
|
|
171
|
+
request_id = correlation_id(response, redactor)
|
|
172
|
+
if response.status_code == 204:
|
|
173
|
+
return TransportResponse(204, None, request_id)
|
|
174
|
+
if not 200 <= response.status_code < 300:
|
|
175
|
+
raise api_error(response, redactor)
|
|
176
|
+
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
177
|
+
if content_type != "application/json" and not content_type.endswith("+json"):
|
|
178
|
+
raise ETradeResponseError(
|
|
179
|
+
f"Expected a JSON response ({response_diagnostic(response, redactor)})",
|
|
180
|
+
status_code=response.status_code,
|
|
181
|
+
request_id=request_id,
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
data = decode_json(response.content)
|
|
185
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
186
|
+
raise ETradeResponseError(
|
|
187
|
+
f"Invalid JSON response ({response_diagnostic(response, redactor)})",
|
|
188
|
+
status_code=response.status_code,
|
|
189
|
+
request_id=request_id,
|
|
190
|
+
) from None
|
|
191
|
+
return TransportResponse(response.status_code, data, request_id)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _error_fields(content: bytes) -> tuple[str | None, str | None]:
|
|
195
|
+
# Error payloads are never exposed wholesale. Refuse large or entity-bearing XML.
|
|
196
|
+
if len(content) > 65536:
|
|
197
|
+
return None, None
|
|
198
|
+
try:
|
|
199
|
+
data = decode_json(content)
|
|
200
|
+
if isinstance(data, dict):
|
|
201
|
+
error = data.get("Error", data)
|
|
202
|
+
if isinstance(error, dict):
|
|
203
|
+
code = error.get("code")
|
|
204
|
+
message = error.get("message")
|
|
205
|
+
return (
|
|
206
|
+
str(code) if isinstance(code, (str, int)) else None,
|
|
207
|
+
message if isinstance(message, str) else None,
|
|
208
|
+
)
|
|
209
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
210
|
+
pass
|
|
211
|
+
if b"<!" in content or b"\x00" in content:
|
|
212
|
+
return None, None
|
|
213
|
+
try:
|
|
214
|
+
root = ElementTree.fromstring(content)
|
|
215
|
+
if root.tag.rsplit("}", 1)[-1] != "Error":
|
|
216
|
+
return None, None
|
|
217
|
+
fields = {node.tag.rsplit("}", 1)[-1]: node.text for node in root}
|
|
218
|
+
return fields.get("code"), fields.get("message")
|
|
219
|
+
except ElementTree.ParseError:
|
|
220
|
+
return None, None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def api_error(response: httpx.Response, redactor: Redactor) -> ETradeApiError:
|
|
224
|
+
code, message = _error_fields(response.content)
|
|
225
|
+
code = redactor.clean(code) if code else None
|
|
226
|
+
message = redactor.clean(message) if message else None
|
|
227
|
+
error_type: type[ETradeApiError] = ETradeApiError
|
|
228
|
+
status = response.status_code
|
|
229
|
+
if status in {401, 403}:
|
|
230
|
+
error_type = ETradeHttpAuthenticationError
|
|
231
|
+
elif status == 404:
|
|
232
|
+
error_type = ETradeNotFoundError
|
|
233
|
+
elif status == 429:
|
|
234
|
+
error_type = ETradeRateLimitError
|
|
235
|
+
elif status >= 500:
|
|
236
|
+
error_type = ETradeUnavailableError
|
|
237
|
+
return error_type(
|
|
238
|
+
f"E*TRADE request failed (HTTP {status})",
|
|
239
|
+
status_code=status,
|
|
240
|
+
broker_code=code,
|
|
241
|
+
broker_message=message,
|
|
242
|
+
request_id=correlation_id(response, redactor),
|
|
243
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Explicit retry classification; HTTP verbs alone never establish safety."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import random
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from etrade_python.exceptions import ETradeValidationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RetrySafety(StrEnum):
|
|
16
|
+
NEVER = "never"
|
|
17
|
+
SAFE_READ = "safe_read"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RetryPolicy:
|
|
22
|
+
max_attempts: int = 3
|
|
23
|
+
base_delay_seconds: float = 0.5
|
|
24
|
+
max_delay_seconds: float = 30.0
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if (
|
|
28
|
+
isinstance(self.max_attempts, bool)
|
|
29
|
+
or type(self.max_attempts) is not int
|
|
30
|
+
or self.max_attempts < 1
|
|
31
|
+
or not math.isfinite(self.base_delay_seconds)
|
|
32
|
+
or not math.isfinite(self.max_delay_seconds)
|
|
33
|
+
or self.base_delay_seconds < 0
|
|
34
|
+
or self.max_delay_seconds < self.base_delay_seconds
|
|
35
|
+
):
|
|
36
|
+
raise ETradeValidationError("Invalid retry policy")
|
|
37
|
+
|
|
38
|
+
def delay(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
safety: RetrySafety,
|
|
42
|
+
attempt: int,
|
|
43
|
+
response: httpx.Response | None = None,
|
|
44
|
+
error: httpx.TransportError | None = None,
|
|
45
|
+
now: datetime | None = None,
|
|
46
|
+
) -> float | None:
|
|
47
|
+
"""Return a delay or None. Attempt numbering starts at one."""
|
|
48
|
+
if safety is not RetrySafety.SAFE_READ or attempt >= self.max_attempts:
|
|
49
|
+
return None
|
|
50
|
+
transient = isinstance(error, (httpx.ConnectError, httpx.TimeoutException))
|
|
51
|
+
if response is not None:
|
|
52
|
+
transient = response.status_code in {429, 502, 503, 504}
|
|
53
|
+
if not transient:
|
|
54
|
+
return None
|
|
55
|
+
if response is not None and (header := response.headers.get("retry-after")):
|
|
56
|
+
wait = self._retry_after(header, now or datetime.now(UTC))
|
|
57
|
+
if wait is not None:
|
|
58
|
+
return wait if wait <= self.max_delay_seconds else None
|
|
59
|
+
ceiling = min(self.max_delay_seconds, self.base_delay_seconds * 2 ** min(attempt - 1, 30))
|
|
60
|
+
return random.uniform(0, ceiling)
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _retry_after(value: str, now: datetime) -> float | None:
|
|
64
|
+
if value.isascii() and value.isdecimal():
|
|
65
|
+
try:
|
|
66
|
+
return float(int(value))
|
|
67
|
+
except (ValueError, OverflowError):
|
|
68
|
+
return math.inf
|
|
69
|
+
try:
|
|
70
|
+
parsed = parsedate_to_datetime(value)
|
|
71
|
+
if parsed.tzinfo is None:
|
|
72
|
+
return None
|
|
73
|
+
return max(0.0, (parsed - now).total_seconds())
|
|
74
|
+
except (ValueError, TypeError, OverflowError):
|
|
75
|
+
return None
|