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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lr-lumenlabel-cli
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: CLI for the LumenRadio LumenLabel label printing station: import label packages fetched from Arena with gladiator, list labels and inspect the station, locally, on the LAN or through the Fleet relay.
|
|
5
|
+
Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://gitlab.com/lumenradio/production/lumenlabel
|
|
8
|
+
Project-URL: Changelog, https://gitlab.com/lumenradio/production/lumenlabel/-/blob/main/CHANGELOG.md
|
|
9
|
+
Keywords: LumenRadio,LumenLabel,label printing,Arena,CLI
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: typer>=0.12
|
|
18
|
+
Requires-Dist: rich>=13.7
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: lr-fleet>=0.7
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.2.1; extra == "dev"
|
|
23
|
+
Requires-Dist: twine>=6.1; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest>=8.4; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
26
|
+
Requires-Dist: black>=25.9; extra == "dev"
|
|
27
|
+
Requires-Dist: setuptools_scm>=8; extra == "dev"
|
|
28
|
+
Provides-Extra: package
|
|
29
|
+
Requires-Dist: pyinstaller==6.21.0; extra == "package"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# lr-lumenlabel-cli
|
|
33
|
+
|
|
34
|
+
The `lumenlabel` command for LumenRadio LumenLabel label printing stations.
|
|
35
|
+
It turns a label fetched from Arena with gladiator into a label package and
|
|
36
|
+
imports it into a station, locally, on the LAN or through the Fleet relay.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install lr-lumenlabel-cli
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The LumenLabel installers also put it on every station: the `.msi` as
|
|
45
|
+
`lumenlabel.exe` on the system `PATH`, the `.deb` as `/usr/bin/lumenlabel`.
|
|
46
|
+
There it imports from a USB stick without a network (airgapped mode).
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Fetch the label with its Arena metadata and BOM children
|
|
52
|
+
gladiator get-files 109-1182 --recursive --meta-data --out 109-1182
|
|
53
|
+
|
|
54
|
+
# Write the manifest and show Build Words and media warnings, offline
|
|
55
|
+
lumenlabel label import 109-1182 --dry-run
|
|
56
|
+
|
|
57
|
+
# Import into the LumenLabel running on this computer
|
|
58
|
+
lumenlabel label import 109-1182
|
|
59
|
+
|
|
60
|
+
# Or remember a station once, then import into it
|
|
61
|
+
lumenlabel login --station https://fleet.cloud.lumenradio.com/v1/s/<uid>
|
|
62
|
+
lumenlabel label import 109-1182
|
|
63
|
+
lumenlabel label list
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Commands
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
lumenlabel [--station URL] [--token TOKEN] COMMAND # global options first
|
|
70
|
+
|
|
71
|
+
lumenlabel login --station URL [--token TOKEN]
|
|
72
|
+
# Checks the station and remembers it, with its token, in
|
|
73
|
+
# ~/.config/lumenlabel/login.json (0600). Every later command then talks
|
|
74
|
+
# to that station as if it were the local one. --base-url is an alias of
|
|
75
|
+
# --station, as in lumentest.
|
|
76
|
+
lumenlabel logout
|
|
77
|
+
|
|
78
|
+
lumenlabel label import DIR [--dry-run] [--invalidate] [--output rich|json]
|
|
79
|
+
lumenlabel label list [--output rich|json]
|
|
80
|
+
lumenlabel label remove ARTICLE [--yes]
|
|
81
|
+
|
|
82
|
+
lumenlabel station [--output rich|json] # info
|
|
83
|
+
lumenlabel station history [--limit N] [--before ID]
|
|
84
|
+
lumenlabel station logs [--tail N]
|
|
85
|
+
|
|
86
|
+
lumenlabel api METHOD PATH [--data JSON|@FILE] # any /api/ endpoint
|
|
87
|
+
lumenlabel --version
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Which station
|
|
91
|
+
|
|
92
|
+
1. `--station URL` (alias `--base-url`), or `LUMENLABEL_STATION` /
|
|
93
|
+
`LUMENLABEL_URL`.
|
|
94
|
+
2. The station saved by `lumenlabel login`.
|
|
95
|
+
3. The LumenLabel on this computer, `http://127.0.0.1:<port>`. The port is
|
|
96
|
+
read from the station's `lumenlabel.json` (default 17081).
|
|
97
|
+
|
|
98
|
+
| URL | Authentication |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `https://<fleet>/v1/s/<uid>` (Fleet relay) | Your Fleet sign-in (`fleet login`; the first command opens the browser if needed) |
|
|
101
|
+
| `http://127.0.0.1:<port>` (on the station) | None |
|
|
102
|
+
| `http://<station>:<port>` (LAN, `instrumentation.host` `0.0.0.0` on the station) | The station token, shown in admin mode on the station |
|
|
103
|
+
|
|
104
|
+
## `label import`
|
|
105
|
+
|
|
106
|
+
- The manifest (`artifact-manifest.json`) is built from the `.arena.json`
|
|
107
|
+
sidecars gladiator writes with `--meta-data`. A folder with a manifest and no
|
|
108
|
+
sidecars is imported as it is.
|
|
109
|
+
- Private attachments (NiceLabel `.nlbl` sources), web links and BOM child
|
|
110
|
+
folders are left out. Only the declared files are uploaded.
|
|
111
|
+
- The media in `configuration.json` (`media_article`, `media_name`) is checked
|
|
112
|
+
against the BOM's 111 item. A mismatch is a warning; fix it with a new
|
|
113
|
+
revision in Arena. The CLI never changes `configuration.json`.
|
|
114
|
+
- A read-only folder (a write-protected USB stick) is imported through a
|
|
115
|
+
temporary copy.
|
|
116
|
+
- An interrupted import resumes: run it again and only the files that did not
|
|
117
|
+
arrive are sent. `--invalidate` discards what the station has staged.
|
|
118
|
+
- The output shows the revision, the Build Words, the media and any warnings.
|
|
119
|
+
|
|
120
|
+
Specification: `doc/label-package.md` and `doc/api-reference.md` in the
|
|
121
|
+
[LumenLabel repository](https://gitlab.com/lumenradio/production/lumenlabel).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
lr_lumenlabel_cli-3.0.0.dist-info/licenses/LICENSE,sha256=cEnG3HeMPa3GKtuENkytOQaNJS9rwMMU_KWHObdWbOY,1070
|
|
2
|
+
lumenlabel/__init__.py,sha256=fj1drjftWKEMssQcz1DSbwsgdSPDNARj-L2vVZ9WFxc,233
|
|
3
|
+
lumenlabel/__main__.py,sha256=J0EGKk19CsNu3cRu60xavdV_0XGE3eIcSG5ELFJnj4U,60
|
|
4
|
+
lumenlabel/_version.py,sha256=Q5KDuzxagP6DhS0ZWCmUUKtPbVMNBgYpVtHVHkQdyPQ,520
|
|
5
|
+
lumenlabel/build_words.py,sha256=GcBtfcxUpFNWKeHBFt-is_KfItmzOaxFMgVw3Hewdds,2639
|
|
6
|
+
lumenlabel/cli.py,sha256=3xw7OmX13MdbRXb2zklgckmDuQrJA_9HKCnweBC0bao,1547
|
|
7
|
+
lumenlabel/client.py,sha256=Urf3csuoACv88p1bkh8AlXRTSh06YRxwWNZMAXerIoo,12654
|
|
8
|
+
lumenlabel/config.py,sha256=aViVRNq4z3mOrTXt2qzjUd3QSSe3g1fOpSAt9tC3DCc,4344
|
|
9
|
+
lumenlabel/config_rules.py,sha256=vbeQyAMAQrQq8jJsSFldheayUKOGWopw_wmAuHyFDkA,3593
|
|
10
|
+
lumenlabel/importer.py,sha256=SL5DaED9BcCTtkgdor5u7S76lYFVmIFejcplkqmBG3U,1755
|
|
11
|
+
lumenlabel/manifest_gen.py,sha256=vo6dO_Rx_m_DvgAF9rMBMFrADNhok8tmpIYByq2Xvgs,8683
|
|
12
|
+
lumenlabel/media_check.py,sha256=AACA8pbOmuu1jCwJcyUNGOeIb93g7_7tu7Bx4DLpGRE,2534
|
|
13
|
+
lumenlabel/package.py,sha256=65KIamKMW-hecW-QU0u2Lwo2TPfpuOyw676JPObuQng,10330
|
|
14
|
+
lumenlabel/shared.py,sha256=5OGLGoH1MN_ZsiQqaA37LUpzOBP64DdSmoenxOVTS4w,4056
|
|
15
|
+
lumenlabel/commands/__init__.py,sha256=yH4or3_GDcDktoZVW8xswsjNEgmy4JJu3oKhvIfM7Jw,44
|
|
16
|
+
lumenlabel/commands/api.py,sha256=7vWAWLUxDu57xZnm5iLPX73oSNRqDZPC6d9DBManb2Q,1934
|
|
17
|
+
lumenlabel/commands/auth.py,sha256=ryg1gxLHw1Bwnv3bqFSWf7iJvF71eoLRXb3qyOYBP-4,1763
|
|
18
|
+
lumenlabel/commands/label.py,sha256=jIiVCCHice5DaxjFa1bpsimQ7qcAklyB-Nl9bi7CTS8,8404
|
|
19
|
+
lumenlabel/commands/station.py,sha256=g-0mzHs6zZAJcfK-b5MAnf-Wa_fahZANR2Ocn_eyJeQ,3829
|
|
20
|
+
lumenlabel/wordlist/bip39-english.txt,sha256=L17tU6Rye0v4iA2PPxme_JDlhQNkbZ_47_Oi7Tsk29o,13116
|
|
21
|
+
lr_lumenlabel_cli-3.0.0.dist-info/METADATA,sha256=hrsjqls1XkeKXb-FmDvMCQihaczt3SQL82JrTQzDcQg,4908
|
|
22
|
+
lr_lumenlabel_cli-3.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
23
|
+
lr_lumenlabel_cli-3.0.0.dist-info/entry_points.txt,sha256=CUcNRGGZTs_AOOIfeQT4s9I_sG9cGm-QGu8acga9roo,50
|
|
24
|
+
lr_lumenlabel_cli-3.0.0.dist-info/top_level.txt,sha256=xW-gRTlQyGA7rjRy7FaqXLuItrMeY9LL9k6koSbxAZQ,11
|
|
25
|
+
lr_lumenlabel_cli-3.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LumenRadio AB
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lumenlabel
|
lumenlabel/__init__.py
ADDED
lumenlabel/__main__.py
ADDED
lumenlabel/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '3.0.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (3, 0, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Build Words: a two-word BIP39 fingerprint of a label package's files[].
|
|
2
|
+
|
|
3
|
+
A copy of the station's ``src/build_words.py`` and ``src/wordlist/``, so the
|
|
4
|
+
CLI and the station always give the same words for the same files.
|
|
5
|
+
``cli/tests/test_build_words.py`` fails when the copies drift.
|
|
6
|
+
Algorithm: doc/label-package.md §6.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Iterable
|
|
15
|
+
|
|
16
|
+
BUILD_WORDS_COUNT = 2
|
|
17
|
+
_BITS_PER_WORD = 11 # 2048-word list
|
|
18
|
+
_TOTAL_BITS = BUILD_WORDS_COUNT * _BITS_PER_WORD # 22
|
|
19
|
+
|
|
20
|
+
# The fields of LumenTest's ManifestFile with their defaults. LumenLabel's
|
|
21
|
+
# ManifestFile has no ``version`` and no ``os``; they hash as null and "any".
|
|
22
|
+
_FIELD_DEFAULTS: dict[str, Any] = {
|
|
23
|
+
"path": None,
|
|
24
|
+
"role": None,
|
|
25
|
+
"title": None,
|
|
26
|
+
"version": None,
|
|
27
|
+
"sha256": None,
|
|
28
|
+
"os": "any",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
WORDLIST_PATH = Path(__file__).resolve().parent / "wordlist" / "bip39-english.txt"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_wordlist(path: Path) -> tuple[str, ...]:
|
|
35
|
+
words = tuple(path.read_text(encoding="utf-8").split())
|
|
36
|
+
if len(words) != 2048:
|
|
37
|
+
raise RuntimeError(f"expected 2048-word BIP39 wordlist, got {len(words)}")
|
|
38
|
+
return words
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_WORDLIST: tuple[str, ...] = _load_wordlist(WORDLIST_PATH)
|
|
42
|
+
|
|
43
|
+
__all__ = ["BUILD_WORDS_COUNT", "content_digest", "build_words"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _canonical_entry(entry: Any) -> dict[str, Any]:
|
|
47
|
+
"""Reduces a ManifestFile model or a files[] dict to the canonical fields."""
|
|
48
|
+
if hasattr(entry, "model_dump"):
|
|
49
|
+
entry = entry.model_dump(mode="json")
|
|
50
|
+
return {key: entry.get(key, default) for key, default in _FIELD_DEFAULTS.items()}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def content_digest(files: Iterable[Any]) -> str:
|
|
54
|
+
"""SHA-256 hex digest over the canonical ``files[]`` entries, sorted by path."""
|
|
55
|
+
entries = sorted((_canonical_entry(f) for f in files), key=lambda f: f["path"])
|
|
56
|
+
digest = hashlib.sha256()
|
|
57
|
+
for obj in entries:
|
|
58
|
+
blob = json.dumps(
|
|
59
|
+
obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
60
|
+
).encode("utf-8")
|
|
61
|
+
# JSON escapes newlines inside strings, so "\n" separates unambiguously.
|
|
62
|
+
digest.update(blob + b"\n")
|
|
63
|
+
return digest.hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def words_from_digest(digest: str) -> str:
|
|
67
|
+
"""The two words for a :func:`content_digest` value."""
|
|
68
|
+
head = bytes.fromhex(digest)[:3]
|
|
69
|
+
value = int.from_bytes(head, "big") >> (8 * len(head) - _TOTAL_BITS)
|
|
70
|
+
return f"{_WORDLIST[value >> _BITS_PER_WORD]} {_WORDLIST[value & 0x7FF]}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_words(files: Iterable[Any]) -> str:
|
|
74
|
+
"""Two BIP39 words for *files*. The manifest header is not hashed."""
|
|
75
|
+
return words_from_digest(content_digest(files))
|
lumenlabel/cli.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The ``lumenlabel`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .commands.api import api
|
|
9
|
+
from .commands.auth import login, logout
|
|
10
|
+
from .commands.label import label_app
|
|
11
|
+
from .commands.station import station_app
|
|
12
|
+
from .shared import GlobalOptions
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
help=(
|
|
16
|
+
"Import labels into a LumenLabel station and inspect it, locally, on the "
|
|
17
|
+
"LAN or through the Fleet relay."
|
|
18
|
+
),
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
)
|
|
21
|
+
app.command("login")(login)
|
|
22
|
+
app.command("logout")(logout)
|
|
23
|
+
app.add_typer(label_app, name="label")
|
|
24
|
+
app.add_typer(station_app, name="station")
|
|
25
|
+
app.command("api")(api)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _version(value: bool) -> None:
|
|
29
|
+
if value:
|
|
30
|
+
typer.echo(__version__)
|
|
31
|
+
raise typer.Exit()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.callback()
|
|
35
|
+
def main(
|
|
36
|
+
ctx: typer.Context,
|
|
37
|
+
station: str = typer.Option(
|
|
38
|
+
None,
|
|
39
|
+
"--station",
|
|
40
|
+
"--base-url",
|
|
41
|
+
"-s",
|
|
42
|
+
envvar=["LUMENLABEL_STATION", "LUMENLABEL_URL"],
|
|
43
|
+
help="Station URL for this command (--base-url as in lumentest). "
|
|
44
|
+
"Default: the remembered station, else the local station API.",
|
|
45
|
+
),
|
|
46
|
+
token: str = typer.Option(
|
|
47
|
+
None,
|
|
48
|
+
"--token",
|
|
49
|
+
envvar="LUMENLABEL_TOKEN",
|
|
50
|
+
help="Station token for a LAN URL. Default: the remembered one.",
|
|
51
|
+
),
|
|
52
|
+
version: bool = typer.Option(
|
|
53
|
+
False,
|
|
54
|
+
"--version",
|
|
55
|
+
callback=_version,
|
|
56
|
+
is_eager=True,
|
|
57
|
+
help="Show the version and exit.",
|
|
58
|
+
),
|
|
59
|
+
) -> None:
|
|
60
|
+
ctx.obj = GlobalOptions(station=station, token=token)
|