plexus-python 0.3.0__py3-none-any.whl → 0.4.2__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.
- plexus/__init__.py +1 -1
- plexus/cli.py +486 -0
- plexus/client.py +15 -1
- plexus/config.py +48 -0
- plexus/ws.py +31 -2
- {plexus_python-0.3.0.dist-info → plexus_python-0.4.2.dist-info}/METADATA +16 -1
- plexus_python-0.4.2.dist-info/RECORD +11 -0
- plexus_python-0.4.2.dist-info/entry_points.txt +2 -0
- plexus_python-0.3.0.dist-info/RECORD +0 -9
- {plexus_python-0.3.0.dist-info → plexus_python-0.4.2.dist-info}/WHEEL +0 -0
- {plexus_python-0.3.0.dist-info → plexus_python-0.4.2.dist-info}/licenses/LICENSE +0 -0
plexus/__init__.py
CHANGED
plexus/cli.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plexus CLI — `plexus init` style auth, plus a few sibling commands.
|
|
3
|
+
|
|
4
|
+
Designed to feel like fly.io / vercel CLIs:
|
|
5
|
+
$ pip install plexus
|
|
6
|
+
$ plexus init
|
|
7
|
+
Opening browser to https://app.plexus.company/auth/cli...
|
|
8
|
+
✓ Saved API key as cli-<host>. You're set up.
|
|
9
|
+
|
|
10
|
+
Implementation:
|
|
11
|
+
- Spin up a local HTTP listener on a random free port.
|
|
12
|
+
- Open the browser to /auth/cli with the callback URL embedded.
|
|
13
|
+
- Block until the browser POSTs (well — redirects with key) to /callback.
|
|
14
|
+
- Verify the `state` parameter matches what we generated.
|
|
15
|
+
- Persist the key via plexus.config.save_config; the SDK already reads
|
|
16
|
+
`~/.plexus/config.json` for `PLEXUS_API_KEY`.
|
|
17
|
+
|
|
18
|
+
Stdlib only — keep dependency footprint minimal.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import http.server
|
|
25
|
+
import secrets
|
|
26
|
+
import socket
|
|
27
|
+
import socketserver
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import urllib.parse
|
|
31
|
+
import webbrowser
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
from . import config
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DEFAULT_TIMEOUT_SECONDS = 300
|
|
38
|
+
SUCCESS_REDIRECT_SECONDS = 10
|
|
39
|
+
SUCCESS_HTML_TEMPLATE = """<!doctype html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8" />
|
|
43
|
+
<title>Plexus CLI</title>
|
|
44
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
45
|
+
<meta http-equiv="refresh" content="{seconds};url={target}" />
|
|
46
|
+
<style>
|
|
47
|
+
:root {{ color-scheme: dark; }}
|
|
48
|
+
* {{ box-sizing: border-box; }}
|
|
49
|
+
html, body {{ height: 100%; }}
|
|
50
|
+
body {{
|
|
51
|
+
margin: 0;
|
|
52
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Inter',
|
|
53
|
+
'Segoe UI', system-ui, sans-serif;
|
|
54
|
+
background: #000;
|
|
55
|
+
color: #fafafa;
|
|
56
|
+
display: flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
justify-content: center;
|
|
59
|
+
padding: 24px;
|
|
60
|
+
-webkit-font-smoothing: antialiased;
|
|
61
|
+
}}
|
|
62
|
+
.shell {{
|
|
63
|
+
width: 100%;
|
|
64
|
+
max-width: 380px;
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-direction: column;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 24px;
|
|
69
|
+
}}
|
|
70
|
+
.brand {{
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
gap: 10px;
|
|
74
|
+
color: #e4e4e7;
|
|
75
|
+
font-size: 14px;
|
|
76
|
+
font-weight: 500;
|
|
77
|
+
letter-spacing: -0.01em;
|
|
78
|
+
}}
|
|
79
|
+
.brand .mark {{
|
|
80
|
+
width: 22px;
|
|
81
|
+
height: 22px;
|
|
82
|
+
border-radius: 6px;
|
|
83
|
+
background: linear-gradient(135deg, #fafafa 0%, #71717a 100%);
|
|
84
|
+
display: inline-block;
|
|
85
|
+
}}
|
|
86
|
+
.card {{
|
|
87
|
+
width: 100%;
|
|
88
|
+
background: #09090b;
|
|
89
|
+
border: 1px solid #27272a;
|
|
90
|
+
border-radius: 12px;
|
|
91
|
+
padding: 28px 28px 24px;
|
|
92
|
+
text-align: center;
|
|
93
|
+
}}
|
|
94
|
+
.check {{
|
|
95
|
+
width: 36px;
|
|
96
|
+
height: 36px;
|
|
97
|
+
border-radius: 999px;
|
|
98
|
+
background: rgba(34, 197, 94, 0.12);
|
|
99
|
+
color: #4ade80;
|
|
100
|
+
display: inline-flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
justify-content: center;
|
|
103
|
+
margin: 0 auto 16px;
|
|
104
|
+
}}
|
|
105
|
+
.check svg {{ width: 18px; height: 18px; }}
|
|
106
|
+
h1 {{
|
|
107
|
+
margin: 0 0 6px;
|
|
108
|
+
font-size: 16px;
|
|
109
|
+
font-weight: 600;
|
|
110
|
+
color: #fafafa;
|
|
111
|
+
letter-spacing: -0.01em;
|
|
112
|
+
}}
|
|
113
|
+
.lede {{
|
|
114
|
+
margin: 0 0 20px;
|
|
115
|
+
color: #a1a1aa;
|
|
116
|
+
font-size: 13px;
|
|
117
|
+
line-height: 1.5;
|
|
118
|
+
}}
|
|
119
|
+
.meta {{
|
|
120
|
+
margin-top: 20px;
|
|
121
|
+
padding-top: 16px;
|
|
122
|
+
border-top: 1px solid #18181b;
|
|
123
|
+
color: #71717a;
|
|
124
|
+
font-size: 12px;
|
|
125
|
+
}}
|
|
126
|
+
.meta a {{
|
|
127
|
+
color: #a1a1aa;
|
|
128
|
+
text-decoration: none;
|
|
129
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono',
|
|
130
|
+
Menlo, Consolas, monospace;
|
|
131
|
+
}}
|
|
132
|
+
.meta a:hover {{ color: #fafafa; }}
|
|
133
|
+
#countdown {{
|
|
134
|
+
font-variant-numeric: tabular-nums;
|
|
135
|
+
color: #e4e4e7;
|
|
136
|
+
}}
|
|
137
|
+
</style>
|
|
138
|
+
</head>
|
|
139
|
+
<body>
|
|
140
|
+
<div class="shell">
|
|
141
|
+
<div class="brand">
|
|
142
|
+
<span class="mark" aria-hidden="true"></span>
|
|
143
|
+
<span>Plexus</span>
|
|
144
|
+
</div>
|
|
145
|
+
<div class="card">
|
|
146
|
+
<div class="check" aria-hidden="true">
|
|
147
|
+
<svg viewBox="0 0 20 20" fill="none"
|
|
148
|
+
stroke="currentColor" stroke-width="2.5"
|
|
149
|
+
stroke-linecap="round" stroke-linejoin="round">
|
|
150
|
+
<polyline points="5 10.5 8.5 14 15 7" />
|
|
151
|
+
</svg>
|
|
152
|
+
</div>
|
|
153
|
+
<h1>You’re all set</h1>
|
|
154
|
+
<p class="lede">
|
|
155
|
+
Return to your terminal — the CLI has your key.
|
|
156
|
+
</p>
|
|
157
|
+
<div class="meta">
|
|
158
|
+
Opening <a href="{target}">{target_label}</a> in
|
|
159
|
+
<span id="countdown">{seconds}</span>s…
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
<script>
|
|
164
|
+
(function () {{
|
|
165
|
+
var n = {seconds};
|
|
166
|
+
var el = document.getElementById('countdown');
|
|
167
|
+
var t = setInterval(function () {{
|
|
168
|
+
n -= 1;
|
|
169
|
+
if (el) el.textContent = n;
|
|
170
|
+
if (n <= 0) {{
|
|
171
|
+
clearInterval(t);
|
|
172
|
+
window.location.replace({target_js!s});
|
|
173
|
+
}}
|
|
174
|
+
}}, 1000);
|
|
175
|
+
}})();
|
|
176
|
+
</script>
|
|
177
|
+
</body>
|
|
178
|
+
</html>"""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _success_html(target: str) -> bytes:
|
|
182
|
+
label = target.replace("https://", "").replace("http://", "").rstrip("/")
|
|
183
|
+
return SUCCESS_HTML_TEMPLATE.format(
|
|
184
|
+
seconds=SUCCESS_REDIRECT_SECONDS,
|
|
185
|
+
target=target,
|
|
186
|
+
target_label=label,
|
|
187
|
+
target_js=repr(target),
|
|
188
|
+
).encode("utf-8")
|
|
189
|
+
|
|
190
|
+
ERROR_HTML = """<!doctype html>
|
|
191
|
+
<html lang="en">
|
|
192
|
+
<head>
|
|
193
|
+
<meta charset="utf-8" />
|
|
194
|
+
<title>Plexus CLI</title>
|
|
195
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
196
|
+
<style>
|
|
197
|
+
:root { color-scheme: dark; }
|
|
198
|
+
* { box-sizing: border-box; }
|
|
199
|
+
html, body { height: 100%; }
|
|
200
|
+
body {
|
|
201
|
+
margin: 0;
|
|
202
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Inter',
|
|
203
|
+
'Segoe UI', system-ui, sans-serif;
|
|
204
|
+
background: #000;
|
|
205
|
+
color: #fafafa;
|
|
206
|
+
display: flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
justify-content: center;
|
|
209
|
+
padding: 24px;
|
|
210
|
+
-webkit-font-smoothing: antialiased;
|
|
211
|
+
}
|
|
212
|
+
.shell {
|
|
213
|
+
width: 100%;
|
|
214
|
+
max-width: 380px;
|
|
215
|
+
display: flex;
|
|
216
|
+
flex-direction: column;
|
|
217
|
+
align-items: center;
|
|
218
|
+
gap: 24px;
|
|
219
|
+
}
|
|
220
|
+
.brand {
|
|
221
|
+
display: flex; align-items: center; gap: 10px;
|
|
222
|
+
color: #e4e4e7; font-size: 14px; font-weight: 500;
|
|
223
|
+
letter-spacing: -0.01em;
|
|
224
|
+
}
|
|
225
|
+
.brand .mark {
|
|
226
|
+
width: 22px; height: 22px; border-radius: 6px;
|
|
227
|
+
background: linear-gradient(135deg, #fafafa 0%, #71717a 100%);
|
|
228
|
+
display: inline-block;
|
|
229
|
+
}
|
|
230
|
+
.card {
|
|
231
|
+
width: 100%;
|
|
232
|
+
background: #09090b;
|
|
233
|
+
border: 1px solid #27272a;
|
|
234
|
+
border-radius: 12px;
|
|
235
|
+
padding: 28px;
|
|
236
|
+
text-align: center;
|
|
237
|
+
}
|
|
238
|
+
.icon {
|
|
239
|
+
width: 36px; height: 36px; border-radius: 999px;
|
|
240
|
+
background: rgba(239, 68, 68, 0.12);
|
|
241
|
+
color: #f87171;
|
|
242
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
243
|
+
margin: 0 auto 16px;
|
|
244
|
+
}
|
|
245
|
+
.icon svg { width: 18px; height: 18px; }
|
|
246
|
+
h1 {
|
|
247
|
+
margin: 0 0 6px; font-size: 16px; font-weight: 600;
|
|
248
|
+
color: #fafafa; letter-spacing: -0.01em;
|
|
249
|
+
}
|
|
250
|
+
p {
|
|
251
|
+
margin: 0; color: #a1a1aa; font-size: 13px; line-height: 1.5;
|
|
252
|
+
}
|
|
253
|
+
</style>
|
|
254
|
+
</head>
|
|
255
|
+
<body>
|
|
256
|
+
<div class="shell">
|
|
257
|
+
<div class="brand">
|
|
258
|
+
<span class="mark" aria-hidden="true"></span>
|
|
259
|
+
<span>Plexus</span>
|
|
260
|
+
</div>
|
|
261
|
+
<div class="card">
|
|
262
|
+
<div class="icon" aria-hidden="true">
|
|
263
|
+
<svg viewBox="0 0 20 20" fill="none"
|
|
264
|
+
stroke="currentColor" stroke-width="2.5"
|
|
265
|
+
stroke-linecap="round" stroke-linejoin="round">
|
|
266
|
+
<line x1="6" y1="6" x2="14" y2="14" />
|
|
267
|
+
<line x1="14" y1="6" x2="6" y2="14" />
|
|
268
|
+
</svg>
|
|
269
|
+
</div>
|
|
270
|
+
<h1>Authorization failed</h1>
|
|
271
|
+
<p>Return to your terminal for details.</p>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</body>
|
|
275
|
+
</html>""".encode("utf-8")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class _CallbackResult:
|
|
279
|
+
key: Optional[str] = None
|
|
280
|
+
state: Optional[str] = None
|
|
281
|
+
error: Optional[str] = None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _pick_free_port() -> int:
|
|
285
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
286
|
+
s.bind(("127.0.0.1", 0))
|
|
287
|
+
return s.getsockname()[1]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _make_handler(
|
|
291
|
+
result: _CallbackResult,
|
|
292
|
+
expected_state: str,
|
|
293
|
+
done: threading.Event,
|
|
294
|
+
redirect_target: str,
|
|
295
|
+
):
|
|
296
|
+
success_html = _success_html(redirect_target)
|
|
297
|
+
|
|
298
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
299
|
+
# Silence the default request log — we don't want CLI noise.
|
|
300
|
+
def log_message(self, *_args, **_kwargs): # type: ignore[override]
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
def do_GET(self): # type: ignore[override]
|
|
304
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
305
|
+
if parsed.path != "/callback":
|
|
306
|
+
self.send_response(404)
|
|
307
|
+
self.end_headers()
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
311
|
+
got_state = (params.get("state") or [""])[0]
|
|
312
|
+
got_key = (params.get("key") or [""])[0]
|
|
313
|
+
|
|
314
|
+
if got_state != expected_state:
|
|
315
|
+
result.error = "state mismatch"
|
|
316
|
+
self.send_response(400)
|
|
317
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
318
|
+
self.end_headers()
|
|
319
|
+
self.wfile.write(ERROR_HTML)
|
|
320
|
+
done.set()
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
if not got_key:
|
|
324
|
+
result.error = "no key in callback"
|
|
325
|
+
self.send_response(400)
|
|
326
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
327
|
+
self.end_headers()
|
|
328
|
+
self.wfile.write(ERROR_HTML)
|
|
329
|
+
done.set()
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
result.key = got_key
|
|
333
|
+
result.state = got_state
|
|
334
|
+
self.send_response(200)
|
|
335
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
336
|
+
self.end_headers()
|
|
337
|
+
self.wfile.write(success_html)
|
|
338
|
+
done.set()
|
|
339
|
+
|
|
340
|
+
return Handler
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _hostname_label() -> str:
|
|
344
|
+
try:
|
|
345
|
+
host = socket.gethostname() or "device"
|
|
346
|
+
except Exception:
|
|
347
|
+
host = "device"
|
|
348
|
+
# Strip the trailing .local etc. and clean it for display.
|
|
349
|
+
safe = host.split(".")[0].lower().replace(" ", "-")
|
|
350
|
+
return f"cli-{safe}" if safe else "cli"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
354
|
+
"""Open the browser, capture an API key, save it locally."""
|
|
355
|
+
existing = config.get_api_key()
|
|
356
|
+
if existing and not args.force:
|
|
357
|
+
print(
|
|
358
|
+
"An API key is already configured. "
|
|
359
|
+
"Re-run with --force to replace it.",
|
|
360
|
+
file=sys.stderr,
|
|
361
|
+
)
|
|
362
|
+
return 1
|
|
363
|
+
|
|
364
|
+
endpoint = config.get_endpoint().rstrip("/")
|
|
365
|
+
state = secrets.token_urlsafe(24)
|
|
366
|
+
name = args.name or _hostname_label()
|
|
367
|
+
port = _pick_free_port()
|
|
368
|
+
callback = f"http://127.0.0.1:{port}/callback"
|
|
369
|
+
|
|
370
|
+
auth_url = (
|
|
371
|
+
f"{endpoint}/auth/cli"
|
|
372
|
+
f"?state={urllib.parse.quote(state)}"
|
|
373
|
+
f"&callback={urllib.parse.quote(callback)}"
|
|
374
|
+
f"&name={urllib.parse.quote(name)}"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
result = _CallbackResult()
|
|
378
|
+
done = threading.Event()
|
|
379
|
+
handler = _make_handler(result, state, done, redirect_target=endpoint)
|
|
380
|
+
|
|
381
|
+
server = socketserver.TCPServer(("127.0.0.1", port), handler)
|
|
382
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
383
|
+
thread.start()
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
print(f"Opening {auth_url}")
|
|
387
|
+
try:
|
|
388
|
+
webbrowser.open(auth_url, new=1, autoraise=True)
|
|
389
|
+
except Exception:
|
|
390
|
+
pass # User can copy the URL manually.
|
|
391
|
+
|
|
392
|
+
print("Waiting for browser confirmation...", flush=True)
|
|
393
|
+
finished = done.wait(timeout=args.timeout)
|
|
394
|
+
if not finished:
|
|
395
|
+
print(
|
|
396
|
+
f"Timed out after {args.timeout}s. Re-run `plexus init`.",
|
|
397
|
+
file=sys.stderr,
|
|
398
|
+
)
|
|
399
|
+
return 2
|
|
400
|
+
finally:
|
|
401
|
+
server.shutdown()
|
|
402
|
+
server.server_close()
|
|
403
|
+
|
|
404
|
+
if result.error or not result.key:
|
|
405
|
+
print(
|
|
406
|
+
f"Authorization failed: {result.error or 'no key returned'}",
|
|
407
|
+
file=sys.stderr,
|
|
408
|
+
)
|
|
409
|
+
return 3
|
|
410
|
+
|
|
411
|
+
cfg = config.load_config()
|
|
412
|
+
cfg["api_key"] = result.key
|
|
413
|
+
config.save_config(cfg)
|
|
414
|
+
print(f"✓ Saved API key as {name}.")
|
|
415
|
+
print(" ~/.plexus/config.json")
|
|
416
|
+
return 0
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def cmd_logout(_args: argparse.Namespace) -> int:
|
|
420
|
+
"""Forget the locally stored API key."""
|
|
421
|
+
cfg = config.load_config()
|
|
422
|
+
if not cfg.get("api_key"):
|
|
423
|
+
print("Nothing to do — no key on file.")
|
|
424
|
+
return 0
|
|
425
|
+
cfg["api_key"] = None
|
|
426
|
+
config.save_config(cfg)
|
|
427
|
+
print("✓ Cleared local API key.")
|
|
428
|
+
return 0
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def cmd_whoami(_args: argparse.Namespace) -> int:
|
|
432
|
+
"""Print the prefix of the locally stored key + the configured endpoint."""
|
|
433
|
+
key = config.get_api_key()
|
|
434
|
+
endpoint = config.get_endpoint()
|
|
435
|
+
if not key:
|
|
436
|
+
print("Not signed in. Run `plexus init` to authorize this machine.")
|
|
437
|
+
return 1
|
|
438
|
+
masked = f"{key[:8]}…{key[-4:]}" if len(key) > 12 else key
|
|
439
|
+
print(f"key: {masked}")
|
|
440
|
+
print(f"endpoint: {endpoint}")
|
|
441
|
+
return 0
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
445
|
+
parser = argparse.ArgumentParser(
|
|
446
|
+
prog="plexus",
|
|
447
|
+
description="Plexus CLI — auth, send, query telemetry from your terminal.",
|
|
448
|
+
)
|
|
449
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
450
|
+
|
|
451
|
+
init = sub.add_parser(
|
|
452
|
+
"init",
|
|
453
|
+
help="Authorize this machine and save an API key locally.",
|
|
454
|
+
aliases=["login"],
|
|
455
|
+
)
|
|
456
|
+
init.add_argument("--name", help="Label for the issued key (default: cli-<hostname>).")
|
|
457
|
+
init.add_argument(
|
|
458
|
+
"--timeout",
|
|
459
|
+
type=int,
|
|
460
|
+
default=DEFAULT_TIMEOUT_SECONDS,
|
|
461
|
+
help="Seconds to wait for the browser callback.",
|
|
462
|
+
)
|
|
463
|
+
init.add_argument(
|
|
464
|
+
"--force",
|
|
465
|
+
action="store_true",
|
|
466
|
+
help="Overwrite an existing local key.",
|
|
467
|
+
)
|
|
468
|
+
init.set_defaults(func=cmd_init)
|
|
469
|
+
|
|
470
|
+
logout = sub.add_parser("logout", help="Forget the local API key.")
|
|
471
|
+
logout.set_defaults(func=cmd_logout)
|
|
472
|
+
|
|
473
|
+
whoami = sub.add_parser("whoami", help="Show the local credential summary.")
|
|
474
|
+
whoami.set_defaults(func=cmd_whoami)
|
|
475
|
+
|
|
476
|
+
return parser
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def main(argv: Optional[list] = None) -> int:
|
|
480
|
+
parser = build_parser()
|
|
481
|
+
args = parser.parse_args(argv)
|
|
482
|
+
return args.func(args)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
if __name__ == "__main__":
|
|
486
|
+
sys.exit(main())
|
plexus/client.py
CHANGED
|
@@ -49,7 +49,9 @@ from plexus.config import (
|
|
|
49
49
|
get_endpoint,
|
|
50
50
|
get_gateway_url,
|
|
51
51
|
get_gateway_ws_url,
|
|
52
|
+
get_install_id,
|
|
52
53
|
get_source_id,
|
|
54
|
+
set_source_id,
|
|
53
55
|
)
|
|
54
56
|
logger = logging.getLogger(__name__)
|
|
55
57
|
|
|
@@ -259,7 +261,7 @@ class Plexus:
|
|
|
259
261
|
("position", {"x": 1.0, "y": 2.0}),
|
|
260
262
|
])
|
|
261
263
|
"""
|
|
262
|
-
ts = timestamp
|
|
264
|
+
ts = timestamp if timestamp is not None else time.time()
|
|
263
265
|
data_points = [self._make_point(m, v, ts, tags) for m, v in points]
|
|
264
266
|
return self._send_points(data_points)
|
|
265
267
|
|
|
@@ -273,11 +275,23 @@ class Plexus:
|
|
|
273
275
|
api_key=self.api_key,
|
|
274
276
|
source_id=self.source_id,
|
|
275
277
|
ws_url=self._ws_url,
|
|
278
|
+
install_id=get_install_id(),
|
|
276
279
|
agent_version=__version__,
|
|
280
|
+
on_source_id_assigned=self._on_source_id_assigned,
|
|
277
281
|
)
|
|
278
282
|
self._ws.start()
|
|
279
283
|
return self._ws
|
|
280
284
|
|
|
285
|
+
def _on_source_id_assigned(self, assigned: str) -> None:
|
|
286
|
+
"""Callback from WebSocketTransport when the gateway returns an
|
|
287
|
+
auto-suffixed source_id. Persists it so subsequent runs (and the HTTP
|
|
288
|
+
fallback path in this process) use the assigned name directly."""
|
|
289
|
+
self.source_id = assigned
|
|
290
|
+
try:
|
|
291
|
+
set_source_id(assigned)
|
|
292
|
+
except Exception as e: # pragma: no cover - persistence failure is non-fatal
|
|
293
|
+
logger.debug("failed to persist assigned source_id: %s", e)
|
|
294
|
+
|
|
281
295
|
def on_command(
|
|
282
296
|
self,
|
|
283
297
|
name: str,
|
plexus/config.py
CHANGED
|
@@ -145,6 +145,54 @@ def get_source_id() -> Optional[str]:
|
|
|
145
145
|
return source_id
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
def get_install_id() -> str:
|
|
149
|
+
"""Get the device install ID, generating one if not set.
|
|
150
|
+
|
|
151
|
+
The install_id is a stable per-installation UUID. It is generated lazily
|
|
152
|
+
on first run (NOT at image-build time) so that cloned SD-card images
|
|
153
|
+
naturally get distinct install_ids on their first boot. The gateway uses
|
|
154
|
+
it to tell "same device reconnecting" from "different device claiming the
|
|
155
|
+
same name" when resolving source_id collisions.
|
|
156
|
+
|
|
157
|
+
Resolution order:
|
|
158
|
+
1. ``PLEXUS_INSTALL_ID`` env var — lets ephemeral containers (Fly
|
|
159
|
+
machines, CI runners, Kubernetes pods) pin a stable identity
|
|
160
|
+
across restarts when the config filesystem is ephemeral. Without
|
|
161
|
+
this, every redeploy generates a new install_id and the gateway
|
|
162
|
+
auto-suffixes the source_id to avoid a collision with the prior
|
|
163
|
+
install ("gw-001" → "gw-001_2" → "gw-001_3"…).
|
|
164
|
+
2. ``install_id`` in the on-disk config.
|
|
165
|
+
3. Newly-generated UUID, persisted to config.
|
|
166
|
+
"""
|
|
167
|
+
env_id = os.environ.get("PLEXUS_INSTALL_ID", "").strip()
|
|
168
|
+
if env_id:
|
|
169
|
+
return env_id
|
|
170
|
+
|
|
171
|
+
config = load_config()
|
|
172
|
+
install_id = config.get("install_id")
|
|
173
|
+
|
|
174
|
+
if not install_id:
|
|
175
|
+
import uuid
|
|
176
|
+
install_id = uuid.uuid4().hex
|
|
177
|
+
config["install_id"] = install_id
|
|
178
|
+
save_config(config)
|
|
179
|
+
|
|
180
|
+
return install_id
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def set_source_id(source_id: str) -> None:
|
|
184
|
+
"""Persist an updated source_id to the config file.
|
|
185
|
+
|
|
186
|
+
Called by the SDK when the gateway returns an auto-suffixed name so the
|
|
187
|
+
assigned name is stable across reconnects.
|
|
188
|
+
"""
|
|
189
|
+
config = load_config()
|
|
190
|
+
if config.get("source_id") == source_id:
|
|
191
|
+
return
|
|
192
|
+
config["source_id"] = source_id
|
|
193
|
+
save_config(config)
|
|
194
|
+
|
|
195
|
+
|
|
148
196
|
def get_persistent_buffer() -> bool:
|
|
149
197
|
"""Get persistent buffer setting. Default True (store-and-forward enabled)."""
|
|
150
198
|
config = load_config()
|
plexus/ws.py
CHANGED
|
@@ -5,8 +5,14 @@ Wire-compatible with the C SDK (`plexus_ws.c`). Targets the gateway's
|
|
|
5
5
|
`/ws/device` endpoint and exchanges the same JSON frames:
|
|
6
6
|
|
|
7
7
|
client → {"type": "device_auth", "api_key": ..., "source_id": ...,
|
|
8
|
-
"platform": "python-sdk",
|
|
8
|
+
"install_id": ..., "platform": "python-sdk",
|
|
9
|
+
"agent_version": ..., "commands": [...]}
|
|
9
10
|
server → {"type": "authenticated", "source_id": ...}
|
|
11
|
+
|
|
12
|
+
The server-returned `source_id` in the `authenticated` frame is
|
|
13
|
+
authoritative: if the gateway auto-suffixed on a collision (e.g. the
|
|
14
|
+
desired name was already claimed by a different install_id), the
|
|
15
|
+
client's `source_id` is updated in place to match.
|
|
10
16
|
client → {"type": "telemetry", "points": [...]}
|
|
11
17
|
client → {"type": "heartbeat", "source_id": ..., "agent_version": ...} # every 30s
|
|
12
18
|
server → {"type": "typed_command", "id": ..., "command": ..., "params": {...}}
|
|
@@ -78,9 +84,11 @@ class WebSocketTransport:
|
|
|
78
84
|
source_id: str,
|
|
79
85
|
ws_url: str,
|
|
80
86
|
*,
|
|
87
|
+
install_id: str = "",
|
|
81
88
|
agent_version: str = "0.0.0",
|
|
82
89
|
platform: str = "python-sdk",
|
|
83
90
|
auto_reconnect: bool = True,
|
|
91
|
+
on_source_id_assigned: Optional[Callable[[str], None]] = None,
|
|
84
92
|
):
|
|
85
93
|
if not api_key:
|
|
86
94
|
raise ValueError("api_key required")
|
|
@@ -89,10 +97,12 @@ class WebSocketTransport:
|
|
|
89
97
|
|
|
90
98
|
self.api_key = api_key
|
|
91
99
|
self.source_id = source_id
|
|
100
|
+
self.install_id = install_id
|
|
92
101
|
self.ws_url = _ensure_device_path(ws_url)
|
|
93
102
|
self.agent_version = agent_version
|
|
94
103
|
self.platform = platform
|
|
95
104
|
self.auto_reconnect = auto_reconnect
|
|
105
|
+
self._on_source_id_assigned = on_source_id_assigned
|
|
96
106
|
|
|
97
107
|
self._commands: Dict[str, _RegisteredCommand] = {}
|
|
98
108
|
self._ws: Optional[websocket.WebSocket] = None
|
|
@@ -184,13 +194,16 @@ class WebSocketTransport:
|
|
|
184
194
|
self._ws = ws
|
|
185
195
|
|
|
186
196
|
# 1. Send device_auth
|
|
197
|
+
desired_source_id = self.source_id
|
|
187
198
|
auth = {
|
|
188
199
|
"type": "device_auth",
|
|
189
200
|
"api_key": self.api_key,
|
|
190
|
-
"source_id":
|
|
201
|
+
"source_id": desired_source_id,
|
|
191
202
|
"platform": self.platform,
|
|
192
203
|
"agent_version": self.agent_version,
|
|
193
204
|
}
|
|
205
|
+
if self.install_id:
|
|
206
|
+
auth["install_id"] = self.install_id
|
|
194
207
|
if self._commands:
|
|
195
208
|
auth["commands"] = [c.to_manifest() for c in self._commands.values()]
|
|
196
209
|
ws.send(json.dumps(auth))
|
|
@@ -206,6 +219,22 @@ class WebSocketTransport:
|
|
|
206
219
|
if msg.get("type") != "authenticated":
|
|
207
220
|
raise RuntimeError(f"auth failed: {msg}")
|
|
208
221
|
|
|
222
|
+
# The gateway may return a different source_id if the desired name
|
|
223
|
+
# was already claimed by another install — adopt the assigned value
|
|
224
|
+
# so all subsequent frames (heartbeats, future reconnects) use it.
|
|
225
|
+
assigned = msg.get("source_id")
|
|
226
|
+
if isinstance(assigned, str) and assigned and assigned != self.source_id:
|
|
227
|
+
logger.info(
|
|
228
|
+
"plexus ws source_id auto-suffixed: requested=%s assigned=%s",
|
|
229
|
+
desired_source_id, assigned,
|
|
230
|
+
)
|
|
231
|
+
self.source_id = assigned
|
|
232
|
+
if self._on_source_id_assigned is not None:
|
|
233
|
+
try:
|
|
234
|
+
self._on_source_id_assigned(assigned)
|
|
235
|
+
except Exception as e: # pragma: no cover - callback errors must not break auth
|
|
236
|
+
logger.debug("on_source_id_assigned callback raised: %s", e)
|
|
237
|
+
|
|
209
238
|
self._authenticated.set()
|
|
210
239
|
self._backoff_attempt = 0
|
|
211
240
|
logger.info("plexus ws authenticated as %s", self.source_id)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plexus-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Thin Python SDK for Plexus — send telemetry in one line
|
|
5
5
|
Project-URL: Homepage, https://plexus.dev
|
|
6
6
|
Project-URL: Documentation, https://docs.plexus.dev
|
|
@@ -54,6 +54,21 @@ px.send("temperature", 72.5)
|
|
|
54
54
|
|
|
55
55
|
Get an API key at [app.plexus.company](https://app.plexus.company) → Devices → Add Device.
|
|
56
56
|
|
|
57
|
+
## Device identity
|
|
58
|
+
|
|
59
|
+
Every device needs a unique `source_id`. The recommended way to set one on a real host is the bootstrap script, which requires a device name up front:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
curl -sL https://app.plexus.company/setup | bash -s -- \
|
|
63
|
+
--key plx_xxx --name drone-01
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The name must match `^[a-z0-9][a-z0-9_-]{1,62}$`. `setup.sh` refuses to run without `--name` (or without a TTY to prompt for one) — this is deliberate, because the previous `hostname` fallback silently merged telemetry from cloned SD-card images that all booted as `raspberrypi`.
|
|
67
|
+
|
|
68
|
+
**If two devices end up requesting the same name**, the gateway auto-suffixes: the first connection gets `drone-01`, the second gets `drone-01_2`, the third `drone-01_3`, and so on. The SDK logs the rename at INFO and persists the assigned name to `~/.plexus/config.json` so the device keeps its identity across reboots. Under the hood, a per-installation UUID (`install_id`, lazily generated on first run) is what lets the gateway tell "same device reconnecting" from "different device claiming the same name."
|
|
69
|
+
|
|
70
|
+
In normal code, you usually just pass `source_id=...` explicitly to `Plexus(...)` and never have to think about it.
|
|
71
|
+
|
|
57
72
|
## Usage
|
|
58
73
|
|
|
59
74
|
```python
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
plexus/__init__.py,sha256=sIeMuUgTaztA5jYnSxh6T-2lBBjRR7TXQiVHXut5SXI,345
|
|
2
|
+
plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
|
|
3
|
+
plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
|
|
4
|
+
plexus/client.py,sha256=Hp-qUdLkZ83OQeF_3d2FH5kCZXK9iJOSmO7o0opOR8U,19395
|
|
5
|
+
plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
|
|
6
|
+
plexus/ws.py,sha256=upQ9SpekDYa7MltUW5ZDEuCm_E8hEVpxC0QFNP_jT1g,12581
|
|
7
|
+
plexus_python-0.4.2.dist-info/METADATA,sha256=1hIhHwH2CtEf-CN8iC6DBNyknKE-NkmaF7pfM1bEiFQ,6800
|
|
8
|
+
plexus_python-0.4.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
plexus_python-0.4.2.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
10
|
+
plexus_python-0.4.2.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
11
|
+
plexus_python-0.4.2.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
plexus/__init__.py,sha256=pWOj-JeHkHxRS6r5bbXFLWjk3Iv36TjsHdhvWHNRe98,345
|
|
2
|
-
plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
|
|
3
|
-
plexus/client.py,sha256=Pv9xmZMlHoNIr3SjFw8iLwu06QPUO4qEDaAGz3gx-f4,18698
|
|
4
|
-
plexus/config.py,sha256=RNym2Fon6JOCVi1rXPSRWjPFAdT8DSmokY5JPEljQOc,4450
|
|
5
|
-
plexus/ws.py,sha256=U8FtIxzTDIOgjpfSUtCnqN738uIX8b07MytGHwHGqWE,11112
|
|
6
|
-
plexus_python-0.3.0.dist-info/METADATA,sha256=Fk-PyYEGSE_b5LY78p7bRMDRnD702VyXG3pbVIV7-mY,5613
|
|
7
|
-
plexus_python-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
-
plexus_python-0.3.0.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
9
|
-
plexus_python-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|