exante-cli 1.0.29__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.
exante_cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Exante CLI — Python port of the Rust terminal client.
2
+
3
+ Trade, query, and manage an Exante account from the terminal. This package
4
+ mirrors the behaviour of the original Rust CLI; the Rust sources in ``../src``
5
+ remain the reference while the port is being validated.
6
+ """
7
+
8
+ __version__ = "1.0.29"
exante_cli/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m exante_cli`` to run the CLI."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
exante_cli/auth.py ADDED
@@ -0,0 +1,38 @@
1
+ """Exante API scopes.
2
+
3
+ Requests are signed with HTTP Basic (``app_id:shared_secret``), so scopes are no
4
+ longer used for signing — they are kept only as named constants that command
5
+ modules pass to the client for documentation/symmetry. (The original Rust CLI
6
+ also supported HS256 JWT minting; that path was dropped when the credential model
7
+ became app_id + key only.)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ class scope:
14
+ """Scope strings defined by the Exante API spec, grouped by resource."""
15
+
16
+ ACCOUNTS = "accounts"
17
+ SUMMARY = "summary"
18
+ SYMBOLS = "symbols"
19
+ FEED = "feed"
20
+ OHLC = "ohlc"
21
+ CROSSRATES = "crossrates"
22
+ CHANGE = "change"
23
+ ORDERS = "orders"
24
+ TRANSACTIONS = "transactions"
25
+
26
+ @staticmethod
27
+ def all_read() -> list[str]:
28
+ """Full set of read scopes (``orders`` intentionally excluded)."""
29
+ return [
30
+ scope.ACCOUNTS,
31
+ scope.SUMMARY,
32
+ scope.SYMBOLS,
33
+ scope.FEED,
34
+ scope.OHLC,
35
+ scope.CROSSRATES,
36
+ scope.CHANGE,
37
+ scope.TRANSACTIONS,
38
+ ]
@@ -0,0 +1,317 @@
1
+ """`exante login` — browser login via Clients Area (PKCE + keychain).
2
+
3
+ Implements PYTHON_LOGIN_PLAN.md. Flow:
4
+
5
+ 1. Generate a PKCE pair; only ``code_challenge`` leaves the process.
6
+ 2. Bind a one-shot HTTP server on 127.0.0.1 on a random free port.
7
+ 3. Open the browser at Clients Area with ``client_name``, ``device_name``,
8
+ ``code_challenge`` and the local ``redirect_url``.
9
+ 4. The user confirms; Clients Area redirects back to us with ``one_time_token``,
10
+ the echoed ``code_challenge``, and a ``redirect_url``. We verify the challenge
11
+ matches our local one.
12
+ 5. POST ``{code, one_time_token}`` to the token-exchange endpoint, verify the
13
+ echoed ``code_challenge`` again, then save **only** ``app_id`` + ``key.key``
14
+ into the system keychain — or, if no keychain backend is available (headless
15
+ Linux), fall back to writing them into the config file's ``[auth]`` section.
16
+ 6. Only after the keys are stored, redirect the browser back to the callback's
17
+ ``redirect_url`` (falling back to a local success page if none was supplied).
18
+
19
+ The ``code`` (verifier) is never sent to the browser and never logged.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import html
25
+ import sys
26
+ import webbrowser
27
+ from http.server import BaseHTTPRequestHandler, HTTPServer
28
+ from urllib.parse import parse_qs, urlencode, urlsplit
29
+
30
+ import httpx
31
+
32
+ from . import config, keystore, pkce, urls
33
+ from .device import device_name
34
+ from .errors import AuthError
35
+
36
+ # --- Constants (hard-coded; see §4) -----------------------------------------
37
+
38
+ # Hard-coded application name that Clients Area shows/checks.
39
+ CLIENT_NAME = "Exante CLI"
40
+
41
+ # The Clients Area / token-exchange URLs are resolved per technical environment
42
+ # from `urls.py` at flow start (see `run`), so they are not module constants.
43
+
44
+ AUTH_TIMEOUT_SECS = 1200 # how long we wait for browser confirmation
45
+ _EXCHANGE_TIMEOUT_SECS = 30.0
46
+
47
+
48
+ def _client_name() -> str:
49
+ # Clients Area receives the app name with the CLI version appended, separated
50
+ # by a space (e.g. "Exante CLI 1.0.0") so it reads cleanly once URL-decoded.
51
+ from . import __version__
52
+
53
+ return f"{CLIENT_NAME} {__version__}"
54
+
55
+
56
+ # --- Browser response pages -------------------------------------------------
57
+
58
+ _SUCCESS_HTML = (
59
+ b"<!doctype html><html><head><meta charset=utf-8><title>exante-cli</title></head>"
60
+ b"<body style='font-family:sans-serif'><h2>Authentication successful</h2>"
61
+ b"<p>You can return to the terminal.</p></body></html>"
62
+ )
63
+
64
+
65
+ def _error_html(message: str) -> bytes:
66
+ safe = html.escape(message)
67
+ return (
68
+ b"<!doctype html><html><head><meta charset=utf-8><title>exante-cli</title></head>"
69
+ b"<body style='font-family:sans-serif'><h2>Authentication failed</h2><p>"
70
+ + safe.encode("utf-8")
71
+ + b"</p></body></html>"
72
+ )
73
+
74
+
75
+ # --- One-shot callback server -----------------------------------------------
76
+
77
+
78
+ class _CallbackServer(HTTPServer):
79
+ """HTTPServer carrying flow state for the handler to read/write.
80
+
81
+ The handler does the full token exchange + keychain save inline (so it can
82
+ redirect the browser back to the ``redirect_url`` Clients Area supplies only
83
+ after the keys are stored), hence it needs the PKCE ``verifier`` here.
84
+ """
85
+
86
+ expected_challenge: str = ""
87
+ verifier: str = ""
88
+ env: str = "live" # trading env half of the keychain slot key
89
+ tech_env: str = "prod" # technical env half of the keychain slot key
90
+ token_exchange_url: str = "" # resolved from the technical env in run()
91
+ # Result populated by the handler once the flow completes.
92
+ app_id: str | None = None
93
+ stored_in: str = "the system keychain" # where creds landed (keychain/config)
94
+ error: str | None = None
95
+ done: bool = False
96
+ timed_out: bool = False
97
+
98
+ def handle_timeout(self) -> None:
99
+ # Called by handle_request() when no request arrived within self.timeout.
100
+ self.timed_out = True
101
+
102
+
103
+ class _Handler(BaseHTTPRequestHandler):
104
+ def do_GET(self) -> None: # noqa: N802 (name fixed by BaseHTTPRequestHandler)
105
+ server: _CallbackServer = self.server # type: ignore[assignment]
106
+ parsed = urlsplit(self.path)
107
+
108
+ # Ignore unrelated probes (favicon, etc.) without ending the wait.
109
+ if parsed.path not in ("/", ""):
110
+ self._respond(404, b"not found\n", "text/plain")
111
+ return
112
+
113
+ params = parse_qs(parsed.query)
114
+ # Clients Area redirects back with the one-time token, the echoed
115
+ # code_challenge, and a redirect_url to send the browser to afterwards.
116
+ one_time_token = (params.get("one_time_token") or [None])[0]
117
+ challenge = (params.get("code_challenge") or [None])[0]
118
+ return_url = (params.get("redirect_url") or [None])[0]
119
+
120
+ if not one_time_token or not challenge:
121
+ server.error = "Callback missing one_time_token or code_challenge."
122
+ self._respond(400, _error_html(server.error), "text/html")
123
+ server.done = True
124
+ return
125
+
126
+ # Binding check #1: the challenge echoed back must be our local one.
127
+ if challenge != server.expected_challenge:
128
+ server.error = (
129
+ "code_challenge mismatch on callback (possible spoofed redirect)."
130
+ )
131
+ self._respond(400, _error_html(server.error), "text/html")
132
+ server.done = True
133
+ return
134
+
135
+ # Exchange the one-time token for permanent creds and store them, THEN
136
+ # redirect the browser back to Clients Area's redirect_url. The browser
137
+ # only leaves the local page once the keys are safely in the keychain.
138
+ try:
139
+ app_id, key = _exchange(
140
+ server.verifier, one_time_token, challenge, server.token_exchange_url
141
+ )
142
+ except AuthError as e:
143
+ # Store the bare message; run() re-wraps it in AuthError, which would
144
+ # otherwise double the "Authentication failed:" prefix.
145
+ server.error = e.message
146
+ self._respond(400, _error_html(e.message), "text/html")
147
+ server.done = True
148
+ return
149
+
150
+ # Prefer the system keychain; if no backend is available (typical on
151
+ # headless Linux), fall back to the config file so the creds we just
152
+ # exchanged aren't lost. Only a keychain-write failure triggers the
153
+ # fallback — a config-write failure is fatal (nowhere left to store).
154
+ try:
155
+ keystore.save_key(app_id, key, server.env, server.tech_env)
156
+ server.stored_in = "the system keychain"
157
+ except AuthError:
158
+ try:
159
+ config.save_auth(app_id, key, server.env, server.tech_env)
160
+ server.stored_in = f"the config file ({config.config_path()})"
161
+ except Exception as e: # noqa: BLE001 (surface any write failure)
162
+ server.error = f"Could not store credentials: {e}"
163
+ self._respond(400, _error_html(server.error), "text/html")
164
+ server.done = True
165
+ return
166
+
167
+ server.app_id = app_id
168
+ if return_url:
169
+ self._redirect(return_url)
170
+ else:
171
+ self._respond(200, _SUCCESS_HTML, "text/html")
172
+ server.done = True
173
+
174
+ def _respond(self, status: int, body: bytes, content_type: str) -> None:
175
+ self.send_response(status)
176
+ self.send_header("Content-Type", f"{content_type}; charset=utf-8")
177
+ self.send_header("Content-Length", str(len(body)))
178
+ self.send_header("Cache-Control", "no-store")
179
+ self.end_headers()
180
+ self.wfile.write(body)
181
+
182
+ def _redirect(self, location: str) -> None:
183
+ self.send_response(302)
184
+ self.send_header("Location", location)
185
+ self.send_header("Content-Length", "0")
186
+ self.send_header("Cache-Control", "no-store")
187
+ self.end_headers()
188
+
189
+ def log_message(self, format: str, *args) -> None: # noqa: A002
190
+ # Silence the default stderr access log; keep the console clean.
191
+ return
192
+
193
+
194
+ # --- Token exchange ---------------------------------------------------------
195
+
196
+
197
+ def _exchange(
198
+ verifier: str, one_time_token: str, expected_challenge: str, token_exchange_url: str
199
+ ) -> tuple[str, str]:
200
+ """POST {code, one_time_token}, verify the echoed challenge, return (app_id, key)."""
201
+ try:
202
+ resp = httpx.post(
203
+ token_exchange_url,
204
+ json={"code": verifier, "one_time_token": one_time_token},
205
+ timeout=_EXCHANGE_TIMEOUT_SECS,
206
+ )
207
+ except httpx.HTTPError as e:
208
+ raise AuthError(f"Token exchange request failed: {e}") from e
209
+
210
+ if resp.status_code < 200 or resp.status_code >= 300:
211
+ body = resp.text[:400]
212
+ raise AuthError(f"Token exchange failed (HTTP {resp.status_code}): {body}")
213
+
214
+ try:
215
+ data = resp.json()
216
+ except ValueError as e:
217
+ raise AuthError(f"Token exchange returned non-JSON response: {e}") from e
218
+
219
+ # Binding check #2: the response must echo our local challenge.
220
+ if data.get("code_challenge") != expected_challenge:
221
+ raise AuthError("code_challenge mismatch in token-exchange response.")
222
+
223
+ app_id = data.get("app_id")
224
+ key_obj = data.get("key") or {}
225
+ key = key_obj.get("key") if isinstance(key_obj, dict) else None
226
+ if not app_id or not key:
227
+ raise AuthError("Token exchange response missing app_id or key.")
228
+ return app_id, key
229
+
230
+
231
+ # --- Entry point ------------------------------------------------------------
232
+
233
+
234
+ def run(
235
+ *,
236
+ open_browser: bool = True,
237
+ env: str = "live",
238
+ tech_env: str = urls.DEFAULT_TECH_ENV,
239
+ ) -> None:
240
+ from . import client
241
+
242
+ # The technical env selects which deployment's URLs we use for this flow.
243
+ endpoints = urls.endpoints_for(tech_env)
244
+
245
+ verifier = pkce.generate_verifier()
246
+ challenge = pkce.challenge_from_verifier(verifier)
247
+
248
+ # Port 0 asks the OS for a free port; listen on loopback only.
249
+ server = _CallbackServer(("127.0.0.1", 0), _Handler)
250
+ server.expected_challenge = challenge
251
+ server.verifier = verifier
252
+ server.env = client.parse_env(env)
253
+ server.tech_env = tech_env
254
+ server.token_exchange_url = endpoints.token_exchange_url
255
+ port = server.server_address[1]
256
+ # Where Clients Area sends the callback (our one-shot local server). This is
257
+ # distinct from the `redirect_url` Clients Area later echoes back to us, which
258
+ # is where we send the browser once the keys are saved.
259
+ local_redirect_url = f"http://127.0.0.1:{port}"
260
+
261
+ # Selected environment, encoded the way Clients Area expects: live=0, demo=1.
262
+ env_code = client.env_code(env)
263
+
264
+ query = urlencode(
265
+ {
266
+ "client_name": _client_name(),
267
+ "device_name": device_name(),
268
+ "code_challenge": challenge,
269
+ "redirect_url": local_redirect_url,
270
+ "env": env_code,
271
+ }
272
+ )
273
+ clients_area_url = endpoints.clients_area_auth_url
274
+ sep = "&" if "?" in clients_area_url else "?"
275
+ auth_url = f"{clients_area_url}{sep}{query}"
276
+
277
+ print(
278
+ f"Opening Clients Area to authorize this device ({env}, env={env_code}):",
279
+ file=sys.stderr,
280
+ )
281
+ print(f" {auth_url}", file=sys.stderr)
282
+
283
+ opened = False
284
+ if open_browser:
285
+ try:
286
+ opened = webbrowser.open(auth_url)
287
+ except Exception:
288
+ opened = False
289
+ if not opened:
290
+ print(
291
+ "Could not open a browser automatically — copy the URL above into your browser.",
292
+ file=sys.stderr,
293
+ )
294
+
295
+ print("Waiting for confirmation in the browser...", file=sys.stderr)
296
+
297
+ # Single expected callback; loop so unrelated probes don't end the wait.
298
+ server.timeout = AUTH_TIMEOUT_SECS
299
+ try:
300
+ while not server.done:
301
+ server.handle_request() # processes one request, or times out
302
+ if server.timed_out:
303
+ raise AuthError("Timed out waiting for browser confirmation.")
304
+ except KeyboardInterrupt:
305
+ raise AuthError("Authentication cancelled.")
306
+ finally:
307
+ server.server_close()
308
+
309
+ if server.error:
310
+ raise AuthError(server.error)
311
+ if not server.app_id:
312
+ raise AuthError("Timed out waiting for browser confirmation.")
313
+
314
+ print(
315
+ f"Authentication successful. Credentials stored in {server.stored_in} for '{env}'.",
316
+ file=sys.stderr,
317
+ )