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 ADDED
@@ -0,0 +1,3 @@
1
+ """``nama``: the NM portal from a terminal."""
2
+
3
+ __version__ = "0.2.1"
nama/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from nama.cli import main
4
+
5
+ sys.exit(main())
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,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: nm-portal-cli
3
+ Version: 0.2.1
4
+ Summary: The NM portal from a terminal
5
+ Requires-Python: >=3.12
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nama = nama.cli:main
@@ -0,0 +1 @@
1
+ nama