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,136 @@
|
|
|
1
|
+
"""Accounts API service."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
|
|
7
|
+
from etrade_python.accounts.models import (
|
|
8
|
+
Account,
|
|
9
|
+
AccountBalanceRequest,
|
|
10
|
+
AccountBalanceResponse,
|
|
11
|
+
AccountListResponse,
|
|
12
|
+
)
|
|
13
|
+
from etrade_python.exceptions import ETradeResponseError, ETradeValidationError
|
|
14
|
+
from etrade_python.transport.http import ApiTransport
|
|
15
|
+
from etrade_python.transport.response import (
|
|
16
|
+
JsonValue,
|
|
17
|
+
raise_response_validation_error,
|
|
18
|
+
validate_response_shape,
|
|
19
|
+
)
|
|
20
|
+
from etrade_python.transport.retry import RetrySafety
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AccountsService:
|
|
24
|
+
"""Access E*TRADE account list and balance endpoints."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, transport: ApiTransport) -> None:
|
|
27
|
+
self._transport = transport
|
|
28
|
+
|
|
29
|
+
async def list(self) -> AccountListResponse:
|
|
30
|
+
response = await self._transport.request(
|
|
31
|
+
"GET",
|
|
32
|
+
"/v1/accounts/list.json",
|
|
33
|
+
safety=RetrySafety.SAFE_READ,
|
|
34
|
+
operation="accounts.list",
|
|
35
|
+
)
|
|
36
|
+
if response.status_code == 204 and response.data is None:
|
|
37
|
+
return AccountListResponse(accounts=[])
|
|
38
|
+
|
|
39
|
+
data = _require_mapping(response.data)
|
|
40
|
+
accounts_data = _unwrap(data, "AccountListResponse")
|
|
41
|
+
accounts_container = _optional_mapping(accounts_data, "accounts", "Accounts")
|
|
42
|
+
if accounts_container is None:
|
|
43
|
+
raise ETradeResponseError("Invalid account list response")
|
|
44
|
+
|
|
45
|
+
raw_accounts_value = _optional_value(accounts_container, "account", "Account")
|
|
46
|
+
if raw_accounts_value is None:
|
|
47
|
+
if not any(key in accounts_container for key in ("account", "Account")):
|
|
48
|
+
raise ETradeResponseError("Invalid account list response: missing collection")
|
|
49
|
+
return AccountListResponse(accounts=[])
|
|
50
|
+
if isinstance(raw_accounts_value, dict):
|
|
51
|
+
raw_accounts: list[Any] = [raw_accounts_value]
|
|
52
|
+
elif isinstance(raw_accounts_value, list):
|
|
53
|
+
raw_accounts = cast(list[Any], raw_accounts_value)
|
|
54
|
+
else:
|
|
55
|
+
raise ETradeResponseError("Invalid account list response")
|
|
56
|
+
|
|
57
|
+
accounts: list[Account] = []
|
|
58
|
+
for index, account in enumerate(raw_accounts):
|
|
59
|
+
try:
|
|
60
|
+
accounts.append(
|
|
61
|
+
validate_response_shape(Account.model_validate(account), "account list")
|
|
62
|
+
)
|
|
63
|
+
except ValidationError as error:
|
|
64
|
+
raise_response_validation_error(
|
|
65
|
+
"Invalid account list response", error, prefix=f"accounts.{index}"
|
|
66
|
+
)
|
|
67
|
+
return AccountListResponse(accounts=accounts)
|
|
68
|
+
|
|
69
|
+
async def get_balance(
|
|
70
|
+
self, account_id_key: str, request: AccountBalanceRequest | None = None
|
|
71
|
+
) -> AccountBalanceResponse:
|
|
72
|
+
if not account_id_key.strip():
|
|
73
|
+
raise ETradeValidationError("account_id_key is required")
|
|
74
|
+
|
|
75
|
+
balance_request = request or AccountBalanceRequest()
|
|
76
|
+
response = await self._transport.request(
|
|
77
|
+
"GET",
|
|
78
|
+
f"/v1/accounts/{account_id_key}/balance.json",
|
|
79
|
+
params=balance_request.query_params(),
|
|
80
|
+
safety=RetrySafety.SAFE_READ,
|
|
81
|
+
operation="accounts.balance",
|
|
82
|
+
)
|
|
83
|
+
data = _require_mapping(response.data)
|
|
84
|
+
balance_data = _unwrap(data, "BalanceResponse")
|
|
85
|
+
try:
|
|
86
|
+
return validate_response_shape(
|
|
87
|
+
AccountBalanceResponse.model_validate(_normalize_balance(balance_data)),
|
|
88
|
+
"account balance",
|
|
89
|
+
)
|
|
90
|
+
except ValidationError as error:
|
|
91
|
+
raise_response_validation_error("Invalid account balance response", error)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _require_mapping(data: JsonValue) -> dict[str, Any]:
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
raise ETradeResponseError("Invalid account response")
|
|
97
|
+
return cast(dict[str, Any], data)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _unwrap(data: dict[str, Any], envelope: str) -> dict[str, Any]:
|
|
101
|
+
value = data.get(envelope, data)
|
|
102
|
+
if not isinstance(value, dict):
|
|
103
|
+
raise ETradeResponseError("Invalid account response")
|
|
104
|
+
return cast(dict[str, Any], value)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _optional_mapping(data: dict[str, Any], *keys: str) -> dict[str, Any] | None:
|
|
108
|
+
value = _optional_value(data, *keys)
|
|
109
|
+
if value is None:
|
|
110
|
+
return None
|
|
111
|
+
if isinstance(value, dict):
|
|
112
|
+
return cast(dict[str, Any], value)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _optional_value(data: dict[str, Any], *keys: str) -> Any | None:
|
|
117
|
+
for key in keys:
|
|
118
|
+
if key in data:
|
|
119
|
+
return data[key]
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _normalize_balance(data: dict[str, Any]) -> dict[str, Any]:
|
|
124
|
+
normalized = dict(data)
|
|
125
|
+
aliases = {
|
|
126
|
+
"Cash": "cash",
|
|
127
|
+
"Computed": "computedBalance",
|
|
128
|
+
"ComputedBalance": "computedBalance",
|
|
129
|
+
"Margin": "margin",
|
|
130
|
+
"Lending": "lending",
|
|
131
|
+
}
|
|
132
|
+
for source, target in aliases.items():
|
|
133
|
+
if source in normalized and target not in normalized:
|
|
134
|
+
normalized[target] = normalized[source]
|
|
135
|
+
del normalized[source]
|
|
136
|
+
return normalized
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Alerts service and models."""
|
|
2
|
+
|
|
3
|
+
from etrade_python.alerts.models import (
|
|
4
|
+
Alert,
|
|
5
|
+
AlertDetailsRequest,
|
|
6
|
+
AlertDetailsResponse,
|
|
7
|
+
AlertsRequest,
|
|
8
|
+
AlertsResponse,
|
|
9
|
+
DeleteAlertsResponse,
|
|
10
|
+
FailedAlerts,
|
|
11
|
+
)
|
|
12
|
+
from etrade_python.alerts.service import AlertsService
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Alert",
|
|
16
|
+
"AlertDetailsRequest",
|
|
17
|
+
"AlertDetailsResponse",
|
|
18
|
+
"AlertsRequest",
|
|
19
|
+
"AlertsResponse",
|
|
20
|
+
"AlertsService",
|
|
21
|
+
"DeleteAlertsResponse",
|
|
22
|
+
"FailedAlerts",
|
|
23
|
+
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Typed alert request and response models."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
7
|
+
|
|
8
|
+
from etrade_python._dates import parse_broker_datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BrokerModel(BaseModel):
|
|
12
|
+
"""Base response model that discards unmodeled broker fields."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(extra="ignore", frozen=True, populate_by_name=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _bool_query(value: bool | None) -> str | None:
|
|
18
|
+
if value is None:
|
|
19
|
+
return None
|
|
20
|
+
return "true" if value else "false"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AlertsRequest(BaseModel):
|
|
24
|
+
"""Query parameters for alert list lookup."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(frozen=True)
|
|
27
|
+
|
|
28
|
+
count: int | None = Field(default=None, ge=1, le=300)
|
|
29
|
+
category: str | None = None
|
|
30
|
+
status: str | None = None
|
|
31
|
+
direction: str | None = None
|
|
32
|
+
search: str | None = None
|
|
33
|
+
|
|
34
|
+
@field_validator("category")
|
|
35
|
+
@classmethod
|
|
36
|
+
def valid_category(cls, value: str | None) -> str | None:
|
|
37
|
+
return _normalize_choice(value, {"STOCK", "ACCOUNT"}, "category")
|
|
38
|
+
|
|
39
|
+
@field_validator("status")
|
|
40
|
+
@classmethod
|
|
41
|
+
def valid_status(cls, value: str | None) -> str | None:
|
|
42
|
+
return _normalize_choice(value, {"READ", "UNREAD", "DELETED"}, "status")
|
|
43
|
+
|
|
44
|
+
@field_validator("direction")
|
|
45
|
+
@classmethod
|
|
46
|
+
def valid_direction(cls, value: str | None) -> str | None:
|
|
47
|
+
return _normalize_choice(value, {"ASC", "DESC"}, "direction")
|
|
48
|
+
|
|
49
|
+
@field_validator("search")
|
|
50
|
+
@classmethod
|
|
51
|
+
def nonempty_search(cls, value: str | None) -> str | None:
|
|
52
|
+
if value is not None and not value.strip():
|
|
53
|
+
raise ValueError("A nonempty value is required")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
def query_params(self) -> dict[str, str | int | None]:
|
|
57
|
+
return {
|
|
58
|
+
"count": self.count,
|
|
59
|
+
"category": self.category,
|
|
60
|
+
"status": self.status,
|
|
61
|
+
"direction": self.direction,
|
|
62
|
+
"search": self.search,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AlertDetailsRequest(BaseModel):
|
|
67
|
+
"""Query parameters for alert details lookup."""
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(frozen=True, populate_by_name=True)
|
|
70
|
+
|
|
71
|
+
html_tags: bool | None = Field(default=None, alias="htmlTags")
|
|
72
|
+
|
|
73
|
+
def query_params(self) -> dict[str, str | None]:
|
|
74
|
+
return {"htmlTags": _bool_query(self.html_tags)}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Alert(BrokerModel):
|
|
78
|
+
id: int | None = None
|
|
79
|
+
create_time: datetime | None = Field(default=None, alias="createTime")
|
|
80
|
+
subject: str | None = None
|
|
81
|
+
status: str | None = None
|
|
82
|
+
|
|
83
|
+
@field_validator("create_time", mode="before")
|
|
84
|
+
@classmethod
|
|
85
|
+
def parse_create_time(cls, value: object) -> datetime | None:
|
|
86
|
+
return _parse_alert_time(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AlertDetailsResponse(Alert):
|
|
90
|
+
msg_text: str | None = Field(default=None, alias="msgText")
|
|
91
|
+
read_time: datetime | None = Field(default=None, alias="readTime")
|
|
92
|
+
delete_time: datetime | None = Field(default=None, alias="deleteTime")
|
|
93
|
+
symbol: str | None = None
|
|
94
|
+
next: str | None = None
|
|
95
|
+
prev: str | None = None
|
|
96
|
+
|
|
97
|
+
@field_validator("read_time", "delete_time", mode="before")
|
|
98
|
+
@classmethod
|
|
99
|
+
def parse_optional_times(cls, value: object) -> datetime | None:
|
|
100
|
+
return _parse_alert_time(value)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _empty_alerts() -> list[Alert]:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AlertsResponse(BrokerModel):
|
|
108
|
+
total_alerts: int | None = Field(default=None, alias="totalAlerts")
|
|
109
|
+
alerts: list[Alert] = Field(default_factory=_empty_alerts, alias="alerts")
|
|
110
|
+
|
|
111
|
+
@model_validator(mode="before")
|
|
112
|
+
@classmethod
|
|
113
|
+
def normalize_response(cls, value: object) -> object:
|
|
114
|
+
if not isinstance(value, dict):
|
|
115
|
+
return value
|
|
116
|
+
data = dict(cast(dict[str, Any], value))
|
|
117
|
+
for source in ("Alert", "alert"):
|
|
118
|
+
if source in data and "alerts" not in data:
|
|
119
|
+
data["alerts"] = data[source]
|
|
120
|
+
data.pop(source, None)
|
|
121
|
+
return data
|
|
122
|
+
|
|
123
|
+
@field_validator("alerts", mode="before")
|
|
124
|
+
@classmethod
|
|
125
|
+
def normalize_alerts(cls, value: object) -> object:
|
|
126
|
+
if value is None:
|
|
127
|
+
return []
|
|
128
|
+
if isinstance(value, dict):
|
|
129
|
+
return [cast(dict[str, Any], value)]
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _empty_failed_alert_ids() -> list[int]:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class FailedAlerts(BrokerModel):
|
|
138
|
+
alert_ids: list[int] = Field(default_factory=_empty_failed_alert_ids, alias="alertId")
|
|
139
|
+
|
|
140
|
+
@field_validator("alert_ids", mode="before")
|
|
141
|
+
@classmethod
|
|
142
|
+
def normalize_alert_ids(cls, value: object) -> object:
|
|
143
|
+
if value is None:
|
|
144
|
+
return []
|
|
145
|
+
if isinstance(value, int):
|
|
146
|
+
return [value]
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class DeleteAlertsResponse(BrokerModel):
|
|
151
|
+
result: str | None = None
|
|
152
|
+
failed_alerts: FailedAlerts | None = Field(default=None, alias="failedAlerts")
|
|
153
|
+
|
|
154
|
+
@model_validator(mode="before")
|
|
155
|
+
@classmethod
|
|
156
|
+
def normalize_response(cls, value: object) -> object:
|
|
157
|
+
if not isinstance(value, dict):
|
|
158
|
+
return value
|
|
159
|
+
data = dict(cast(dict[str, Any], value))
|
|
160
|
+
if "FailedAlerts" in data and "failedAlerts" not in data:
|
|
161
|
+
data["failedAlerts"] = data["FailedAlerts"]
|
|
162
|
+
data.pop("FailedAlerts", None)
|
|
163
|
+
return data
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _normalize_choice(value: str | None, choices: set[str], label: str) -> str | None:
|
|
167
|
+
if value is None:
|
|
168
|
+
return None
|
|
169
|
+
normalized = value.strip().upper()
|
|
170
|
+
if not normalized:
|
|
171
|
+
raise ValueError("A nonempty value is required")
|
|
172
|
+
if normalized not in choices:
|
|
173
|
+
raise ValueError(f"Unsupported alert {label}")
|
|
174
|
+
return normalized
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_alert_time(value: object) -> datetime | None:
|
|
178
|
+
if value in (None, "", 0, "0"):
|
|
179
|
+
return None
|
|
180
|
+
return parse_broker_datetime(value)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Alerts API service."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from etrade_python.alerts.models import (
|
|
9
|
+
AlertDetailsRequest,
|
|
10
|
+
AlertDetailsResponse,
|
|
11
|
+
AlertsRequest,
|
|
12
|
+
AlertsResponse,
|
|
13
|
+
DeleteAlertsResponse,
|
|
14
|
+
)
|
|
15
|
+
from etrade_python.exceptions import ETradeNotFoundError, ETradeResponseError, ETradeValidationError
|
|
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 AlertsService:
|
|
26
|
+
"""Access E*TRADE alert endpoints."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, transport: ApiTransport) -> None:
|
|
29
|
+
self._transport = transport
|
|
30
|
+
|
|
31
|
+
async def list(self, request: AlertsRequest | None = None) -> AlertsResponse:
|
|
32
|
+
alerts_request = request or AlertsRequest()
|
|
33
|
+
try:
|
|
34
|
+
response = await self._transport.request(
|
|
35
|
+
"GET",
|
|
36
|
+
"/v1/user/alerts.json",
|
|
37
|
+
params=alerts_request.query_params(),
|
|
38
|
+
safety=RetrySafety.SAFE_READ,
|
|
39
|
+
operation="alerts.list",
|
|
40
|
+
)
|
|
41
|
+
except ETradeNotFoundError as error:
|
|
42
|
+
if _is_no_alerts_error(error):
|
|
43
|
+
return AlertsResponse(totalAlerts=0, alerts=[])
|
|
44
|
+
raise
|
|
45
|
+
if response.status_code == 204 and response.data is None:
|
|
46
|
+
return AlertsResponse(totalAlerts=0, alerts=[])
|
|
47
|
+
data = _require_mapping(response.data, "alerts")
|
|
48
|
+
alerts_data = _unwrap(data, "AlertsResponse", "alerts")
|
|
49
|
+
try:
|
|
50
|
+
return validate_response_shape(
|
|
51
|
+
AlertsResponse.model_validate(alerts_data), "alerts", collection_field="alerts"
|
|
52
|
+
)
|
|
53
|
+
except ValidationError as error:
|
|
54
|
+
raise_response_validation_error("Invalid alerts response", error)
|
|
55
|
+
|
|
56
|
+
async def get(
|
|
57
|
+
self, alert_id: int, request: AlertDetailsRequest | None = None
|
|
58
|
+
) -> AlertDetailsResponse:
|
|
59
|
+
normalized_id = _normalize_alert_id(alert_id)
|
|
60
|
+
details_request = request or AlertDetailsRequest()
|
|
61
|
+
response = await self._transport.request(
|
|
62
|
+
"GET",
|
|
63
|
+
f"/v1/user/alerts/{normalized_id}.json",
|
|
64
|
+
params=details_request.query_params(),
|
|
65
|
+
safety=RetrySafety.SAFE_READ,
|
|
66
|
+
operation="alerts.get",
|
|
67
|
+
)
|
|
68
|
+
data = _require_mapping(response.data, "alert details")
|
|
69
|
+
details_data = _unwrap(data, "AlertDetailsResponse", "alert details")
|
|
70
|
+
try:
|
|
71
|
+
return validate_response_shape(
|
|
72
|
+
AlertDetailsResponse.model_validate(details_data), "alert details"
|
|
73
|
+
)
|
|
74
|
+
except ValidationError as error:
|
|
75
|
+
raise_response_validation_error("Invalid alert details response", error)
|
|
76
|
+
|
|
77
|
+
async def delete(self, alert_ids: Sequence[int] | int) -> DeleteAlertsResponse:
|
|
78
|
+
ids = _normalize_alert_ids(alert_ids)
|
|
79
|
+
response = await self._transport.request(
|
|
80
|
+
"DELETE",
|
|
81
|
+
f"/v1/user/alerts/{','.join(str(alert_id) for alert_id in ids)}.json",
|
|
82
|
+
safety=RetrySafety.NEVER,
|
|
83
|
+
operation="alerts.delete",
|
|
84
|
+
)
|
|
85
|
+
data = _require_mapping(response.data, "delete alerts")
|
|
86
|
+
delete_data = _unwrap(data, "DeleteAlertsResponse", "delete alerts")
|
|
87
|
+
try:
|
|
88
|
+
return validate_response_shape(
|
|
89
|
+
DeleteAlertsResponse.model_validate(delete_data), "delete alerts"
|
|
90
|
+
)
|
|
91
|
+
except ValidationError as error:
|
|
92
|
+
raise_response_validation_error("Invalid delete alerts response", error)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _normalize_alert_id(alert_id: int) -> int:
|
|
96
|
+
if type(alert_id) is not int or alert_id < 1:
|
|
97
|
+
raise ETradeValidationError("alert_id must be a positive integer")
|
|
98
|
+
return alert_id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _normalize_alert_ids(alert_ids: Sequence[int] | int) -> list[int]:
|
|
102
|
+
if isinstance(alert_ids, bool):
|
|
103
|
+
raise ETradeValidationError("alert_ids must be integers")
|
|
104
|
+
if isinstance(alert_ids, int):
|
|
105
|
+
return [_normalize_alert_id(alert_ids)]
|
|
106
|
+
if isinstance(alert_ids, (str, bytes)) or not isinstance(cast(object, alert_ids), Sequence):
|
|
107
|
+
raise ETradeValidationError("alert_ids must be integers")
|
|
108
|
+
normalized = [_normalize_alert_id(alert_id) for alert_id in alert_ids]
|
|
109
|
+
if not normalized:
|
|
110
|
+
raise ETradeValidationError("at least one alert_id is required")
|
|
111
|
+
return normalized
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_no_alerts_error(error: ETradeNotFoundError) -> bool:
|
|
115
|
+
return error.broker_code == "53" or "no alerts" in (error.broker_message or "").lower()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _require_mapping(data: JsonValue, response_name: str) -> dict[str, Any]:
|
|
119
|
+
if not isinstance(data, dict):
|
|
120
|
+
raise ETradeResponseError(f"Invalid {response_name} response")
|
|
121
|
+
return cast(dict[str, Any], data)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _unwrap(data: dict[str, Any], envelope: str, response_name: str) -> dict[str, Any]:
|
|
125
|
+
value = data.get(envelope, data)
|
|
126
|
+
if not isinstance(value, dict):
|
|
127
|
+
raise ETradeResponseError(f"Invalid {response_name} response")
|
|
128
|
+
return cast(dict[str, Any], value)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""OAuth and session lifecycle support."""
|
|
2
|
+
|
|
3
|
+
from etrade_python.auth.credentials import (
|
|
4
|
+
AuthorizationUrl,
|
|
5
|
+
ETradeCredentials,
|
|
6
|
+
RenewalResult,
|
|
7
|
+
RequestToken,
|
|
8
|
+
RevocationResult,
|
|
9
|
+
)
|
|
10
|
+
from etrade_python.auth.oauth import OAuthClient
|
|
11
|
+
from etrade_python.auth.session import SessionAuthenticator, SessionManager, TokenStatus
|
|
12
|
+
from etrade_python.auth.stores import (
|
|
13
|
+
CredentialStore,
|
|
14
|
+
KeyringCredentialStore,
|
|
15
|
+
MemoryCredentialStore,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AuthorizationUrl",
|
|
20
|
+
"CredentialStore",
|
|
21
|
+
"ETradeCredentials",
|
|
22
|
+
"KeyringCredentialStore",
|
|
23
|
+
"MemoryCredentialStore",
|
|
24
|
+
"OAuthClient",
|
|
25
|
+
"RenewalResult",
|
|
26
|
+
"RequestToken",
|
|
27
|
+
"RevocationResult",
|
|
28
|
+
"SessionAuthenticator",
|
|
29
|
+
"SessionManager",
|
|
30
|
+
"TokenStatus",
|
|
31
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Credential models for OAuth lifecycle state."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RequestToken(BaseModel):
|
|
9
|
+
"""Temporary token used only during the browser authorization flow."""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(frozen=True)
|
|
12
|
+
|
|
13
|
+
oauth_token: SecretStr = Field(repr=False)
|
|
14
|
+
oauth_token_secret: SecretStr = Field(repr=False)
|
|
15
|
+
oauth_callback_confirmed: bool = False
|
|
16
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
17
|
+
|
|
18
|
+
@field_validator("oauth_token", "oauth_token_secret")
|
|
19
|
+
@classmethod
|
|
20
|
+
def nonempty_secret(cls, value: SecretStr) -> SecretStr:
|
|
21
|
+
if not value.get_secret_value().strip():
|
|
22
|
+
raise ValueError("A nonempty OAuth token value is required")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ETradeCredentials(BaseModel):
|
|
27
|
+
"""Persisted access-token credentials and lifecycle timestamps."""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(frozen=True)
|
|
30
|
+
|
|
31
|
+
access_token: SecretStr = Field(repr=False)
|
|
32
|
+
access_token_secret: SecretStr = Field(repr=False)
|
|
33
|
+
acquired_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
34
|
+
last_used_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
35
|
+
renewed_at: datetime | None = None
|
|
36
|
+
|
|
37
|
+
@field_validator("access_token", "access_token_secret")
|
|
38
|
+
@classmethod
|
|
39
|
+
def nonempty_secret(cls, value: SecretStr) -> SecretStr:
|
|
40
|
+
if not value.get_secret_value().strip():
|
|
41
|
+
raise ValueError("A nonempty OAuth credential is required")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
@field_validator("acquired_at", "last_used_at", "renewed_at")
|
|
45
|
+
@classmethod
|
|
46
|
+
def require_aware_datetime(cls, value: datetime | None) -> datetime | None:
|
|
47
|
+
if value is None:
|
|
48
|
+
return value
|
|
49
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
50
|
+
raise ValueError("Credential timestamps must be timezone-aware")
|
|
51
|
+
return value.astimezone(UTC)
|
|
52
|
+
|
|
53
|
+
def with_last_used(self, when: datetime) -> "ETradeCredentials":
|
|
54
|
+
return self.model_copy(update={"last_used_at": when.astimezone(UTC)})
|
|
55
|
+
|
|
56
|
+
def with_renewal(self, when: datetime) -> "ETradeCredentials":
|
|
57
|
+
normalized = when.astimezone(UTC)
|
|
58
|
+
return self.model_copy(update={"last_used_at": normalized, "renewed_at": normalized})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AuthorizationUrl(BaseModel):
|
|
62
|
+
"""Browser URL for authorizing a request token."""
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(frozen=True)
|
|
65
|
+
|
|
66
|
+
url: str = Field(repr=False)
|
|
67
|
+
request_token: RequestToken
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RenewalResult(BaseModel):
|
|
71
|
+
"""Result of a successful E*TRADE access-token renewal."""
|
|
72
|
+
|
|
73
|
+
model_config = ConfigDict(frozen=True)
|
|
74
|
+
|
|
75
|
+
credentials: ETradeCredentials
|
|
76
|
+
message: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RevocationResult(BaseModel):
|
|
80
|
+
"""Result of a successful E*TRADE access-token revocation."""
|
|
81
|
+
|
|
82
|
+
model_config = ConfigDict(frozen=True)
|
|
83
|
+
|
|
84
|
+
revoked: bool
|
|
85
|
+
message: str
|