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,268 @@
1
+ """OAuth 1.0a protocol operations for E*TRADE."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import logging
7
+ import re
8
+ import secrets
9
+ import time
10
+ from collections.abc import Callable
11
+ from typing import Final
12
+ from urllib.parse import parse_qsl, quote, urlencode
13
+
14
+ import httpx
15
+ from pydantic import SecretStr
16
+
17
+ from etrade_python.auth.credentials import (
18
+ AuthorizationUrl,
19
+ ETradeCredentials,
20
+ RenewalResult,
21
+ RequestToken,
22
+ RevocationResult,
23
+ )
24
+ from etrade_python.config import ETradeSettings
25
+ from etrade_python.enums import AUTHORIZATION_URL, OAUTH_BASE_URL
26
+ from etrade_python.exceptions import (
27
+ ETradeResponseError,
28
+ ETradeTransportError,
29
+ ETradeValidationError,
30
+ )
31
+ from etrade_python.transport.logging import private_http_exchange
32
+ from etrade_python.transport.response import Redactor, parse_response
33
+
34
+ LOGGER = logging.getLogger("etrade_python.auth.oauth")
35
+ SIGNATURE_METHOD: Final[str] = "HMAC-SHA1"
36
+
37
+
38
+ def _encode(value: str) -> str:
39
+ return quote(value, safe="~-._")
40
+
41
+
42
+ def _base_url(url: httpx.URL) -> str:
43
+ scheme = url.scheme.lower()
44
+ host = (url.host or "").lower()
45
+ port = url.port
46
+ authority = host
47
+ if port is not None and not (
48
+ (scheme == "http" and port == 80) or (scheme == "https" and port == 443)
49
+ ):
50
+ authority = f"{authority}:{port}"
51
+ path = url.raw_path.decode("ascii").split("?", 1)[0] or "/"
52
+ return f"{scheme}://{authority}{path}"
53
+
54
+
55
+ def _authorization_header(parameters: dict[str, str]) -> str:
56
+ header = ",".join(
57
+ f'{_encode(key)}="{_encode(value)}"' for key, value in sorted(parameters.items())
58
+ )
59
+ return f'OAuth realm="",{header}'
60
+
61
+
62
+ class OAuthClient:
63
+ """Protocol-level OAuth operations and request signing."""
64
+
65
+ def __init__(
66
+ self,
67
+ settings: ETradeSettings,
68
+ *,
69
+ http_client: httpx.AsyncClient | None = None,
70
+ http_transport: httpx.AsyncBaseTransport | None = None,
71
+ nonce_factory: Callable[[], str] | None = None,
72
+ timestamp_factory: Callable[[], int] | None = None,
73
+ ) -> None:
74
+ if http_client is not None and http_transport is not None:
75
+ raise ETradeValidationError("Supply either http_client or http_transport, not both")
76
+ self._settings = settings
77
+ self._owns_client = http_client is None
78
+ self._client = http_client or httpx.AsyncClient(
79
+ transport=http_transport,
80
+ timeout=settings.request_timeout_seconds,
81
+ follow_redirects=False,
82
+ trust_env=False,
83
+ )
84
+ self._nonce_factory = nonce_factory or (lambda: secrets.token_hex(16))
85
+ self._timestamp_factory = timestamp_factory or (lambda: int(time.time()))
86
+ self._closed = False
87
+
88
+ async def __aenter__(self) -> "OAuthClient":
89
+ if self._closed:
90
+ raise ETradeTransportError("OAuth client is closed")
91
+ return self
92
+
93
+ async def __aexit__(self, *_: object) -> None:
94
+ await self.aclose()
95
+
96
+ async def aclose(self) -> None:
97
+ if not self._closed:
98
+ self._closed = True
99
+ if self._owns_client:
100
+ await self._client.aclose()
101
+
102
+ async def get_request_token(self) -> RequestToken:
103
+ request = self._signed_get(
104
+ f"{OAUTH_BASE_URL}/oauth/request_token",
105
+ oauth_parameters={"oauth_callback": "oob"},
106
+ )
107
+ text = await self._send(request, operation="oauth.request_token")
108
+ values = self._parse_form(text)
109
+ try:
110
+ return RequestToken(
111
+ oauth_token=SecretStr(values["oauth_token"]),
112
+ oauth_token_secret=SecretStr(values["oauth_token_secret"]),
113
+ oauth_callback_confirmed=values.get("oauth_callback_confirmed", "").lower()
114
+ == "true",
115
+ )
116
+ except KeyError:
117
+ raise ETradeResponseError("Invalid OAuth request-token response") from None
118
+
119
+ def get_authorization_url(self, request_token: RequestToken) -> AuthorizationUrl:
120
+ query = urlencode(
121
+ {
122
+ "key": self._settings.consumer_key.get_secret_value(),
123
+ "token": request_token.oauth_token.get_secret_value(),
124
+ }
125
+ )
126
+ return AuthorizationUrl(url=f"{AUTHORIZATION_URL}?{query}", request_token=request_token)
127
+
128
+ async def exchange_verifier(
129
+ self, request_token: RequestToken, verifier: str
130
+ ) -> ETradeCredentials:
131
+ if not verifier.strip():
132
+ raise ETradeValidationError("OAuth verifier is required")
133
+ request = self._signed_get(
134
+ f"{OAUTH_BASE_URL}/oauth/access_token",
135
+ token=request_token.oauth_token,
136
+ token_secret=request_token.oauth_token_secret,
137
+ oauth_parameters={"oauth_verifier": verifier},
138
+ )
139
+ text = await self._send(request, operation="oauth.access_token")
140
+ values = self._parse_form(text)
141
+ try:
142
+ return ETradeCredentials(
143
+ access_token=SecretStr(values["oauth_token"]),
144
+ access_token_secret=SecretStr(values["oauth_token_secret"]),
145
+ )
146
+ except KeyError:
147
+ raise ETradeResponseError("Invalid OAuth access-token response") from None
148
+
149
+ async def renew_access_token(self, credentials: ETradeCredentials) -> RenewalResult:
150
+ request = self._signed_get(
151
+ f"{OAUTH_BASE_URL}/oauth/renew_access_token",
152
+ token=credentials.access_token,
153
+ token_secret=credentials.access_token_secret,
154
+ )
155
+ text = await self._send(request, operation="oauth.renew_access_token")
156
+ return RenewalResult(credentials=credentials, message=text.strip())
157
+
158
+ async def revoke_access_token(self, credentials: ETradeCredentials) -> RevocationResult:
159
+ request = self._signed_get(
160
+ f"{OAUTH_BASE_URL}/oauth/revoke_access_token",
161
+ token=credentials.access_token,
162
+ token_secret=credentials.access_token_secret,
163
+ )
164
+ text = await self._send(request, operation="oauth.revoke_access_token")
165
+ return RevocationResult(revoked=True, message=text.strip())
166
+
167
+ def sign_request(self, request: httpx.Request, credentials: ETradeCredentials) -> None:
168
+ parameters = self._oauth_parameters(token=credentials.access_token)
169
+ signature = self._signature(
170
+ request.method,
171
+ request.url,
172
+ parameters,
173
+ token_secret=credentials.access_token_secret,
174
+ )
175
+ parameters["oauth_signature"] = signature
176
+ request.headers["Authorization"] = _authorization_header(parameters)
177
+
178
+ def _signed_get(
179
+ self,
180
+ url: str,
181
+ *,
182
+ token: SecretStr | None = None,
183
+ token_secret: SecretStr | None = None,
184
+ oauth_parameters: dict[str, str] | None = None,
185
+ ) -> httpx.Request:
186
+ request = self._client.build_request(
187
+ "GET", url, timeout=self._settings.request_timeout_seconds
188
+ )
189
+ parameters = self._oauth_parameters(token=token)
190
+ parameters.update(oauth_parameters or {})
191
+ signature = self._signature("GET", request.url, parameters, token_secret=token_secret)
192
+ parameters["oauth_signature"] = signature
193
+ request.headers["Authorization"] = _authorization_header(parameters)
194
+ return request
195
+
196
+ def _oauth_parameters(self, *, token: SecretStr | None = None) -> dict[str, str]:
197
+ parameters = {
198
+ "oauth_consumer_key": self._settings.consumer_key.get_secret_value(),
199
+ "oauth_nonce": self._nonce_factory(),
200
+ "oauth_signature_method": SIGNATURE_METHOD,
201
+ "oauth_timestamp": str(self._timestamp_factory()),
202
+ }
203
+ if token is not None:
204
+ parameters["oauth_token"] = token.get_secret_value()
205
+ return parameters
206
+
207
+ def _signature(
208
+ self,
209
+ method: str,
210
+ url: httpx.URL,
211
+ oauth_parameters: dict[str, str],
212
+ *,
213
+ token_secret: SecretStr | None,
214
+ ) -> str:
215
+ parameters = [
216
+ (key, value) for key, value in oauth_parameters.items() if key != "oauth_signature"
217
+ ]
218
+ parameters.extend((key, value) for key, value in url.params.multi_items())
219
+ parameter_string = "&".join(
220
+ f"{_encode(key)}={_encode(value)}" for key, value in sorted(parameters)
221
+ )
222
+ base_string = "&".join(
223
+ [_encode(method.upper()), _encode(_base_url(url)), _encode(parameter_string)]
224
+ )
225
+ secret = self._settings.consumer_secret.get_secret_value()
226
+ token_secret_value = "" if token_secret is None else token_secret.get_secret_value()
227
+ signing_key = f"{_encode(secret)}&{_encode(token_secret_value)}"
228
+ digest = hmac.new(
229
+ signing_key.encode("utf-8"), base_string.encode("utf-8"), hashlib.sha1
230
+ ).digest()
231
+ return base64.b64encode(digest).decode("ascii")
232
+
233
+ async def _send(self, request: httpx.Request, *, operation: str) -> str:
234
+ if self._closed:
235
+ raise ETradeTransportError("OAuth client is closed")
236
+ redactor = self._redactor(request)
237
+ try:
238
+ with private_http_exchange():
239
+ response = await self._client.send(request, follow_redirects=False, auth=None)
240
+ except httpx.TransportError:
241
+ raise ETradeTransportError("OAuth HTTP exchange failed") from None
242
+ except httpx.HTTPError:
243
+ raise ETradeTransportError("OAuth HTTP exchange failed") from None
244
+ try:
245
+ if not 200 <= response.status_code < 300:
246
+ parse_response(response, redactor)
247
+ LOGGER.info("E*TRADE OAuth request", extra={"operation": operation})
248
+ return response.text
249
+ finally:
250
+ await response.aclose()
251
+
252
+ @staticmethod
253
+ def _parse_form(text: str) -> dict[str, str]:
254
+ values = dict(parse_qsl(text, keep_blank_values=True, strict_parsing=False))
255
+ if not values:
256
+ raise ETradeResponseError("Invalid OAuth form response")
257
+ return values
258
+
259
+ def _redactor(self, request: httpx.Request) -> Redactor:
260
+ authorization = request.headers.get("authorization", "")
261
+ secrets_to_mask = [
262
+ self._settings.consumer_key.get_secret_value(),
263
+ self._settings.consumer_secret.get_secret_value(),
264
+ authorization,
265
+ ]
266
+ # Redactor also masks decoded forms of percent-encoded OAuth values.
267
+ secrets_to_mask.extend(re.findall(r'="([^"\r\n]*)"', authorization))
268
+ return Redactor(secrets_to_mask)
@@ -0,0 +1,154 @@
1
+ """OAuth access-token lifecycle management."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ from collections.abc import Awaitable, Callable
6
+ from datetime import UTC, datetime, timedelta
7
+ from enum import StrEnum
8
+ from zoneinfo import ZoneInfo
9
+
10
+ import httpx
11
+
12
+ from etrade_python.auth.credentials import ETradeCredentials
13
+ from etrade_python.auth.oauth import OAuthClient
14
+ from etrade_python.auth.stores.base import CredentialStore
15
+ from etrade_python.config import ETradeSettings
16
+ from etrade_python.exceptions import AuthenticationRequired, AuthorizationExpired
17
+
18
+ EASTERN = ZoneInfo("America/New_York")
19
+ INACTIVITY_SECONDS = 7200
20
+
21
+
22
+ class TokenStatus(StrEnum):
23
+ ACTIVE = "active"
24
+ INACTIVE_RENEWABLE = "inactive_renewable"
25
+ EXPIRED = "expired"
26
+ MISSING = "missing"
27
+
28
+
29
+ class SessionManager:
30
+ """Decide whether persisted credentials are usable, renewable, or expired."""
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ settings: ETradeSettings,
36
+ oauth_client: OAuthClient,
37
+ credential_store: CredentialStore,
38
+ profile: str = "default",
39
+ clock: Callable[[], datetime] | None = None,
40
+ ) -> None:
41
+ self._settings = settings
42
+ self._oauth_client = oauth_client
43
+ self._credential_store = credential_store
44
+ consumer_fingerprint = hashlib.sha256(
45
+ settings.consumer_key.get_secret_value().encode("utf-8")
46
+ ).hexdigest()
47
+ self._storage_key = f"{settings.environment.value}:{consumer_fingerprint}:{profile}"
48
+ self._clock = clock or (lambda: datetime.now(UTC))
49
+ self._renewal_lock = asyncio.Lock()
50
+
51
+ async def ensure_active(self) -> ETradeCredentials:
52
+ async with self._renewal_lock:
53
+ now = self._now()
54
+ credentials = await self.load()
55
+ status = self.status(credentials, now=now)
56
+ if status is TokenStatus.MISSING:
57
+ raise AuthenticationRequired("E*TRADE authorization is required")
58
+ if credentials is None:
59
+ raise AuthenticationRequired("E*TRADE authorization is required")
60
+ if status is TokenStatus.EXPIRED:
61
+ raise AuthorizationExpired("E*TRADE authorization has expired")
62
+ if status is TokenStatus.ACTIVE:
63
+ return credentials
64
+ return await self._renew(credentials)
65
+
66
+ async def renew(self) -> ETradeCredentials:
67
+ """Renew same-day credentials with the broker, even when locally active."""
68
+ async with self._renewal_lock:
69
+ credentials = await self.load()
70
+ if credentials is None:
71
+ raise AuthenticationRequired("E*TRADE authorization is required")
72
+ if self.status(credentials) is TokenStatus.EXPIRED:
73
+ raise AuthorizationExpired("E*TRADE authorization has expired")
74
+ return await self._renew(credentials)
75
+
76
+ async def _renew(self, credentials: ETradeCredentials) -> ETradeCredentials:
77
+ """Perform renewal while the caller holds the renewal lock."""
78
+ result = await self._oauth_client.renew_access_token(credentials)
79
+ renewed = result.credentials.with_renewal(self._now())
80
+ await self._save(renewed)
81
+ return renewed
82
+
83
+ async def save(self, credentials: ETradeCredentials) -> None:
84
+ async with self._renewal_lock:
85
+ await self._save(credentials)
86
+
87
+ async def _save(self, credentials: ETradeCredentials) -> None:
88
+ await self._credential_store.save(self._storage_key, credentials)
89
+
90
+ async def load(self) -> ETradeCredentials | None:
91
+ return await self._credential_store.load(self._storage_key)
92
+
93
+ async def delete(self) -> None:
94
+ async with self._renewal_lock:
95
+ await self._credential_store.delete(self._storage_key)
96
+
97
+ async def record_usage(self, credentials: ETradeCredentials) -> None:
98
+ """Record confirmed usage without overwriting newer or replaced credentials."""
99
+ used_at = self._now()
100
+ async with self._renewal_lock:
101
+ current = await self.load()
102
+ if current is None or (
103
+ current.access_token != credentials.access_token
104
+ or current.access_token_secret != credentials.access_token_secret
105
+ or current.acquired_at != credentials.acquired_at
106
+ ):
107
+ return
108
+ if used_at > current.last_used_at:
109
+ await self._save(current.with_last_used(used_at))
110
+
111
+ def status(
112
+ self, credentials: ETradeCredentials | None, *, now: datetime | None = None
113
+ ) -> TokenStatus:
114
+ if credentials is None:
115
+ return TokenStatus.MISSING
116
+ current = (now or self._now()).astimezone(UTC)
117
+ if self._is_expired(credentials, now=current):
118
+ return TokenStatus.EXPIRED
119
+ inactive_after = timedelta(
120
+ seconds=INACTIVITY_SECONDS - self._settings.inactivity_buffer_seconds
121
+ )
122
+ if current - credentials.last_used_at >= inactive_after:
123
+ return TokenStatus.INACTIVE_RENEWABLE
124
+ return TokenStatus.ACTIVE
125
+
126
+ def _is_expired(self, credentials: ETradeCredentials, *, now: datetime) -> bool:
127
+ acquired_eastern = credentials.acquired_at.astimezone(EASTERN)
128
+ next_midnight = (acquired_eastern + timedelta(days=1)).replace(
129
+ hour=0, minute=0, second=0, microsecond=0
130
+ )
131
+ return now.astimezone(EASTERN) >= next_midnight
132
+
133
+ def _now(self) -> datetime:
134
+ value = self._clock()
135
+ if value.tzinfo is None or value.utcoffset() is None:
136
+ return value.replace(tzinfo=UTC)
137
+ return value.astimezone(UTC)
138
+
139
+
140
+ class SessionAuthenticator:
141
+ """Transport authenticator backed by SessionManager credentials."""
142
+
143
+ def __init__(self, session_manager: SessionManager, oauth_client: OAuthClient) -> None:
144
+ self._session_manager = session_manager
145
+ self._oauth_client = oauth_client
146
+
147
+ async def authenticate(self, request: httpx.Request) -> Callable[[], Awaitable[None]]:
148
+ credentials = await self._session_manager.ensure_active()
149
+ self._oauth_client.sign_request(request, credentials)
150
+
151
+ async def record_usage() -> None:
152
+ await self._session_manager.record_usage(credentials)
153
+
154
+ return record_usage
@@ -0,0 +1,7 @@
1
+ """Credential store implementations."""
2
+
3
+ from etrade_python.auth.stores.base import CredentialStore
4
+ from etrade_python.auth.stores.keyring import KeyringCredentialStore
5
+ from etrade_python.auth.stores.memory import MemoryCredentialStore
6
+
7
+ __all__ = ["CredentialStore", "KeyringCredentialStore", "MemoryCredentialStore"]
@@ -0,0 +1,18 @@
1
+ """Credential store protocol."""
2
+
3
+ from typing import Protocol
4
+
5
+ from etrade_python.auth.credentials import ETradeCredentials
6
+
7
+
8
+ class CredentialStore(Protocol):
9
+ """Async persistence boundary for OAuth credentials."""
10
+
11
+ async def load(self, profile: str) -> ETradeCredentials | None:
12
+ """Load credentials for a profile, returning None when absent."""
13
+
14
+ async def save(self, profile: str, credentials: ETradeCredentials) -> None:
15
+ """Persist credentials for a profile."""
16
+
17
+ async def delete(self, profile: str) -> None:
18
+ """Delete credentials for a profile."""
@@ -0,0 +1,49 @@
1
+ """Keyring-backed credential store."""
2
+
3
+ import asyncio
4
+ import json
5
+
6
+ import keyring
7
+ from keyring.errors import PasswordDeleteError
8
+ from pydantic import ValidationError
9
+
10
+ from etrade_python.auth.credentials import ETradeCredentials
11
+ from etrade_python.exceptions import ETradeValidationError
12
+
13
+
14
+ class KeyringCredentialStore:
15
+ """Store credentials in the user's configured system keyring."""
16
+
17
+ def __init__(self, *, service_name: str = "etrade-python") -> None:
18
+ self._service_name = service_name
19
+
20
+ async def load(self, profile: str) -> ETradeCredentials | None:
21
+ raw = await asyncio.to_thread(keyring.get_password, self._service_name, profile)
22
+ if raw is None:
23
+ return None
24
+ try:
25
+ data = json.loads(raw)
26
+ return ETradeCredentials.model_validate(data)
27
+ except (json.JSONDecodeError, TypeError, ValidationError):
28
+ raise ETradeValidationError("Stored E*TRADE credentials are invalid") from None
29
+
30
+ async def save(self, profile: str, credentials: ETradeCredentials) -> None:
31
+ payload = json.dumps(
32
+ {
33
+ "access_token": credentials.access_token.get_secret_value(),
34
+ "access_token_secret": credentials.access_token_secret.get_secret_value(),
35
+ "acquired_at": credentials.acquired_at.isoformat(),
36
+ "last_used_at": credentials.last_used_at.isoformat(),
37
+ "renewed_at": None
38
+ if credentials.renewed_at is None
39
+ else credentials.renewed_at.isoformat(),
40
+ },
41
+ separators=(",", ":"),
42
+ )
43
+ await asyncio.to_thread(keyring.set_password, self._service_name, profile, payload)
44
+
45
+ async def delete(self, profile: str) -> None:
46
+ try:
47
+ await asyncio.to_thread(keyring.delete_password, self._service_name, profile)
48
+ except PasswordDeleteError:
49
+ return
@@ -0,0 +1,19 @@
1
+ """In-memory credential store."""
2
+
3
+ from etrade_python.auth.credentials import ETradeCredentials
4
+
5
+
6
+ class MemoryCredentialStore:
7
+ """Process-local credential store, mainly for tests and temporary sessions."""
8
+
9
+ def __init__(self) -> None:
10
+ self._credentials: dict[str, ETradeCredentials] = {}
11
+
12
+ async def load(self, profile: str) -> ETradeCredentials | None:
13
+ return self._credentials.get(profile)
14
+
15
+ async def save(self, profile: str, credentials: ETradeCredentials) -> None:
16
+ self._credentials[profile] = credentials
17
+
18
+ async def delete(self, profile: str) -> None:
19
+ self._credentials.pop(profile, None)
@@ -0,0 +1 @@
1
+ """Command-line interface package."""