pi-sync-cli 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.4.0}/PKG-INFO +9 -2
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.4.0}/README.md +8 -1
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.4.0}/pyproject.toml +1 -1
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.4.0}/src/pi_sync/cli.py +67 -14
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.4.0}/tests/test_cli.py +127 -9
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.4.0}/uv.lock +1 -1
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.4.0}/.github/workflows/publish.yml +0 -0
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.4.0}/.gitignore +0 -0
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.4.0}/LICENSE +0 -0
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.4.0}/pyrightconfig.json +0 -0
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.4.0}/src/pi_sync/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pi-sync-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Sync pi agent config and extensions between hosts over rsync
|
|
5
5
|
Project-URL: Repository, https://github.com/say4n/pi-sync
|
|
6
6
|
Project-URL: Issues, https://github.com/say4n/pi-sync/issues
|
|
@@ -143,7 +143,14 @@ without regenerating anything.
|
|
|
143
143
|
|
|
144
144
|
- `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
|
|
145
145
|
toggles), so two hosts pushing it will overwrite each other's local
|
|
146
|
-
preferences. Sync it when you change `packages`, not reflexively
|
|
146
|
+
preferences. Sync it when you change `packages`, not reflexively — and note the
|
|
147
|
+
overwritten copy is kept as `settings.json.backup` on the receiving host.
|
|
148
|
+
- Anything pi-sync overwrites is kept on the destination as `<name>.backup`, and
|
|
149
|
+
`*.backup` is never synced, so those copies stay host-local and never
|
|
150
|
+
trampoline between hosts. `--delete` suppresses backups for the mirrored
|
|
151
|
+
directory, because mirroring means "match exactly" — which also sidesteps an
|
|
152
|
+
openrsync bug where backing up a file it deletes fails with
|
|
153
|
+
`fchownat: Operation not permitted`.
|
|
147
154
|
- Extensions that write runtime files inside their own directory (logs,
|
|
148
155
|
checkpoints) get those files synced too, and each host's copy is overwritten by
|
|
149
156
|
whichever side pushed last — exclude them with `-x '*/logs/*'`.
|
|
@@ -126,7 +126,14 @@ without regenerating anything.
|
|
|
126
126
|
|
|
127
127
|
- `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
|
|
128
128
|
toggles), so two hosts pushing it will overwrite each other's local
|
|
129
|
-
preferences. Sync it when you change `packages`, not reflexively
|
|
129
|
+
preferences. Sync it when you change `packages`, not reflexively — and note the
|
|
130
|
+
overwritten copy is kept as `settings.json.backup` on the receiving host.
|
|
131
|
+
- Anything pi-sync overwrites is kept on the destination as `<name>.backup`, and
|
|
132
|
+
`*.backup` is never synced, so those copies stay host-local and never
|
|
133
|
+
trampoline between hosts. `--delete` suppresses backups for the mirrored
|
|
134
|
+
directory, because mirroring means "match exactly" — which also sidesteps an
|
|
135
|
+
openrsync bug where backing up a file it deletes fails with
|
|
136
|
+
`fchownat: Operation not permitted`.
|
|
130
137
|
- Extensions that write runtime files inside their own directory (logs,
|
|
131
138
|
checkpoints) get those files synced too, and each host's copy is overwritten by
|
|
132
139
|
whichever side pushed last — exclude them with `-x '*/logs/*'`.
|
|
@@ -16,6 +16,7 @@ import os
|
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
18
|
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
|
|
21
22
|
import click
|
|
@@ -54,10 +55,29 @@ def local_agent_dir(override: str | None = None) -> Path:
|
|
|
54
55
|
return Path(raw).expanduser()
|
|
55
56
|
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class SshHost:
|
|
60
|
+
"""A host alias, plus the details worth showing in shell completions."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
hostname: str | None = None
|
|
64
|
+
user: str | None = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def detail(self) -> str:
|
|
68
|
+
if self.hostname and self.user:
|
|
69
|
+
return f"{self.user}@{self.hostname}"
|
|
70
|
+
return self.hostname or self.user or ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ssh_config_hosts(
|
|
58
74
|
config: str = SSH_CONFIG, _seen: frozenset[str] = frozenset()
|
|
59
|
-
) -> list[
|
|
60
|
-
"""
|
|
75
|
+
) -> list[SshHost]:
|
|
76
|
+
"""Hosts from an ssh config, following Include and skipping wildcards.
|
|
77
|
+
|
|
78
|
+
Directives bind to the Host line that precedes them, so the current block is
|
|
79
|
+
flushed when the next Host or Include appears.
|
|
80
|
+
"""
|
|
61
81
|
path = Path(config).expanduser()
|
|
62
82
|
if not path.is_file():
|
|
63
83
|
return []
|
|
@@ -65,7 +85,22 @@ def ssh_hosts(
|
|
|
65
85
|
if resolved in _seen:
|
|
66
86
|
return []
|
|
67
87
|
seen = _seen | {resolved}
|
|
68
|
-
hosts: list[
|
|
88
|
+
hosts: list[SshHost] = []
|
|
89
|
+
names: set[str] = set()
|
|
90
|
+
pending: list[str] = []
|
|
91
|
+
hostname: str | None = None
|
|
92
|
+
user: str | None = None
|
|
93
|
+
|
|
94
|
+
def add(entries: list[SshHost]) -> None:
|
|
95
|
+
for entry in entries:
|
|
96
|
+
if entry.name not in names:
|
|
97
|
+
names.add(entry.name)
|
|
98
|
+
hosts.append(entry)
|
|
99
|
+
|
|
100
|
+
def flush() -> None:
|
|
101
|
+
add([SshHost(name, hostname, user) for name in pending])
|
|
102
|
+
pending.clear()
|
|
103
|
+
|
|
69
104
|
for raw_line in path.read_text(errors="replace").splitlines():
|
|
70
105
|
line = raw_line.strip()
|
|
71
106
|
if not line or line.startswith("#"):
|
|
@@ -75,22 +110,32 @@ def ssh_hosts(
|
|
|
75
110
|
continue
|
|
76
111
|
keyword, rest = parts[0].lower(), parts[1].strip()
|
|
77
112
|
if keyword == "host":
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
113
|
+
flush()
|
|
114
|
+
hostname = user = None
|
|
115
|
+
pending += [t for t in rest.split() if not any(c in t for c in "*?!")]
|
|
116
|
+
elif keyword == "hostname":
|
|
117
|
+
hostname = rest.split()[0]
|
|
118
|
+
elif keyword == "user":
|
|
119
|
+
user = rest.split()[0]
|
|
82
120
|
elif keyword == "include":
|
|
121
|
+
flush()
|
|
83
122
|
for pattern in rest.split():
|
|
84
123
|
target = Path(pattern).expanduser()
|
|
85
124
|
if not target.is_absolute():
|
|
86
125
|
target = Path.home() / ".ssh" / target
|
|
87
126
|
for included in sorted(target.parent.glob(target.name)):
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
]
|
|
127
|
+
add(ssh_config_hosts(str(included), seen))
|
|
128
|
+
flush()
|
|
91
129
|
return hosts
|
|
92
130
|
|
|
93
131
|
|
|
132
|
+
def ssh_hosts(
|
|
133
|
+
config: str = SSH_CONFIG, _seen: frozenset[str] = frozenset()
|
|
134
|
+
) -> list[str]:
|
|
135
|
+
"""Host alias names from an ssh config (see ssh_config_hosts)."""
|
|
136
|
+
return [host.name for host in ssh_config_hosts(config, _seen)]
|
|
137
|
+
|
|
138
|
+
|
|
94
139
|
def complete_target(
|
|
95
140
|
ctx: click.Context, param: click.Parameter, incomplete: str
|
|
96
141
|
) -> list[CompletionItem]:
|
|
@@ -98,9 +143,9 @@ def complete_target(
|
|
|
98
143
|
prefix = incomplete.rpartition("@")[2]
|
|
99
144
|
lead = incomplete[: len(incomplete) - len(prefix)] if prefix else incomplete
|
|
100
145
|
return [
|
|
101
|
-
CompletionItem(f"{lead}{host}")
|
|
102
|
-
for host in
|
|
103
|
-
if host.startswith(prefix)
|
|
146
|
+
CompletionItem(f"{lead}{host.name}", help=host.detail)
|
|
147
|
+
for host in ssh_config_hosts()
|
|
148
|
+
if host.name.startswith(prefix)
|
|
104
149
|
]
|
|
105
150
|
|
|
106
151
|
|
|
@@ -128,7 +173,14 @@ def rsync_argv(
|
|
|
128
173
|
argv = ["rsync", "-az", "-i"]
|
|
129
174
|
argv += [f"--exclude={pattern}" for pattern in excludes]
|
|
130
175
|
if delete and rel in DIRECTORY_ITEMS:
|
|
176
|
+
# Mirroring wins over backups here: the destination is meant to match the
|
|
177
|
+
# source exactly, and openrsync (the rsync macOS ships) errors out when it
|
|
178
|
+
# has to back up a file it deletes.
|
|
131
179
|
argv.append("--delete-during")
|
|
180
|
+
else:
|
|
181
|
+
# Keep the destination's copy of whatever we replace, as <file>.backup.
|
|
182
|
+
argv += ["--backup", "--suffix=.backup"]
|
|
183
|
+
argv.append("--exclude=*.backup") # backups stay host-local
|
|
132
184
|
if dry_run:
|
|
133
185
|
argv.append("-n")
|
|
134
186
|
argv += [remote, local] if pull else [local, remote]
|
|
@@ -309,6 +361,7 @@ def sync_host(
|
|
|
309
361
|
|
|
310
362
|
|
|
311
363
|
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
364
|
+
@click.version_option(package_name="pi-sync-cli")
|
|
312
365
|
@click.argument(
|
|
313
366
|
"targets",
|
|
314
367
|
nargs=-1,
|
|
@@ -13,6 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
|
|
14
14
|
import click
|
|
15
15
|
import pytest
|
|
16
|
+
from click.shell_completion import CompletionItem
|
|
16
17
|
from click.testing import CliRunner
|
|
17
18
|
|
|
18
19
|
from pi_sync import cli
|
|
@@ -130,10 +131,31 @@ def test_select_items(groups: set[str], expected: list[str]) -> None:
|
|
|
130
131
|
@pytest.mark.parametrize(
|
|
131
132
|
("argv_kwargs", "expected"),
|
|
132
133
|
[
|
|
133
|
-
(
|
|
134
|
+
(
|
|
135
|
+
{},
|
|
136
|
+
[
|
|
137
|
+
"rsync",
|
|
138
|
+
"-az",
|
|
139
|
+
"-i",
|
|
140
|
+
"--backup",
|
|
141
|
+
"--suffix=.backup",
|
|
142
|
+
"--exclude=*.backup",
|
|
143
|
+
"/a/models.json",
|
|
144
|
+
"host:~/.pi/agent/models.json",
|
|
145
|
+
],
|
|
146
|
+
),
|
|
134
147
|
(
|
|
135
148
|
{"pull": True},
|
|
136
|
-
[
|
|
149
|
+
[
|
|
150
|
+
"rsync",
|
|
151
|
+
"-az",
|
|
152
|
+
"-i",
|
|
153
|
+
"--backup",
|
|
154
|
+
"--suffix=.backup",
|
|
155
|
+
"--exclude=*.backup",
|
|
156
|
+
"host:~/.pi/agent/models.json",
|
|
157
|
+
"/a/models.json",
|
|
158
|
+
],
|
|
137
159
|
),
|
|
138
160
|
(
|
|
139
161
|
{"dry_run": True},
|
|
@@ -141,6 +163,9 @@ def test_select_items(groups: set[str], expected: list[str]) -> None:
|
|
|
141
163
|
"rsync",
|
|
142
164
|
"-az",
|
|
143
165
|
"-i",
|
|
166
|
+
"--backup",
|
|
167
|
+
"--suffix=.backup",
|
|
168
|
+
"--exclude=*.backup",
|
|
144
169
|
"-n",
|
|
145
170
|
"/a/models.json",
|
|
146
171
|
"host:~/.pi/agent/models.json",
|
|
@@ -148,7 +173,16 @@ def test_select_items(groups: set[str], expected: list[str]) -> None:
|
|
|
148
173
|
),
|
|
149
174
|
(
|
|
150
175
|
{"delete": True},
|
|
151
|
-
[
|
|
176
|
+
[
|
|
177
|
+
"rsync",
|
|
178
|
+
"-az",
|
|
179
|
+
"-i",
|
|
180
|
+
"--backup",
|
|
181
|
+
"--suffix=.backup",
|
|
182
|
+
"--exclude=*.backup",
|
|
183
|
+
"/a/models.json",
|
|
184
|
+
"host:~/.pi/agent/models.json",
|
|
185
|
+
],
|
|
152
186
|
),
|
|
153
187
|
],
|
|
154
188
|
)
|
|
@@ -173,6 +207,20 @@ def test_rsync_argv_delete_only_for_directories() -> None:
|
|
|
173
207
|
)
|
|
174
208
|
|
|
175
209
|
|
|
210
|
+
def test_backups_are_taken_except_when_mirroring() -> None:
|
|
211
|
+
"""Originals are kept as .backup, unless --delete means "match exactly"."""
|
|
212
|
+
plain = cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent")
|
|
213
|
+
assert "--backup" in plain
|
|
214
|
+
assert "--suffix=.backup" in plain
|
|
215
|
+
assert "--exclude=*.backup" in plain # never propagate backups to other hosts
|
|
216
|
+
|
|
217
|
+
mirroring = cli.rsync_argv(
|
|
218
|
+
"extensions", "host", Path("/a"), "~/.pi/agent", delete=True
|
|
219
|
+
)
|
|
220
|
+
assert "--backup" not in mirroring
|
|
221
|
+
assert "--exclude=*.backup" in mirroring # existing backups survive the delete
|
|
222
|
+
|
|
223
|
+
|
|
176
224
|
def test_rsync_argv_excludes() -> None:
|
|
177
225
|
argv = cli.rsync_argv(
|
|
178
226
|
"extensions", "host", Path("/a"), "~/.pi/agent", excludes=("*/logs/*",)
|
|
@@ -396,28 +444,51 @@ class TestSshHosts:
|
|
|
396
444
|
|
|
397
445
|
class TestCompletion:
|
|
398
446
|
@staticmethod
|
|
399
|
-
def
|
|
447
|
+
def hosts(*names: str) -> list[cli.SshHost]:
|
|
448
|
+
return [cli.SshHost(name) for name in names]
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def items(incomplete: str) -> list[CompletionItem]:
|
|
400
452
|
"""Run the completion callback the way click would."""
|
|
401
453
|
ctx = click.Context(cli.main)
|
|
402
|
-
|
|
403
|
-
|
|
454
|
+
return cli.complete_target(ctx, cli.main.params[0], incomplete)
|
|
455
|
+
|
|
456
|
+
def complete(self, incomplete: str) -> list[str]:
|
|
457
|
+
return [item.value for item in self.items(incomplete)]
|
|
404
458
|
|
|
405
459
|
def test_completes_matching_hosts(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
406
460
|
monkeypatch.setattr(
|
|
407
|
-
cli, "
|
|
461
|
+
cli, "ssh_config_hosts", lambda *a, **k: self.hosts("tinfoil", "zero-frame")
|
|
408
462
|
)
|
|
409
463
|
assert self.complete("t") == ["tinfoil"]
|
|
410
464
|
|
|
411
465
|
def test_empty_incomplete_lists_everything(
|
|
412
466
|
self, monkeypatch: pytest.MonkeyPatch
|
|
413
467
|
) -> None:
|
|
414
|
-
monkeypatch.setattr(
|
|
468
|
+
monkeypatch.setattr(
|
|
469
|
+
cli, "ssh_config_hosts", lambda *a, **k: self.hosts("a", "b")
|
|
470
|
+
)
|
|
415
471
|
assert self.complete("") == ["a", "b"]
|
|
416
472
|
|
|
417
473
|
def test_user_at_prefix_is_preserved(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
418
|
-
monkeypatch.setattr(
|
|
474
|
+
monkeypatch.setattr(
|
|
475
|
+
cli, "ssh_config_hosts", lambda *a, **k: self.hosts("tinfoil", "zero-frame")
|
|
476
|
+
)
|
|
419
477
|
assert self.complete("sayan@zer") == ["sayan@zero-frame"]
|
|
420
478
|
|
|
479
|
+
def test_suggestions_carry_the_real_hostname(
|
|
480
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
481
|
+
) -> None:
|
|
482
|
+
"""zsh and fish render CompletionItem.help next to the suggestion."""
|
|
483
|
+
monkeypatch.setattr(
|
|
484
|
+
cli,
|
|
485
|
+
"ssh_config_hosts",
|
|
486
|
+
lambda *a, **k: [cli.SshHost("tinfoil", "tinfoil.sayan.page", "sayan")],
|
|
487
|
+
)
|
|
488
|
+
(item,) = self.items("t")
|
|
489
|
+
assert item.value == "tinfoil"
|
|
490
|
+
assert item.help == "sayan@tinfoil.sayan.page"
|
|
491
|
+
|
|
421
492
|
|
|
422
493
|
class TestProbe:
|
|
423
494
|
def test_reports_pi_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -630,3 +701,50 @@ class TestUninstall:
|
|
|
630
701
|
assert result.exit_code == 1
|
|
631
702
|
assert "unreachable" in result.output
|
|
632
703
|
assert self.uninstall_calls(fake) == []
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
class TestSshHostDetails:
|
|
707
|
+
def test_hostname_and_user_are_captured(self, tmp_path: Path) -> None:
|
|
708
|
+
cfg = tmp_path / "config"
|
|
709
|
+
cfg.write_text(
|
|
710
|
+
"Host tinfoil\n HostName tinfoil.sayan.page\n User sayan\nHost bare\n"
|
|
711
|
+
)
|
|
712
|
+
hosts = cli.ssh_config_hosts(str(cfg))
|
|
713
|
+
assert hosts == [
|
|
714
|
+
cli.SshHost("tinfoil", "tinfoil.sayan.page", "sayan"),
|
|
715
|
+
cli.SshHost("bare"),
|
|
716
|
+
]
|
|
717
|
+
assert hosts[0].detail == "sayan@tinfoil.sayan.page"
|
|
718
|
+
assert hosts[1].detail == ""
|
|
719
|
+
|
|
720
|
+
def test_directives_bind_to_the_preceding_host(self, tmp_path: Path) -> None:
|
|
721
|
+
cfg = tmp_path / "config"
|
|
722
|
+
cfg.write_text("Host first\n HostName one.example\nHost second\n User bob\n")
|
|
723
|
+
hosts = cli.ssh_config_hosts(str(cfg))
|
|
724
|
+
assert [h.hostname for h in hosts] == ["one.example", None]
|
|
725
|
+
assert [h.user for h in hosts] == [None, "bob"]
|
|
726
|
+
assert hosts[1].detail == "bob"
|
|
727
|
+
|
|
728
|
+
def test_details_survive_includes(self, tmp_path: Path) -> None:
|
|
729
|
+
extra = tmp_path / "extra"
|
|
730
|
+
extra.write_text("Host orb\n HostName orb.local\n")
|
|
731
|
+
cfg = tmp_path / "config"
|
|
732
|
+
cfg.write_text(f"Host tinfoil\n User sayan\nInclude {extra}\n")
|
|
733
|
+
hosts = cli.ssh_config_hosts(str(cfg))
|
|
734
|
+
assert [(h.name, h.hostname, h.user) for h in hosts] == [
|
|
735
|
+
("tinfoil", None, "sayan"),
|
|
736
|
+
("orb", "orb.local", None),
|
|
737
|
+
]
|
|
738
|
+
|
|
739
|
+
def test_names_still_available_as_strings(self, tmp_path: Path) -> None:
|
|
740
|
+
cfg = tmp_path / "config"
|
|
741
|
+
cfg.write_text("Host tinfoil\n HostName tinfoil.sayan.page\n")
|
|
742
|
+
assert cli.ssh_hosts(str(cfg)) == ["tinfoil"]
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def test_version_option_reports_the_installed_version() -> None:
|
|
746
|
+
from importlib.metadata import version
|
|
747
|
+
|
|
748
|
+
result = CliRunner().invoke(cli.main, ["--version"])
|
|
749
|
+
assert result.exit_code == 0, result.output
|
|
750
|
+
assert version("pi-sync-cli") in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|