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 +23 -0
- jp/__main__.py +10 -0
- jp/api.py +538 -0
- jp/cli.py +91 -0
- jp/commands/__init__.py +39 -0
- jp/commands/_context.py +99 -0
- jp/commands/_mirror.py +100 -0
- jp/commands/_report.py +47 -0
- jp/commands/clone.py +97 -0
- jp/commands/config_cmd.py +132 -0
- jp/commands/diff.py +93 -0
- jp/commands/doctor.py +103 -0
- jp/commands/ignore_cmd.py +41 -0
- jp/commands/init.py +66 -0
- jp/commands/kernel.py +238 -0
- jp/commands/login.py +137 -0
- jp/commands/ls.py +40 -0
- jp/commands/pull.py +58 -0
- jp/commands/push.py +58 -0
- jp/commands/rm.py +204 -0
- jp/commands/status.py +69 -0
- jp/commands/update.py +210 -0
- jp/commands/version.py +18 -0
- jp/config.py +246 -0
- jp/credentials.py +259 -0
- jp/errors.py +108 -0
- jp/ignore.py +141 -0
- jp/index.py +121 -0
- jp/paths.py +427 -0
- jp/settings_schema.py +86 -0
- jp/sync.py +594 -0
- jp/tui.py +533 -0
- jp/ui.py +184 -0
- jp/urls.py +103 -0
- jpsync-1.0.0.dist-info/METADATA +406 -0
- jpsync-1.0.0.dist-info/RECORD +39 -0
- jpsync-1.0.0.dist-info/WHEEL +4 -0
- jpsync-1.0.0.dist-info/entry_points.txt +2 -0
- jpsync-1.0.0.dist-info/licenses/LICENSE +21 -0
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
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())
|