nm-portal-cli 0.2.1__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.
- nama/__init__.py +3 -0
- nama/__main__.py +5 -0
- nama/cli.py +305 -0
- nama/download.py +94 -0
- nama/portal.py +102 -0
- nama/signin.py +285 -0
- nama/store.py +73 -0
- nama/terminal.py +23 -0
- nm_portal_cli-0.2.1.dist-info/METADATA +5 -0
- nm_portal_cli-0.2.1.dist-info/RECORD +13 -0
- nm_portal_cli-0.2.1.dist-info/WHEEL +5 -0
- nm_portal_cli-0.2.1.dist-info/entry_points.txt +2 -0
- nm_portal_cli-0.2.1.dist-info/top_level.txt +1 -0
nama/__init__.py
ADDED
nama/__main__.py
ADDED
nama/cli.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""The ``nama`` command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from nama import __version__, download, portal as portal_api, signin, store, terminal
|
|
11
|
+
|
|
12
|
+
#: A job in one of these states may still be writing its results.
|
|
13
|
+
UNFINISHED = ("queued", "running", "cancelling")
|
|
14
|
+
#: How the states a job stops in read after "job <id>".
|
|
15
|
+
ENDED = {"failed": "failed", "cancelled": "was cancelled"}
|
|
16
|
+
#: The archive's name in the job's folder, and the name it is saved under.
|
|
17
|
+
ARCHIVE_NAME = "results.tar.gz"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _login(args) -> int:
|
|
21
|
+
portal = portal_api.normalize(args.portal)
|
|
22
|
+
if args.local:
|
|
23
|
+
entry = {"kind": "local", "token": portal_api.local_token(portal)}
|
|
24
|
+
else:
|
|
25
|
+
entry = signin.sign_in(portal)
|
|
26
|
+
store.put(portal, entry, make_default=True)
|
|
27
|
+
who = f" as {entry['email']}" if entry.get("email") else ""
|
|
28
|
+
terminal.say(f"Signed in to {portal}{who}.")
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _logout(args) -> int:
|
|
33
|
+
portal, entry = store.get(_portal_arg(args))
|
|
34
|
+
problem = None
|
|
35
|
+
if entry["kind"] == "cognito":
|
|
36
|
+
try:
|
|
37
|
+
signin.revoke(entry)
|
|
38
|
+
except signin.SignInError as exc:
|
|
39
|
+
problem = exc
|
|
40
|
+
store.remove(portal)
|
|
41
|
+
terminal.say(f"Signed out of {portal}.")
|
|
42
|
+
if problem is not None:
|
|
43
|
+
terminal.say(f"nama: the portal did not take back the sign-in ({problem}); it "
|
|
44
|
+
"stays usable until it expires", err=True)
|
|
45
|
+
return 1
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _status(args) -> int:
|
|
50
|
+
portal, token = _signed_in(_portal_arg(args))
|
|
51
|
+
job = _api(portal, token, "GET", f"/api/jobs/{portal_api.segment(args.job_id)}")
|
|
52
|
+
rows = [
|
|
53
|
+
("job", job["job_id"] + (f" ({job['job_name']})" if job.get("job_name") else "")),
|
|
54
|
+
("status", job["status"] + (f" ({job['step']})" if job.get("step") else "")),
|
|
55
|
+
("tool", job.get("tool_id")),
|
|
56
|
+
("machine", job.get("target_id")),
|
|
57
|
+
("submitted", _when(job.get("submitted_at"))),
|
|
58
|
+
("finished", _when(job.get("finished_at"))),
|
|
59
|
+
("error", job.get("error")),
|
|
60
|
+
]
|
|
61
|
+
for label, value in rows:
|
|
62
|
+
if value:
|
|
63
|
+
terminal.say(f"{label:<10} {value}")
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _jobs(args) -> int:
|
|
68
|
+
portal, token = _signed_in(_portal_arg(args))
|
|
69
|
+
query = urllib.parse.urlencode({"limit": args.limit, "offset": args.offset})
|
|
70
|
+
listing = _api(portal, token, "GET", f"/api/jobs?{query}")
|
|
71
|
+
jobs, total = listing["jobs"], listing.get("total")
|
|
72
|
+
if not jobs:
|
|
73
|
+
terminal.say("No jobs." if args.offset == 0 else
|
|
74
|
+
f"No jobs past the first {args.offset}.")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
names = listing.get("names") or {}
|
|
78
|
+
rows = [("JOB", "STATUS", "TOOL", "MACHINE", "SUBMITTED", "NAME")]
|
|
79
|
+
for job in jobs:
|
|
80
|
+
target = job.get("target_id")
|
|
81
|
+
rows.append(tuple(terminal.safe(cell) for cell in (
|
|
82
|
+
job["job_id"],
|
|
83
|
+
job["status"] + (f" ({job['step']})" if job.get("step") else ""),
|
|
84
|
+
job.get("tool_id") or "",
|
|
85
|
+
names.get(target, target) or "",
|
|
86
|
+
_when(job.get("submitted_at"), "%Y-%m-%d %H:%M") or "",
|
|
87
|
+
job.get("job_name") or "",
|
|
88
|
+
)))
|
|
89
|
+
widths = [max(len(row[i]) for row in rows) for i in range(len(rows[0]) - 1)]
|
|
90
|
+
for row in rows:
|
|
91
|
+
terminal.say((" ".join(cell.ljust(w) for cell, w in zip(row, widths)) + " "
|
|
92
|
+
+ row[-1]).rstrip())
|
|
93
|
+
|
|
94
|
+
last = args.offset + len(jobs)
|
|
95
|
+
footer = f"\n{args.offset + 1}-{last}" + (f" of {total}" if total is not None else "")
|
|
96
|
+
if total is not None and last < total:
|
|
97
|
+
portal_flag = f" --portal {portal}" if args.portal else ""
|
|
98
|
+
footer += f"; `nama jobs --offset {last}{portal_flag}` for older ones"
|
|
99
|
+
terminal.say(footer)
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _download(args) -> int:
|
|
104
|
+
portal, token = _signed_in(_portal_arg(args))
|
|
105
|
+
job_path = f"/api/jobs/{portal_api.segment(args.job_id)}"
|
|
106
|
+
job = _api(portal, token, "GET", job_path)
|
|
107
|
+
job_id, state = job["job_id"], job["status"]
|
|
108
|
+
if state in UNFINISHED:
|
|
109
|
+
raise download.DownloadError(f"job {job_id} is {state}; download it once it "
|
|
110
|
+
"has finished")
|
|
111
|
+
folder = Path(job_id)
|
|
112
|
+
if args.archive:
|
|
113
|
+
return _download_archive(job, folder)
|
|
114
|
+
|
|
115
|
+
listing = _api(portal, token, "GET", f"{job_path}/results")
|
|
116
|
+
if listing.get("truncated"):
|
|
117
|
+
limit = listing.get("limit")
|
|
118
|
+
instead = (f"; `nama download {job_id} --archive` saves the archive of all of "
|
|
119
|
+
"them" if job.get("download_url") else
|
|
120
|
+
", and it has no archive to save instead")
|
|
121
|
+
raise download.DownloadError(
|
|
122
|
+
f"job {job_id} has more result files than the portal lists at once"
|
|
123
|
+
f"{f' ({limit:,})' if isinstance(limit, int) else ''}, so none were saved"
|
|
124
|
+
f"{instead}")
|
|
125
|
+
files = listing["files"]
|
|
126
|
+
if not files:
|
|
127
|
+
terminal.say(f"Job {job_id} has no files under results/.")
|
|
128
|
+
return 0
|
|
129
|
+
if state != "done":
|
|
130
|
+
terminal.say(f"nama: job {job_id} {_ended(state)}; saving what it wrote before "
|
|
131
|
+
"it stopped", err=True)
|
|
132
|
+
|
|
133
|
+
targets = [(f, download.local_path(folder, f["path"])) for f in files]
|
|
134
|
+
saved, saved_bytes = 0, 0
|
|
135
|
+
for listed, target in targets:
|
|
136
|
+
size = listed["size_bytes"]
|
|
137
|
+
if download.same_size(target, size):
|
|
138
|
+
continue
|
|
139
|
+
terminal.say(f"{listed['path']} {download.human(size)}")
|
|
140
|
+
# A download can outlast the hour a token lasts.
|
|
141
|
+
_, token = _signed_in(portal)
|
|
142
|
+
link = _api(portal, token, "GET",
|
|
143
|
+
f"{job_path}/results/{download.link_path(listed['path'])}")
|
|
144
|
+
if link["size_bytes"] != size:
|
|
145
|
+
raise download.DownloadError(
|
|
146
|
+
f"results/{listed['path']} was {size} bytes when listed and "
|
|
147
|
+
f"{link['size_bytes']} when its link was made; it was not saved")
|
|
148
|
+
with download.open_link(link["url"], listed["path"]) as resp:
|
|
149
|
+
download.write(resp, target, size)
|
|
150
|
+
saved, saved_bytes = saved + 1, saved_bytes + size
|
|
151
|
+
|
|
152
|
+
skipped = len(targets) - saved
|
|
153
|
+
if saved:
|
|
154
|
+
already = f"; {_count(skipped, 'file')} already there" if skipped else ""
|
|
155
|
+
terminal.say(f"Saved {_count(saved, 'file')} ({download.human(saved_bytes)}) to "
|
|
156
|
+
f"{folder}/{already}.")
|
|
157
|
+
else:
|
|
158
|
+
terminal.say(f"All {_count(skipped, 'file')} already in {folder}/.")
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _download_archive(job: dict, folder: Path) -> int:
|
|
163
|
+
job_id, state = job["job_id"], job["status"]
|
|
164
|
+
url = job.get("download_url")
|
|
165
|
+
if not url:
|
|
166
|
+
why = (f"job {job_id} has no archive" if state == "done" else
|
|
167
|
+
f"job {job_id} {_ended(state)}, and only a job that is done has an archive")
|
|
168
|
+
raise download.DownloadError(f"{why}; without --archive, `nama download` "
|
|
169
|
+
"saves its files one by one")
|
|
170
|
+
target = folder / ARCHIVE_NAME
|
|
171
|
+
with download.open_link(url, ARCHIVE_NAME) as resp:
|
|
172
|
+
size = download.length(resp)
|
|
173
|
+
if download.same_size(target, size):
|
|
174
|
+
terminal.say(f"{target} is already here.")
|
|
175
|
+
return 0
|
|
176
|
+
terminal.say(ARCHIVE_NAME
|
|
177
|
+
+ (f" {download.human(size)}" if size is not None else ""))
|
|
178
|
+
download.write(resp, target, size)
|
|
179
|
+
terminal.say(f"Saved {target}.")
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _ended(state: str) -> str:
|
|
184
|
+
return ENDED.get(state, f"is {state}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _count(n: int, noun: str) -> str:
|
|
188
|
+
return f"{n} {noun}" + ("" if n == 1 else "s")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _portal_arg(args) -> str | None:
|
|
192
|
+
return portal_api.normalize(args.portal) if args.portal else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _signed_in(portal: str | None) -> tuple[str, str]:
|
|
196
|
+
"""The portal to use and a token for it, renewing the kept sign-in if due."""
|
|
197
|
+
portal, entry = store.get(portal)
|
|
198
|
+
try:
|
|
199
|
+
fresh = signin.renewed(entry)
|
|
200
|
+
except signin.SignInExpired as exc:
|
|
201
|
+
raise signin.SignInError(f"{exc}; run `nama login {portal}`") from None
|
|
202
|
+
if fresh is not entry:
|
|
203
|
+
store.put(portal, fresh, make_default=False)
|
|
204
|
+
return portal, signin.token(fresh)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _api(portal: str, token: str, method: str, path: str) -> dict:
|
|
208
|
+
try:
|
|
209
|
+
return portal_api.request(portal, token, method, path)
|
|
210
|
+
except portal_api.ApiError as exc:
|
|
211
|
+
if exc.status == 401:
|
|
212
|
+
raise portal_api.ApiError(401, f"{portal} did not accept the sign-in "
|
|
213
|
+
f"({exc.message}); run `nama login {portal}`") from None
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _when(epoch: float | None, fmt: str = "%Y-%m-%d %H:%M:%S %Z") -> str | None:
|
|
218
|
+
if epoch is None:
|
|
219
|
+
return None
|
|
220
|
+
return datetime.fromtimestamp(epoch).astimezone().strftime(fmt)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parser() -> argparse.ArgumentParser:
|
|
224
|
+
parser = argparse.ArgumentParser(prog="nama", description="The NM portal from a terminal.")
|
|
225
|
+
parser.add_argument("--version", action="version", version=f"nama {__version__}")
|
|
226
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
227
|
+
|
|
228
|
+
login = commands.add_parser(
|
|
229
|
+
"login", help="sign in to a portal",
|
|
230
|
+
description="Sign in to a portal through its sign-in page. The portal becomes "
|
|
231
|
+
"the one other commands use when none is named.")
|
|
232
|
+
login.add_argument("portal", help="the portal's address, e.g. https://nm-portal.example.com")
|
|
233
|
+
# For developers: a local portal, which has no sign-in page.
|
|
234
|
+
login.add_argument("--local", action="store_true", help=argparse.SUPPRESS)
|
|
235
|
+
login.set_defaults(run=_login)
|
|
236
|
+
|
|
237
|
+
portal_help = "the portal's address; by default the one last signed in to"
|
|
238
|
+
|
|
239
|
+
logout = commands.add_parser("logout", help="sign out of a portal")
|
|
240
|
+
logout.add_argument("--portal", help=portal_help)
|
|
241
|
+
logout.set_defaults(run=_logout)
|
|
242
|
+
|
|
243
|
+
status = commands.add_parser("status", help="show a job's status")
|
|
244
|
+
status.add_argument("job_id")
|
|
245
|
+
status.add_argument("--portal", help=portal_help)
|
|
246
|
+
status.set_defaults(run=_status)
|
|
247
|
+
|
|
248
|
+
jobs = commands.add_parser(
|
|
249
|
+
"jobs", help="list your jobs, newest first",
|
|
250
|
+
description="List your jobs on the portal, newest first, a page at a time.")
|
|
251
|
+
jobs.add_argument("--limit", type=_positive, default=10,
|
|
252
|
+
help="how many jobs to list (default 10)")
|
|
253
|
+
jobs.add_argument("--offset", type=_not_negative, default=0,
|
|
254
|
+
help="how many of the newest jobs to pass over (default 0)")
|
|
255
|
+
jobs.add_argument("--portal", help=portal_help)
|
|
256
|
+
jobs.set_defaults(run=_jobs)
|
|
257
|
+
|
|
258
|
+
fetch = commands.add_parser(
|
|
259
|
+
"download", help="save a job's results to this machine",
|
|
260
|
+
description="Save every file under a finished job's results/ into ./<job id>/ "
|
|
261
|
+
"here, one by one. A file already there at the same size is "
|
|
262
|
+
"passed over, so running it again picks up where it stopped.")
|
|
263
|
+
fetch.add_argument("job_id")
|
|
264
|
+
fetch.add_argument("--archive", action="store_true",
|
|
265
|
+
help=f"save the job's single archive instead, as "
|
|
266
|
+
f"./<job id>/{ARCHIVE_NAME}")
|
|
267
|
+
fetch.add_argument("--portal", help=portal_help)
|
|
268
|
+
fetch.set_defaults(run=_download)
|
|
269
|
+
return parser
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _positive(value: str) -> int:
|
|
273
|
+
return _whole(value, 1)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _not_negative(value: str) -> int:
|
|
277
|
+
return _whole(value, 0)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _whole(value: str, least: int) -> int:
|
|
281
|
+
try:
|
|
282
|
+
n = int(value)
|
|
283
|
+
except ValueError:
|
|
284
|
+
n = None
|
|
285
|
+
if n is None or n < least:
|
|
286
|
+
raise argparse.ArgumentTypeError(f"{value!r} is not a whole number of {least} "
|
|
287
|
+
"or more")
|
|
288
|
+
return n
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main(argv: list[str] | None = None) -> int:
|
|
292
|
+
args = _parser().parse_args(argv)
|
|
293
|
+
try:
|
|
294
|
+
return args.run(args)
|
|
295
|
+
except (store.NotSignedIn, store.StoreError, signin.SignInError,
|
|
296
|
+
portal_api.ApiError, portal_api.AddressError, download.DownloadError) as exc:
|
|
297
|
+
terminal.say(f"nama: {exc}", err=True)
|
|
298
|
+
return 1
|
|
299
|
+
except KeyboardInterrupt:
|
|
300
|
+
terminal.say("\nnama: stopped", err=True)
|
|
301
|
+
return 130
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
sys.exit(main())
|
nama/download.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Saving a job's files to this machine.
|
|
2
|
+
|
|
3
|
+
The files come straight from storage, through links the portal signs, and
|
|
4
|
+
never pass through the portal. Each is written under a temporary name beside
|
|
5
|
+
its own and renamed once every byte has arrived, so a file under its own name
|
|
6
|
+
is whole.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from http.client import HTTPException, HTTPResponse
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from nama import portal as portal_api
|
|
18
|
+
|
|
19
|
+
CHUNK_BYTES = 1 << 20
|
|
20
|
+
PARTIAL_SUFFIX = ".part"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DownloadError(Exception):
|
|
24
|
+
"""A file could not be saved whole."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def link_path(listed: str) -> str:
|
|
28
|
+
"""A path from a results listing as it goes in the address that asks for
|
|
29
|
+
its link: each part encoded on its own, the slashes kept."""
|
|
30
|
+
return "/".join(urllib.parse.quote(part, safe="") for part in listed.split("/"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def local_path(folder: Path, listed: str) -> Path:
|
|
34
|
+
"""Where a file the portal listed is saved, inside ``folder``. Refuses a
|
|
35
|
+
path that would land outside it."""
|
|
36
|
+
parts = listed.split("/")
|
|
37
|
+
if "\\" in listed or any(part in ("", ".", "..") for part in parts):
|
|
38
|
+
raise DownloadError(f"the portal listed {listed!r}, which is not a path "
|
|
39
|
+
"inside the job's folder; nothing more is saved")
|
|
40
|
+
return folder.joinpath(*parts)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def open_link(url: str, name: str) -> HTTPResponse:
|
|
44
|
+
"""Start fetching a signed storage link. No Authorization header: storage
|
|
45
|
+
refuses a signed link that also carries one."""
|
|
46
|
+
try:
|
|
47
|
+
return urllib.request.urlopen(url, timeout=portal_api.TIMEOUT_SECONDS)
|
|
48
|
+
except urllib.error.HTTPError as exc:
|
|
49
|
+
raise DownloadError(f"storage refused {name}: {exc.code} {exc.reason}") from None
|
|
50
|
+
except urllib.error.URLError as exc:
|
|
51
|
+
raise DownloadError(f"cannot reach storage for {name}: {exc.reason}") from None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def length(resp: HTTPResponse) -> int | None:
|
|
55
|
+
value = resp.headers.get("Content-Length")
|
|
56
|
+
return int(value) if value is not None else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write(resp: HTTPResponse, target: Path, size: int | None) -> None:
|
|
60
|
+
"""Stream ``resp`` into ``target``, checking the byte count against
|
|
61
|
+
``size`` when there is one."""
|
|
62
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
partial = target.with_name(target.name + PARTIAL_SUFFIX)
|
|
64
|
+
written = 0
|
|
65
|
+
try:
|
|
66
|
+
with open(partial, "wb") as out:
|
|
67
|
+
while chunk := resp.read(CHUNK_BYTES):
|
|
68
|
+
out.write(chunk)
|
|
69
|
+
written += len(chunk)
|
|
70
|
+
if size is not None and written != size:
|
|
71
|
+
raise DownloadError(f"{target} arrived with {written} bytes of {size}; "
|
|
72
|
+
"it was not saved")
|
|
73
|
+
os.replace(partial, target)
|
|
74
|
+
except (OSError, HTTPException) as exc:
|
|
75
|
+
partial.unlink(missing_ok=True)
|
|
76
|
+
raise DownloadError(f"{target} was not saved: {exc}") from None
|
|
77
|
+
except BaseException:
|
|
78
|
+
partial.unlink(missing_ok=True)
|
|
79
|
+
raise
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def same_size(target: Path, size: int | None) -> bool:
|
|
83
|
+
"""Whether ``target`` is already here at ``size`` bytes."""
|
|
84
|
+
return size is not None and target.is_file() and target.stat().st_size == size
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def human(size: int) -> str:
|
|
88
|
+
"""A byte count for reading: 8.4 MB, 512 bytes."""
|
|
89
|
+
value = float(size)
|
|
90
|
+
for unit in ("bytes", "KB", "MB", "GB", "TB"):
|
|
91
|
+
if value < 1000 or unit == "TB":
|
|
92
|
+
break
|
|
93
|
+
value /= 1000
|
|
94
|
+
return f"{size} bytes" if unit == "bytes" else f"{value:.1f} {unit}"
|
nama/portal.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Requests to a portal, over its public addresses and its API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
#: Long enough for a slow answer, short enough that a portal that has stopped
|
|
10
|
+
#: answering shows as an error rather than a command that never returns.
|
|
11
|
+
TIMEOUT_SECONDS = 30
|
|
12
|
+
|
|
13
|
+
#: The only hosts nama reaches over plain http: this machine, where a local
|
|
14
|
+
#: portal runs. Anywhere else the sign-in would cross the network unencrypted.
|
|
15
|
+
LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiError(RuntimeError):
|
|
19
|
+
"""A portal's refusal or failure, with the status it answered (0 when it
|
|
20
|
+
could not be reached)."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, status: int, message: str):
|
|
23
|
+
super().__init__(f"{status}: {message}" if status else message)
|
|
24
|
+
self.status = status
|
|
25
|
+
self.message = message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AddressError(ValueError):
|
|
29
|
+
"""Something given as a portal's address that is not one."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Unfollowed(urllib.request.HTTPRedirectHandler):
|
|
33
|
+
"""Refuses every redirect: Python sends a request's headers, the sign-in
|
|
34
|
+
among them, on to wherever a redirect points."""
|
|
35
|
+
|
|
36
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
37
|
+
fp.close()
|
|
38
|
+
raise ApiError(code, f"{req.full_url} redirects to {newurl}; nama does not "
|
|
39
|
+
"follow a redirect, so as not to send the sign-in there")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_OPENER = urllib.request.build_opener(_Unfollowed)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize(address: str) -> str:
|
|
46
|
+
"""A portal's address as it is kept: scheme, host and port. Anything after
|
|
47
|
+
them, such as the page a browser was showing, is not part of it."""
|
|
48
|
+
split = urllib.parse.urlsplit(address.strip())
|
|
49
|
+
try:
|
|
50
|
+
split.port
|
|
51
|
+
except ValueError:
|
|
52
|
+
split = None
|
|
53
|
+
if (split is None or split.scheme not in ("http", "https") or not split.hostname
|
|
54
|
+
or split.username is not None):
|
|
55
|
+
raise AddressError(f"{address!r} is not a portal's address; give it whole, "
|
|
56
|
+
"as https://... or, for a local portal, http://localhost:...")
|
|
57
|
+
if split.scheme == "http" and split.hostname not in LOCAL_HOSTS:
|
|
58
|
+
raise AddressError(f"{address} is not encrypted, and the sign-in goes with every "
|
|
59
|
+
f"request; use https://{split.netloc}")
|
|
60
|
+
return f"{split.scheme}://{split.netloc}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def site_config(portal: str) -> dict:
|
|
64
|
+
"""The settings the portal gives its website, which name its sign-in page."""
|
|
65
|
+
return request(portal, None, "GET", "/config.json")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def local_token(portal: str) -> str:
|
|
69
|
+
"""The token a local portal takes: it has no sign-in page."""
|
|
70
|
+
return request(portal, None, "GET", "/local")["token"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def request(portal: str, token: str | None, method: str, path: str) -> dict:
|
|
74
|
+
"""Return the parsed JSON answer, or raise ApiError. A redirect is refused."""
|
|
75
|
+
headers = {"Accept": "application/json"}
|
|
76
|
+
if token:
|
|
77
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
78
|
+
req = urllib.request.Request(f"{portal}{path}", headers=headers, method=method)
|
|
79
|
+
try:
|
|
80
|
+
with _OPENER.open(req, timeout=TIMEOUT_SECONDS) as resp:
|
|
81
|
+
raw = resp.read()
|
|
82
|
+
except urllib.error.HTTPError as exc:
|
|
83
|
+
raw = exc.read()
|
|
84
|
+
try:
|
|
85
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
86
|
+
message = parsed.get("error") or parsed.get("message") or str(parsed)
|
|
87
|
+
except (ValueError, AttributeError):
|
|
88
|
+
message = raw.decode("utf-8", "replace").strip() or exc.reason
|
|
89
|
+
raise ApiError(exc.code, message) from None
|
|
90
|
+
except urllib.error.URLError as exc:
|
|
91
|
+
raise ApiError(0, f"cannot reach {portal}: {exc.reason}") from None
|
|
92
|
+
if not raw:
|
|
93
|
+
return {}
|
|
94
|
+
try:
|
|
95
|
+
return json.loads(raw.decode("utf-8"))
|
|
96
|
+
except ValueError:
|
|
97
|
+
raise ApiError(0, f"{portal}{path} did not answer with JSON") from None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def segment(value: str) -> str:
|
|
101
|
+
"""One path segment, percent-encoded."""
|
|
102
|
+
return urllib.parse.quote(value, safe="")
|
nama/signin.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Signing in through a portal's sign-in page, as its website does: the
|
|
2
|
+
authorization code flow with PKCE (Proof Key for Code Exchange).
|
|
3
|
+
|
|
4
|
+
The command makes a one-time secret, sends the person's browser to the sign-in
|
|
5
|
+
page with a hash of it, and listens on a fixed port of this machine for the
|
|
6
|
+
browser to be sent back with a code. Only the holder of the secret can turn
|
|
7
|
+
that code into tokens. When the browser is on another machine, the address it
|
|
8
|
+
is sent back to does not load there, and the person pastes it here instead.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import queue
|
|
17
|
+
import secrets
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
import webbrowser
|
|
25
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
26
|
+
|
|
27
|
+
from nama import portal as portal_api, terminal
|
|
28
|
+
|
|
29
|
+
#: The portal's sign-in settings for nama list this address and no other, so
|
|
30
|
+
#: the port cannot move.
|
|
31
|
+
CALLBACK_PORT = 18780
|
|
32
|
+
CALLBACK_URL = f"http://localhost:{CALLBACK_PORT}/"
|
|
33
|
+
SCOPES = "openid email profile"
|
|
34
|
+
|
|
35
|
+
#: A sign-in's ID token is renewed when it has less than this left.
|
|
36
|
+
RENEW_BEFORE_SECONDS = 120
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SignInError(Exception):
|
|
40
|
+
"""Signing in, renewing or signing out did not succeed."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SignInExpired(SignInError):
|
|
44
|
+
"""The sign-in can no longer be renewed; the person signs in again."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Refused(SignInError):
|
|
48
|
+
"""The sign-in service's refusal, with its OAuth error code."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str, error: str):
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.error = error
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sign_in(portal: str) -> dict:
|
|
56
|
+
"""Sign in to ``portal`` and return the sign-in to keep."""
|
|
57
|
+
config = portal_api.site_config(portal)
|
|
58
|
+
client_id = config.get("cliClientId")
|
|
59
|
+
if not client_id:
|
|
60
|
+
hint = (" It is a local portal: use `nama login --local`."
|
|
61
|
+
if config.get("userPoolClientId") == "local" else "")
|
|
62
|
+
raise SignInError(f"{portal} does not offer command-line sign-in: its "
|
|
63
|
+
f"config.json names no cliClientId.{hint}")
|
|
64
|
+
domain = config.get("cognitoDomainUrl")
|
|
65
|
+
if not isinstance(domain, str) or not domain.startswith("https://"):
|
|
66
|
+
raise SignInError(f"{portal} names its sign-in page as {domain!r}, which is not "
|
|
67
|
+
"an https:// address; nama signs in only over an encrypted "
|
|
68
|
+
"connection")
|
|
69
|
+
domain = domain.rstrip("/")
|
|
70
|
+
|
|
71
|
+
verifier = secrets.token_urlsafe(64)
|
|
72
|
+
challenge = _b64(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
73
|
+
state = secrets.token_urlsafe(24)
|
|
74
|
+
address = f"{domain}/oauth2/authorize?" + urllib.parse.urlencode({
|
|
75
|
+
"response_type": "code",
|
|
76
|
+
"client_id": client_id,
|
|
77
|
+
"redirect_uri": CALLBACK_URL,
|
|
78
|
+
"scope": SCOPES,
|
|
79
|
+
"state": state,
|
|
80
|
+
"code_challenge": challenge,
|
|
81
|
+
"code_challenge_method": "S256",
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
returned = _wait_for_return(portal, address)
|
|
85
|
+
code = _code_from(returned, state)
|
|
86
|
+
tokens = _token_request(domain, {
|
|
87
|
+
"grant_type": "authorization_code",
|
|
88
|
+
"client_id": client_id,
|
|
89
|
+
"code": code,
|
|
90
|
+
"redirect_uri": CALLBACK_URL,
|
|
91
|
+
"code_verifier": verifier,
|
|
92
|
+
})
|
|
93
|
+
claims = _claims(tokens["id_token"])
|
|
94
|
+
return {
|
|
95
|
+
"kind": "cognito",
|
|
96
|
+
"cognito_domain": domain,
|
|
97
|
+
"client_id": client_id,
|
|
98
|
+
"email": claims.get("email"),
|
|
99
|
+
"id_token": tokens["id_token"],
|
|
100
|
+
"refresh_token": tokens["refresh_token"],
|
|
101
|
+
"expires_at": claims["exp"],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def renewed(entry: dict) -> dict:
|
|
106
|
+
"""The sign-in with an ID token that has time left, renewed if need be.
|
|
107
|
+
Returns the same dict when nothing changed."""
|
|
108
|
+
if entry["kind"] == "local" or entry["expires_at"] - time.time() > RENEW_BEFORE_SECONDS:
|
|
109
|
+
return entry
|
|
110
|
+
tokens = _token_request(entry["cognito_domain"], {
|
|
111
|
+
"grant_type": "refresh_token",
|
|
112
|
+
"client_id": entry["client_id"],
|
|
113
|
+
"refresh_token": entry["refresh_token"],
|
|
114
|
+
})
|
|
115
|
+
return {
|
|
116
|
+
**entry,
|
|
117
|
+
"id_token": tokens["id_token"],
|
|
118
|
+
"refresh_token": tokens.get("refresh_token") or entry["refresh_token"],
|
|
119
|
+
"expires_at": _claims(tokens["id_token"])["exp"],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def token(entry: dict) -> str:
|
|
124
|
+
return entry["token"] if entry["kind"] == "local" else entry["id_token"]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def revoke(entry: dict) -> None:
|
|
128
|
+
"""Cancel the sign-in with the portal, so its renewal token stops working."""
|
|
129
|
+
_post(f"{entry['cognito_domain']}/oauth2/revoke", {
|
|
130
|
+
"token": entry["refresh_token"],
|
|
131
|
+
"client_id": entry["client_id"],
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _wait_for_return(portal: str, address: str) -> str:
|
|
136
|
+
"""The address the browser was sent back to, from the listener or pasted."""
|
|
137
|
+
answers: queue.Queue = queue.Queue()
|
|
138
|
+
server = _listen(answers)
|
|
139
|
+
_say(f"Sign in to {portal} in a browser, at this address:")
|
|
140
|
+
_say()
|
|
141
|
+
_say(f" {address}")
|
|
142
|
+
_say()
|
|
143
|
+
if server is None:
|
|
144
|
+
_say(f"(Port {CALLBACK_PORT} is in use on this machine, so nama cannot see the")
|
|
145
|
+
_say(" browser come back; paste the address as below.)")
|
|
146
|
+
_say("If the browser is on another machine, it ends on a page that does not")
|
|
147
|
+
_say("load. Copy that page's address from the address bar and paste it here.")
|
|
148
|
+
if server is not None and _has_own_browser():
|
|
149
|
+
webbrowser.open(address)
|
|
150
|
+
|
|
151
|
+
threading.Thread(target=_read_pasted, args=(answers,), daemon=True).start()
|
|
152
|
+
try:
|
|
153
|
+
while True:
|
|
154
|
+
source, text = answers.get()
|
|
155
|
+
if source == "eof":
|
|
156
|
+
if server is None:
|
|
157
|
+
raise SignInError("nothing to read the address from: the port is in "
|
|
158
|
+
"use and nothing can be pasted here")
|
|
159
|
+
continue
|
|
160
|
+
if source == "pasted" and not ("code=" in text or "error=" in text):
|
|
161
|
+
if text:
|
|
162
|
+
_say("That is not the address the sign-in ended on. Paste the whole "
|
|
163
|
+
"address from the browser's address bar.")
|
|
164
|
+
continue
|
|
165
|
+
return text
|
|
166
|
+
finally:
|
|
167
|
+
if server is not None:
|
|
168
|
+
server.shutdown()
|
|
169
|
+
server.server_close()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _listen(answers: queue.Queue) -> HTTPServer | None:
|
|
173
|
+
class Return(BaseHTTPRequestHandler):
|
|
174
|
+
def do_GET(self):
|
|
175
|
+
query = urllib.parse.urlsplit(self.path).query
|
|
176
|
+
if not ("code=" in query or "error=" in query):
|
|
177
|
+
self.send_error(404)
|
|
178
|
+
return
|
|
179
|
+
answers.put(("listener", query))
|
|
180
|
+
body = (b"<!doctype html><title>nama</title><p>Back with nama. You can "
|
|
181
|
+
b"close this tab; the terminal says whether signing in worked.</p>")
|
|
182
|
+
self.send_response(200)
|
|
183
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
184
|
+
self.send_header("Content-Length", str(len(body)))
|
|
185
|
+
self.end_headers()
|
|
186
|
+
self.wfile.write(body)
|
|
187
|
+
|
|
188
|
+
def log_message(self, *args):
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
server = HTTPServer(("127.0.0.1", CALLBACK_PORT), Return)
|
|
193
|
+
except OSError:
|
|
194
|
+
return None
|
|
195
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
196
|
+
return server
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _read_pasted(answers: queue.Queue) -> None:
|
|
200
|
+
# os.read, not sys.stdin: a thread still blocked in sys.stdin's buffered
|
|
201
|
+
# reader when the command ends makes Python abort at exit.
|
|
202
|
+
pending = b""
|
|
203
|
+
while True:
|
|
204
|
+
try:
|
|
205
|
+
chunk = os.read(0, 4096)
|
|
206
|
+
except OSError:
|
|
207
|
+
chunk = b""
|
|
208
|
+
if not chunk:
|
|
209
|
+
answers.put(("eof", None))
|
|
210
|
+
return
|
|
211
|
+
pending += chunk
|
|
212
|
+
while b"\n" in pending:
|
|
213
|
+
line, pending = pending.split(b"\n", 1)
|
|
214
|
+
answers.put(("pasted", line.decode("utf-8", "replace").strip()))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _say(text: str = "") -> None:
|
|
218
|
+
terminal.say(text, err=True)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _has_own_browser() -> bool:
|
|
222
|
+
"""Whether a browser opened here would be in front of the person. Over SSH
|
|
223
|
+
it would not, and Python could start a text browser in the terminal."""
|
|
224
|
+
if os.environ.get("SSH_CONNECTION"):
|
|
225
|
+
return False
|
|
226
|
+
if sys.platform in ("darwin", "win32"):
|
|
227
|
+
return True
|
|
228
|
+
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _code_from(returned: str, state: str) -> str:
|
|
232
|
+
query = returned.split("?", 1)[1] if "?" in returned else returned
|
|
233
|
+
params = urllib.parse.parse_qs(query.split("#", 1)[0])
|
|
234
|
+
if "error" in params:
|
|
235
|
+
detail = params.get("error_description", params["error"])[0]
|
|
236
|
+
raise SignInError(f"the sign-in page refused: {detail}")
|
|
237
|
+
if params.get("state", [None])[0] != state:
|
|
238
|
+
raise SignInError("that address is from a different sign-in; run `nama login` again")
|
|
239
|
+
if "code" not in params:
|
|
240
|
+
raise SignInError("that address holds no sign-in code")
|
|
241
|
+
return params["code"][0]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _token_request(domain: str, form: dict) -> dict:
|
|
245
|
+
try:
|
|
246
|
+
return _post(f"{domain}/oauth2/token", form)
|
|
247
|
+
except _Refused as exc:
|
|
248
|
+
if exc.error == "invalid_grant" and form["grant_type"] == "refresh_token":
|
|
249
|
+
raise SignInExpired("the sign-in has expired or was cancelled") from None
|
|
250
|
+
raise
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _post(url: str, form: dict) -> dict:
|
|
254
|
+
data = urllib.parse.urlencode(form).encode("ascii")
|
|
255
|
+
req = urllib.request.Request(url, data=data, method="POST", headers={
|
|
256
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
257
|
+
"Accept": "application/json",
|
|
258
|
+
})
|
|
259
|
+
try:
|
|
260
|
+
with urllib.request.urlopen(req, timeout=portal_api.TIMEOUT_SECONDS) as resp:
|
|
261
|
+
raw = resp.read()
|
|
262
|
+
except urllib.error.HTTPError as exc:
|
|
263
|
+
raw = exc.read().decode("utf-8", "replace")
|
|
264
|
+
try:
|
|
265
|
+
parsed = json.loads(raw)
|
|
266
|
+
error = parsed.get("error", "")
|
|
267
|
+
detail = error
|
|
268
|
+
if parsed.get("error_description"):
|
|
269
|
+
detail = f"{error}: {parsed['error_description']}"
|
|
270
|
+
except (ValueError, AttributeError):
|
|
271
|
+
error, detail = "", raw.strip() or exc.reason
|
|
272
|
+
raise _Refused(f"{exc.code} from {url}: {detail}", error) from None
|
|
273
|
+
except urllib.error.URLError as exc:
|
|
274
|
+
raise SignInError(f"cannot reach {url}: {exc.reason}") from None
|
|
275
|
+
return json.loads(raw) if raw else {}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _claims(jwt: str) -> dict:
|
|
279
|
+
"""A token's claims, read and not checked: the portal checks the signature."""
|
|
280
|
+
payload = jwt.split(".")[1]
|
|
281
|
+
return json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _b64(raw: bytes) -> str:
|
|
285
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
nama/store.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""The sign-ins kept on this machine: one per portal, in one file that only its
|
|
2
|
+
owner can read, and the portal last signed in to as the one a command uses
|
|
3
|
+
when none is named."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NotSignedIn(Exception):
|
|
12
|
+
"""No sign-in is kept for the portal asked about."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StoreError(Exception):
|
|
16
|
+
"""The file of sign-ins cannot be read."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def path() -> Path:
|
|
20
|
+
base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
|
|
21
|
+
return Path(base) / "nama" / "sign-ins.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read() -> dict:
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(path().read_text())
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
return {"default": None, "portals": {}}
|
|
29
|
+
except ValueError as exc:
|
|
30
|
+
raise StoreError(f"{path()} cannot be read ({exc}); delete it and sign in "
|
|
31
|
+
"again") from None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write(state: dict) -> None:
|
|
35
|
+
target = path()
|
|
36
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
37
|
+
temp = target.with_suffix(".tmp")
|
|
38
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
39
|
+
os.fchmod(fd, 0o600)
|
|
40
|
+
with os.fdopen(fd, "w") as f:
|
|
41
|
+
json.dump(state, f, indent=2)
|
|
42
|
+
os.replace(temp, target)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get(portal: str | None) -> tuple[str, dict]:
|
|
46
|
+
"""The portal named, or the default one, and its sign-in."""
|
|
47
|
+
state = _read()
|
|
48
|
+
if portal is None:
|
|
49
|
+
portal = state["default"]
|
|
50
|
+
if portal is None:
|
|
51
|
+
raise NotSignedIn("not signed in to any portal; run `nama login "
|
|
52
|
+
"https://nm-portal.example.com`, with the portal's own "
|
|
53
|
+
"address")
|
|
54
|
+
entry = state["portals"].get(portal)
|
|
55
|
+
if entry is None:
|
|
56
|
+
raise NotSignedIn(f"not signed in to {portal}; run `nama login {portal}`")
|
|
57
|
+
return portal, entry
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def put(portal: str, entry: dict, *, make_default: bool) -> None:
|
|
61
|
+
state = _read()
|
|
62
|
+
state["portals"][portal] = entry
|
|
63
|
+
if make_default:
|
|
64
|
+
state["default"] = portal
|
|
65
|
+
_write(state)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def remove(portal: str) -> None:
|
|
69
|
+
state = _read()
|
|
70
|
+
state["portals"].pop(portal, None)
|
|
71
|
+
if state["default"] == portal:
|
|
72
|
+
state["default"] = None
|
|
73
|
+
_write(state)
|
nama/terminal.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Printing to the terminal text that comes from elsewhere: the portal, the
|
|
2
|
+
tool that ran a job, the machine it ran on.
|
|
3
|
+
|
|
4
|
+
A terminal acts on control characters rather than showing them: they move the
|
|
5
|
+
cursor, rewrite what is already on the screen and, in some terminals, set the
|
|
6
|
+
clipboard. Everything nama prints goes through ``say``, which shows each such
|
|
7
|
+
character as its escape, ``\\x1b`` for one, instead.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
#: Every control character but tab and newline: C0, DEL and C1.
|
|
15
|
+
_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def safe(text: object) -> str:
|
|
19
|
+
return _CONTROL.sub(lambda m: f"\\x{ord(m.group()):02x}", str(text))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def say(text: object = "", *, err: bool = False) -> None:
|
|
23
|
+
print(safe(text), file=sys.stderr if err else sys.stdout, flush=True)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
nama/__init__.py,sha256=ezbXL_FvDOTTZPCVnW-roulsBPd7JVzpsN8IU4g9QmY,70
|
|
2
|
+
nama/__main__.py,sha256=hefzpCFgr3oZYfWANBU-lSQ4b5sXB8Jdx-mG8i22_rM,56
|
|
3
|
+
nama/cli.py,sha256=I_9qeKIwitvb8km9j0WV7Y9B-TtHRCcqA6zzMlyyW3s,12086
|
|
4
|
+
nama/download.py,sha256=hr-arTUfVVR89EnDihadzTZVSI-io4VP9XXUkm8P9fw,3506
|
|
5
|
+
nama/portal.py,sha256=m7RjUzKpsVblL9XTr2zY-dMQ4KFHJMiE70oay4UOf8M,3992
|
|
6
|
+
nama/signin.py,sha256=IGXyS5Xik3YRSuGHvp3g8-9ZmHbo02tDhQ2grzvy6FY,10577
|
|
7
|
+
nama/store.py,sha256=BQrVj1A_DQX0vXsiueXUW3YUZLw7BWipqxgiZZUfP80,2212
|
|
8
|
+
nama/terminal.py,sha256=u-RVpRNcMvflEg0zAQsuOaWAwm8MaB8sG7ob-jtb4_g,830
|
|
9
|
+
nm_portal_cli-0.2.1.dist-info/METADATA,sha256=LVdN3FfxH3ClYtBmIaFjkF5Y4FEN0_1Q9TvE-0o13Fs,120
|
|
10
|
+
nm_portal_cli-0.2.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
nm_portal_cli-0.2.1.dist-info/entry_points.txt,sha256=HTZYQWBOByJovHQVld1IAHz5ggF1whVLiYnb7JcN5kc,39
|
|
12
|
+
nm_portal_cli-0.2.1.dist-info/top_level.txt,sha256=q1f2veWOLFh5b5Fvccr7kmzWD167MTJe3w918LdfqXk,5
|
|
13
|
+
nm_portal_cli-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nama
|