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.
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.")