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