lr-lumenlabel-cli 3.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.
- lr_lumenlabel_cli-3.0.0.dist-info/METADATA +121 -0
- lr_lumenlabel_cli-3.0.0.dist-info/RECORD +25 -0
- lr_lumenlabel_cli-3.0.0.dist-info/WHEEL +5 -0
- lr_lumenlabel_cli-3.0.0.dist-info/entry_points.txt +2 -0
- lr_lumenlabel_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
- lr_lumenlabel_cli-3.0.0.dist-info/top_level.txt +1 -0
- lumenlabel/__init__.py +6 -0
- lumenlabel/__main__.py +3 -0
- lumenlabel/_version.py +24 -0
- lumenlabel/build_words.py +75 -0
- lumenlabel/cli.py +60 -0
- lumenlabel/client.py +362 -0
- lumenlabel/commands/__init__.py +1 -0
- lumenlabel/commands/api.py +64 -0
- lumenlabel/commands/auth.py +59 -0
- lumenlabel/commands/label.py +253 -0
- lumenlabel/commands/station.py +105 -0
- lumenlabel/config.py +132 -0
- lumenlabel/config_rules.py +92 -0
- lumenlabel/importer.py +47 -0
- lumenlabel/manifest_gen.py +232 -0
- lumenlabel/media_check.py +73 -0
- lumenlabel/package.py +294 -0
- lumenlabel/shared.py +131 -0
- lumenlabel/wordlist/bip39-english.txt +2048 -0
lumenlabel/client.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""HTTP client for the LumenLabel station API (doc/api-reference.md).
|
|
2
|
+
|
|
3
|
+
A station URL is one of three kinds, and the kind decides authentication:
|
|
4
|
+
|
|
5
|
+
- **relay** ``https://<fleet>/v1/s/<uid>``: ``X-Fleet-Authorization: Bearer``
|
|
6
|
+
with the technician's Fleet token from ``lr_fleet.auth.access_token()``.
|
|
7
|
+
Fleet's own refusals carry ``X-Fleet-Error`` and become :class:`FleetError`.
|
|
8
|
+
- **loopback** ``http://127.0.0.1:<port>``: no authentication; Fleet is never
|
|
9
|
+
contacted.
|
|
10
|
+
- **lan** any other URL: ``Authorization: Bearer <station token>``.
|
|
11
|
+
|
|
12
|
+
The CLI never signs as the station: the station's Fleet identity belongs to
|
|
13
|
+
the running app alone.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ipaddress
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Callable, Iterator
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
RELAY = "relay"
|
|
27
|
+
LOOPBACK = "loopback"
|
|
28
|
+
LAN = "lan"
|
|
29
|
+
|
|
30
|
+
UPLOAD_TIMEOUT_S = 300.0
|
|
31
|
+
COMMIT_TIMEOUT_S = 120.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ClientError(RuntimeError):
|
|
35
|
+
"""A request failed. The message is meant for the technician."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NetworkError(ClientError):
|
|
39
|
+
"""No response came back: refused, dropped or timed out."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NotRunningError(NetworkError):
|
|
43
|
+
"""Nothing listens on the loopback station URL."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StatusError(ClientError):
|
|
47
|
+
"""The station answered with a non-2xx status."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self, message: str, status_code: int, code: str | None, body: Any
|
|
51
|
+
) -> None:
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.status_code = status_code
|
|
54
|
+
self.code = code
|
|
55
|
+
self.body = body
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FleetError(StatusError):
|
|
59
|
+
"""Fleet refused or could not relay the request (``X-Fleet-Error``)."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FleetSignInError(ClientError):
|
|
63
|
+
"""A relay URL needs a signed-in technician and there is none."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_RELAY_PATH = re.compile(r"^/v1/s/([0-9a-f]{32})/?$")
|
|
67
|
+
|
|
68
|
+
# doc/fleet-wire-contract.md §12.7, the X-Fleet-Error table.
|
|
69
|
+
FLEET_MESSAGES: dict[str, str] = {
|
|
70
|
+
"unauthorized": "not signed in to Fleet; run `fleet login`",
|
|
71
|
+
"forbidden": "your Fleet account lacks write access",
|
|
72
|
+
"unknown_device": "Fleet does not know station {uid}, or it is revoked",
|
|
73
|
+
"path_not_allowed": "Fleet does not relay this path to station {uid}",
|
|
74
|
+
"device_offline": (
|
|
75
|
+
"station {uid} is offline: its tunnel to Fleet is down. Check that "
|
|
76
|
+
"LumenLabel runs and remote access is on"
|
|
77
|
+
),
|
|
78
|
+
"timeout": "station {uid} did not answer within Fleet's time limit",
|
|
79
|
+
"busy": "too many requests in flight to station {uid}; retry",
|
|
80
|
+
"device_protocol_error": "station {uid} sent Fleet an invalid reply",
|
|
81
|
+
"device_reset": "station {uid} dropped the request before answering; retry",
|
|
82
|
+
"headers_too_large": "the request headers are too large for the Fleet relay",
|
|
83
|
+
"fleet_unavailable": "Fleet is restarting; retry in a minute",
|
|
84
|
+
"gateway_timeout": "Fleet timed out waiting for station {uid}; retry",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# A load balancer answers 502/503 while Fleet has no healthy target and 504
|
|
88
|
+
# on its idle timeout. Those pages carry no X-Fleet-Error and are not JSON.
|
|
89
|
+
_GATEWAY_STATUSES = frozenset({502, 503, 504})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def relay_uid(url: str) -> str | None:
|
|
93
|
+
"""The station uid of a Fleet relay URL, else None."""
|
|
94
|
+
match = _RELAY_PATH.match(httpx.URL(url).path)
|
|
95
|
+
return match.group(1) if match else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_loopback_host(host: str) -> bool:
|
|
99
|
+
if host.lower() == "localhost":
|
|
100
|
+
return True
|
|
101
|
+
try:
|
|
102
|
+
addr = ipaddress.ip_address(host.strip("[]"))
|
|
103
|
+
except ValueError:
|
|
104
|
+
return False
|
|
105
|
+
mapped = getattr(addr, "ipv4_mapped", None)
|
|
106
|
+
return addr.is_loopback or bool(mapped and mapped.is_loopback)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_station_url(url: str) -> httpx.URL:
|
|
110
|
+
"""*url* parsed; raises :class:`ClientError` unless it is an http(s) URL
|
|
111
|
+
with a host."""
|
|
112
|
+
try:
|
|
113
|
+
parsed = httpx.URL(url)
|
|
114
|
+
except httpx.InvalidURL as exc:
|
|
115
|
+
raise ClientError(f"{url!r} is not a valid station URL: {exc}") from exc
|
|
116
|
+
if parsed.scheme not in ("http", "https") or not parsed.host:
|
|
117
|
+
raise ClientError(
|
|
118
|
+
f"{url!r} is not a station URL; expected http(s)://<host>[:<port>]"
|
|
119
|
+
)
|
|
120
|
+
return parsed
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def station_kind(url: str) -> str:
|
|
124
|
+
"""``relay``, ``loopback`` or ``lan`` for a station URL.
|
|
125
|
+
|
|
126
|
+
Raises :class:`ClientError` for a malformed URL.
|
|
127
|
+
"""
|
|
128
|
+
parsed = check_station_url(url)
|
|
129
|
+
if relay_uid(url) is not None:
|
|
130
|
+
return RELAY
|
|
131
|
+
return LOOPBACK if _is_loopback_host(parsed.host) else LAN
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def fleet_message(code: str, uid: str | None) -> str:
|
|
135
|
+
template = FLEET_MESSAGES.get(code, "Fleet failed the request ({code})")
|
|
136
|
+
return template.format(uid=uid or "?", code=code)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _stdin_is_tty() -> bool:
|
|
140
|
+
try:
|
|
141
|
+
return bool(sys.stdin and sys.stdin.isatty())
|
|
142
|
+
except (ValueError, OSError):
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def fleet_token(
|
|
147
|
+
interactive: bool, on_sign_in: Callable[[str], None] | None = None
|
|
148
|
+
) -> str:
|
|
149
|
+
"""The technician's Fleet token. Raises :class:`FleetSignInError`.
|
|
150
|
+
|
|
151
|
+
The cached sign-in is tried first. Only an interactive call with a
|
|
152
|
+
terminal starts the browser sign-in.
|
|
153
|
+
"""
|
|
154
|
+
from lr_fleet import auth as fleet_auth
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
return fleet_auth.access_token(interactive=False)
|
|
158
|
+
except fleet_auth.NotSignedIn as exc:
|
|
159
|
+
reason = str(exc)
|
|
160
|
+
hint = "" if "fleet login" in reason else "; run `fleet login`"
|
|
161
|
+
error = FleetSignInError(f"Fleet sign-in required: {reason}{hint}")
|
|
162
|
+
if not interactive or not _stdin_is_tty():
|
|
163
|
+
raise error from exc
|
|
164
|
+
if on_sign_in is not None:
|
|
165
|
+
on_sign_in(reason)
|
|
166
|
+
try:
|
|
167
|
+
return fleet_auth.access_token(interactive=True)
|
|
168
|
+
except fleet_auth.NotSignedIn as exc:
|
|
169
|
+
raise error from exc
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _FleetAuth(httpx.Auth):
|
|
173
|
+
"""Stamps each relay request with a fresh Fleet token.
|
|
174
|
+
|
|
175
|
+
A Fleet 401 ``unauthorized`` is retried once. The body is read first so
|
|
176
|
+
that a multipart upload can be resent too.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
requires_request_body = True
|
|
180
|
+
|
|
181
|
+
def _stamp(self, request: httpx.Request) -> None:
|
|
182
|
+
request.headers["X-Fleet-Authorization"] = f"Bearer {fleet_token(False)}"
|
|
183
|
+
|
|
184
|
+
def auth_flow(self, request: httpx.Request) -> Iterator[httpx.Request]:
|
|
185
|
+
self._stamp(request)
|
|
186
|
+
response = yield request
|
|
187
|
+
if (
|
|
188
|
+
response.status_code == 401
|
|
189
|
+
and response.headers.get("X-Fleet-Error") == "unauthorized"
|
|
190
|
+
):
|
|
191
|
+
self._stamp(request)
|
|
192
|
+
yield request
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _json_or_none(response: httpx.Response) -> Any:
|
|
196
|
+
try:
|
|
197
|
+
return response.json()
|
|
198
|
+
except ValueError:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class StationClient:
|
|
203
|
+
"""Synchronous client for one station.
|
|
204
|
+
|
|
205
|
+
*transport* replaces the network, for tests.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
station: str,
|
|
211
|
+
token: str | None = None,
|
|
212
|
+
*,
|
|
213
|
+
timeout: float = 30.0,
|
|
214
|
+
interactive: bool = False,
|
|
215
|
+
on_fleet_sign_in: Callable[[str], None] | None = None,
|
|
216
|
+
transport: httpx.BaseTransport | None = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
self.station = station.rstrip("/")
|
|
219
|
+
self.kind = station_kind(self.station)
|
|
220
|
+
self.relay_uid = relay_uid(self.station)
|
|
221
|
+
headers: dict[str, str] = {}
|
|
222
|
+
auth: httpx.Auth | None = None
|
|
223
|
+
if self.kind == RELAY:
|
|
224
|
+
fleet_token(interactive, on_fleet_sign_in)
|
|
225
|
+
auth = _FleetAuth()
|
|
226
|
+
elif self.kind == LAN and token:
|
|
227
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
228
|
+
self._client = httpx.Client(
|
|
229
|
+
base_url=self.station,
|
|
230
|
+
headers=headers,
|
|
231
|
+
auth=auth,
|
|
232
|
+
timeout=timeout,
|
|
233
|
+
transport=transport,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def __enter__(self) -> "StationClient":
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def __exit__(self, *_: object) -> None:
|
|
240
|
+
self.close()
|
|
241
|
+
|
|
242
|
+
def close(self) -> None:
|
|
243
|
+
self._client.close()
|
|
244
|
+
|
|
245
|
+
# -- endpoints -------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def station_info(self) -> dict:
|
|
248
|
+
return self.request("GET", "/api/station")
|
|
249
|
+
|
|
250
|
+
def labels(self) -> list[dict]:
|
|
251
|
+
return self.request("GET", "/api/labels")
|
|
252
|
+
|
|
253
|
+
def label(self, article: str) -> dict:
|
|
254
|
+
return self.request("GET", f"/api/labels/{article}")
|
|
255
|
+
|
|
256
|
+
def delete_label(self, article: str) -> None:
|
|
257
|
+
self.request("DELETE", f"/api/labels/{article}")
|
|
258
|
+
|
|
259
|
+
def print_history(self, limit: int, before: str | None = None) -> dict:
|
|
260
|
+
params: dict[str, Any] = {"limit": limit}
|
|
261
|
+
if before:
|
|
262
|
+
params["before"] = before
|
|
263
|
+
return self.request("GET", "/api/print-history", params=params)
|
|
264
|
+
|
|
265
|
+
def logs(self, tail: int) -> dict:
|
|
266
|
+
return self.request("GET", "/api/logs", params={"tail": tail})
|
|
267
|
+
|
|
268
|
+
def preflight(
|
|
269
|
+
self, article: str, revision: str, files: list[dict], invalidate: bool
|
|
270
|
+
) -> list[dict]:
|
|
271
|
+
body = {
|
|
272
|
+
"article": article,
|
|
273
|
+
"revision": revision,
|
|
274
|
+
"files": files,
|
|
275
|
+
"invalidate": invalidate,
|
|
276
|
+
}
|
|
277
|
+
return self.request("POST", "/api/labels/preflight", json=body)["missing"]
|
|
278
|
+
|
|
279
|
+
def upload(self, article: str, revision: str, path: str, file: Path) -> dict:
|
|
280
|
+
"""Streams one file into the station's staging folder."""
|
|
281
|
+
with file.open("rb") as fh:
|
|
282
|
+
return self.request(
|
|
283
|
+
"POST",
|
|
284
|
+
"/api/labels/import/upload",
|
|
285
|
+
data={"article": article, "revision": revision, "path": path},
|
|
286
|
+
files={"file": (Path(path).name, fh, "application/octet-stream")},
|
|
287
|
+
timeout=UPLOAD_TIMEOUT_S,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def commit(self, article: str, revision: str) -> dict:
|
|
291
|
+
return self.request(
|
|
292
|
+
"POST",
|
|
293
|
+
"/api/labels/import/commit",
|
|
294
|
+
json={"article": article, "revision": revision},
|
|
295
|
+
timeout=COMMIT_TIMEOUT_S,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# -- transport -------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def send(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
301
|
+
"""One request; any status. Raises :class:`NetworkError`."""
|
|
302
|
+
try:
|
|
303
|
+
return self._client.request(method, path, **kwargs)
|
|
304
|
+
except httpx.RequestError as exc:
|
|
305
|
+
raise self._network_error(exc) from exc
|
|
306
|
+
|
|
307
|
+
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
308
|
+
"""One request; the JSON body of a 2xx answer, else raises."""
|
|
309
|
+
response = self.send(method, path, **kwargs)
|
|
310
|
+
if not response.is_success:
|
|
311
|
+
raise self.status_error(response)
|
|
312
|
+
if response.status_code == 204 or not response.content:
|
|
313
|
+
return None
|
|
314
|
+
body = _json_or_none(response)
|
|
315
|
+
return response.text if body is None else body
|
|
316
|
+
|
|
317
|
+
def _network_error(self, exc: httpx.RequestError) -> NetworkError:
|
|
318
|
+
if isinstance(exc, httpx.ConnectError) and self.kind == LOOPBACK:
|
|
319
|
+
return NotRunningError(
|
|
320
|
+
f"LumenLabel is not running: nothing answers on {self.station}. "
|
|
321
|
+
"Start LumenLabel on this station and retry."
|
|
322
|
+
)
|
|
323
|
+
if isinstance(exc, httpx.ConnectError) and self.kind == LAN:
|
|
324
|
+
return NetworkError(
|
|
325
|
+
f"Cannot reach the station at {self.station} ({exc}). Check that "
|
|
326
|
+
"LumenLabel runs there with instrumentation.host set to 0.0.0.0."
|
|
327
|
+
)
|
|
328
|
+
where = "Fleet" if self.kind == RELAY else f"the station at {self.station}"
|
|
329
|
+
return NetworkError(f"The connection to {where} failed: {exc}")
|
|
330
|
+
|
|
331
|
+
def status_error(self, response: httpx.Response) -> StatusError:
|
|
332
|
+
"""The error for a non-2xx response; Fleet refusals speak about Fleet."""
|
|
333
|
+
status = response.status_code
|
|
334
|
+
body = _json_or_none(response)
|
|
335
|
+
fleet_code = response.headers.get("X-Fleet-Error")
|
|
336
|
+
if fleet_code:
|
|
337
|
+
message = fleet_message(fleet_code, self.relay_uid)
|
|
338
|
+
return FleetError(message, status, fleet_code, body)
|
|
339
|
+
if self.kind == RELAY and status in _GATEWAY_STATUSES and body is None:
|
|
340
|
+
code = "gateway_timeout" if status == 504 else "fleet_unavailable"
|
|
341
|
+
message = fleet_message(code, self.relay_uid)
|
|
342
|
+
return FleetError(
|
|
343
|
+
f"{message} (HTTP {status} from Fleet's load balancer)",
|
|
344
|
+
status,
|
|
345
|
+
code,
|
|
346
|
+
None,
|
|
347
|
+
)
|
|
348
|
+
code = body.get("code") if isinstance(body, dict) else None
|
|
349
|
+
return StatusError(_detail(response, body), status, code, body)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _detail(response: httpx.Response, body: Any) -> str:
|
|
353
|
+
if isinstance(body, dict):
|
|
354
|
+
for key in ("detail", "error"):
|
|
355
|
+
if body.get(key):
|
|
356
|
+
return str(body[key])
|
|
357
|
+
text = response.text.strip()
|
|
358
|
+
return (
|
|
359
|
+
f"HTTP {response.status_code}: {text}"
|
|
360
|
+
if text
|
|
361
|
+
else (f"HTTP {response.status_code}")
|
|
362
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command groups of the lumenlabel CLI."""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""``lumenlabel api``: any ``/api/`` endpoint, with the CLI's authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ..client import ClientError
|
|
12
|
+
from ..shared import emit_json, explain, fail, open_client
|
|
13
|
+
|
|
14
|
+
METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _body(data: str | None) -> Any:
|
|
18
|
+
if data is None:
|
|
19
|
+
return None
|
|
20
|
+
if data.startswith("@"):
|
|
21
|
+
try:
|
|
22
|
+
data = Path(data[1:]).read_text(encoding="utf-8")
|
|
23
|
+
except OSError as exc:
|
|
24
|
+
fail(f"cannot read {data[1:]}: {exc}")
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(data)
|
|
27
|
+
except json.JSONDecodeError as exc:
|
|
28
|
+
fail(f"--data is not JSON: {exc}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def api(
|
|
32
|
+
ctx: typer.Context,
|
|
33
|
+
method: str = typer.Argument(..., help="GET, POST, PUT, PATCH or DELETE."),
|
|
34
|
+
path: str = typer.Argument(..., help="An /api/ path, e.g. /api/labels."),
|
|
35
|
+
data: str = typer.Option(
|
|
36
|
+
None, "--data", "-d", help="JSON request body, or @FILE to read it."
|
|
37
|
+
),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Call a station API endpoint and print its JSON answer."""
|
|
40
|
+
method = method.upper()
|
|
41
|
+
if method not in METHODS:
|
|
42
|
+
fail(f"method must be one of {', '.join(METHODS)}")
|
|
43
|
+
if not path.startswith("/api/"):
|
|
44
|
+
fail("path must start with /api/")
|
|
45
|
+
body = _body(data)
|
|
46
|
+
client = open_client(ctx)
|
|
47
|
+
try:
|
|
48
|
+
kwargs = {"json": body} if body is not None else {}
|
|
49
|
+
response = client.send(method, path, **kwargs)
|
|
50
|
+
if not response.is_success:
|
|
51
|
+
error = client.status_error(response)
|
|
52
|
+
if response.content:
|
|
53
|
+
typer.echo(response.text)
|
|
54
|
+
fail(explain(error, client))
|
|
55
|
+
except ClientError as exc:
|
|
56
|
+
fail(explain(exc, client))
|
|
57
|
+
finally:
|
|
58
|
+
client.close()
|
|
59
|
+
if not response.content:
|
|
60
|
+
return
|
|
61
|
+
try:
|
|
62
|
+
emit_json(response.json())
|
|
63
|
+
except ValueError:
|
|
64
|
+
typer.echo(response.text)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""``lumenlabel login`` and ``logout``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ..client import LAN, RELAY, ClientError, station_kind
|
|
8
|
+
from ..config import LoginState, forget_login, save_login
|
|
9
|
+
from ..shared import explain, fail, make_client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def login(
|
|
13
|
+
station: str = typer.Option(
|
|
14
|
+
...,
|
|
15
|
+
"--station",
|
|
16
|
+
"--base-url",
|
|
17
|
+
"-s",
|
|
18
|
+
help="Fleet relay URL (https://<fleet>/v1/s/<uid>), LAN URL or loopback "
|
|
19
|
+
"URL. --base-url is the same option, as in lumentest.",
|
|
20
|
+
),
|
|
21
|
+
token: str = typer.Option(
|
|
22
|
+
None,
|
|
23
|
+
"--token",
|
|
24
|
+
help="Station token, for a LAN URL. Shown in admin mode on the station.",
|
|
25
|
+
envvar="LUMENLABEL_TOKEN",
|
|
26
|
+
),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Check a station and remember it for the following commands."""
|
|
29
|
+
station = station.rstrip("/")
|
|
30
|
+
try:
|
|
31
|
+
kind = station_kind(station)
|
|
32
|
+
except ClientError as exc:
|
|
33
|
+
fail(str(exc))
|
|
34
|
+
if kind == LAN and not token:
|
|
35
|
+
token = typer.prompt("Station token", hide_input=True)
|
|
36
|
+
if kind != LAN:
|
|
37
|
+
token = None
|
|
38
|
+
client = make_client(station, token)
|
|
39
|
+
try:
|
|
40
|
+
info = client.station_info()
|
|
41
|
+
client.labels() # needs the token on a LAN URL
|
|
42
|
+
except ClientError as exc:
|
|
43
|
+
fail(explain(exc, client))
|
|
44
|
+
finally:
|
|
45
|
+
client.close()
|
|
46
|
+
path = save_login(LoginState(station, token))
|
|
47
|
+
via = " through Fleet" if kind == RELAY else ""
|
|
48
|
+
typer.echo(
|
|
49
|
+
f"Station {info.get('hostname')} (LumenLabel {info.get('version')}) at "
|
|
50
|
+
f"{station}{via}. Saved to {path}."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def logout() -> None:
|
|
55
|
+
"""Forget the remembered station."""
|
|
56
|
+
if forget_login():
|
|
57
|
+
typer.echo("Forgot the remembered station.")
|
|
58
|
+
else:
|
|
59
|
+
typer.echo("No station was remembered.")
|