leancpms-cli 0.1.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.
cpms/cli/main.py ADDED
@@ -0,0 +1,982 @@
1
+ """`cpms`: the operator's command line, a thin client of the JSON API (architecture §3, appendix).
2
+
3
+ Rules: `--json` on every command, tables by default; exit 0 (ok), 1 (the
4
+ action failed), 2 (usage); never a prompt without a TTY. `cpms` alone prints
5
+ the live state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import csv
12
+ import json
13
+ import os
14
+ import sys
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Any, Callable
18
+ from zoneinfo import ZoneInfo
19
+
20
+ from cpms.manifest.documents import ManifestError, diff, json_schema, parse_yaml, to_data, to_yaml
21
+
22
+ from . import login
23
+ from .client import Api, ApiError, UsageError, config_path, load_config
24
+
25
+ Out = Callable[[str], None]
26
+
27
+
28
+ def _print(s: str = "") -> None:
29
+ sys.stdout.write(s + "\n")
30
+
31
+
32
+ def _err(s: str) -> None:
33
+ sys.stderr.write(s + "\n")
34
+
35
+
36
+ def _dump(data: Any) -> None:
37
+ _print(json.dumps(data, indent=2, ensure_ascii=False))
38
+
39
+
40
+ def table(rows: list[list[Any]], header: list[str], right: int | None = None) -> None:
41
+ """A plain text table. Columns from index `right` on are right-aligned (numbers)."""
42
+ cells = [[("" if v is None else str(v)) for v in r] for r in rows]
43
+ widths = [max(len(h), *(len(r[i]) for r in cells)) if cells else len(h) for i, h in enumerate(header)]
44
+
45
+ def line(vs: list[str]) -> str:
46
+ return " ".join(v.rjust(w) if right is not None and i >= right else v.ljust(w)
47
+ for i, (v, w) in enumerate(zip(vs, widths))).rstrip()
48
+
49
+ _print(line(header))
50
+ for r in cells:
51
+ _print(line(r))
52
+
53
+
54
+ def _hm(ts: str | None) -> str:
55
+ """An ISO time as 'YYYY-MM-DD HH:MM:SS' (UTC, as stored)."""
56
+ return ts[:19].replace("T", " ") if ts else ""
57
+
58
+
59
+ def _amps(c: dict[str, Any]) -> str:
60
+ return " ".join(f"{p} {float(c.get(p) or 0):.1f}" for p in ("L1", "L2", "L3"))
61
+
62
+
63
+ def _money(amount: str | None, currency: str) -> str:
64
+ return f"{amount} {currency}" if amount is not None else ""
65
+
66
+
67
+ # --- live state ---------------------------------------------------------------------
68
+
69
+
70
+ def cmd_status(api: Api, a: argparse.Namespace) -> int:
71
+ body = api.get("/status", organization=getattr(a, "org", None), site=getattr(a, "site", None))
72
+ if a.json:
73
+ _dump(body)
74
+ return 0
75
+ if not body["sites"]:
76
+ _print("no sites")
77
+ for site in body["sites"]:
78
+ _print(f"{site['organization']}/{site['key']} {site['name']} station A: {_amps(site['station_current'])}")
79
+ for fg in site["fuse_groups"]:
80
+ _print(f" fuse group {fg['key']}: budget {fg['budget_amps']} A of {fg['physical_fuse_amps']} A, "
81
+ f"{fg['active_sessions']} active, station A: {_amps(fg['station_current'])}")
82
+ rows = []
83
+ for cp in fg["charge_points"]:
84
+ for o in cp["outlets"]:
85
+ s = o["session"]
86
+ rows.append([
87
+ cp["key"], cp["ocpp_id"], "up" if cp["connected"] else "down", o["key"],
88
+ o["status"] or "-", s["transaction_id"] if s else "", s["wh"] if s else "",
89
+ _money(s["amount_incl_vat"], s["currency"]) if s else "",
90
+ _amps(o["station_current"]), (o["issued_limit"] or {}).get("amps", ""), o["verdict"],
91
+ _hm(cp["last_event_at"]),
92
+ ])
93
+ if rows:
94
+ _print()
95
+ table(rows, ["CP", "OCPP ID", "LINK", "OUTLET", "STATUS", "TX", "WH", "INCL VAT", "STATION A",
96
+ "LIMIT", "VERDICT", "LAST EVENT"])
97
+ _print()
98
+ if body.get("unclaimed"):
99
+ _print(f"{body['unclaimed']} unclaimed charge point(s): cpms chargepoints --unclaimed")
100
+ return 0
101
+
102
+
103
+ def _key_column(r: dict[str, Any]) -> str:
104
+ """The box's key as an operator needs it: blank for `auth: none`, otherwise its state, and a refusal."""
105
+ live = r["live"] or {}
106
+ if r["auth"] != "basic" and not live.get("auth_key"):
107
+ return ""
108
+ state = live.get("auth_key") or "none"
109
+ return f"{state} (refused: {live['refused']})" if live.get("refused") else state
110
+
111
+
112
+ def cmd_chargepoints(api: Api, a: argparse.Namespace) -> int:
113
+ if a.verb == "rekey":
114
+ if not a.key:
115
+ raise UsageError("rekey needs a charge point key: cpms chargepoints rekey <key>")
116
+ r = api.call("POST", f"/chargepoints/{a.key}/rekey", params={"organization": a.org}, json={})
117
+ if a.json:
118
+ _dump(r)
119
+ elif r["rotated"]:
120
+ _print(f"{a.key}: the box answered {r['gateway']['box_status']}; it reconnects with the new key")
121
+ else:
122
+ _print(f"{a.key} is not connected: key requirement cleared; it gets a new key when it is back")
123
+ return 0
124
+ body = api.get("/chargepoints", organization=a.org, unclaimed="true" if a.unclaimed else None)
125
+ if a.json:
126
+ _dump(body)
127
+ return 0
128
+ rows = body["chargepoints"]
129
+ if a.unclaimed:
130
+ table([[r["ocpp_id"], "up" if r["connected"] else "down", r["remote_address"], _hm(r["first_seen_at"]),
131
+ _hm(r["last_event_at"]), r["boot"].get("vendor"), r["boot"].get("model"), r["sessions"],
132
+ r["refused"]] for r in rows],
133
+ ["OCPP ID", "LINK", "ADDRESS", "FIRST SEEN", "LAST EVENT", "VENDOR", "MODEL", "SESSIONS", "REFUSED"])
134
+ return 0
135
+ table([[r["organization"], r["site"], r["fuse_group"], r["key"], r["ocpp_id"],
136
+ ("up" if r["live"]["connected"] else "down") if r["live"] else "never seen",
137
+ r["live"]["status"] if r["live"] else "", _hm(r["live"]["last_event_at"]) if r["live"] else "",
138
+ (r["live"]["boot"].get("model") or "") if r["live"] else "",
139
+ r["push"]["status"] if r["push"] else "", ",".join(r["phase_rotation"]),
140
+ _key_column(r)] for r in rows],
141
+ ["ORG", "SITE", "FUSE", "CP", "OCPP ID", "LINK", "STATUS", "LAST EVENT", "MODEL", "PUSH", "ROTATION", "KEY"])
142
+ return 0
143
+
144
+
145
+ # --- sessions ------------------------------------------------------------------------
146
+
147
+
148
+ SESSION_CSV = ["ocpp_id", "transaction_id", "organization", "site", "charge_point", "outlet", "connector", "status",
149
+ "started_at", "ended_at", "end_reason", "end_inferred", "learned", "wh", "plan", "tariff",
150
+ "price_per_kwh", "price_amounts", "vat_rate", "amount_incl_vat", "amount_excl_vat", "currency"]
151
+
152
+
153
+ def cmd_sessions(api: Api, a: argparse.Namespace) -> int:
154
+ body = api.get("/sessions", organization=a.org, site=a.site, since=a.since, status=a.status, limit=a.limit)
155
+ if a.json:
156
+ _dump(body)
157
+ return 0
158
+ rows = body["sessions"]
159
+ if a.csv:
160
+ w = csv.DictWriter(sys.stdout, fieldnames=SESSION_CSV, extrasaction="ignore", lineterminator="\n")
161
+ w.writeheader()
162
+ w.writerows(rows)
163
+ return 0
164
+ table([[f"{s['ocpp_id']}/{s['transaction_id']}", s["organization"] or "(unclaimed)", s["site"], s["outlet"],
165
+ s["status"], _hm(s["started_at"]), _hm(s["ended_at"]), s["wh"],
166
+ _money(s["amount_incl_vat"], s["currency"]), s["end_reason"]] for s in rows],
167
+ ["SESSION", "ORG", "SITE", "OUTLET", "STATUS", "STARTED (UTC)", "ENDED (UTC)", "WH", "INCL VAT", "END"])
168
+ return 0
169
+
170
+
171
+ def _ref(ref: str) -> tuple[str, int]:
172
+ ocpp_id, _, tx = ref.rpartition("/")
173
+ if not ocpp_id or not tx.isdigit():
174
+ raise UsageError(f"a session is <ocpp_id>/<transaction_id>, not {ref!r}")
175
+ return ocpp_id, int(tx)
176
+
177
+
178
+ def _event_rows(events: list[dict[str, Any]]) -> None:
179
+ def summary(e: dict[str, Any]) -> str:
180
+ d = {k: v for k, v in (e.get("data") or {}).items() if k not in ("raw", "capabilities", "voltage") and v is not None}
181
+ return json.dumps(d, ensure_ascii=False)[:100]
182
+
183
+ table([[e["seq"], _hm(e["ts"]), e["chargepoint_id"], e["connector_id"], e["transaction_id"], e["event"],
184
+ summary(e)] for e in events], ["SEQ", "TS (UTC)", "CHARGE POINT", "C", "TX", "EVENT", "DATA"])
185
+
186
+
187
+ def cmd_session(api: Api, a: argparse.Namespace) -> int:
188
+ ocpp_id, tx = _ref(a.ref)
189
+ base = f"/sessions/{ocpp_id}/{tx}"
190
+ if a.verb == "stop":
191
+ result = api.post(f"{base}/stop")
192
+ _dump(result) if a.json else _print(f"stop requested for {ocpp_id}/{tx}; it ends when the box reports it")
193
+ return 0
194
+ if a.verb == "close":
195
+ if not a.message:
196
+ raise UsageError("close needs a reason: -m \"…\"")
197
+ result = api.post(f"{base}/close", {"reason": a.message})
198
+ _dump(result) if a.json else _print(f"closed {ocpp_id}/{tx}" if result["closed"] else "not closed")
199
+ return 0
200
+ s = api.get(base)
201
+ events = api.get(f"{base}/events")["events"] if a.events else None
202
+ if a.json:
203
+ _dump({**s, "events": events} if events is not None else s)
204
+ return 0
205
+ side = {"incl_vat": "incl. VAT", "excl_vat": "excl. VAT"}.get(s["price_amounts"], "")
206
+ lines = [
207
+ ("session", f"{s['ocpp_id']}/{s['transaction_id']}"),
208
+ ("organization", s["organization"] or "(unclaimed)"),
209
+ ("site / cp / outlet", f"{s['site'] or '-'} / {s['charge_point'] or '-'} / {s['outlet'] or '-'} "
210
+ f"(connector {s['connector']})"),
211
+ ("status", s["status"] + (" (learned from meter values)" if s["learned"] else "")),
212
+ ("started (UTC)", _hm(s["started_at"])),
213
+ ("ended (UTC)", f"{_hm(s['ended_at'])} {s['end_reason']}" + (
214
+ " (not the box's own stop)" if s["end_inferred"] else "") if s["ended_at"] else "-"),
215
+ ("energy", f"{s['wh']} Wh (register {s['meter_start_wh']} → {s['meter_last_wh']})"),
216
+ ("price", f"{s['price_per_kwh']} {s['currency']}/kWh {side}, plan {s['plan']}" if s["price_per_kwh"] else "-"),
217
+ ("amount", f"{s['amount_incl_vat']} incl. VAT, {s['amount_excl_vat']} excl. VAT {s['currency']}"
218
+ if s["amount_incl_vat"] is not None else "-"),
219
+ ("samples", str(len(s["samples"]))),
220
+ ]
221
+ if s.get("close"):
222
+ lines.append(("closed by", f"{s['close']['actor']}: {s['close']['reason']}"))
223
+ for k, v in lines:
224
+ _print(f"{k:<20}{v}")
225
+ if events is not None:
226
+ _print()
227
+ _event_rows(events)
228
+ return 0
229
+
230
+
231
+ def cmd_events(api: Api, a: argparse.Namespace) -> int:
232
+ body = api.get("/events", since=a.since, limit=a.limit, chargepoint=a.chargepoint, event=a.event)
233
+ if a.json:
234
+ _dump(body)
235
+ return 0
236
+ _event_rows(body["events"])
237
+ _err(f"next: cpms events --since {body['next_since']} (latest {body['latest_seq']})")
238
+ return 0
239
+
240
+
241
+ # --- reports -------------------------------------------------------------------------------
242
+
243
+
244
+ _RECEIPT_FIGURES = ["SESSIONS", "KWH (RECEIPTS)", "INCL VAT", "EXCL VAT", "VAT"]
245
+ _SESSION_FIGURES = ["SESSIONS", "KWH (SESSIONS)"]
246
+
247
+
248
+ def _figures(f: dict[str, Any], receipts: bool) -> list[Any]:
249
+ if not receipts:
250
+ return [f["sessions"], f["kwh"]]
251
+ return [f["sessions"], f["kwh_on_receipts"], f["amount_incl_vat"], f["amount_excl_vat"], f["vat_amount"]]
252
+
253
+
254
+ def _local(ts: str, tz: str) -> str:
255
+ """An ISO time with offset as local wall time, for people only."""
256
+ return datetime.fromisoformat(ts).astimezone(ZoneInfo(tz)).strftime("%Y-%m-%d %H:%M")
257
+
258
+
259
+ def _statement_part(part: dict[str, Any], receipts: bool) -> None:
260
+ label = "collected_by" if receipts else "reason"
261
+ figs = _RECEIPT_FIGURES if receipts else _SESSION_FIGURES
262
+ lines, cps, sites = [], [], []
263
+ for site in part["sites"]:
264
+ sites.append([site["key"], site["name"], *_figures(site, receipts)])
265
+ for cp in site["charge_points"]:
266
+ cps.append([site["key"], cp["key"], cp["ocpp_id"], *_figures(cp, receipts)])
267
+ for o in cp["outlets"]:
268
+ for x in o["by_channel"]:
269
+ lines.append([site["key"], cp["key"], o["key"], x["channel"], x[label],
270
+ *_figures(x, receipts)])
271
+ table(lines, ["SITE", "CP", "OUTLET", "CHANNEL", label.replace("_", " ").upper(), *figs], right=5)
272
+ if receipts:
273
+ _print()
274
+ table(cps, ["SITE", "CP", "OCPP ID", *figs], right=3)
275
+ _print()
276
+ table(sites, ["SITE", "NAME", *figs], right=2)
277
+ _print()
278
+ table([[x["channel"], x[label], *_figures(x, receipts)] for x in part["by_channel"]]
279
+ + [["total", "", *_figures(part, receipts)]], ["CHANNEL", label.replace("_", " ").upper(), *figs], right=2)
280
+
281
+
282
+ def cmd_statement(api: Api, a: argparse.Namespace) -> int:
283
+ body = api.get("/reports/statement", month=a.month, organization=a.org)
284
+ if a.json:
285
+ _dump(body)
286
+ return 0
287
+ p, tz = body["period"], body["period"]["timezone"]
288
+ _print(f"Statement {p['month']} {body['organization']} ({body['organization_name']})")
289
+ _print(f"{_local(p['from'], tz)} to {_local(p['to'], tz)} {tz}; amounts in {body['currency']}")
290
+ _print()
291
+ receipts, none = body["receipts"], body["no_receipt"]
292
+ _print("Receipts issued in the month (money by document date; kWh is the energy on the receipts, "
293
+ "not energy delivered in the month)")
294
+ if receipts["sessions"]:
295
+ _statement_part(receipts, True)
296
+ else:
297
+ _print("no receipts")
298
+ _print()
299
+ _print("No receipt: sessions that ended in the month without one (cancelled, unpaid, or pending capture); "
300
+ "no money")
301
+ if none["sessions"]:
302
+ _statement_part(none, False)
303
+ else:
304
+ _print("none")
305
+ if body["active_sessions"]:
306
+ _print()
307
+ _print(f"{body['active_sessions']} session(s) still active")
308
+ for w in body["warnings"]:
309
+ _err(f"warning: {w}")
310
+ return 0
311
+
312
+
313
+ # --- manifest ------------------------------------------------------------------------------
314
+
315
+
316
+ def _read_docs(path: str) -> list[dict[str, Any]]:
317
+ try:
318
+ text = sys.stdin.read() if path == "-" else Path(path).read_text()
319
+ except OSError as e:
320
+ raise UsageError(str(e)) from None
321
+ try:
322
+ docs = parse_yaml(text)
323
+ except ManifestError as e:
324
+ raise ApiError("document does not validate", detail=e.errors) from None
325
+ if not docs:
326
+ raise UsageError(f"{path}: no documents")
327
+ order = {"organization": 0, "pricelist": 1, "site": 2}
328
+ return [to_data(d) for d in sorted(docs, key=lambda d: order[d.kind])]
329
+
330
+
331
+ def _show_plan(p: dict[str, Any]) -> None:
332
+ cur = f"version {p['current_version']}" if p["current_version"] else "new"
333
+ _print(f"{p['organization']} {p['kind']} {p['key']}: {cur}")
334
+ for c in p["changes"] or ["(no changes)"]:
335
+ _print(f" {c}")
336
+ for w in p["warnings"]:
337
+ _print(f" ! {w}")
338
+ for i in p["info"]:
339
+ _print(f" {i}")
340
+ for e in p["errors"]:
341
+ _print(f" error: {e}")
342
+
343
+
344
+ def _deferrable(e: ApiError, doc: dict[str, Any], docs: list[dict[str, Any]]) -> bool:
345
+ """A document for an organization that this file creates can only be planned after it exists."""
346
+ org = doc.get("organization")
347
+ return e.status == 404 and any(d["kind"] == "organization" and d["organization"]["key"] == org for d in docs)
348
+
349
+
350
+ def _plans(api: Api, docs: list[dict[str, Any]], prune: bool) -> list[dict[str, Any] | None]:
351
+ out: list[dict[str, Any] | None] = []
352
+ for d in docs:
353
+ try:
354
+ out.append(api.post("/manifest/plan", {"document": d, "prune": prune}))
355
+ except ApiError as e:
356
+ if not _deferrable(e, d, docs):
357
+ raise
358
+ out.append(None)
359
+ return out
360
+
361
+
362
+ def _show_plans(docs: list[dict[str, Any]], plans: list[dict[str, Any] | None]) -> None:
363
+ for d, p in zip(docs, plans):
364
+ if p is None:
365
+ _print(f"{d['organization']} {d['kind']}: planned after its organization is applied")
366
+ else:
367
+ _show_plan(p)
368
+
369
+
370
+ def cmd_plan(api: Api, a: argparse.Namespace) -> int:
371
+ docs = _read_docs(a.file)
372
+ plans = _plans(api, docs, a.prune)
373
+ if a.json:
374
+ _dump({"plans": plans})
375
+ else:
376
+ _show_plans(docs, plans)
377
+ return 1 if any(p and p["errors"] for p in plans) else 0
378
+
379
+
380
+ def cmd_apply(api: Api, a: argparse.Namespace) -> int:
381
+ if not a.message or not a.message.strip():
382
+ raise UsageError("apply needs a message: -m \"why\"")
383
+ docs = _read_docs(a.file)
384
+ plans = _plans(api, docs, a.prune)
385
+ if not a.json:
386
+ _show_plans(docs, plans)
387
+ if any(p and p["errors"] for p in plans):
388
+ if a.json:
389
+ _dump({"plans": plans, "applied": []})
390
+ _err("not applied: the plan has errors")
391
+ return 1
392
+ if all(p is not None and not p["changes"] for p in plans):
393
+ _dump({"plans": plans, "applied": []}) if a.json else _print("nothing to apply")
394
+ return 0
395
+ if not a.yes:
396
+ # Never a prompt without a TTY, and never an unconfirmed apply either:
397
+ # scripts and agents opt in with --yes (Terraform's -auto-approve).
398
+ if not sys.stdin.isatty():
399
+ if a.json:
400
+ _dump({"plans": plans, "applied": []})
401
+ _err("cpms: not a TTY: re-run with --yes to apply")
402
+ return 2
403
+ if input("apply? [y/N] ").strip().lower() not in ("y", "yes"):
404
+ _err("not applied")
405
+ return 1
406
+ results = []
407
+ for d, p in zip(docs, plans):
408
+ if p is None:
409
+ p = api.post("/manifest/plan", {"document": d, "prune": a.prune})
410
+ if not a.json:
411
+ _show_plan(p)
412
+ if p["errors"]:
413
+ _err("stopped: the plan has errors")
414
+ return 1
415
+ if not p["changes"]:
416
+ continue
417
+ r = api.post("/manifest/apply", {"document": d, "base_digest": p["base_digest"], "message": a.message,
418
+ "prune": a.prune})
419
+ results.append(r)
420
+ if not a.json:
421
+ _print(f"applied {r['organization']} {r['kind']} {r['key']} version {r['version']}")
422
+ if a.json:
423
+ _dump({"plans": plans, "applied": results})
424
+ return 0
425
+
426
+
427
+ def _selector(a: argparse.Namespace) -> tuple[str, str] | None:
428
+ if a.site:
429
+ return "site", a.site
430
+ if a.pricelist:
431
+ return "pricelist", "pricelist"
432
+ if a.organization:
433
+ return "organization", a.org
434
+ return None
435
+
436
+
437
+ def cmd_export(api: Api, a: argparse.Namespace) -> int:
438
+ sel = _selector(a)
439
+ if sel:
440
+ docs = [api.get(f"/manifest/{a.org}/{sel[0]}/{sel[1]}", version=a.version)]
441
+ else:
442
+ if a.version:
443
+ raise UsageError("--version needs --site, --pricelist or --organization")
444
+ docs = api.get(f"/manifest/{a.org}")["documents"]
445
+ if a.json:
446
+ _dump({"documents": docs})
447
+ return 0
448
+ order = {"organization": 0, "pricelist": 1, "site": 2}
449
+ docs.sort(key=lambda d: (order.get(d["kind"], 9), d["key"]))
450
+ sys.stdout.write(to_yaml([d["document"] for d in docs]))
451
+ return 0
452
+
453
+
454
+ def _need_selector(a: argparse.Namespace) -> tuple[str, str]:
455
+ sel = _selector(a)
456
+ if sel is None:
457
+ raise UsageError("which document? --site KEY, --pricelist or --organization")
458
+ return sel
459
+
460
+
461
+ def cmd_history(api: Api, a: argparse.Namespace) -> int:
462
+ kind, key = _need_selector(a)
463
+ body = api.get(f"/manifest/{a.org}/{kind}/{key}/history")
464
+ if a.json:
465
+ _dump(body)
466
+ return 0
467
+ table([[f"v{v['version']}", _hm(v["applied_at"]), v["author"], v["message"]] for v in body["versions"]],
468
+ ["VERSION", "APPLIED (UTC)", "AUTHOR", "MESSAGE"])
469
+ return 0
470
+
471
+
472
+ def _version(v: str) -> int:
473
+ try:
474
+ return int(v.lstrip("v"))
475
+ except ValueError:
476
+ raise UsageError(f"not a version: {v!r}") from None
477
+
478
+
479
+ def cmd_diff(api: Api, a: argparse.Namespace) -> int:
480
+ kind, key = _need_selector(a)
481
+ new = _version(a.new) if a.new else None
482
+ old = _version(a.old)
483
+ path = f"/manifest/{a.org}/{kind}/{key}"
484
+ before = api.get(path, version=old)
485
+ after = api.get(path, version=new)
486
+ changes = [str(c) for c in diff(before["document"], after["document"])]
487
+ if a.json:
488
+ _dump({"from": before["version"], "to": after["version"], "changes": changes})
489
+ return 0
490
+ _print(f"{a.org} {kind} {key}: v{before['version']} → v{after['version']}")
491
+ for c in changes or ["(no changes)"]:
492
+ _print(f" {c}")
493
+ return 0
494
+
495
+
496
+ def cmd_validate(api: Api | None, a: argparse.Namespace) -> int:
497
+ """Offline: the schema and what one document can check on its own. `plan` checks the rest."""
498
+ try:
499
+ text = sys.stdin.read() if a.file == "-" else Path(a.file).read_text()
500
+ except OSError as e:
501
+ raise UsageError(str(e)) from None
502
+ try:
503
+ docs = parse_yaml(text)
504
+ except ManifestError as e:
505
+ if a.json:
506
+ _dump({"valid": False, "errors": e.errors})
507
+ else:
508
+ for line in e.errors:
509
+ _print(f"error: {line}")
510
+ return 1
511
+ if a.json:
512
+ _dump({"valid": True, "documents": [{"kind": d.kind, "organization": d.org_key, "key": d.doc_key}
513
+ for d in docs]})
514
+ else:
515
+ for d in docs:
516
+ _print(f"ok {d.org_key} {d.kind} {d.doc_key}")
517
+ return 0
518
+
519
+
520
+ def cmd_schema(api: Api | None, a: argparse.Namespace) -> int:
521
+ """Offline: the documents' JSON Schema, for an editor (schema/manifest.cpms-v1.schema.json)."""
522
+ _dump(json_schema())
523
+ return 0
524
+
525
+
526
+ # --- keys ------------------------------------------------------------------------------------
527
+
528
+
529
+ def cmd_keys_issue(api: Api, a: argparse.Namespace) -> int:
530
+ r = api.post("/keys", {"name": a.name, "organization": a.org, "scope": a.scope})
531
+ if a.json:
532
+ _dump(r)
533
+ else:
534
+ whose = f"organization {r['organization']}" if r["organization"] else "operator"
535
+ _err(f"issued {whose} {r['scope']} key {r['prefix']} ({r['name']}); it is shown once:")
536
+ _print(r["secret"])
537
+ return 0
538
+
539
+
540
+ def cmd_keys_list(api: Api, a: argparse.Namespace) -> int:
541
+ r = api.get("/keys", organization=a.org)
542
+ if a.json:
543
+ _dump(r)
544
+ else:
545
+ table([[k["prefix"], k["organization"] or "(operator)", k["scope"], k["name"], k["owner"] or "",
546
+ k["issued_by"] or "", _hm(k["created_at"]), _hm(k["revoked_at"])] for k in r["keys"]],
547
+ ["prefix", "organization", "scope", "name", "personal (owner)", "issued by", "created", "revoked"])
548
+ return 0
549
+
550
+
551
+ def cmd_keys_revoke(api: Api, a: argparse.Namespace) -> int:
552
+ r = api.post(f"/keys/{a.prefix}/revoke")
553
+ _dump(r) if a.json else _print(f"revoked {r['prefix']} at {_hm(r['revoked_at'])}")
554
+ return 0
555
+
556
+
557
+ # --- login ---------------------------------------------------------------------------------
558
+
559
+
560
+ def cmd_login(api: None, a: argparse.Namespace) -> int:
561
+ """The device flow: approve in the portal, get a personal key, save it as a profile."""
562
+ path = config_path()
563
+ name = login.profile_name(getattr(a, "profile", None) or os.environ.get("CPMS_PROFILE") or "default")
564
+ text = login.read(path)
565
+ url = (a.url or login.profile_value(path, text, name, "url") or os.environ.get("CPMS_URL", "")).rstrip("/")
566
+ if not url.startswith(("https://", "http://")):
567
+ raise UsageError(f"not an http(s) address: {url!r}" if url else "give the instance: cpms login --url https://…")
568
+ login.set_profile(path, text, name, url, "lcp_check") # refuse now rather than after a key is issued
569
+ http, started = login.start(url, a.scope)
570
+ try:
571
+ page = login.verification_url(url, str(started.get("user_code", "")))
572
+ _err(f"To log in, open this page and approve, logged in to the portal as yourself:\n\n"
573
+ f" {page}\n\n"
574
+ f"Check that the page shows the code {started['user_code']}. It is valid for "
575
+ f"{int(started['expires_in']) // 60} minutes.")
576
+ if not a.no_browser and login.can_open_browser() and login.open_browser(page):
577
+ _err("(opened in your browser)")
578
+ _err("Waiting for approval… (Ctrl-C to stop)")
579
+ key = login.poll(http, started)
580
+ finally:
581
+ http.close()
582
+ try:
583
+ login.write(path, login.set_profile(path, login.read(path), name, url, key["api_key"]))
584
+ except (OSError, UsageError) as e: # never leave a key nobody can find: revoke it
585
+ login.revoke(url, key["api_key"])
586
+ raise ApiError(f"could not save the key ({e}); it was revoked") from None
587
+ result = {"profile": name, "url": url, "config": str(path), **{k: key[k] for k in ("prefix", "name",
588
+ "organization", "scope")}}
589
+ if a.json:
590
+ _dump(result)
591
+ else:
592
+ whose = f"organization {key['organization']}" if key["organization"] else "the operator"
593
+ _print(f"logged in: a personal {key['scope']} key {key['prefix']} for {whose}, saved as profile [{name}] "
594
+ f"in {path}")
595
+ return 0
596
+
597
+
598
+ def cmd_logout(api: Api, a: argparse.Namespace) -> int:
599
+ """Revoke the key in use, then forget the profile, if the key came from it: a key from the environment
600
+ is only revoked, and a profile holding another key is left alone."""
601
+ name = getattr(a, "profile", None) or os.environ.get("CPMS_PROFILE") or "default"
602
+ path = config_path()
603
+ text = login.read(path)
604
+ from_profile = login.profile_value(path, text, name, "api_key") == api.config.api_key
605
+ try:
606
+ r: dict[str, Any] = api.post("/auth/logout")
607
+ except ApiError as e:
608
+ if e.status != 401: # 401: the key no longer works anyway; forget it
609
+ raise
610
+ r = {"prefix": None, "revoked_at": None}
611
+ removed = False
612
+ if from_profile:
613
+ rest = login.drop_profile(path, login.read(path), name)
614
+ if rest is not None:
615
+ login.write(path, rest)
616
+ removed = True
617
+ if a.json:
618
+ _dump({**r, "profile": name if from_profile else None, "removed": removed})
619
+ else:
620
+ _print(f"revoked key {r['prefix']}" if r["prefix"] else "the key no longer worked")
621
+ if removed:
622
+ _print(f"removed profile [{name}] from {path}")
623
+ else:
624
+ _print("the key came from CPMS_API_KEY; unset it")
625
+ return 0
626
+
627
+
628
+ def cmd_whoami(api: Api, a: argparse.Namespace) -> int:
629
+ r = api.get("/auth/me")
630
+ if a.json:
631
+ _dump(r)
632
+ return 0
633
+ who = r["email"] if r["personal"] else "a machine key"
634
+ whose = f"organization {r['organization']}" if r["organization"] else "the operator (every organization)"
635
+ _print(f"{who}: {r['scope']} key {r['prefix']} ({r['name']}) for {whose}")
636
+ return 0
637
+
638
+
639
+ # --- admission ------------------------------------------------------------------------------
640
+
641
+
642
+ def _duration(text: str) -> int:
643
+ """Seconds from '900', '90s', '15m' or '1h'."""
644
+ t = text.strip().lower()
645
+ unit = {"s": 1, "m": 60, "h": 3600}.get(t[-1:], 0)
646
+ number = t[:-1] if unit else t
647
+ if not number.isdecimal() or int(number) <= 0:
648
+ raise UsageError(f"not a duration: {text!r} (e.g. 900, 90s, 15m, 1h)")
649
+ return int(number) * (unit or 1)
650
+
651
+
652
+ def _show_admission(r: dict[str, Any]) -> None:
653
+ w = r["window"]
654
+ if r["default"] == "open":
655
+ _print("admission: open (OCPP_ADMISSION=open): every unknown box is admitted unclaimed")
656
+ elif w is None:
657
+ _print("admission: closed; unknown boxes are refused. cpms admission open --for 15m to admit them for a while")
658
+ else:
659
+ _print(f"admission: OPEN until {_hm(w['until'])} UTC ({int(w['remaining_s'])} s left); then closed")
660
+ _print("what it lets in: cpms events --event unknown_chargepoint (reason \"window\")")
661
+ _print(f"longest window: {int(r['window_max_s'])} s; a restart of the gateway closes it")
662
+
663
+
664
+ def cmd_admission(api: Api, a: argparse.Namespace) -> int:
665
+ verb = getattr(a, "admission_command", None) or "status"
666
+ if verb == "open":
667
+ r = api.post("/admission/open", {"for_s": _duration(a.duration)})
668
+ elif verb == "close":
669
+ r = api.post("/admission/close")
670
+ if not a.json:
671
+ _print("closed the window" if r.get("closed") else "no window was open")
672
+ else:
673
+ r = api.get("/admission")
674
+ _dump(r) if a.json else _show_admission(r)
675
+ return 0
676
+
677
+
678
+ # --- members ---------------------------------------------------------------------------------
679
+
680
+
681
+ def cmd_members(api: Api, a: argparse.Namespace) -> int:
682
+ r = api.get("/members")
683
+ if a.json:
684
+ _dump(r)
685
+ else:
686
+ table([[m["organization"] or "(operator)", m["email"], m["role"], "yes" if m["active"] else "no",
687
+ _hm(m["added_at"]), m["added_by"] or ""] for m in r["members"]],
688
+ ["organization", "email", "role", "active", "added", "added by"])
689
+ return 0
690
+
691
+
692
+ def _member_body(a: argparse.Namespace) -> dict[str, Any]:
693
+ if bool(a.org) == bool(a.operator):
694
+ raise UsageError("give --org KEY or --operator")
695
+ return {"email": a.email, "organization": a.org}
696
+
697
+
698
+ def cmd_members_add(api: Api, a: argparse.Namespace) -> int:
699
+ r = api.post("/members", {**_member_body(a), "role": a.role})
700
+ scope = f"organization {r['organization']}" if r["organization"] else "the operator (every organization)"
701
+ _dump(r) if a.json else _print(f"{a.email} may log in to the portal for {scope}, role {r['role']}"
702
+ f"{'' if r['added'] else ' (already could)'}")
703
+ return 0
704
+
705
+
706
+ def cmd_members_set_role(api: Api, a: argparse.Namespace) -> int:
707
+ r = api.post("/members/role", {**_member_body(a), "role": a.role})
708
+ if a.json:
709
+ _dump(r)
710
+ else:
711
+ _print(f"{a.email} has role {r['role']} in {r['organization'] or 'the operator'}"
712
+ f"{'' if r['changed'] else ' (already had)'}")
713
+ if r["revoked_keys"]:
714
+ _print(f"revoked keys it no longer allows: {', '.join(r['revoked_keys'])}")
715
+ return 0
716
+
717
+
718
+ def cmd_members_remove(api: Api, a: argparse.Namespace) -> int:
719
+ r = api.post("/members/remove", _member_body(a))
720
+ if a.json:
721
+ _dump(r)
722
+ else:
723
+ _print(f"removed {a.email} from {r['organization'] or 'the operator'}" if r["removed"]
724
+ else f"{a.email} was not a member of {r['organization'] or 'the operator'}")
725
+ if r.get("active") is False: # only the operator is told
726
+ _print(f"{a.email} has no membership left and can no longer log in")
727
+ if r.get("revoked_keys"):
728
+ _print(f"revoked their keys, and keys issued from them: {', '.join(r['revoked_keys'])}")
729
+ return 0
730
+
731
+
732
+ # --- OCPI ------------------------------------------------------------------------------------
733
+
734
+
735
+ def cmd_ocpi_connect(api: Api, a: argparse.Namespace) -> int:
736
+ r = api.post("/ocpi/connections", {"organization": a.org, "name": a.name})
737
+ if a.json:
738
+ _dump(r)
739
+ return 0
740
+ _err(f"connection {r['organization']}/{r['name']} is {r['state']}. Send the partner both lines below; "
741
+ "Token A is shown once and works until they register:")
742
+ _print(f"versions URL: {r['versions_url']}")
743
+ _print(f"Token A: {r['token_a']}")
744
+ return 0
745
+
746
+
747
+ def cmd_ocpi_connections(api: Api, a: argparse.Namespace) -> int:
748
+ body = api.get("/ocpi/connections", organization=a.org)
749
+ if a.json:
750
+ _dump(body)
751
+ return 0
752
+ table([[c["organization"], c["name"], c["state"], ", ".join(c["their_roles"]), c["locations_receiver"] or "-",
753
+ _hm(c["registered_at"])] for c in body["connections"]],
754
+ ["ORG", "NAME", "STATE", "THEIR ROLES", "LOCATIONS RECEIVER", "REGISTERED (UTC)"])
755
+ return 0
756
+
757
+
758
+ def cmd_ocpi_disconnect(api: Api, a: argparse.Namespace) -> int:
759
+ r = api.post(f"/ocpi/connections/{a.org}/{a.name}/disconnect")
760
+ _dump(r) if a.json else _print(f"{r['organization']}/{r['name']} is {r['state']}; its tokens no longer work")
761
+ return 0
762
+
763
+
764
+ def cmd_ocpi_preview(api: Api, a: argparse.Namespace) -> int:
765
+ r = api.get(f"/ocpi/{a.org}/locations/{a.site}")
766
+ if not a.json and not r["published"]:
767
+ _err("not published: partners do not get this Location (publish: true in the site document)")
768
+ _dump(r["location"] if not a.json else r)
769
+ return 0
770
+
771
+
772
+ # --- the parser ------------------------------------------------------------------------------
773
+
774
+
775
+ class _Parser(argparse.ArgumentParser):
776
+ def error(self, message: str) -> Any: # exit 2, as argparse does, but through our handler
777
+ raise UsageError(f"{self.prog}: {message}")
778
+
779
+
780
+ def parser() -> argparse.ArgumentParser:
781
+ common = _Parser(add_help=False)
782
+ common.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help="machine-readable output")
783
+ common.add_argument("--profile", default=argparse.SUPPRESS, help="profile in ~/.config/cpms/config.toml")
784
+ p = _Parser(prog="cpms", parents=[common], description="LeanCPMS from the command line. "
785
+ "Alone, prints the live state.")
786
+ sub = p.add_subparsers(dest="command", parser_class=_Parser)
787
+
788
+ def cmd(name: str, fn: Callable[..., int], help: str, **kw: Any) -> argparse.ArgumentParser:
789
+ sp = sub.add_parser(name, parents=[common], help=help, description=help, **kw)
790
+ sp.set_defaults(fn=fn)
791
+ return sp
792
+
793
+ s = cmd("status", cmd_status, "live state per site, fuse group, charge point and outlet")
794
+ s.add_argument("--site")
795
+ s.add_argument("--org", help="operator: one organization")
796
+
797
+ s = cmd("chargepoints", cmd_chargepoints, "declared charge points with live state [rekey KEY]")
798
+ s.add_argument("verb", nargs="?", choices=["rekey"], help="rekey: a new key for a box declared auth: basic")
799
+ s.add_argument("key", nargs="?")
800
+ s.add_argument("--unclaimed", action="store_true", help="operator: boxes nobody has declared")
801
+ s.add_argument("--org")
802
+
803
+ s = cmd("sessions", cmd_sessions, "sessions, newest first")
804
+ s.add_argument("--site")
805
+ s.add_argument("--since", help="ISO date (UTC) or time")
806
+ s.add_argument("--status", choices=["active", "ended"])
807
+ s.add_argument("--org")
808
+ s.add_argument("--limit", type=int, default=100)
809
+ s.add_argument("--csv", action="store_true")
810
+
811
+ s = cmd("session", cmd_session, "one session: <ocpp_id>/<transaction_id> [stop|close -m REASON]")
812
+ s.add_argument("ref")
813
+ s.add_argument("verb", nargs="?", choices=["stop", "close"])
814
+ s.add_argument("--events", action="store_true", help="the box's events for this session")
815
+ s.add_argument("-m", "--message", help="the reason for close")
816
+
817
+ s = cmd("events", cmd_events, "the gateway's event log (pass-through)")
818
+ s.add_argument("--since", type=int, default=0)
819
+ s.add_argument("--chargepoint")
820
+ s.add_argument("--event")
821
+ s.add_argument("--limit", type=int, default=100)
822
+
823
+ r = sub.add_parser("report", parents=[common], help="reports: the monthly statement")
824
+ rsub = r.add_subparsers(dest="report_command", parser_class=_Parser, required=True)
825
+ s = rsub.add_parser("statement", parents=[common], help="a month's receipts (incl./excl. VAT) and sessions without one, per site, "
826
+ "charge point and outlet",
827
+ description="The monthly statement (PRD §6.1), from this system's sessions and ledger.")
828
+ s.set_defaults(fn=cmd_statement)
829
+ s.add_argument("--month", help="YYYY-MM in the organization's time zone (default: this month so far)")
830
+ s.add_argument("--org", help="operator: which organization (an organization key knows its own)")
831
+
832
+ m = sub.add_parser("manifest", parents=[common], help="declared state: export, plan, apply, history, diff, validate, schema")
833
+ msub = m.add_subparsers(dest="manifest_command", parser_class=_Parser, required=True)
834
+
835
+ def mcmd(name: str, fn: Callable[..., int], help: str) -> argparse.ArgumentParser:
836
+ sp = msub.add_parser(name, parents=[common], help=help, description=help)
837
+ sp.set_defaults(fn=fn)
838
+ return sp
839
+
840
+ def selector(sp: argparse.ArgumentParser) -> None:
841
+ sp.add_argument("org")
842
+ g = sp.add_mutually_exclusive_group()
843
+ g.add_argument("--site", metavar="KEY")
844
+ g.add_argument("--pricelist", action="store_true")
845
+ g.add_argument("--organization", action="store_true")
846
+
847
+ s = mcmd("export", cmd_export, "the current documents as YAML (all, or one)")
848
+ selector(s)
849
+ s.add_argument("--version", type=int)
850
+ for name, fn, help in (("plan", cmd_plan, "what applying FILE would change"),
851
+ ("apply", cmd_apply, "plan FILE, show it, confirm on a TTY (or --yes), apply")):
852
+ s = mcmd(name, fn, help)
853
+ s.add_argument("file", help="YAML, one or more documents separated by ---; - for stdin")
854
+ s.add_argument("--prune", action="store_true", help="allow removals")
855
+ if name == "apply":
856
+ s.add_argument("-m", "--message", help="why (stored with the version)")
857
+ s.add_argument("--yes", "-y", action="store_true", help="apply without asking; required without a TTY")
858
+ s = mcmd("history", cmd_history, "the versions of one document")
859
+ selector(s)
860
+ s = mcmd("diff", cmd_diff, "the changes between two versions of one document (NEW defaults to current)")
861
+ selector(s)
862
+ s.add_argument("old", help="e.g. v3")
863
+ s.add_argument("new", nargs="?")
864
+ s = mcmd("validate", cmd_validate, "check FILE against the schema, offline")
865
+ s.add_argument("file")
866
+ mcmd("schema", cmd_schema, "the documents' JSON Schema, for an editor, offline")
867
+
868
+ oc = sub.add_parser("ocpi", parents=[common], help="OCPI partners (Nobil, roaming) and what they see")
869
+ osub = oc.add_subparsers(dest="ocpi_command", parser_class=_Parser, required=True)
870
+ s = osub.add_parser("connect", parents=[common], help="a connection for a partner: prints Token A once and "
871
+ "the versions URL to send them")
872
+ s.set_defaults(fn=cmd_ocpi_connect)
873
+ s.add_argument("org")
874
+ s.add_argument("--name", required=True, help="the partner, e.g. nobil-test")
875
+ s = osub.add_parser("connections", parents=[common], help="connections and their state")
876
+ s.set_defaults(fn=cmd_ocpi_connections)
877
+ s.add_argument("--org")
878
+ s = osub.add_parser("disconnect", parents=[common], help="close a connection: its tokens stop working")
879
+ s.set_defaults(fn=cmd_ocpi_disconnect)
880
+ s.add_argument("org")
881
+ s.add_argument("name")
882
+ s = osub.add_parser("preview", parents=[common], help="the Location JSON for a site, as OCPI would send it now")
883
+ s.set_defaults(fn=cmd_ocpi_preview)
884
+ s.add_argument("org")
885
+ s.add_argument("site")
886
+
887
+ s = cmd("login", cmd_login, "log in with your portal login: approve in the browser, get a personal key, "
888
+ "saved as a profile (--profile, default [default])")
889
+ s.add_argument("--url", help="the instance, e.g. https://cpms.example.com (default: the profile's)")
890
+ s.add_argument("--scope", choices=["read", "write"], default="write",
891
+ help="what to ask for; you decide when approving (default write)")
892
+ s.add_argument("--no-browser", action="store_true", help="only print the address to open")
893
+ cmd("logout", cmd_logout, "revoke this profile's key and remove the profile")
894
+ cmd("whoami", cmd_whoami, "the key in use: whose, which organization, read or write")
895
+
896
+ k = sub.add_parser("keys", parents=[common], help="API keys (an organization's write key: its own) "
897
+ "[list|issue|revoke]")
898
+ ksub = k.add_subparsers(dest="keys_command", parser_class=_Parser, required=True)
899
+ s = ksub.add_parser("list", parents=[common], help="the keys you may manage, revoked ones too")
900
+ s.set_defaults(fn=cmd_keys_list)
901
+ s.add_argument("--org", help="operator: one organization")
902
+ s = ksub.add_parser("issue", parents=[common], help="issue a key; printed once")
903
+ s.set_defaults(fn=cmd_keys_issue)
904
+ s.add_argument("--name", required=True)
905
+ s.add_argument("--org", help="an organization; without it an operator key (operator) or your own organization's")
906
+ s.add_argument("--scope", choices=["read", "write"], default="write",
907
+ help="read: may only look (GET); write (default): may also act")
908
+ s = ksub.add_parser("revoke", parents=[common], help="revoke a key by its prefix")
909
+ s.set_defaults(fn=cmd_keys_revoke)
910
+ s.add_argument("prefix")
911
+
912
+ ad = sub.add_parser("admission", parents=[common], help="the gateway's admission window for unknown boxes "
913
+ "(operator) [status|open --for 15m|close]")
914
+ ad.set_defaults(fn=cmd_admission)
915
+ asub = ad.add_subparsers(dest="admission_command", parser_class=_Parser)
916
+ s = asub.add_parser("status", parents=[common], help="closed, open, or a window and what it let in")
917
+ s.set_defaults(fn=cmd_admission)
918
+ s = asub.add_parser("open", parents=[common], help="admit unknown boxes for a while (the gateway caps it)")
919
+ s.set_defaults(fn=cmd_admission)
920
+ s.add_argument("--for", dest="duration", required=True, metavar="DURATION", help="e.g. 900, 15m, 1h")
921
+ s = asub.add_parser("close", parents=[common], help="close the window now")
922
+ s.set_defaults(fn=cmd_admission)
923
+
924
+ m = sub.add_parser("members", parents=[common], help="who may log in to the portal (an organization's "
925
+ "write key: its own) [list|add|remove|set-role]")
926
+ m.set_defaults(fn=cmd_members)
927
+ msub = m.add_subparsers(dest="members_command", parser_class=_Parser)
928
+ s = msub.add_parser("list", parents=[common], help="the members you may manage")
929
+ s.set_defaults(fn=cmd_members)
930
+ for name, fn, help in (("add", cmd_members_add, "let EMAIL log in with a one-time code by e-mail"),
931
+ ("remove", cmd_members_remove, "take away one membership, and revoke the keys the "
932
+ "member got with cpms login and every key issued from them"),
933
+ ("set-role", cmd_members_set_role, "change the widest key EMAIL may get with cpms "
934
+ "login; lowered, their write keys and keys issued from them are revoked")):
935
+ s = msub.add_parser(name, parents=[common], help=help, description=help)
936
+ s.set_defaults(fn=fn)
937
+ s.add_argument("email")
938
+ g = s.add_mutually_exclusive_group()
939
+ g.add_argument("--org", help="the organization the member sees")
940
+ g.add_argument("--operator", action="store_true", help="operator key: every organization (us, on the "
941
+ "hosted instance)")
942
+ if name != "remove":
943
+ s.add_argument("--role", choices=["read", "write"], default="read" if name == "add" else None,
944
+ required=name == "set-role",
945
+ help="read (default for add): cpms login gives read keys only; write: may act too. "
946
+ "The portal only reads, whatever the role")
947
+ return p
948
+
949
+
950
+ def main(argv: list[str] | None = None) -> int:
951
+ try:
952
+ a = parser().parse_args(argv)
953
+ a.json = getattr(a, "json", False)
954
+ fn = getattr(a, "fn", None)
955
+ if fn is None: # `cpms` alone: the live state
956
+ a.site = a.org = None
957
+ fn = cmd_status
958
+ if fn in (cmd_validate, cmd_schema, cmd_login): # offline, or a login: no key yet
959
+ return int(fn(None, a))
960
+ api = Api(load_config(getattr(a, "profile", None)))
961
+ try:
962
+ return int(fn(api, a))
963
+ finally:
964
+ api.close()
965
+ except UsageError as e:
966
+ _err(str(e) if str(e).startswith("cpms") else f"cpms: {e}")
967
+ return 2
968
+ except ApiError as e:
969
+ if getattr(e, "detail", None):
970
+ details = e.detail if isinstance(e.detail, list) else [e.detail]
971
+ _err(f"cpms: {e}")
972
+ for d in details:
973
+ _err(f" {d if isinstance(d, str) else json.dumps(d, ensure_ascii=False)}")
974
+ else:
975
+ _err(f"cpms: {e}")
976
+ return 1
977
+ except KeyboardInterrupt:
978
+ return 130
979
+
980
+
981
+ if __name__ == "__main__":
982
+ sys.exit(main())