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,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