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.
@@ -0,0 +1,39 @@
1
+ """Command registry: each module exposes add_parser(subparsers)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import (
6
+ clone,
7
+ config_cmd,
8
+ diff,
9
+ doctor,
10
+ ignore_cmd,
11
+ init,
12
+ kernel,
13
+ login,
14
+ ls,
15
+ pull,
16
+ push,
17
+ rm,
18
+ status,
19
+ update,
20
+ version,
21
+ )
22
+
23
+ ALL = [
24
+ clone,
25
+ init,
26
+ login,
27
+ pull,
28
+ push,
29
+ status,
30
+ ls,
31
+ diff,
32
+ config_cmd,
33
+ ignore_cmd,
34
+ rm,
35
+ kernel,
36
+ doctor,
37
+ update,
38
+ version,
39
+ ]
@@ -0,0 +1,99 @@
1
+ """Shared helpers for commands: locate the repo and build a live API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .. import config as config_mod
9
+ from ..api import Api
10
+ from ..config import Config
11
+ from ..errors import ConfigError
12
+ from ..ignore import IgnoreSet
13
+ from ..index import Index
14
+
15
+
16
+ @dataclass
17
+ class RepoContext:
18
+ root: Path
19
+ cfg: Config
20
+ index: Index
21
+ ignore: IgnoreSet
22
+
23
+
24
+ def load_repo() -> RepoContext:
25
+ """Locate the jp repo from the cwd and load its config/index/ignore."""
26
+ from ..paths import find_root
27
+
28
+ root = find_root()
29
+ if root is None:
30
+ raise ConfigError(
31
+ "not inside a jp repository (no .jp directory found). "
32
+ "Run 'jp init' or 'jp clone' first."
33
+ )
34
+ cfg = config_mod.load(root)
35
+ # Apply the workspace color policy (env/--no-color still override it).
36
+ from .. import ui
37
+
38
+ ui.set_color_mode(cfg.color)
39
+ index = Index.load(root)
40
+ ignore = IgnoreSet.from_root(root)
41
+ return RepoContext(root=root, cfg=cfg, index=index, ignore=ignore)
42
+
43
+
44
+ def build_api(cfg: Config) -> Api:
45
+ """Construct an Api client, loading the token (and registering it for redaction)."""
46
+ token = config_mod.load_token(cfg)
47
+ return Api(cfg.base_url, token)
48
+
49
+
50
+ def choose_credential(args: object, root: Path | None = None) -> str:
51
+ """Decide which saved credential ``clone``/``init`` should record.
52
+
53
+ Returns the credential NAME to store in the new workspace's config, or ``""``
54
+ when no credential applies (an explicit ``--token-path`` or a ``JP_TOKEN``/
55
+ ``JP_TOKEN_FILE`` env token is being used instead).
56
+
57
+ Resolution: an explicit ``--credential`` is validated against what exists;
58
+ otherwise zero credentials is an error (run ``jp login`` first), exactly one
59
+ is used silently, and several trigger an interactive picker (or, in a
60
+ non-interactive shell, an error asking for ``--credential``).
61
+ """
62
+ import os
63
+
64
+ from .. import credentials, tui
65
+ from ..errors import AuthError, UsageError
66
+
67
+ # An explicit token path bypasses the credential system entirely.
68
+ if getattr(args, "token_path", ""):
69
+ return ""
70
+
71
+ requested = (getattr(args, "credential", "") or "").strip()
72
+ available = credentials.list_credentials(root=root)
73
+
74
+ if requested:
75
+ if not any(c.name == requested for c in available):
76
+ names = ", ".join(c.name for c in available) or "(none)"
77
+ raise UsageError(f"no saved credential named {requested!r}. Available: {names}")
78
+ return requested
79
+
80
+ if not available:
81
+ if os.environ.get("JP_TOKEN") or os.environ.get("JP_TOKEN_FILE"):
82
+ return ""
83
+ raise AuthError("no credentials configured. Run 'jp login' first to save your API token.")
84
+
85
+ if len(available) == 1:
86
+ return available[0].name
87
+
88
+ # More than one: let the user choose.
89
+ if tui.interactive():
90
+ labels = [f"{c.name} ({c.scope})" for c in available]
91
+ idx = tui.select_one(labels, title="Select a credential for this workspace")
92
+ if idx is None:
93
+ raise UsageError("no credential selected")
94
+ return available[idx].name
95
+
96
+ names = ", ".join(c.name for c in available)
97
+ raise UsageError(
98
+ f"multiple saved credentials; choose one with --credential NAME (one of: {names})"
99
+ )
jp/commands/_mirror.py ADDED
@@ -0,0 +1,100 @@
1
+ """Mirror-mode deletion handling, shared by ``jp push`` and ``jp pull``.
2
+
3
+ Mirror mode is OFF by default. When ON, after the additive sync, files that
4
+ exist on one side but not the other become *deletion candidates*. This module
5
+ NEVER deletes without consent:
6
+
7
+ * In an interactive terminal it shows a keep/delete selector (every file
8
+ defaults to KEEP) and only deletes what the user explicitly marks.
9
+ * With ``--yes`` it deletes every candidate (for scripts that opt in).
10
+ * In a non-interactive shell WITHOUT ``--yes`` it deletes nothing and says so.
11
+
12
+ Every remote deletion is re-validated against the workspace prefix immediately
13
+ before the call, so a mirror delete can never escape the user's own subtree.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .. import paths, tui, ui
19
+ from ..sync import Outcome
20
+ from ._context import RepoContext
21
+
22
+
23
+ def handle(
24
+ side: str, # "remote" (push) or "local" (pull)
25
+ ctx: RepoContext,
26
+ api: object,
27
+ outcome: Outcome,
28
+ *,
29
+ yes: bool,
30
+ dry_run: bool,
31
+ ) -> None:
32
+ candidates = list(outcome.deletable)
33
+ if not candidates:
34
+ return
35
+
36
+ if dry_run:
37
+ ui.warn(
38
+ f"mirror mode: {len(candidates)} file(s) exist on {side} but not on the "
39
+ f"other side and could be deleted (run without --dry-run to choose):"
40
+ )
41
+ for rel in candidates:
42
+ ui.detail(f" {rel}")
43
+ return
44
+
45
+ # Decide what to delete.
46
+ if yes:
47
+ selected = candidates
48
+ elif tui.interactive():
49
+ selected = tui.confirm_deletions(candidates, side)
50
+ else:
51
+ ui.warn(
52
+ f"mirror mode: {len(candidates)} file(s) exist on {side} but not on the "
53
+ "other side. Refusing to delete without a terminal; re-run interactively "
54
+ "or pass --yes to delete them all."
55
+ )
56
+ for rel in candidates:
57
+ ui.detail(f" {rel}")
58
+ return
59
+
60
+ if not selected:
61
+ ui.info("mirror: kept everything (nothing deleted)")
62
+ return
63
+
64
+ if side == "remote":
65
+ _delete_remote(ctx, api, selected, outcome)
66
+ else:
67
+ _delete_local(ctx, selected, outcome)
68
+
69
+
70
+ def _delete_remote(ctx: RepoContext, api: object, selected: list[str], outcome: Outcome) -> None:
71
+ prefix = paths.validate_prefix(ctx.cfg.prefix)
72
+ for rel in selected:
73
+ try:
74
+ remote_path = paths.remote_path_for(prefix, rel)
75
+ # SAFETY: re-assert containment immediately before the destructive call.
76
+ paths.assert_within_prefix(remote_path, prefix)
77
+ api.delete(remote_path) # type: ignore[attr-defined]
78
+ ctx.index.remove(rel)
79
+ ctx.index.save()
80
+ outcome.deleted.append(rel)
81
+ ui.info(f" deleted remote: {rel}")
82
+ except Exception as exc: # keep going; report per-file
83
+ outcome.failures.append((rel, f"delete failed: {exc}"))
84
+
85
+
86
+ def _delete_local(ctx: RepoContext, selected: list[str], outcome: Outcome) -> None:
87
+ for rel in selected:
88
+ try:
89
+ dest = paths.safe_local_dest(ctx.root, rel)
90
+ if dest.is_symlink():
91
+ outcome.failures.append((rel, "refusing to delete through a symlink"))
92
+ continue
93
+ if dest.is_file():
94
+ dest.unlink()
95
+ ctx.index.remove(rel)
96
+ ctx.index.save()
97
+ outcome.deleted.append(rel)
98
+ ui.info(f" deleted local: {rel}")
99
+ except Exception as exc:
100
+ outcome.failures.append((rel, f"delete failed: {exc}"))
jp/commands/_report.py ADDED
@@ -0,0 +1,47 @@
1
+ """Shared summary printing for sync outcomes (push/pull/clone)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .. import ui
6
+ from ..sync import Outcome
7
+
8
+
9
+ def report_outcome(verb: str, outcome: Outcome, *, dry_run: bool) -> None:
10
+ """Print a human summary of a sync run.
11
+
12
+ Conflicts and per-file failures are surfaced clearly. Skipped dotfiles are
13
+ reported but never treated as an error (see docs/architecture.md).
14
+ """
15
+ prefix = "[dry-run] would " if dry_run else ""
16
+
17
+ for rel in outcome.transferred:
18
+ ui.out(f" {prefix}{verb}: {rel}")
19
+
20
+ if outcome.skipped_hidden:
21
+ ui.warn(
22
+ f"skipped {len(outcome.skipped_hidden)} hidden/dotfile(s) "
23
+ "(server rejects hidden uploads):"
24
+ )
25
+ ui.bullets(outcome.skipped_hidden, indent=" - ")
26
+
27
+ if outcome.conflicts:
28
+ ui.warn(
29
+ f"{len(outcome.conflicts)} conflict(s) NOT touched "
30
+ "(both sides changed; resolve manually):"
31
+ )
32
+ ui.bullets(outcome.conflicts, indent=" ! ")
33
+
34
+ if outcome.failures:
35
+ ui.error(f"{len(outcome.failures)} file(s) failed:")
36
+ for rel, reason in outcome.failures:
37
+ ui.error(f" x {rel}: {reason}")
38
+
39
+ n = len(outcome.transferred)
40
+ if dry_run:
41
+ ui.info(f"{prefix}{verb} {n} file(s); {len(outcome.up_to_date)} already up to date")
42
+ else:
43
+ ui.success(
44
+ f"{verb}: {n} transferred, {len(outcome.up_to_date)} up to date, "
45
+ f"{len(outcome.skipped_hidden)} skipped, {len(outcome.conflicts)} conflict(s), "
46
+ f"{len(outcome.failures)} failed"
47
+ )
jp/commands/clone.py ADDED
@@ -0,0 +1,97 @@
1
+ """``jp clone`` -- create a local workspace from a Jupyter URL and pull it.
2
+
3
+ Usage mirrors git::
4
+
5
+ jp clone https://host/user/<name>/lab/tree/<folder> [dir]
6
+
7
+ The URL is parsed into a Contents-API base URL and a remote prefix; you can
8
+ also pass ``--base-url``/``--prefix`` explicitly instead of a URL.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from pathlib import Path
15
+
16
+ from .. import config as config_mod
17
+ from .. import sync, ui
18
+ from ..config import Config
19
+ from ..errors import EXIT_OK, EXIT_PARTIAL, UsageError
20
+ from ..ignore import IgnoreSet
21
+ from ..index import Index
22
+ from ..paths import DOT_DIR, validate_prefix
23
+ from ..urls import parse_clone_url
24
+ from . import _context
25
+ from ._report import report_outcome
26
+
27
+
28
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
29
+ p = subparsers.add_parser(
30
+ "clone",
31
+ help="clone a remote Jupyter folder into a new local directory",
32
+ )
33
+ p.add_argument(
34
+ "url",
35
+ nargs="?",
36
+ default="",
37
+ help="Jupyter folder URL, e.g. https://host/user/<name>/lab/tree/<folder>",
38
+ )
39
+ p.add_argument("dir", nargs="?", default="", help="target directory (default: prefix basename)")
40
+ p.add_argument("--base-url", default="", help="Contents API base URL (instead of a URL)")
41
+ p.add_argument("--prefix", default="", help="remote prefix (instead of a URL)")
42
+ p.add_argument("--token-path", default="", help="path to the token file")
43
+ p.add_argument(
44
+ "--credential", default="", help="name of a saved credential to use (see 'jp login')"
45
+ )
46
+ p.add_argument(
47
+ "--dry-run", action="store_true", help="show what would be downloaded; write nothing"
48
+ )
49
+ p.set_defaults(func=run)
50
+
51
+
52
+ def _resolve_source(args: argparse.Namespace) -> tuple[str, str]:
53
+ """Return (base_url, prefix) from either a URL or explicit flags."""
54
+ if args.url:
55
+ base_url, prefix = parse_clone_url(args.url)
56
+ else:
57
+ base_url, prefix = str(args.base_url).strip(), str(args.prefix).strip()
58
+ if not base_url:
59
+ raise UsageError("provide a Jupyter URL, or both --base-url and --prefix")
60
+ if not base_url.startswith(("http://", "https://")):
61
+ raise UsageError(f"base URL must be http(s): {base_url!r}")
62
+ # validate_prefix refuses an empty/root/shared prefix with a clear message.
63
+ prefix = validate_prefix(prefix)
64
+ return base_url.rstrip("/"), prefix
65
+
66
+
67
+ def run(args: argparse.Namespace) -> int:
68
+ base_url, prefix = _resolve_source(args)
69
+
70
+ target = args.dir or prefix.rstrip("/").split("/")[-1]
71
+ root = Path(target).resolve()
72
+ if (root / DOT_DIR).exists():
73
+ raise UsageError(f"{root} is already a jp workspace")
74
+ root.mkdir(parents=True, exist_ok=True)
75
+
76
+ # Pick which saved credential this workspace will use (no repo exists yet,
77
+ # so only global credentials are in play here).
78
+ credential = _context.choose_credential(args, root=None)
79
+ cfg = Config(
80
+ base_url=base_url,
81
+ prefix=prefix,
82
+ token_path=str(args.token_path or ""),
83
+ credential=credential,
84
+ )
85
+ cfg._config_dir = root / DOT_DIR
86
+ if not args.dry_run:
87
+ config_mod.save(root, cfg)
88
+ Index(root).save()
89
+
90
+ index = Index.load(root) if not args.dry_run else Index(root)
91
+ ignore = IgnoreSet.from_root(root)
92
+ api = _context.build_api(cfg)
93
+
94
+ ui.heading(f"cloning {cfg.prefix} -> {root}")
95
+ outcome = sync.pull(root, cfg, api, index, ignore, dry_run=args.dry_run)
96
+ report_outcome("clone", outcome, dry_run=args.dry_run)
97
+ return EXIT_PARTIAL if outcome.had_failures else EXIT_OK
@@ -0,0 +1,132 @@
1
+ """``jp config`` -- view and edit workspace configuration.
2
+
3
+ With no action, opens an interactive settings screen (arrows to move, Space to
4
+ change, ``i`` for info, ``/`` to search, Enter to save, Esc to cancel) -- much
5
+ like Claude Code's settings. In a non-interactive shell it falls back to
6
+ printing the current settings.
7
+
8
+ The scriptable forms still work for automation:
9
+
10
+ jp config list
11
+ jp config get <key>
12
+ jp config set <key> <value>
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+
19
+ from .. import config as config_mod
20
+ from .. import credentials, tui, ui
21
+ from ..errors import EXIT_OK, UsageError
22
+ from ..settings_schema import BY_KEY, SPECS
23
+ from ._context import load_repo
24
+
25
+ # Connection fields shown as read-only context above the editable settings.
26
+ _CONNECTION_KEYS = ("base_url", "prefix", "credential", "token_path")
27
+
28
+
29
+ def _token_source(cfg: config_mod.Config, root: object) -> str:
30
+ """Human-readable description of where this workspace's token comes from.
31
+
32
+ Prefers the named credential (the modern path); falls back to a direct
33
+ ``token_path`` in the config; otherwise reports that none is configured.
34
+ Never prints the token value itself -- only its name/location.
35
+ """
36
+ if cfg.credential:
37
+ from pathlib import Path
38
+
39
+ cred = credentials.resolve(cfg.credential, Path(str(root)))
40
+ if cred is not None:
41
+ return f"{cfg.credential} ({cred.scope}: {cred.token_path})"
42
+ return f"{cfg.credential} (not found -- run 'jp login --name {cfg.credential}')"
43
+ if cfg.token_path:
44
+ return cfg.token_path
45
+ return "(unset -- run 'jp login')"
46
+
47
+
48
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
49
+ p = subparsers.add_parser("config", help="view/edit settings (interactive by default)")
50
+ p.add_argument("action", nargs="?", choices=["get", "set", "list"], help="scriptable action")
51
+ p.add_argument("key", nargs="?", help="config key")
52
+ p.add_argument("value", nargs="?", help="value to set")
53
+ p.set_defaults(func=run)
54
+
55
+
56
+ def _print_list(cfg: config_mod.Config) -> None:
57
+ for key in (*_CONNECTION_KEYS, *(s.key for s in SPECS)):
58
+ ui.info(f"{key} = {getattr(cfg, key, '')}")
59
+
60
+
61
+ def run(args: argparse.Namespace) -> int:
62
+ ctx = load_repo()
63
+ cfg = ctx.cfg
64
+
65
+ # --- scriptable paths ---------------------------------------------------
66
+ if args.action == "list":
67
+ _print_list(cfg)
68
+ return EXIT_OK
69
+ if args.action == "get":
70
+ if not args.key:
71
+ raise UsageError("config get requires a key")
72
+ ui.info(f"{getattr(cfg, args.key, '')}")
73
+ return EXIT_OK
74
+ if args.action == "set":
75
+ if not args.key or args.value is None:
76
+ raise UsageError("config set requires a key and a value")
77
+ if not hasattr(cfg, args.key):
78
+ raise UsageError(f"unknown config key: {args.key}")
79
+ spec = BY_KEY.get(args.key)
80
+ value: object = args.value
81
+ if spec is not None:
82
+ try:
83
+ value = spec.coerce(args.value)
84
+ except (TypeError, ValueError) as exc:
85
+ raise UsageError(f"invalid value for {args.key}: {args.value!r} ({exc})") from exc
86
+ if spec.options and value not in spec.options:
87
+ allowed = ", ".join(spec.fmt(o) for o in spec.options)
88
+ raise UsageError(f"{args.key} must be one of: {allowed}")
89
+ setattr(cfg, args.key, value)
90
+ config_mod.save(ctx.root, cfg)
91
+ ui.success(f"set {args.key} = {spec.fmt(value) if spec else value}")
92
+ return EXIT_OK
93
+
94
+ # --- interactive (no action) -------------------------------------------
95
+ if not tui.interactive():
96
+ # Non-interactive shell: just show the settings (never block on a prompt).
97
+ _print_list(cfg)
98
+ ui.info("\n(run in a terminal for the interactive editor, or use 'jp config set')")
99
+ return EXIT_OK
100
+
101
+ # Connection context (read-only here; change via 'jp config set').
102
+ ui.heading(f"jp workspace: {ctx.root}")
103
+ ui.detail(f" base_url = {cfg.base_url or '(unset)'}")
104
+ ui.detail(f" prefix = {cfg.prefix or '(unset)'}")
105
+ ui.detail(f" token = {_token_source(cfg, ctx.root)}")
106
+ ui.info("")
107
+
108
+ rows = [
109
+ tui.Setting(
110
+ key=s.key,
111
+ label=s.label,
112
+ value=getattr(cfg, s.key),
113
+ options=s.options,
114
+ help_text=s.help_text,
115
+ fmt=s.fmt,
116
+ )
117
+ for s in SPECS
118
+ ]
119
+ result = tui.settings_menu(rows, title="Settings")
120
+ if result is None:
121
+ ui.info("no changes saved")
122
+ return EXIT_OK
123
+
124
+ changed = [r for r in result if r.changed]
125
+ if not changed:
126
+ ui.info("no changes")
127
+ return EXIT_OK
128
+ for r in changed:
129
+ setattr(cfg, r.key, r.value)
130
+ config_mod.save(ctx.root, cfg)
131
+ ui.success(f"saved {len(changed)} change(s): " + ", ".join(r.key for r in changed))
132
+ return EXIT_OK
jp/commands/diff.py ADDED
@@ -0,0 +1,93 @@
1
+ """``jp diff`` -- show a unified text diff of changed files (read-only).
2
+
3
+ For text files, prints a unified diff between the remote (or base) and local
4
+ content. Binary files are reported as "binary differs". Read-only: never writes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import difflib
11
+
12
+ from .. import ui
13
+ from ..errors import EXIT_OK
14
+ from ..sync import Change
15
+ from . import _context
16
+ from ._context import load_repo
17
+
18
+
19
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
20
+ p = subparsers.add_parser("diff", help="show unified diffs of changed files (read-only)")
21
+ p.add_argument("path", nargs="?", default="", help="limit to a single relative path")
22
+ p.set_defaults(func=run)
23
+
24
+
25
+ def run(args: argparse.Namespace) -> int:
26
+ ctx = load_repo()
27
+ api = _context.build_api(ctx.cfg)
28
+ from .. import sync
29
+
30
+ states = sync.diff(ctx.root, ctx.cfg, api, ctx.index, ctx.ignore)
31
+ only = args.path.replace("\\", "/").strip("/") if args.path else ""
32
+
33
+ shown = 0
34
+ for st in states:
35
+ if only and st.rel != only:
36
+ continue
37
+ if st.change in (Change.UNCHANGED,):
38
+ continue
39
+ if st.change in (Change.REMOTE_NEW,):
40
+ ui.heading(f"remote-only: {st.rel}")
41
+ continue
42
+ if st.change == Change.LOCAL_NEW:
43
+ ui.heading(f"local-only: {st.rel}")
44
+ continue
45
+
46
+ local_text = _read_local_text(ctx.root, st.rel)
47
+ remote_text = _read_remote_text(api, st)
48
+ if local_text is None or remote_text is None:
49
+ ui.heading(f"{st.rel}: binary differs")
50
+ shown += 1
51
+ continue
52
+
53
+ ui.heading(f"diff {st.rel}")
54
+ diff_lines = difflib.unified_diff(
55
+ remote_text.splitlines(keepends=True),
56
+ local_text.splitlines(keepends=True),
57
+ fromfile=f"remote/{st.rel}",
58
+ tofile=f"local/{st.rel}",
59
+ )
60
+ for line in diff_lines:
61
+ ui.out(line.rstrip("\n"))
62
+ shown += 1
63
+
64
+ if shown == 0:
65
+ ui.info("no textual differences")
66
+ return EXIT_OK
67
+
68
+
69
+ def _read_local_text(root, rel: str) -> str | None:
70
+ try:
71
+ data = (root / rel).read_bytes()
72
+ except OSError:
73
+ return None
74
+ return _decode(data)
75
+
76
+
77
+ def _read_remote_text(api, st) -> str | None:
78
+ if st.remote_entry is None:
79
+ return ""
80
+ try:
81
+ data = api.get_file_bytes(st.remote_entry.path)
82
+ except Exception:
83
+ return None
84
+ return _decode(data)
85
+
86
+
87
+ def _decode(data: bytes) -> str | None:
88
+ if b"\x00" in data:
89
+ return None
90
+ try:
91
+ return data.decode("utf-8")
92
+ except UnicodeDecodeError:
93
+ return None