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 +8 -0
- exante_cli/__main__.py +6 -0
- exante_cli/auth.py +38 -0
- exante_cli/auth_server.py +317 -0
- exante_cli/cli.py +837 -0
- exante_cli/client.py +261 -0
- exante_cli/commands/__init__.py +2 -0
- exante_cli/commands/accounts.py +99 -0
- exante_cli/commands/auth_cli.py +68 -0
- exante_cli/commands/config_cli.py +79 -0
- exante_cli/commands/helpers.py +53 -0
- exante_cli/commands/market.py +167 -0
- exante_cli/commands/orders.py +394 -0
- exante_cli/commands/stream.py +49 -0
- exante_cli/commands/symbols.py +151 -0
- exante_cli/commands/transactions.py +60 -0
- exante_cli/config.py +390 -0
- exante_cli/context.py +103 -0
- exante_cli/device.py +32 -0
- exante_cli/errors.py +183 -0
- exante_cli/keystore.py +77 -0
- exante_cli/output/__init__.py +87 -0
- exante_cli/output/json_out.py +42 -0
- exante_cli/output/table.py +136 -0
- exante_cli/pkce.py +27 -0
- exante_cli/repl.py +71 -0
- exante_cli/urls.py +80 -0
- exante_cli/util.py +45 -0
- exante_cli-1.0.29.dist-info/METADATA +215 -0
- exante_cli-1.0.29.dist-info/RECORD +33 -0
- exante_cli-1.0.29.dist-info/WHEEL +5 -0
- exante_cli-1.0.29.dist-info/entry_points.txt +2 -0
- exante_cli-1.0.29.dist-info/top_level.txt +1 -0
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
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
|
+
)
|