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/commands/__init__.py
ADDED
|
@@ -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
|
+
]
|
jp/commands/_context.py
ADDED
|
@@ -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
|