jpsync 1.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.
jp/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """jp -- a git-like CLI that safely syncs local folders with a remote JupyterHub.
2
+
3
+ Stdlib-only, zero external dependencies. The package version is hard-coded as a
4
+ reliable fallback and overridden by installed package metadata when available.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+
11
+ __all__ = ["__version__"]
12
+
13
+ __version__ = "1.0.0" # x-release-please-version
14
+
15
+ try: # Prefer the installed distribution's version when present.
16
+ from importlib.metadata import PackageNotFoundError
17
+ from importlib.metadata import version as _version
18
+
19
+ with contextlib.suppress(PackageNotFoundError):
20
+ __version__ = _version("jpsync")
21
+ del _version, PackageNotFoundError
22
+ except Exception: # pragma: no cover - importlib.metadata always present on 3.8+
23
+ pass
jp/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Enable ``python -m jp``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
jp/api.py ADDED
@@ -0,0 +1,538 @@
1
+ """Thin JupyterHub / Jupyter-server Contents API client over stdlib urllib.
2
+
3
+ Security rules (see docs/architecture.md):
4
+ * Authorization header ONLY -- the token is never put in the URL/query string.
5
+ * TLS is always verified (a real, default ``ssl`` context). We never disable
6
+ certificate checks.
7
+ * We refuse to send a token over plain ``http://`` (no cleartext credentials).
8
+ * Errors raised here are mapped to NetworkError/ApiError; the CLI passes their
9
+ messages through ``ui.redact`` so a token (and absolute server paths) can
10
+ never leak via an error.
11
+
12
+ Empirical behaviour baked in (research/01-jupyter-api.md, JupyterHub 5.2.1):
13
+ * ``GET <file>?content=0&hash=1`` returns the **sha256** of the raw bytes
14
+ WITHOUT downloading the body (cheap even at 20 MiB). :meth:`hash` uses this
15
+ as the primary remote change-detection signal so we only download when the
16
+ remote hash differs from the local sha256.
17
+ * PUT auto-creates parent directories (``mkdir -p``); PATCH does NOT.
18
+ * PUT returns 201 (created) vs 200 (overwrote) -- surfaced as ``created``.
19
+ * ``GET`` 404 bodies are **plain text** (other errors are JSON) -> the error
20
+ parser is defensive and never raises while parsing.
21
+ * ``format=text`` does NOT reject binary; jp decides text-vs-binary itself.
22
+ Downloads always force ``type=file`` (byte-faithful, incl. ``.ipynb``) and
23
+ base64 ``content`` carries a trailing newline that must be stripped.
24
+ * Health probe: ``GET /api/status`` must NOT follow redirects; a 3xx to
25
+ ``/hub/...`` means the server is stopped (-> :class:`ServerDownError`).
26
+
27
+ This module performs raw transport only; it does NOT enforce the path-jail and
28
+ does NOT know the workspace prefix. The path-jail lives ENTIRELY in the callers
29
+ (``sync.py`` / ``commands/rm.py``): they MUST call ``paths.remote_path_for`` to
30
+ compose a remote path and ``paths.assert_within_prefix`` immediately before
31
+ invoking any mutating method here (put/mkdir/rename/delete).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import base64
37
+ import json
38
+ import ssl
39
+ import urllib.error
40
+ import urllib.parse
41
+ import urllib.request
42
+ from dataclasses import dataclass
43
+ from typing import Any
44
+
45
+ from . import ui
46
+ from .errors import ApiError, AuthError, NetworkError, ServerDownError
47
+
48
+ _DEFAULT_TIMEOUT = 30.0
49
+
50
+ # Warn (do not hard-fail) before loading a very large file fully into memory:
51
+ # there is no chunking and base64 inflates payloads by ~33% (see docs/architecture.md).
52
+ _LARGE_FILE_WARN_BYTES = 50 * 1024 * 1024 # ~50 MiB
53
+
54
+ # Extensions we treat as opaque bytes even though the server reports them as a
55
+ # "notebook" model. Forcing type=file keeps the bytes faithful (research §11).
56
+ _NOTEBOOK_EXTS = (".ipynb",)
57
+
58
+
59
+ @dataclass
60
+ class RemoteEntry:
61
+ """One item from a Contents API directory listing."""
62
+
63
+ name: str
64
+ path: str
65
+ type: str # "file" | "directory" | "notebook"
66
+ size: int | None
67
+ last_modified: str
68
+ mimetype: str | None = None
69
+
70
+
71
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
72
+ """A redirect handler that NEVER follows 3xx -- it re-raises the response.
73
+
74
+ We need this for the health probe: a stopped JupyterHub server answers
75
+ ``/api/status`` with a 302 into ``/hub/...``. urllib follows redirects by
76
+ default, which would hide that signal, so we install a dedicated opener that
77
+ surfaces the 3xx as an :class:`urllib.error.HTTPError` instead.
78
+ """
79
+
80
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
81
+ return None # do not build a follow-up request
82
+
83
+ def http_error_302(self, req, fp, code, msg, headers):
84
+ raise urllib.error.HTTPError(req.full_url, code, msg, headers, fp)
85
+
86
+ http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302
87
+
88
+
89
+ @dataclass
90
+ class StatusResult:
91
+ """Outcome of the health probe (see :meth:`Api.status_probe`)."""
92
+
93
+ up: bool
94
+ detail: str = ""
95
+
96
+
97
+ def _is_notebook_path(api_path: str) -> bool:
98
+ name = api_path.rsplit("/", 1)[-1].lower()
99
+ return name.endswith(_NOTEBOOK_EXTS)
100
+
101
+
102
+ def _looks_utf8_text(data: bytes) -> bool:
103
+ """True if ``data`` is safe to send as a UTF-8 ``format=text`` payload.
104
+
105
+ Embedded NULs or non-UTF-8 bytes mean we must use base64 instead -- the
106
+ server happily stores raw bytes in a text field (no 400), corrupting them.
107
+ """
108
+ if b"\x00" in data:
109
+ return False
110
+ try:
111
+ data.decode("utf-8")
112
+ except UnicodeDecodeError:
113
+ return False
114
+ return True
115
+
116
+
117
+ class Api:
118
+ """Contents API client. One instance per repo/session."""
119
+
120
+ def __init__(
121
+ self,
122
+ base_url: str,
123
+ token: str,
124
+ *,
125
+ timeout: float = _DEFAULT_TIMEOUT,
126
+ ssl_context: ssl.SSLContext | None = None,
127
+ large_file_warn_bytes: int = _LARGE_FILE_WARN_BYTES,
128
+ ) -> None:
129
+ self.base_url = base_url.rstrip("/")
130
+ self._token = token
131
+ self.timeout = timeout
132
+ self.large_file_warn_bytes = large_file_warn_bytes
133
+ self._warned_large: set[str] = set()
134
+ # Always a verifying context. We never pass an unverified one.
135
+ self._ssl_context = ssl_context or ssl.create_default_context()
136
+ ui.register_secret(token)
137
+
138
+ scheme = urllib.parse.urlsplit(self.base_url).scheme
139
+ if scheme not in ("http", "https"):
140
+ raise NetworkError(f"unsupported base_url scheme: {scheme!r}")
141
+ if scheme == "http" and token:
142
+ # Never transmit credentials over cleartext.
143
+ raise AuthError("refusing to send a token over plain http:// -- use https://")
144
+
145
+ # --- low-level request --------------------------------------------------
146
+ def _url(self, api_path: str) -> str:
147
+ # Contents API lives under /user/<name>/api/contents OR /api/contents;
148
+ # base_url is expected to already include the api root. The token is
149
+ # NEVER appended here.
150
+ #
151
+ # CRITICAL: split off the query string FIRST. We percent-encode only the
152
+ # path segments (keeping '/' raw) and leave the query (?content=0&hash=1)
153
+ # verbatim -- otherwise '?', '=' and '&' get encoded too and the server
154
+ # treats the whole thing as a literal filename, silently ignoring every
155
+ # query parameter (content/hash/type/format).
156
+ raw = api_path.lstrip("/")
157
+ if "?" in raw:
158
+ path_part, query = raw.split("?", 1)
159
+ query = f"?{query}"
160
+ else:
161
+ path_part, query = raw, ""
162
+ quoted = urllib.parse.quote(path_part, safe="/")
163
+ return f"{self.base_url}/{quoted}{query}"
164
+
165
+ def _request(
166
+ self,
167
+ method: str,
168
+ api_path: str,
169
+ body: dict[str, Any] | None = None,
170
+ ) -> Any:
171
+ url = self._url(api_path)
172
+ data = None
173
+ headers = {
174
+ "Authorization": f"token {self._token}",
175
+ "Accept": "application/json",
176
+ }
177
+ if body is not None:
178
+ data = json.dumps(body).encode("utf-8")
179
+ headers["Content-Type"] = "application/json"
180
+
181
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
182
+ try:
183
+ with urllib.request.urlopen(
184
+ req, timeout=self.timeout, context=self._ssl_context
185
+ ) as resp:
186
+ payload = resp.read()
187
+ if not payload:
188
+ return None
189
+ ctype = resp.headers.get("Content-Type", "")
190
+ if "json" in ctype or payload[:1] in (b"{", b"["):
191
+ return json.loads(payload.decode("utf-8"))
192
+ return payload
193
+ except urllib.error.HTTPError as exc:
194
+ self._raise_for_http(exc)
195
+ except urllib.error.URLError as exc:
196
+ # Includes TLS verification failures and DNS/connection errors.
197
+ raise NetworkError(f"network error talking to server: {exc.reason}") from exc
198
+ except (TimeoutError, OSError) as exc:
199
+ raise NetworkError(f"network error: {exc}") from exc
200
+ return None
201
+
202
+ @staticmethod
203
+ def _error_detail(exc: urllib.error.HTTPError) -> str:
204
+ """Best-effort, never-raising extraction of a server error message.
205
+
206
+ 404 bodies on this server are PLAIN TEXT; most other errors are JSON
207
+ ``{"message":...,"reason":...}``. We try JSON first, fall back to text,
208
+ and swallow everything so a malformed body cannot crash error handling.
209
+ Note: messages may leak an absolute ``/lapix/...`` path -- ``ui.redact``
210
+ masks those at the output boundary.
211
+ """
212
+ try:
213
+ raw = exc.read()
214
+ except Exception:
215
+ return ""
216
+ if not raw:
217
+ return ""
218
+ text = raw.decode("utf-8", "replace")
219
+ stripped = text.strip()
220
+ if stripped[:1] in ("{", "["):
221
+ try:
222
+ parsed = json.loads(stripped)
223
+ except ValueError:
224
+ return text[:200]
225
+ if isinstance(parsed, dict):
226
+ return str(parsed.get("message") or parsed.get("reason") or "")[:200]
227
+ return text[:200]
228
+ # Plain-text body (e.g. the 404 case).
229
+ return text[:200]
230
+
231
+ def _raise_for_http(self, exc: urllib.error.HTTPError) -> None:
232
+ status = exc.code
233
+ # Try to extract a server message, but never echo headers (which carry
234
+ # the Authorization we sent), and never raise while parsing.
235
+ detail = self._error_detail(exc)
236
+ msg = f"server returned HTTP {status}"
237
+ if detail:
238
+ msg += f": {detail}"
239
+ if status in (401, 403):
240
+ raise AuthError(msg)
241
+ raise ApiError(msg, status=status)
242
+
243
+ # --- read operations ----------------------------------------------------
244
+ def list_dir(self, api_path: str) -> list[RemoteEntry]:
245
+ """List a remote directory. Returns [] if it does not exist (404).
246
+
247
+ The listing carries only ``name/type/size/last_modified`` per child --
248
+ NO ``hash``/``content`` (research §1) -- so it is purely the cheap first
249
+ pass; per-file equality uses :meth:`hash`.
250
+ """
251
+ try:
252
+ data = self._request("GET", f"api/contents/{api_path}?content=1")
253
+ except ApiError as exc:
254
+ if exc.status == 404:
255
+ return []
256
+ raise
257
+ if not isinstance(data, dict):
258
+ return []
259
+ if data.get("type") != "directory":
260
+ return [self._to_entry(data)]
261
+ out: list[RemoteEntry] = []
262
+ for item in data.get("content") or []:
263
+ if isinstance(item, dict):
264
+ out.append(self._to_entry(item))
265
+ return out
266
+
267
+ def stat(self, api_path: str) -> RemoteEntry | None:
268
+ """Stat a single remote path without fetching content. None if missing."""
269
+ try:
270
+ data = self._request("GET", f"api/contents/{api_path}?content=0")
271
+ except ApiError as exc:
272
+ if exc.status == 404:
273
+ return None
274
+ raise
275
+ if isinstance(data, dict):
276
+ return self._to_entry(data)
277
+ return None
278
+
279
+ def hash(self, api_path: str) -> str | None:
280
+ """Return the server-computed **sha256** of a file WITHOUT downloading it.
281
+
282
+ Uses ``?content=0&hash=1`` -- the cheapest, most definitive remote
283
+ change-detection signal (research §1: identical to local ``shasum -a
284
+ 256``, ~0.1 s even at 20 MiB). Returns None if the path is missing
285
+ (404), is a directory, or the server did not populate a sha256 hash
286
+ (older servers). Callers fall back to a content download in that case.
287
+ """
288
+ try:
289
+ data = self._request("GET", f"api/contents/{api_path}?content=0&hash=1")
290
+ except ApiError as exc:
291
+ if exc.status == 404:
292
+ return None
293
+ raise
294
+ if not isinstance(data, dict):
295
+ return None
296
+ algo = data.get("hash_algorithm")
297
+ digest = data.get("hash")
298
+ if digest and (algo is None or str(algo).lower() == "sha256"):
299
+ return str(digest)
300
+ return None
301
+
302
+ def get_file_bytes(self, api_path: str) -> bytes:
303
+ """Download a file's raw bytes, byte-faithfully.
304
+
305
+ We force ``type=file`` for EVERY path (including ``.ipynb``) so the
306
+ server treats the API as a plain byte store and does not normalize a
307
+ notebook through its JSON model (research §11). base64 ``content`` from
308
+ the server carries a trailing newline which we strip before decoding.
309
+ """
310
+ data = self._request("GET", f"api/contents/{api_path}?content=1&type=file")
311
+ if not isinstance(data, dict):
312
+ raise ApiError(f"unexpected response fetching {api_path}")
313
+ fmt = data.get("format")
314
+ content = data.get("content")
315
+ if fmt == "base64":
316
+ # The server appends a trailing "\n" to base64 content; strip ALL
317
+ # ASCII whitespace before decoding so we recover the exact bytes.
318
+ b64 = "".join(str(content or "").split())
319
+ return base64.b64decode(b64)
320
+ if fmt == "text":
321
+ return str(content or "").encode("utf-8")
322
+ if fmt == "json" or data.get("type") == "notebook":
323
+ # Should not happen now that we force type=file, but stay defensive:
324
+ # re-serialize a parsed model rather than crash.
325
+ return json.dumps(content, indent=1).encode("utf-8")
326
+ if content is None:
327
+ return b""
328
+ return str(content).encode("utf-8")
329
+
330
+ # --- write operations (caller MUST have asserted within prefix) ---------
331
+ def put_file_bytes(self, api_path: str, data: bytes) -> RemoteEntry:
332
+ """Upload raw bytes to ``api_path`` byte-faithfully (type=file).
333
+
334
+ Picks ``format=text`` for UTF-8 content (smaller, diffable) and
335
+ ``format=base64`` for binary/non-UTF-8 -- jp decides client-side because
336
+ the server does NOT reject binary sent as text (research §1, §11).
337
+ Parent dirs are auto-created by the server on PUT. Callers that need the
338
+ create-vs-update (201 vs 200) signal should use :meth:`put_file`, which
339
+ this method delegates to.
340
+ """
341
+ return self.put_file(api_path, data).entry
342
+
343
+ def put_file(self, api_path: str, data: bytes) -> PutResult:
344
+ """Like :meth:`put_file_bytes` but also reports create-vs-update.
345
+
346
+ Returns a :class:`PutResult` whose ``created`` is True on HTTP 201 (new
347
+ file) and False on HTTP 200 (overwrote) -- the empirical create/update
348
+ signal (research §2).
349
+ """
350
+ self._warn_if_large(api_path, len(data))
351
+ if _looks_utf8_text(data) and not _is_notebook_path(api_path):
352
+ body = {
353
+ "type": "file",
354
+ "format": "text",
355
+ "content": data.decode("utf-8"),
356
+ }
357
+ else:
358
+ # Notebooks and any non-UTF-8 bytes go up as base64 for fidelity.
359
+ body = {
360
+ "type": "file",
361
+ "format": "base64",
362
+ "content": base64.b64encode(data).decode("ascii"),
363
+ }
364
+ created, resp = self._request_status("PUT", f"api/contents/{api_path}", body=body)
365
+ if isinstance(resp, dict):
366
+ entry = self._to_entry(resp)
367
+ else:
368
+ entry = RemoteEntry(
369
+ name=api_path.rsplit("/", 1)[-1],
370
+ path=api_path,
371
+ type="file",
372
+ size=len(data),
373
+ last_modified="",
374
+ )
375
+ return PutResult(entry=entry, created=created)
376
+
377
+ def _warn_if_large(self, api_path: str, nbytes: int) -> None:
378
+ if nbytes >= self.large_file_warn_bytes and api_path not in self._warned_large:
379
+ self._warned_large.add(api_path)
380
+ mb = nbytes / (1024 * 1024)
381
+ ui.warn(
382
+ f"{api_path}: {mb:.0f} MiB will be sent as one in-memory request "
383
+ "(no chunking; base64 inflates ~33%); this may be slow or time out."
384
+ )
385
+
386
+ def _request_status(
387
+ self, method: str, api_path: str, body: dict[str, Any] | None = None
388
+ ) -> tuple[bool, Any]:
389
+ """Like :meth:`_request` but also returns whether the status was 201.
390
+
391
+ Used by PUT to distinguish 201 (created) from 200 (overwrote). We read
392
+ the status off the response object; on any non-2xx the normal error path
393
+ in :meth:`_request` would have raised, so we only reach here for 2xx.
394
+ """
395
+ url = self._url(api_path)
396
+ data = None
397
+ headers = {
398
+ "Authorization": f"token {self._token}",
399
+ "Accept": "application/json",
400
+ }
401
+ if body is not None:
402
+ data = json.dumps(body).encode("utf-8")
403
+ headers["Content-Type"] = "application/json"
404
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
405
+ try:
406
+ with urllib.request.urlopen(
407
+ req, timeout=self.timeout, context=self._ssl_context
408
+ ) as resp:
409
+ created = resp.status == 201
410
+ payload = resp.read()
411
+ if not payload:
412
+ return created, None
413
+ ctype = resp.headers.get("Content-Type", "")
414
+ if "json" in ctype or payload[:1] in (b"{", b"["):
415
+ return created, json.loads(payload.decode("utf-8"))
416
+ return created, payload
417
+ except urllib.error.HTTPError as exc:
418
+ self._raise_for_http(exc)
419
+ except urllib.error.URLError as exc:
420
+ raise NetworkError(f"network error talking to server: {exc.reason}") from exc
421
+ except (TimeoutError, OSError) as exc:
422
+ raise NetworkError(f"network error: {exc}") from exc
423
+ return False, None
424
+
425
+ def mkdir(self, api_path: str) -> None:
426
+ """Create a remote directory (idempotent-ish: 409/exists is tolerated).
427
+
428
+ Mostly defensive now -- PUT auto-creates parents (research §2). PATCH
429
+ does NOT, so callers that move into a new dir must mkdir it first.
430
+ """
431
+ try:
432
+ self._request("PUT", f"api/contents/{api_path}", body={"type": "directory"})
433
+ except ApiError as exc:
434
+ if exc.status in (409,):
435
+ return
436
+ raise
437
+
438
+ def rename(self, src_path: str, dst_path: str) -> RemoteEntry:
439
+ """Rename/move ``src_path`` to ``dst_path``.
440
+
441
+ PATCH does NOT auto-create the destination's parent (500 if missing) and
442
+ does NOT overwrite an existing target (409), so we mkdir the parent
443
+ first (research §3).
444
+ """
445
+ parent = dst_path.rsplit("/", 1)[0] if "/" in dst_path else ""
446
+ if parent:
447
+ self.mkdir(parent)
448
+ resp = self._request("PATCH", f"api/contents/{src_path}", body={"path": dst_path})
449
+ if isinstance(resp, dict):
450
+ return self._to_entry(resp)
451
+ return RemoteEntry(
452
+ name=dst_path.rsplit("/", 1)[-1],
453
+ path=dst_path,
454
+ type="file",
455
+ size=None,
456
+ last_modified="",
457
+ )
458
+
459
+ def delete(self, api_path: str) -> None:
460
+ """Delete a remote path. Used ONLY by ``jp rm`` (gated).
461
+
462
+ DELETE is NOT recursive on this server: a non-empty directory returns
463
+ 400 "not empty" (research §5). ``jp rm --recursive`` walks bottom-up.
464
+ We re-raise the 400 with a clearer, actionable message.
465
+ """
466
+ try:
467
+ self._request("DELETE", f"api/contents/{api_path}")
468
+ except ApiError as exc:
469
+ if exc.status == 404:
470
+ return
471
+ if exc.status == 400 and "not empty" in exc.message.lower():
472
+ raise ApiError(
473
+ f"refusing to delete non-empty directory {api_path!r}: "
474
+ "the server is not recursive. Use 'jp rm --recursive' instead.",
475
+ status=400,
476
+ ) from exc
477
+ raise
478
+
479
+ # --- health probe -------------------------------------------------------
480
+ def status_probe(self) -> StatusResult:
481
+ """Probe ``GET /api/status`` WITHOUT following redirects.
482
+
483
+ Decision table (research §8):
484
+ * 200 -> server up.
485
+ * 403 (JSON) -> server up, token invalid/expired (AuthError).
486
+ * 3xx -> /hub/.. -> server stopped/not routed (ServerDownError).
487
+ * connection err -> network/Hub down (NetworkError).
488
+ """
489
+ url = f"{self.base_url}/api/status"
490
+ opener = urllib.request.build_opener(_NoRedirect, urllib.request.HTTPSHandler())
491
+ req = urllib.request.Request(
492
+ url,
493
+ headers={"Authorization": f"token {self._token}", "Accept": "application/json"},
494
+ method="GET",
495
+ )
496
+ try:
497
+ with opener.open(req, timeout=self.timeout) as resp:
498
+ return StatusResult(up=resp.status == 200, detail="reached /api/status")
499
+ except urllib.error.HTTPError as exc:
500
+ if exc.code in (301, 302, 303, 307, 308):
501
+ location = exc.headers.get("Location", "") if exc.headers else ""
502
+ if "/hub/" in location or "/hub" in location:
503
+ raise ServerDownError() from exc
504
+ raise ServerDownError(
505
+ f"unexpected redirect from /api/status to {location!r}; "
506
+ "the server may be stopped -- start it from the JupyterHub UI."
507
+ ) from exc
508
+ if exc.code in (401, 403):
509
+ raise AuthError(
510
+ "the server rejected the token (HTTP 403). "
511
+ "Refresh your token via the JupyterHub UI and run 'jp login'."
512
+ ) from exc
513
+ self._raise_for_http(exc)
514
+ except urllib.error.URLError as exc:
515
+ raise NetworkError(f"could not reach the server: {exc.reason}") from exc
516
+ except (TimeoutError, OSError) as exc:
517
+ raise NetworkError(f"could not reach the server: {exc}") from exc
518
+ return StatusResult(up=False, detail="unknown")
519
+
520
+ # --- helpers ------------------------------------------------------------
521
+ @staticmethod
522
+ def _to_entry(item: dict[str, Any]) -> RemoteEntry:
523
+ return RemoteEntry(
524
+ name=str(item.get("name", "")),
525
+ path=str(item.get("path", "")),
526
+ type=str(item.get("type", "")),
527
+ size=item.get("size"),
528
+ last_modified=str(item.get("last_modified", "")),
529
+ mimetype=item.get("mimetype"),
530
+ )
531
+
532
+
533
+ @dataclass
534
+ class PutResult:
535
+ """Result of a PUT: the resulting entry plus the create-vs-update signal."""
536
+
537
+ entry: RemoteEntry
538
+ created: bool # True on HTTP 201 (new file); False on HTTP 200 (overwrote)
jp/cli.py ADDED
@@ -0,0 +1,91 @@
1
+ """jp command-line entry point: argparse subcommands + safe error handling.
2
+
3
+ Every command's ``run`` returns an exit code or raises a ``JpError``. This
4
+ dispatcher translates exceptions into the the design docsexit codes and passes EVERY
5
+ error message through ``ui.redact`` so a token can never leak via an error path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from collections.abc import Sequence
13
+
14
+ from . import __version__, ui
15
+ from .commands import ALL
16
+ from .errors import EXIT_GENERIC, EXIT_OK, JpError
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ # Parent parser holds global flags shared by all subcommands.
21
+ parent = argparse.ArgumentParser(add_help=False)
22
+ parent.add_argument("-q", "--quiet", action="store_true", help="suppress non-essential output")
23
+ parent.add_argument("--no-color", action="store_true", help="disable colored output")
24
+
25
+ parser = argparse.ArgumentParser(
26
+ prog="jp",
27
+ description="git-like safe sync between a local folder and a remote JupyterHub.",
28
+ parents=[parent],
29
+ )
30
+ parser.add_argument("-V", "--version", action="version", version=f"jp {__version__}")
31
+
32
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
33
+ subparsers.required = False
34
+
35
+ # Each command registers its own subparser, inheriting the global flags.
36
+ for mod in ALL:
37
+ # add_parser implementations create the subparser; we re-attach the
38
+ # parent flags so -q/--no-color work after the subcommand too.
39
+ mod.add_parser(_SubparsersWithParent(subparsers, parent))
40
+
41
+ return parser
42
+
43
+
44
+ class _SubparsersWithParent:
45
+ """Wrap add_subparsers so each command's parser inherits global flags."""
46
+
47
+ def __init__(self, subparsers: argparse._SubParsersAction, parent: argparse.ArgumentParser):
48
+ self._subparsers = subparsers
49
+ self._parent = parent
50
+
51
+ def add_parser(self, name: str, **kwargs):
52
+ parents = list(kwargs.pop("parents", []))
53
+ parents.append(self._parent)
54
+ return self._subparsers.add_parser(name, parents=parents, **kwargs)
55
+
56
+
57
+ def main(argv: Sequence[str] | None = None) -> int:
58
+ parser = build_parser()
59
+ args = parser.parse_args(argv)
60
+
61
+ if getattr(args, "no_color", False):
62
+ import os
63
+
64
+ os.environ["JP_NO_COLOR"] = "1"
65
+ if getattr(args, "quiet", False):
66
+ ui.set_quiet(True)
67
+
68
+ func = getattr(args, "func", None)
69
+ if func is None:
70
+ parser.print_help()
71
+ return EXIT_OK
72
+
73
+ try:
74
+ rc = func(args)
75
+ return int(rc) if rc is not None else EXIT_OK
76
+ except JpError as exc:
77
+ # Central redaction at the error boundary.
78
+ ui.error(exc.message)
79
+ return exc.exit_code
80
+ except KeyboardInterrupt:
81
+ ui.error("interrupted")
82
+ return EXIT_GENERIC
83
+ except BrokenPipeError: # pragma: no cover
84
+ return EXIT_OK
85
+ except Exception as exc: # last-resort: never leak a token in a traceback line
86
+ ui.error(f"unexpected error: {exc}")
87
+ return EXIT_GENERIC
88
+
89
+
90
+ if __name__ == "__main__":
91
+ sys.exit(main())