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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pi-sync-cli
3
- Version: 0.3.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/*'`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pi-sync-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Sync pi agent config and extensions between hosts over rsync"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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
- def ssh_hosts(
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[str]:
60
- """Host aliases from an ssh config, following Include and skipping wildcards."""
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[str] = []
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
- for token in rest.split():
79
- wildcard = "*" in token or "?" in token or token.startswith("!")
80
- if not wildcard and token not in hosts:
81
- hosts.append(token)
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
- hosts += [
89
- h for h in ssh_hosts(str(included), seen) if h not in hosts
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 ssh_hosts()
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
- ({}, ["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"]),
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
- ["rsync", "-az", "-i", "host:~/.pi/agent/models.json", "/a/models.json"],
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
- ["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"],
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 complete(incomplete: str) -> list[str]:
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
- items = cli.complete_target(ctx, cli.main.params[0], incomplete)
403
- return [item.value for item in items]
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, "ssh_hosts", lambda *a, **k: ["tinfoil", "zero-frame", "phatboi"]
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(cli, "ssh_hosts", lambda *a, **k: ["a", "b"])
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(cli, "ssh_hosts", lambda *a, **k: ["tinfoil", "zero-frame"])
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
@@ -52,7 +52,7 @@ wheels = [
52
52
 
53
53
  [[package]]
54
54
  name = "pi-sync-cli"
55
- version = "0.3.0"
55
+ version = "0.4.0"
56
56
  source = { editable = "." }
57
57
  dependencies = [
58
58
  { name = "click" },
File without changes
File without changes