pi-sync-cli 0.1.0__tar.gz → 0.2.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.1.0 → pi_sync_cli-0.2.0}/PKG-INFO +55 -1
- {pi_sync_cli-0.1.0 → pi_sync_cli-0.2.0}/README.md +54 -0
- {pi_sync_cli-0.1.0 → pi_sync_cli-0.2.0}/pyproject.toml +1 -1
- pi_sync_cli-0.2.0/pyrightconfig.json +5 -0
- {pi_sync_cli-0.1.0 → pi_sync_cli-0.2.0}/src/pi_sync/cli.py +170 -12
- pi_sync_cli-0.2.0/tests/test_cli.py +552 -0
- {pi_sync_cli-0.1.0 → pi_sync_cli-0.2.0}/uv.lock +1 -1
- pi_sync_cli-0.1.0/tests/test_cli.py +0 -270
- {pi_sync_cli-0.1.0 → pi_sync_cli-0.2.0}/.github/workflows/publish.yml +0 -0
- {pi_sync_cli-0.1.0 → pi_sync_cli-0.2.0}/.gitignore +0 -0
- {pi_sync_cli-0.1.0 → pi_sync_cli-0.2.0}/LICENSE +0 -0
- {pi_sync_cli-0.1.0 → pi_sync_cli-0.2.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.2.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
|
|
@@ -62,6 +62,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
62
62
|
| `--delete` | mirror `extensions/` exactly (deletes extras on the destination) |
|
|
63
63
|
| `--dry-run` | report changes, copy nothing |
|
|
64
64
|
| `-x, --exclude PATTERN` | skip matching files (repeatable) |
|
|
65
|
+
| `--install` | install pi on hosts that lack it, without prompting |
|
|
65
66
|
| `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
|
|
66
67
|
| `--remote-dir` | default `~/.pi/agent` |
|
|
67
68
|
| `-v` / `--verbose` | print each rsync command and its output |
|
|
@@ -69,6 +70,59 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
69
70
|
Multiple hosts are accepted: `pi-sync a b c`. Exits non-zero if any host is
|
|
70
71
|
unreachable or any transfer fails.
|
|
71
72
|
|
|
73
|
+
## Host preflight
|
|
74
|
+
|
|
75
|
+
Each host gets one ssh probe that reports reachability and pi's location in the
|
|
76
|
+
same round trip.
|
|
77
|
+
|
|
78
|
+
If pi is missing, pi-sync hands the terminal to pi's own installer
|
|
79
|
+
(`curl -fsSL https://pi.dev/install.sh | sh`), which keeps full control: its
|
|
80
|
+
prompts (its install/uninstall/do-nothing menu, a Node.js install, a sudo
|
|
81
|
+
password) work normally, and the sync continues once it exits. pi-sync does not
|
|
82
|
+
add a confirmation of its own, because the installer already asks. Without a
|
|
83
|
+
terminal it runs unattended under `--install`, stays quiet otherwise, and
|
|
84
|
+
`--dry-run` never installs anything.
|
|
85
|
+
|
|
86
|
+
The installer's exit status is not treated as proof: its "do nothing" choice
|
|
87
|
+
exits 0, so the host is re-probed afterwards and reported honestly.
|
|
88
|
+
|
|
89
|
+
**A host without pi is skipped** — copying into a host that has never run pi is
|
|
90
|
+
not useful and usually fails anyway, since there is no agent directory to copy
|
|
91
|
+
into. That covers an installer run that installed nothing, a failed install, and
|
|
92
|
+
a non-interactive run without `--install`; a skipped host makes the run exit
|
|
93
|
+
non-zero. After a successful install pi-sync creates the agent directory,
|
|
94
|
+
because rsync will not create intermediate directories on its own.
|
|
95
|
+
|
|
96
|
+
The probe checks `command -v pi` plus the usual install locations
|
|
97
|
+
(`~/.local/bin`, `~/.pi/bin`, `~/.pi/agent/bin`, linuxbrew, homebrew,
|
|
98
|
+
`/usr/local/bin`), because a non-interactive ssh session does not source the
|
|
99
|
+
host's shell init — on a linuxbrew host `command -v pi` alone misses it.
|
|
100
|
+
|
|
101
|
+
## Shell completions
|
|
102
|
+
|
|
103
|
+
Host arguments complete from `~/.ssh/config`, following `Include` directives and
|
|
104
|
+
skipping wildcard entries:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# bash
|
|
108
|
+
_PI_SYNC_COMPLETE=bash_source pi-sync > ~/.pi-sync-complete.bash
|
|
109
|
+
echo 'source ~/.pi-sync-complete.bash' >> ~/.bashrc
|
|
110
|
+
|
|
111
|
+
# zsh
|
|
112
|
+
_PI_SYNC_COMPLETE=zsh_source pi-sync > ~/.pi-sync-complete.zsh
|
|
113
|
+
echo 'source ~/.pi-sync-complete.zsh' >> ~/.zshrc
|
|
114
|
+
|
|
115
|
+
# fish (config.fish)
|
|
116
|
+
_PI_SYNC_COMPLETE=fish_source pi-sync | source
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Writing the script out (rather than `eval "$(_PI_SYNC_COMPLETE=bash_source pi-sync)"`
|
|
120
|
+
on every shell start) keeps shell startup fast, since the eval form runs the
|
|
121
|
+
binary each time. PowerShell works the same way via `powershell_source`.
|
|
122
|
+
|
|
123
|
+
Completions are read from the config at completion time, so new hosts appear
|
|
124
|
+
without regenerating anything.
|
|
125
|
+
|
|
72
126
|
## Caveats
|
|
73
127
|
|
|
74
128
|
- `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
|
|
@@ -45,6 +45,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
45
45
|
| `--delete` | mirror `extensions/` exactly (deletes extras on the destination) |
|
|
46
46
|
| `--dry-run` | report changes, copy nothing |
|
|
47
47
|
| `-x, --exclude PATTERN` | skip matching files (repeatable) |
|
|
48
|
+
| `--install` | install pi on hosts that lack it, without prompting |
|
|
48
49
|
| `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
|
|
49
50
|
| `--remote-dir` | default `~/.pi/agent` |
|
|
50
51
|
| `-v` / `--verbose` | print each rsync command and its output |
|
|
@@ -52,6 +53,59 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
52
53
|
Multiple hosts are accepted: `pi-sync a b c`. Exits non-zero if any host is
|
|
53
54
|
unreachable or any transfer fails.
|
|
54
55
|
|
|
56
|
+
## Host preflight
|
|
57
|
+
|
|
58
|
+
Each host gets one ssh probe that reports reachability and pi's location in the
|
|
59
|
+
same round trip.
|
|
60
|
+
|
|
61
|
+
If pi is missing, pi-sync hands the terminal to pi's own installer
|
|
62
|
+
(`curl -fsSL https://pi.dev/install.sh | sh`), which keeps full control: its
|
|
63
|
+
prompts (its install/uninstall/do-nothing menu, a Node.js install, a sudo
|
|
64
|
+
password) work normally, and the sync continues once it exits. pi-sync does not
|
|
65
|
+
add a confirmation of its own, because the installer already asks. Without a
|
|
66
|
+
terminal it runs unattended under `--install`, stays quiet otherwise, and
|
|
67
|
+
`--dry-run` never installs anything.
|
|
68
|
+
|
|
69
|
+
The installer's exit status is not treated as proof: its "do nothing" choice
|
|
70
|
+
exits 0, so the host is re-probed afterwards and reported honestly.
|
|
71
|
+
|
|
72
|
+
**A host without pi is skipped** — copying into a host that has never run pi is
|
|
73
|
+
not useful and usually fails anyway, since there is no agent directory to copy
|
|
74
|
+
into. That covers an installer run that installed nothing, a failed install, and
|
|
75
|
+
a non-interactive run without `--install`; a skipped host makes the run exit
|
|
76
|
+
non-zero. After a successful install pi-sync creates the agent directory,
|
|
77
|
+
because rsync will not create intermediate directories on its own.
|
|
78
|
+
|
|
79
|
+
The probe checks `command -v pi` plus the usual install locations
|
|
80
|
+
(`~/.local/bin`, `~/.pi/bin`, `~/.pi/agent/bin`, linuxbrew, homebrew,
|
|
81
|
+
`/usr/local/bin`), because a non-interactive ssh session does not source the
|
|
82
|
+
host's shell init — on a linuxbrew host `command -v pi` alone misses it.
|
|
83
|
+
|
|
84
|
+
## Shell completions
|
|
85
|
+
|
|
86
|
+
Host arguments complete from `~/.ssh/config`, following `Include` directives and
|
|
87
|
+
skipping wildcard entries:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# bash
|
|
91
|
+
_PI_SYNC_COMPLETE=bash_source pi-sync > ~/.pi-sync-complete.bash
|
|
92
|
+
echo 'source ~/.pi-sync-complete.bash' >> ~/.bashrc
|
|
93
|
+
|
|
94
|
+
# zsh
|
|
95
|
+
_PI_SYNC_COMPLETE=zsh_source pi-sync > ~/.pi-sync-complete.zsh
|
|
96
|
+
echo 'source ~/.pi-sync-complete.zsh' >> ~/.zshrc
|
|
97
|
+
|
|
98
|
+
# fish (config.fish)
|
|
99
|
+
_PI_SYNC_COMPLETE=fish_source pi-sync | source
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Writing the script out (rather than `eval "$(_PI_SYNC_COMPLETE=bash_source pi-sync)"`
|
|
103
|
+
on every shell start) keeps shell startup fast, since the eval form runs the
|
|
104
|
+
binary each time. PowerShell works the same way via `powershell_source`.
|
|
105
|
+
|
|
106
|
+
Completions are read from the config at completion time, so new hosts appear
|
|
107
|
+
without regenerating anything.
|
|
108
|
+
|
|
55
109
|
## Caveats
|
|
56
110
|
|
|
57
111
|
- `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
|
|
@@ -14,12 +14,29 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import os
|
|
16
16
|
import subprocess
|
|
17
|
+
import sys
|
|
17
18
|
from collections.abc import Sequence
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
|
|
20
21
|
import click
|
|
22
|
+
from click.shell_completion import CompletionItem
|
|
21
23
|
|
|
22
24
|
DEFAULT_AGENT_DIR = "~/.pi/agent"
|
|
25
|
+
SSH_CONFIG = "~/.ssh/config"
|
|
26
|
+
PI_INSTALL_CMD = "curl -fsSL https://pi.dev/install.sh | sh"
|
|
27
|
+
# One ssh round trip answers both "is it reachable" and "where is pi".
|
|
28
|
+
PI_PROBE = """\
|
|
29
|
+
found="$(command -v pi 2>/dev/null)"
|
|
30
|
+
if [ -z "$found" ]; then
|
|
31
|
+
for candidate in "$HOME/.local/bin/pi" "$HOME/.pi/bin/pi" \\
|
|
32
|
+
"$HOME/.linuxbrew/bin/pi" /home/linuxbrew/.linuxbrew/bin/pi \\
|
|
33
|
+
"$HOME/.pi/agent/bin/pi" /opt/homebrew/bin/pi /usr/local/bin/pi; do
|
|
34
|
+
if [ -x "$candidate" ]; then found="$candidate"; break; fi
|
|
35
|
+
done
|
|
36
|
+
fi
|
|
37
|
+
if [ -n "$found" ]; then echo "PI:$found"; fi
|
|
38
|
+
exit 0
|
|
39
|
+
"""
|
|
23
40
|
DIRECTORY_ITEMS = frozenset({"extensions"})
|
|
24
41
|
GROUPS: dict[str, tuple[str, ...]] = {
|
|
25
42
|
"config": ("models.json", "settings.json"),
|
|
@@ -36,6 +53,56 @@ def local_agent_dir(override: str | None = None) -> Path:
|
|
|
36
53
|
return Path(raw).expanduser()
|
|
37
54
|
|
|
38
55
|
|
|
56
|
+
def ssh_hosts(
|
|
57
|
+
config: str = SSH_CONFIG, _seen: frozenset[str] = frozenset()
|
|
58
|
+
) -> list[str]:
|
|
59
|
+
"""Host aliases from an ssh config, following Include and skipping wildcards."""
|
|
60
|
+
path = Path(config).expanduser()
|
|
61
|
+
if not path.is_file():
|
|
62
|
+
return []
|
|
63
|
+
resolved = str(path.resolve())
|
|
64
|
+
if resolved in _seen:
|
|
65
|
+
return []
|
|
66
|
+
seen = _seen | {resolved}
|
|
67
|
+
hosts: list[str] = []
|
|
68
|
+
for raw_line in path.read_text(errors="replace").splitlines():
|
|
69
|
+
line = raw_line.strip()
|
|
70
|
+
if not line or line.startswith("#"):
|
|
71
|
+
continue
|
|
72
|
+
parts = line.split(None, 1)
|
|
73
|
+
if len(parts) < 2:
|
|
74
|
+
continue
|
|
75
|
+
keyword, rest = parts[0].lower(), parts[1].strip()
|
|
76
|
+
if keyword == "host":
|
|
77
|
+
for token in rest.split():
|
|
78
|
+
wildcard = "*" in token or "?" in token or token.startswith("!")
|
|
79
|
+
if not wildcard and token not in hosts:
|
|
80
|
+
hosts.append(token)
|
|
81
|
+
elif keyword == "include":
|
|
82
|
+
for pattern in rest.split():
|
|
83
|
+
target = Path(pattern).expanduser()
|
|
84
|
+
if not target.is_absolute():
|
|
85
|
+
target = Path.home() / ".ssh" / target
|
|
86
|
+
for included in sorted(target.parent.glob(target.name)):
|
|
87
|
+
hosts += [
|
|
88
|
+
h for h in ssh_hosts(str(included), seen) if h not in hosts
|
|
89
|
+
]
|
|
90
|
+
return hosts
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def complete_target(
|
|
94
|
+
ctx: click.Context, param: click.Parameter, incomplete: str
|
|
95
|
+
) -> list[CompletionItem]:
|
|
96
|
+
"""Complete the host part of a target from the user's ssh config."""
|
|
97
|
+
prefix = incomplete.rpartition("@")[2]
|
|
98
|
+
lead = incomplete[: len(incomplete) - len(prefix)] if prefix else incomplete
|
|
99
|
+
return [
|
|
100
|
+
CompletionItem(f"{lead}{host}")
|
|
101
|
+
for host in ssh_hosts()
|
|
102
|
+
if host.startswith(prefix)
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
39
106
|
def select_items(groups: set[str]) -> list[str]:
|
|
40
107
|
return [item for group in GROUP_ORDER if group in groups for item in GROUPS[group]]
|
|
41
108
|
|
|
@@ -67,8 +134,10 @@ def rsync_argv(
|
|
|
67
134
|
return argv
|
|
68
135
|
|
|
69
136
|
|
|
70
|
-
def run_cmd(
|
|
71
|
-
|
|
137
|
+
def run_cmd(
|
|
138
|
+
argv: list[str], input_text: str | None = None, capture: bool = True
|
|
139
|
+
) -> subprocess.CompletedProcess[str]:
|
|
140
|
+
return subprocess.run(argv, capture_output=capture, text=True, input=input_text)
|
|
72
141
|
|
|
73
142
|
|
|
74
143
|
def summarize(output: str) -> tuple[int, int]:
|
|
@@ -82,13 +151,81 @@ def summarize(output: str) -> tuple[int, int]:
|
|
|
82
151
|
return transferred, deleted
|
|
83
152
|
|
|
84
153
|
|
|
85
|
-
def
|
|
86
|
-
"""Return
|
|
87
|
-
proc = run_cmd(["ssh", "-o", "ConnectTimeout=10", target, "
|
|
88
|
-
if proc.returncode
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
154
|
+
def probe_host(target: str) -> tuple[str | None, str | None]:
|
|
155
|
+
"""Return (error, pi path): error is set when the host is unreachable."""
|
|
156
|
+
proc = run_cmd(["ssh", "-o", "ConnectTimeout=10", target, "sh -s"], PI_PROBE)
|
|
157
|
+
if proc.returncode != 0:
|
|
158
|
+
detail = (proc.stderr or proc.stdout or "ssh failed").strip().splitlines()
|
|
159
|
+
return (detail[0] if detail else "ssh failed"), None
|
|
160
|
+
for line in proc.stdout.splitlines():
|
|
161
|
+
if line.startswith("PI:"):
|
|
162
|
+
return None, line.removeprefix("PI:").strip()
|
|
163
|
+
return None, None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def install_pi(target: str) -> str | None:
|
|
167
|
+
"""Run pi's installer on the host. Returns an error message, or None on success.
|
|
168
|
+
|
|
169
|
+
Attached to a terminal the installer streams straight through and gets a
|
|
170
|
+
remote tty, so its prompts are visible and answerable: capturing them would
|
|
171
|
+
leave the user staring at a silent, unanswerable hang. Unattended, output is
|
|
172
|
+
captured for the failure summary and stdin is closed so a prompt fails fast
|
|
173
|
+
instead of blocking forever.
|
|
174
|
+
|
|
175
|
+
The installer's exit status is not proof of anything: its "do nothing" menu
|
|
176
|
+
choice exits 0 without installing, so the host is re-probed afterwards.
|
|
177
|
+
"""
|
|
178
|
+
interactive = sys.stdin.isatty()
|
|
179
|
+
argv = ["ssh", *(["-t"] if interactive else []), target, PI_INSTALL_CMD]
|
|
180
|
+
if interactive:
|
|
181
|
+
proc = run_cmd(argv, capture=False)
|
|
182
|
+
failure = f"installer exited {proc.returncode}" if proc.returncode else None
|
|
183
|
+
else:
|
|
184
|
+
proc = run_cmd(argv, input_text="")
|
|
185
|
+
if proc.returncode:
|
|
186
|
+
detail = ((proc.stderr or proc.stdout) or "").strip().splitlines()
|
|
187
|
+
failure = detail[-1] if detail else f"installer exited {proc.returncode}"
|
|
188
|
+
else:
|
|
189
|
+
failure = None
|
|
190
|
+
if failure:
|
|
191
|
+
return failure
|
|
192
|
+
_, pi_path = probe_host(target)
|
|
193
|
+
if pi_path is None:
|
|
194
|
+
return "installer exited without installing pi (its menu needs 'y')"
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def ensure_pi(target: str, assume_yes: bool, remote_dir: str) -> bool:
|
|
199
|
+
"""Make sure the host has pi. Returns False when the host must be skipped.
|
|
200
|
+
|
|
201
|
+
Copying into a host without pi is not useful and usually fails outright,
|
|
202
|
+
since the agent directory does not exist there yet.
|
|
203
|
+
|
|
204
|
+
Interactively the installer owns the decision: it has its own
|
|
205
|
+
install/uninstall/do-nothing menu, so prompting a second time here would
|
|
206
|
+
only add a redundant question before the one that counts.
|
|
207
|
+
"""
|
|
208
|
+
interactive = sys.stdin.isatty()
|
|
209
|
+
if not interactive and not assume_yes:
|
|
210
|
+
click.secho(
|
|
211
|
+
" pi not installed — skipping (pass --install to add it)", fg="yellow"
|
|
212
|
+
)
|
|
213
|
+
return False
|
|
214
|
+
if interactive:
|
|
215
|
+
click.secho(
|
|
216
|
+
" handing over to pi's installer — the sync continues when it exits"
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
click.secho(" running pi's installer (unattended)")
|
|
220
|
+
error = install_pi(target)
|
|
221
|
+
if error:
|
|
222
|
+
click.secho(f" {error} — skipping host", fg="red")
|
|
223
|
+
return False
|
|
224
|
+
click.secho(" pi installed; continuing with the sync", fg="green")
|
|
225
|
+
# A fresh install has never run, so the agent dir may not exist yet and
|
|
226
|
+
# rsync will not create intermediate directories for us.
|
|
227
|
+
run_cmd(["ssh", target, f"mkdir -p {remote_dir}"])
|
|
228
|
+
return True
|
|
92
229
|
|
|
93
230
|
|
|
94
231
|
def sync_host(
|
|
@@ -103,7 +240,6 @@ def sync_host(
|
|
|
103
240
|
verbose: bool,
|
|
104
241
|
excludes: Sequence[str] = (),
|
|
105
242
|
) -> bool:
|
|
106
|
-
click.secho(f"→ {target}", bold=True)
|
|
107
243
|
ok = True
|
|
108
244
|
for rel in items:
|
|
109
245
|
if not pull and not (local_dir / rel).exists():
|
|
@@ -148,7 +284,13 @@ def sync_host(
|
|
|
148
284
|
|
|
149
285
|
|
|
150
286
|
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
151
|
-
@click.argument(
|
|
287
|
+
@click.argument(
|
|
288
|
+
"targets",
|
|
289
|
+
nargs=-1,
|
|
290
|
+
required=True,
|
|
291
|
+
metavar="[USER@]HOST...",
|
|
292
|
+
shell_complete=complete_target,
|
|
293
|
+
)
|
|
152
294
|
@click.option(
|
|
153
295
|
"--all", "all_", is_flag=True, help="Sync config and extensions (the default)."
|
|
154
296
|
)
|
|
@@ -179,6 +321,12 @@ def sync_host(
|
|
|
179
321
|
metavar="PATTERN",
|
|
180
322
|
help="rsync exclude pattern, e.g. '*/logs/*' (repeatable).",
|
|
181
323
|
)
|
|
324
|
+
@click.option(
|
|
325
|
+
"--install",
|
|
326
|
+
"install_",
|
|
327
|
+
is_flag=True,
|
|
328
|
+
help="Install pi on hosts that lack it, without prompting.",
|
|
329
|
+
)
|
|
182
330
|
@click.option(
|
|
183
331
|
"--local-dir",
|
|
184
332
|
default=None,
|
|
@@ -203,6 +351,7 @@ def main(
|
|
|
203
351
|
delete_: bool,
|
|
204
352
|
dry_run: bool,
|
|
205
353
|
excludes: tuple[str, ...],
|
|
354
|
+
install_: bool,
|
|
206
355
|
local_dir: str | None,
|
|
207
356
|
remote_dir: str,
|
|
208
357
|
verbose: bool,
|
|
@@ -242,11 +391,20 @@ def main(
|
|
|
242
391
|
failed = False
|
|
243
392
|
for raw_target in targets:
|
|
244
393
|
target = raw_target.rstrip(":")
|
|
245
|
-
error =
|
|
394
|
+
error, pi_path = probe_host(target)
|
|
246
395
|
if error:
|
|
247
396
|
failed = True
|
|
248
397
|
click.secho(f"→ {target}\n unreachable: {error}", fg="red")
|
|
249
398
|
continue
|
|
399
|
+
click.secho(f"→ {target}", bold=True)
|
|
400
|
+
if pi_path is None:
|
|
401
|
+
if dry_run:
|
|
402
|
+
click.secho(" pi not installed — nothing would be synced", fg="yellow")
|
|
403
|
+
failed = True
|
|
404
|
+
continue
|
|
405
|
+
if not ensure_pi(target, install_, remote_dir):
|
|
406
|
+
failed = True
|
|
407
|
+
continue
|
|
250
408
|
if not sync_host(
|
|
251
409
|
target,
|
|
252
410
|
items,
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""Tests for pi-sync.
|
|
2
|
+
|
|
3
|
+
Covers flag→item selection, rsync argv construction, ssh-config host
|
|
4
|
+
completion, and the per-host pi probe/install path. No ssh or rsync process is
|
|
5
|
+
ever executed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import pytest
|
|
16
|
+
from click.testing import CliRunner
|
|
17
|
+
|
|
18
|
+
from pi_sync import cli
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeRun:
|
|
22
|
+
"""Records every command; replays canned itemize output for rsync."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
rsync_stdout: str = ">f+++++++ models.json\n",
|
|
27
|
+
rsync_rc: int = 0,
|
|
28
|
+
ssh_rc: int = 0,
|
|
29
|
+
pi_path: str | None = "/usr/bin/pi",
|
|
30
|
+
install_rc: int = 0,
|
|
31
|
+
install_installs: bool = True,
|
|
32
|
+
):
|
|
33
|
+
self.calls: list[list[str]] = []
|
|
34
|
+
self.inputs: list[str | None] = []
|
|
35
|
+
self.captures: list[bool] = []
|
|
36
|
+
self.rsync_stdout = rsync_stdout
|
|
37
|
+
self.rsync_rc = rsync_rc
|
|
38
|
+
self.ssh_rc = ssh_rc
|
|
39
|
+
self.pi_path = pi_path
|
|
40
|
+
self.install_rc = install_rc
|
|
41
|
+
self.install_installs = install_installs
|
|
42
|
+
self.installed = False
|
|
43
|
+
|
|
44
|
+
def __call__(
|
|
45
|
+
self, argv: list[str], input_text: str | None = None, capture: bool = True
|
|
46
|
+
) -> subprocess.CompletedProcess[str]:
|
|
47
|
+
self.calls.append(argv)
|
|
48
|
+
self.inputs.append(input_text)
|
|
49
|
+
self.captures.append(capture)
|
|
50
|
+
if argv[0] != "ssh":
|
|
51
|
+
return subprocess.CompletedProcess(
|
|
52
|
+
argv, self.rsync_rc, self.rsync_stdout, ""
|
|
53
|
+
)
|
|
54
|
+
if any("install.sh" in arg for arg in argv):
|
|
55
|
+
if self.install_rc == 0:
|
|
56
|
+
# a real installer exits 0 even when the user picks "do nothing"
|
|
57
|
+
self.installed = self.install_installs
|
|
58
|
+
stderr = "" if self.install_rc == 0 else "curl: (22) installer failed"
|
|
59
|
+
return subprocess.CompletedProcess(
|
|
60
|
+
argv, self.install_rc, "installed\n", stderr
|
|
61
|
+
)
|
|
62
|
+
if self.ssh_rc:
|
|
63
|
+
return subprocess.CompletedProcess(
|
|
64
|
+
argv, self.ssh_rc, "", "ssh: connect failed"
|
|
65
|
+
)
|
|
66
|
+
path = self.pi_path or ("/usr/local/bin/pi" if self.installed else None)
|
|
67
|
+
probe = f"PI:{path}\n" if path else ""
|
|
68
|
+
return subprocess.CompletedProcess(argv, 0, probe, "")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _TtyStdin:
|
|
72
|
+
"""Stand-in for an interactive terminal."""
|
|
73
|
+
|
|
74
|
+
def isatty(self) -> bool:
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def fake(monkeypatch: pytest.MonkeyPatch) -> FakeRun:
|
|
80
|
+
runner = FakeRun()
|
|
81
|
+
monkeypatch.setattr(cli, "run_cmd", runner)
|
|
82
|
+
return runner
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.fixture
|
|
86
|
+
def agent_dir(tmp_path: Path) -> Path:
|
|
87
|
+
"""A fake local agent dir with every syncable item present."""
|
|
88
|
+
(tmp_path / "models.json").write_text("{}")
|
|
89
|
+
(tmp_path / "settings.json").write_text("{}")
|
|
90
|
+
(tmp_path / "auth.json").write_text("{}")
|
|
91
|
+
(tmp_path / "extensions").mkdir()
|
|
92
|
+
return tmp_path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def rsync_calls(fake: FakeRun) -> list[list[str]]:
|
|
96
|
+
return [call for call in fake.calls if call[0] == "rsync"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sources(fake: FakeRun) -> list[str]:
|
|
100
|
+
"""The local-side path of each rsync call, for push invocations."""
|
|
101
|
+
return [Path(call[-2]).name for call in rsync_calls(fake)]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.parametrize(
|
|
105
|
+
("groups", "expected"),
|
|
106
|
+
[
|
|
107
|
+
({"config"}, ["models.json", "settings.json"]),
|
|
108
|
+
({"extensions"}, ["extensions"]),
|
|
109
|
+
({"auth"}, ["auth.json"]),
|
|
110
|
+
({"config", "extensions"}, ["models.json", "settings.json", "extensions"]),
|
|
111
|
+
({"config", "auth"}, ["models.json", "settings.json", "auth.json"]),
|
|
112
|
+
(set(), []),
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
def test_select_items(groups: set[str], expected: list[str]) -> None:
|
|
116
|
+
assert cli.select_items(groups) == expected
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize(
|
|
120
|
+
("argv_kwargs", "expected"),
|
|
121
|
+
[
|
|
122
|
+
({}, ["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"]),
|
|
123
|
+
(
|
|
124
|
+
{"pull": True},
|
|
125
|
+
["rsync", "-az", "-i", "host:~/.pi/agent/models.json", "/a/models.json"],
|
|
126
|
+
),
|
|
127
|
+
(
|
|
128
|
+
{"dry_run": True},
|
|
129
|
+
[
|
|
130
|
+
"rsync",
|
|
131
|
+
"-az",
|
|
132
|
+
"-i",
|
|
133
|
+
"-n",
|
|
134
|
+
"/a/models.json",
|
|
135
|
+
"host:~/.pi/agent/models.json",
|
|
136
|
+
],
|
|
137
|
+
),
|
|
138
|
+
(
|
|
139
|
+
{"delete": True},
|
|
140
|
+
["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"],
|
|
141
|
+
),
|
|
142
|
+
],
|
|
143
|
+
)
|
|
144
|
+
def test_rsync_argv_files(argv_kwargs: dict, expected: list[str]) -> None:
|
|
145
|
+
assert (
|
|
146
|
+
cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent", **argv_kwargs)
|
|
147
|
+
== expected
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_rsync_argv_directory_gets_trailing_slashes() -> None:
|
|
152
|
+
argv = cli.rsync_argv("extensions", "host", Path("/a"), "~/.pi/agent")
|
|
153
|
+
assert argv[-2:] == ["/a/extensions/", "host:~/.pi/agent/extensions/"]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_rsync_argv_delete_only_for_directories() -> None:
|
|
157
|
+
assert "--delete-during" in cli.rsync_argv(
|
|
158
|
+
"extensions", "host", Path("/a"), "~/.pi/agent", delete=True
|
|
159
|
+
)
|
|
160
|
+
assert "--delete-during" not in cli.rsync_argv(
|
|
161
|
+
"models.json", "host", Path("/a"), "~/.pi/agent", delete=True
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_rsync_argv_excludes() -> None:
|
|
166
|
+
argv = cli.rsync_argv(
|
|
167
|
+
"extensions", "host", Path("/a"), "~/.pi/agent", excludes=("*/logs/*",)
|
|
168
|
+
)
|
|
169
|
+
assert "--exclude=*/logs/*" in argv
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_exclude_flag_passes_through(fake: FakeRun, agent_dir: Path) -> None:
|
|
173
|
+
CliRunner().invoke(
|
|
174
|
+
cli.main,
|
|
175
|
+
["--extensions", "-x", "*/logs/*", "--local-dir", str(agent_dir), "host"],
|
|
176
|
+
)
|
|
177
|
+
calls = rsync_calls(fake)
|
|
178
|
+
assert calls and all("--exclude=*/logs/*" in call for call in calls)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_remote_dir_trailing_slash_is_not_doubled() -> None:
|
|
182
|
+
argv = cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent/")
|
|
183
|
+
assert argv[-1] == "host:~/.pi/agent/models.json"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.mark.parametrize(
|
|
187
|
+
("flags", "expected"),
|
|
188
|
+
[
|
|
189
|
+
([], ["models.json", "settings.json", "extensions"]),
|
|
190
|
+
(["--all"], ["models.json", "settings.json", "extensions"]),
|
|
191
|
+
(["--config"], ["models.json", "settings.json"]),
|
|
192
|
+
(["--extensions"], ["extensions"]),
|
|
193
|
+
(["--config", "--auth"], ["models.json", "settings.json", "auth.json"]),
|
|
194
|
+
(
|
|
195
|
+
["--all", "--auth"],
|
|
196
|
+
["models.json", "settings.json", "extensions", "auth.json"],
|
|
197
|
+
),
|
|
198
|
+
],
|
|
199
|
+
)
|
|
200
|
+
def test_flags_select_items(
|
|
201
|
+
fake: FakeRun, agent_dir: Path, flags: list[str], expected: list[str]
|
|
202
|
+
) -> None:
|
|
203
|
+
result = CliRunner().invoke(
|
|
204
|
+
cli.main, [*flags, "--local-dir", str(agent_dir), "host"]
|
|
205
|
+
)
|
|
206
|
+
assert result.exit_code == 0, result.output
|
|
207
|
+
assert sources(fake) == expected
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_ssh_probe_runs_before_rsync(fake: FakeRun, agent_dir: Path) -> None:
|
|
211
|
+
CliRunner().invoke(cli.main, ["--local-dir", str(agent_dir), "host"])
|
|
212
|
+
assert fake.calls[0] == ["ssh", "-o", "ConnectTimeout=10", "host", "sh -s"]
|
|
213
|
+
assert fake.calls[1][0] == "rsync"
|
|
214
|
+
# the probe script is piped over stdin so it works under fish/csh too
|
|
215
|
+
assert "command -v pi" in (fake.inputs[0] or "")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_missing_local_item_is_skipped(fake: FakeRun, tmp_path: Path) -> None:
|
|
219
|
+
(tmp_path / "models.json").write_text("{}")
|
|
220
|
+
result = CliRunner().invoke(
|
|
221
|
+
cli.main, ["--all", "--local-dir", str(tmp_path), "host"]
|
|
222
|
+
)
|
|
223
|
+
assert result.exit_code == 0
|
|
224
|
+
assert sources(fake) == ["models.json"]
|
|
225
|
+
assert "skipped" in result.output
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_pull_does_not_require_local_files(fake: FakeRun, tmp_path: Path) -> None:
|
|
229
|
+
result = CliRunner().invoke(
|
|
230
|
+
cli.main, ["--all", "--pull", "--local-dir", str(tmp_path), "host"]
|
|
231
|
+
)
|
|
232
|
+
assert result.exit_code == 0
|
|
233
|
+
assert sources(fake) == [
|
|
234
|
+
"models.json",
|
|
235
|
+
"settings.json",
|
|
236
|
+
"extensions",
|
|
237
|
+
] # remote paths, pulled from
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_default_remote_dir(fake: FakeRun, agent_dir: Path) -> None:
|
|
241
|
+
CliRunner().invoke(cli.main, ["--config", "--local-dir", str(agent_dir), "host"])
|
|
242
|
+
assert all("host:~/.pi/agent/" in call[-1] for call in rsync_calls(fake))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_remote_dir_override(fake: FakeRun, agent_dir: Path) -> None:
|
|
246
|
+
CliRunner().invoke(
|
|
247
|
+
cli.main,
|
|
248
|
+
["--config", "--local-dir", str(agent_dir), "--remote-dir", "/srv/pi", "host"],
|
|
249
|
+
)
|
|
250
|
+
assert all("host:/srv/pi/" in call[-1] for call in rsync_calls(fake))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_trailing_colon_is_stripped(fake: FakeRun, agent_dir: Path) -> None:
|
|
254
|
+
CliRunner().invoke(
|
|
255
|
+
cli.main, ["--config", "--local-dir", str(agent_dir), "sayan@host:"]
|
|
256
|
+
)
|
|
257
|
+
assert all(call[-1].startswith("sayan@host:") for call in rsync_calls(fake))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_multiple_hosts(fake: FakeRun, agent_dir: Path) -> None:
|
|
261
|
+
CliRunner().invoke(cli.main, ["--config", "--local-dir", str(agent_dir), "a", "b"])
|
|
262
|
+
targets = {call[-1].split(":")[0] for call in rsync_calls(fake)}
|
|
263
|
+
assert targets == {"a", "b"}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_auth_warns_about_secrets(fake: FakeRun, agent_dir: Path) -> None:
|
|
267
|
+
result = CliRunner().invoke(
|
|
268
|
+
cli.main, ["--auth", "--local-dir", str(agent_dir), "host"]
|
|
269
|
+
)
|
|
270
|
+
assert "API keys" in result.output
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_unreachable_host_exits_nonzero(
|
|
274
|
+
monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
275
|
+
) -> None:
|
|
276
|
+
monkeypatch.setattr(cli, "run_cmd", FakeRun(ssh_rc=255))
|
|
277
|
+
result = CliRunner().invoke(
|
|
278
|
+
cli.main, ["--config", "--local-dir", str(agent_dir), "host"]
|
|
279
|
+
)
|
|
280
|
+
assert result.exit_code == 1
|
|
281
|
+
assert "unreachable" in result.output
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_rsync_failure_exits_nonzero(
|
|
285
|
+
monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
286
|
+
) -> None:
|
|
287
|
+
monkeypatch.setattr(cli, "run_cmd", FakeRun(rsync_rc=23))
|
|
288
|
+
result = CliRunner().invoke(
|
|
289
|
+
cli.main, ["--config", "--local-dir", str(agent_dir), "host"]
|
|
290
|
+
)
|
|
291
|
+
assert result.exit_code == 1
|
|
292
|
+
assert "FAILED" in result.output
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@pytest.mark.parametrize(
|
|
296
|
+
("output", "expected"),
|
|
297
|
+
[
|
|
298
|
+
(">f+++++++ a\n>f+++++++ b\n", (2, 0)),
|
|
299
|
+
("*deleting stale.txt\n>f+++++++++ new.txt\n", (1, 1)),
|
|
300
|
+
(".d..t...... extensions/\n", (0, 0)),
|
|
301
|
+
("", (0, 0)),
|
|
302
|
+
],
|
|
303
|
+
)
|
|
304
|
+
def test_summarize(output: str, expected: tuple[int, int]) -> None:
|
|
305
|
+
assert cli.summarize(output) == expected
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_local_dir_env_override(
|
|
309
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
310
|
+
) -> None:
|
|
311
|
+
monkeypatch.setenv("PI_CODING_AGENT_DIR", str(tmp_path))
|
|
312
|
+
assert cli.local_agent_dir() == tmp_path
|
|
313
|
+
assert cli.local_agent_dir("/explicit") == Path("/explicit")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def ssh_probe_calls(fake: FakeRun) -> list[list[str]]:
|
|
317
|
+
"""ssh calls that are the pi probe (not the installer)."""
|
|
318
|
+
return [
|
|
319
|
+
c for c in fake.calls if c[0] == "ssh" and not any("install.sh" in a for a in c)
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def install_calls(fake: FakeRun) -> list[list[str]]:
|
|
324
|
+
return [c for c in fake.calls if any("install.sh" in a for a in c)]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class TestSshHosts:
|
|
328
|
+
def test_single_host(self, tmp_path: Path) -> None:
|
|
329
|
+
cfg = tmp_path / "config"
|
|
330
|
+
cfg.write_text("Host tinfoil\n HostName tinfoil.example\n")
|
|
331
|
+
assert cli.ssh_hosts(str(cfg)) == ["tinfoil"]
|
|
332
|
+
|
|
333
|
+
def test_multiple_tokens_and_trailing_whitespace(self, tmp_path: Path) -> None:
|
|
334
|
+
cfg = tmp_path / "config"
|
|
335
|
+
cfg.write_text("Host svalbard \nHost a b\n")
|
|
336
|
+
assert cli.ssh_hosts(str(cfg)) == ["svalbard", "a", "b"]
|
|
337
|
+
|
|
338
|
+
def test_wildcards_and_negations_skipped(self, tmp_path: Path) -> None:
|
|
339
|
+
cfg = tmp_path / "config"
|
|
340
|
+
cfg.write_text(
|
|
341
|
+
"Host *.orb.local\nHost api.example.com !api.internal\nHost real\n"
|
|
342
|
+
)
|
|
343
|
+
assert cli.ssh_hosts(str(cfg)) == ["api.example.com", "real"]
|
|
344
|
+
|
|
345
|
+
def test_directives_are_case_insensitive(self, tmp_path: Path) -> None:
|
|
346
|
+
cfg = tmp_path / "config"
|
|
347
|
+
cfg.write_text("HOST upper\nhost lower\n")
|
|
348
|
+
assert cli.ssh_hosts(str(cfg)) == ["upper", "lower"]
|
|
349
|
+
|
|
350
|
+
def test_comments_and_blank_lines_ignored(self, tmp_path: Path) -> None:
|
|
351
|
+
cfg = tmp_path / "config"
|
|
352
|
+
cfg.write_text("# a comment\n\n # indented comment\nHost real\n")
|
|
353
|
+
assert cli.ssh_hosts(str(cfg)) == ["real"]
|
|
354
|
+
|
|
355
|
+
def test_non_host_directives_ignored(self, tmp_path: Path) -> None:
|
|
356
|
+
cfg = tmp_path / "config"
|
|
357
|
+
cfg.write_text("Host real\n HostName example.com\n User bob\n Port 2222\n")
|
|
358
|
+
assert cli.ssh_hosts(str(cfg)) == ["real"]
|
|
359
|
+
|
|
360
|
+
def test_include_is_followed_and_deduped(self, tmp_path: Path) -> None:
|
|
361
|
+
extra = tmp_path / "extra_config"
|
|
362
|
+
extra.write_text("Host orb\nHost tinfoil\n")
|
|
363
|
+
cfg = tmp_path / "config"
|
|
364
|
+
cfg.write_text(f"Host tinfoil\nInclude {extra}\n")
|
|
365
|
+
assert cli.ssh_hosts(str(cfg)) == ["tinfoil", "orb"]
|
|
366
|
+
|
|
367
|
+
def test_include_glob(self, tmp_path: Path) -> None:
|
|
368
|
+
(tmp_path / "conf.d").mkdir()
|
|
369
|
+
(tmp_path / "conf.d" / "a").write_text("Host alpha\n")
|
|
370
|
+
(tmp_path / "conf.d" / "b").write_text("Host beta\n")
|
|
371
|
+
cfg = tmp_path / "config"
|
|
372
|
+
cfg.write_text(f"Include {tmp_path}/conf.d/*\n")
|
|
373
|
+
assert cli.ssh_hosts(str(cfg)) == ["alpha", "beta"]
|
|
374
|
+
|
|
375
|
+
def test_include_cycle_terminates(self, tmp_path: Path) -> None:
|
|
376
|
+
cfg = tmp_path / "config"
|
|
377
|
+
other = tmp_path / "other"
|
|
378
|
+
cfg.write_text(f"Host one\nInclude {other}\n")
|
|
379
|
+
other.write_text(f"Host two\nInclude {cfg}\n")
|
|
380
|
+
assert cli.ssh_hosts(str(cfg)) == ["one", "two"]
|
|
381
|
+
|
|
382
|
+
def test_missing_config_is_empty(self, tmp_path: Path) -> None:
|
|
383
|
+
assert cli.ssh_hosts(str(tmp_path / "nope")) == []
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class TestCompletion:
|
|
387
|
+
@staticmethod
|
|
388
|
+
def complete(incomplete: str) -> list[str]:
|
|
389
|
+
"""Run the completion callback the way click would."""
|
|
390
|
+
ctx = click.Context(cli.main)
|
|
391
|
+
items = cli.complete_target(ctx, cli.main.params[0], incomplete)
|
|
392
|
+
return [item.value for item in items]
|
|
393
|
+
|
|
394
|
+
def test_completes_matching_hosts(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
395
|
+
monkeypatch.setattr(
|
|
396
|
+
cli, "ssh_hosts", lambda *a, **k: ["tinfoil", "zero-frame", "phatboi"]
|
|
397
|
+
)
|
|
398
|
+
assert self.complete("t") == ["tinfoil"]
|
|
399
|
+
|
|
400
|
+
def test_empty_incomplete_lists_everything(
|
|
401
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
402
|
+
) -> None:
|
|
403
|
+
monkeypatch.setattr(cli, "ssh_hosts", lambda *a, **k: ["a", "b"])
|
|
404
|
+
assert self.complete("") == ["a", "b"]
|
|
405
|
+
|
|
406
|
+
def test_user_at_prefix_is_preserved(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
407
|
+
monkeypatch.setattr(cli, "ssh_hosts", lambda *a, **k: ["tinfoil", "zero-frame"])
|
|
408
|
+
assert self.complete("sayan@zer") == ["sayan@zero-frame"]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class TestProbe:
|
|
412
|
+
def test_reports_pi_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
413
|
+
monkeypatch.setattr(
|
|
414
|
+
cli, "run_cmd", FakeRun(pi_path="/home/linuxbrew/.linuxbrew/bin/pi")
|
|
415
|
+
)
|
|
416
|
+
assert cli.probe_host("host") == (None, "/home/linuxbrew/.linuxbrew/bin/pi")
|
|
417
|
+
|
|
418
|
+
def test_reports_missing_pi(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
419
|
+
monkeypatch.setattr(cli, "run_cmd", FakeRun(pi_path=None))
|
|
420
|
+
assert cli.probe_host("host") == (None, None)
|
|
421
|
+
|
|
422
|
+
def test_reports_unreachable(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
423
|
+
monkeypatch.setattr(cli, "run_cmd", FakeRun(ssh_rc=255))
|
|
424
|
+
error, pi_path = cli.probe_host("host")
|
|
425
|
+
assert error == "ssh: connect failed"
|
|
426
|
+
assert pi_path is None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class TestInstallOffering:
|
|
430
|
+
def test_no_install_when_pi_present(
|
|
431
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
432
|
+
) -> None:
|
|
433
|
+
fake = FakeRun(pi_path="/usr/bin/pi")
|
|
434
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
435
|
+
result = CliRunner().invoke(cli.main, ["--local-dir", str(agent_dir), "host"])
|
|
436
|
+
assert result.exit_code == 0
|
|
437
|
+
assert install_calls(fake) == []
|
|
438
|
+
|
|
439
|
+
def test_missing_pi_without_tty_skips_host(
|
|
440
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
441
|
+
) -> None:
|
|
442
|
+
fake = FakeRun(pi_path=None)
|
|
443
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
444
|
+
result = CliRunner().invoke(cli.main, ["--local-dir", str(agent_dir), "host"])
|
|
445
|
+
assert result.exit_code == 1
|
|
446
|
+
assert install_calls(fake) == []
|
|
447
|
+
assert not any(c[0] == "rsync" for c in fake.calls)
|
|
448
|
+
assert "--install" in result.output
|
|
449
|
+
|
|
450
|
+
def test_interactive_install_needs_no_second_prompt(
|
|
451
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
452
|
+
) -> None:
|
|
453
|
+
"""The installer owns the decision; pi-sync must not pre-ask on top of it."""
|
|
454
|
+
fake = FakeRun(pi_path=None)
|
|
455
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
456
|
+
monkeypatch.setattr(sys, "stdin", _TtyStdin())
|
|
457
|
+
asked: list[str] = []
|
|
458
|
+
monkeypatch.setattr(
|
|
459
|
+
click, "confirm", lambda *a, **k: asked.append("confirm") or False
|
|
460
|
+
)
|
|
461
|
+
assert cli.ensure_pi("host", assume_yes=False, remote_dir="~/.pi/agent") is True
|
|
462
|
+
assert asked == []
|
|
463
|
+
assert len(install_calls(fake)) == 1
|
|
464
|
+
|
|
465
|
+
def test_install_flag_installs_then_syncs(
|
|
466
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
467
|
+
) -> None:
|
|
468
|
+
fake = FakeRun(pi_path=None)
|
|
469
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
470
|
+
result = CliRunner().invoke(
|
|
471
|
+
cli.main, ["--install", "--local-dir", str(agent_dir), "host"]
|
|
472
|
+
)
|
|
473
|
+
assert result.exit_code == 0
|
|
474
|
+
(call,) = install_calls(fake)
|
|
475
|
+
assert call[-1] == cli.PI_INSTALL_CMD
|
|
476
|
+
assert "pi installed" in result.output
|
|
477
|
+
# a fresh install has no agent dir yet, so it is created before rsync
|
|
478
|
+
assert any("mkdir -p" in arg for c in fake.calls for arg in c)
|
|
479
|
+
assert any(c[0] == "rsync" for c in fake.calls)
|
|
480
|
+
|
|
481
|
+
def test_install_failure_skips_host(
|
|
482
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
483
|
+
) -> None:
|
|
484
|
+
fake = FakeRun(pi_path=None, install_rc=22)
|
|
485
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
486
|
+
result = CliRunner().invoke(
|
|
487
|
+
cli.main, ["--install", "--local-dir", str(agent_dir), "host"]
|
|
488
|
+
)
|
|
489
|
+
assert result.exit_code == 1
|
|
490
|
+
assert "skipping host" in result.output
|
|
491
|
+
assert not any(c[0] == "rsync" for c in fake.calls)
|
|
492
|
+
|
|
493
|
+
def test_installer_that_installs_nothing_skips_host(
|
|
494
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
495
|
+
) -> None:
|
|
496
|
+
"""The installer exits 0 for its "do nothing" choice; that is not success."""
|
|
497
|
+
fake = FakeRun(pi_path=None, install_installs=False)
|
|
498
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
499
|
+
result = CliRunner().invoke(
|
|
500
|
+
cli.main, ["--install", "--local-dir", str(agent_dir), "host"]
|
|
501
|
+
)
|
|
502
|
+
assert result.exit_code == 1
|
|
503
|
+
assert "without installing pi" in result.output
|
|
504
|
+
assert not any(c[0] == "rsync" for c in fake.calls)
|
|
505
|
+
|
|
506
|
+
def test_dry_run_never_installs_anything(
|
|
507
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
508
|
+
) -> None:
|
|
509
|
+
fake = FakeRun(pi_path=None)
|
|
510
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
511
|
+
result = CliRunner().invoke(
|
|
512
|
+
cli.main,
|
|
513
|
+
["--install", "--dry-run", "--local-dir", str(agent_dir), "host"],
|
|
514
|
+
)
|
|
515
|
+
assert result.exit_code == 1
|
|
516
|
+
assert install_calls(fake) == []
|
|
517
|
+
assert not any(c[0] == "rsync" for c in fake.calls)
|
|
518
|
+
|
|
519
|
+
def test_interactive_install_streams_and_gets_a_tty(
|
|
520
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
521
|
+
) -> None:
|
|
522
|
+
"""The installer prompts on /dev/tty, so its output must not be captured."""
|
|
523
|
+
fake = FakeRun()
|
|
524
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
525
|
+
monkeypatch.setattr(sys, "stdin", _TtyStdin())
|
|
526
|
+
assert cli.install_pi("host") is None
|
|
527
|
+
(call,) = install_calls(fake)
|
|
528
|
+
assert "-t" in call
|
|
529
|
+
assert fake.captures[0] is False # install call streams; the re-probe captures
|
|
530
|
+
|
|
531
|
+
def test_unattended_install_captures_and_closes_stdin(
|
|
532
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
533
|
+
) -> None:
|
|
534
|
+
"""Without a terminal the installer gets EOF instead of blocking on a prompt."""
|
|
535
|
+
fake = FakeRun()
|
|
536
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
537
|
+
assert cli.install_pi("host") is None
|
|
538
|
+
(call,) = install_calls(fake)
|
|
539
|
+
assert "-t" not in call
|
|
540
|
+
assert fake.inputs[0] == ""
|
|
541
|
+
assert fake.captures[0] is True
|
|
542
|
+
|
|
543
|
+
def test_no_install_attempt_when_unreachable(
|
|
544
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
545
|
+
) -> None:
|
|
546
|
+
fake = FakeRun(pi_path=None, ssh_rc=255)
|
|
547
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
548
|
+
result = CliRunner().invoke(
|
|
549
|
+
cli.main, ["--install", "--local-dir", str(agent_dir), "host"]
|
|
550
|
+
)
|
|
551
|
+
assert result.exit_code == 1
|
|
552
|
+
assert install_calls(fake) == []
|
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
"""Tests for pi-sync. No ssh or rsync process is ever executed."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import subprocess
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
import pytest
|
|
9
|
-
from click.testing import CliRunner
|
|
10
|
-
|
|
11
|
-
from pi_sync import cli
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class FakeRun:
|
|
15
|
-
"""Records every command; replays canned itemize output for rsync."""
|
|
16
|
-
|
|
17
|
-
def __init__(
|
|
18
|
-
self,
|
|
19
|
-
rsync_stdout: str = ">f+++++++ models.json\n",
|
|
20
|
-
rsync_rc: int = 0,
|
|
21
|
-
ssh_rc: int = 0,
|
|
22
|
-
):
|
|
23
|
-
self.calls: list[list[str]] = []
|
|
24
|
-
self.rsync_stdout = rsync_stdout
|
|
25
|
-
self.rsync_rc = rsync_rc
|
|
26
|
-
self.ssh_rc = ssh_rc
|
|
27
|
-
|
|
28
|
-
def __call__(self, argv: list[str]) -> subprocess.CompletedProcess[str]:
|
|
29
|
-
self.calls.append(argv)
|
|
30
|
-
if argv[0] == "ssh":
|
|
31
|
-
return subprocess.CompletedProcess(
|
|
32
|
-
argv, self.ssh_rc, "", "" if self.ssh_rc == 0 else "ssh: connect failed"
|
|
33
|
-
)
|
|
34
|
-
return subprocess.CompletedProcess(argv, self.rsync_rc, self.rsync_stdout, "")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@pytest.fixture
|
|
38
|
-
def fake(monkeypatch: pytest.MonkeyPatch) -> FakeRun:
|
|
39
|
-
runner = FakeRun()
|
|
40
|
-
monkeypatch.setattr(cli, "run_cmd", runner)
|
|
41
|
-
return runner
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@pytest.fixture
|
|
45
|
-
def agent_dir(tmp_path: Path) -> Path:
|
|
46
|
-
"""A fake local agent dir with every syncable item present."""
|
|
47
|
-
(tmp_path / "models.json").write_text("{}")
|
|
48
|
-
(tmp_path / "settings.json").write_text("{}")
|
|
49
|
-
(tmp_path / "auth.json").write_text("{}")
|
|
50
|
-
(tmp_path / "extensions").mkdir()
|
|
51
|
-
return tmp_path
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def rsync_calls(fake: FakeRun) -> list[list[str]]:
|
|
55
|
-
return [call for call in fake.calls if call[0] == "rsync"]
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def sources(fake: FakeRun) -> list[str]:
|
|
59
|
-
"""The local-side path of each rsync call, for push invocations."""
|
|
60
|
-
return [Path(call[-2]).name for call in rsync_calls(fake)]
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
@pytest.mark.parametrize(
|
|
64
|
-
("groups", "expected"),
|
|
65
|
-
[
|
|
66
|
-
({"config"}, ["models.json", "settings.json"]),
|
|
67
|
-
({"extensions"}, ["extensions"]),
|
|
68
|
-
({"auth"}, ["auth.json"]),
|
|
69
|
-
({"config", "extensions"}, ["models.json", "settings.json", "extensions"]),
|
|
70
|
-
({"config", "auth"}, ["models.json", "settings.json", "auth.json"]),
|
|
71
|
-
(set(), []),
|
|
72
|
-
],
|
|
73
|
-
)
|
|
74
|
-
def test_select_items(groups: set[str], expected: list[str]) -> None:
|
|
75
|
-
assert cli.select_items(groups) == expected
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
@pytest.mark.parametrize(
|
|
79
|
-
("argv_kwargs", "expected"),
|
|
80
|
-
[
|
|
81
|
-
({}, ["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"]),
|
|
82
|
-
(
|
|
83
|
-
{"pull": True},
|
|
84
|
-
["rsync", "-az", "-i", "host:~/.pi/agent/models.json", "/a/models.json"],
|
|
85
|
-
),
|
|
86
|
-
(
|
|
87
|
-
{"dry_run": True},
|
|
88
|
-
[
|
|
89
|
-
"rsync",
|
|
90
|
-
"-az",
|
|
91
|
-
"-i",
|
|
92
|
-
"-n",
|
|
93
|
-
"/a/models.json",
|
|
94
|
-
"host:~/.pi/agent/models.json",
|
|
95
|
-
],
|
|
96
|
-
),
|
|
97
|
-
(
|
|
98
|
-
{"delete": True},
|
|
99
|
-
["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"],
|
|
100
|
-
),
|
|
101
|
-
],
|
|
102
|
-
)
|
|
103
|
-
def test_rsync_argv_files(argv_kwargs: dict, expected: list[str]) -> None:
|
|
104
|
-
assert (
|
|
105
|
-
cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent", **argv_kwargs)
|
|
106
|
-
== expected
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def test_rsync_argv_directory_gets_trailing_slashes() -> None:
|
|
111
|
-
argv = cli.rsync_argv("extensions", "host", Path("/a"), "~/.pi/agent")
|
|
112
|
-
assert argv[-2:] == ["/a/extensions/", "host:~/.pi/agent/extensions/"]
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def test_rsync_argv_delete_only_for_directories() -> None:
|
|
116
|
-
assert "--delete-during" in cli.rsync_argv(
|
|
117
|
-
"extensions", "host", Path("/a"), "~/.pi/agent", delete=True
|
|
118
|
-
)
|
|
119
|
-
assert "--delete-during" not in cli.rsync_argv(
|
|
120
|
-
"models.json", "host", Path("/a"), "~/.pi/agent", delete=True
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def test_rsync_argv_excludes() -> None:
|
|
125
|
-
argv = cli.rsync_argv(
|
|
126
|
-
"extensions", "host", Path("/a"), "~/.pi/agent", excludes=("*/logs/*",)
|
|
127
|
-
)
|
|
128
|
-
assert "--exclude=*/logs/*" in argv
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def test_exclude_flag_passes_through(fake: FakeRun, agent_dir: Path) -> None:
|
|
132
|
-
CliRunner().invoke(
|
|
133
|
-
cli.main,
|
|
134
|
-
["--extensions", "-x", "*/logs/*", "--local-dir", str(agent_dir), "host"],
|
|
135
|
-
)
|
|
136
|
-
calls = rsync_calls(fake)
|
|
137
|
-
assert calls and all("--exclude=*/logs/*" in call for call in calls)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def test_remote_dir_trailing_slash_is_not_doubled() -> None:
|
|
141
|
-
argv = cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent/")
|
|
142
|
-
assert argv[-1] == "host:~/.pi/agent/models.json"
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
@pytest.mark.parametrize(
|
|
146
|
-
("flags", "expected"),
|
|
147
|
-
[
|
|
148
|
-
([], ["models.json", "settings.json", "extensions"]),
|
|
149
|
-
(["--all"], ["models.json", "settings.json", "extensions"]),
|
|
150
|
-
(["--config"], ["models.json", "settings.json"]),
|
|
151
|
-
(["--extensions"], ["extensions"]),
|
|
152
|
-
(["--config", "--auth"], ["models.json", "settings.json", "auth.json"]),
|
|
153
|
-
(
|
|
154
|
-
["--all", "--auth"],
|
|
155
|
-
["models.json", "settings.json", "extensions", "auth.json"],
|
|
156
|
-
),
|
|
157
|
-
],
|
|
158
|
-
)
|
|
159
|
-
def test_flags_select_items(
|
|
160
|
-
fake: FakeRun, agent_dir: Path, flags: list[str], expected: list[str]
|
|
161
|
-
) -> None:
|
|
162
|
-
result = CliRunner().invoke(
|
|
163
|
-
cli.main, [*flags, "--local-dir", str(agent_dir), "host"]
|
|
164
|
-
)
|
|
165
|
-
assert result.exit_code == 0, result.output
|
|
166
|
-
assert sources(fake) == expected
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def test_ssh_preflight_runs_before_rsync(fake: FakeRun, agent_dir: Path) -> None:
|
|
170
|
-
CliRunner().invoke(cli.main, ["--local-dir", str(agent_dir), "host"])
|
|
171
|
-
assert fake.calls[0] == ["ssh", "-o", "ConnectTimeout=10", "host", "true"]
|
|
172
|
-
assert fake.calls[1][0] == "rsync"
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def test_missing_local_item_is_skipped(fake: FakeRun, tmp_path: Path) -> None:
|
|
176
|
-
(tmp_path / "models.json").write_text("{}")
|
|
177
|
-
result = CliRunner().invoke(
|
|
178
|
-
cli.main, ["--all", "--local-dir", str(tmp_path), "host"]
|
|
179
|
-
)
|
|
180
|
-
assert result.exit_code == 0
|
|
181
|
-
assert sources(fake) == ["models.json"]
|
|
182
|
-
assert "skipped" in result.output
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def test_pull_does_not_require_local_files(fake: FakeRun, tmp_path: Path) -> None:
|
|
186
|
-
result = CliRunner().invoke(
|
|
187
|
-
cli.main, ["--all", "--pull", "--local-dir", str(tmp_path), "host"]
|
|
188
|
-
)
|
|
189
|
-
assert result.exit_code == 0
|
|
190
|
-
assert sources(fake) == [
|
|
191
|
-
"models.json",
|
|
192
|
-
"settings.json",
|
|
193
|
-
"extensions",
|
|
194
|
-
] # remote paths, pulled from
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def test_default_remote_dir(fake: FakeRun, agent_dir: Path) -> None:
|
|
198
|
-
CliRunner().invoke(cli.main, ["--config", "--local-dir", str(agent_dir), "host"])
|
|
199
|
-
assert all("host:~/.pi/agent/" in call[-1] for call in rsync_calls(fake))
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def test_remote_dir_override(fake: FakeRun, agent_dir: Path) -> None:
|
|
203
|
-
CliRunner().invoke(
|
|
204
|
-
cli.main,
|
|
205
|
-
["--config", "--local-dir", str(agent_dir), "--remote-dir", "/srv/pi", "host"],
|
|
206
|
-
)
|
|
207
|
-
assert all("host:/srv/pi/" in call[-1] for call in rsync_calls(fake))
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def test_trailing_colon_is_stripped(fake: FakeRun, agent_dir: Path) -> None:
|
|
211
|
-
CliRunner().invoke(
|
|
212
|
-
cli.main, ["--config", "--local-dir", str(agent_dir), "sayan@host:"]
|
|
213
|
-
)
|
|
214
|
-
assert all(call[-1].startswith("sayan@host:") for call in rsync_calls(fake))
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def test_multiple_hosts(fake: FakeRun, agent_dir: Path) -> None:
|
|
218
|
-
CliRunner().invoke(cli.main, ["--config", "--local-dir", str(agent_dir), "a", "b"])
|
|
219
|
-
targets = {call[-1].split(":")[0] for call in rsync_calls(fake)}
|
|
220
|
-
assert targets == {"a", "b"}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def test_auth_warns_about_secrets(fake: FakeRun, agent_dir: Path) -> None:
|
|
224
|
-
result = CliRunner().invoke(
|
|
225
|
-
cli.main, ["--auth", "--local-dir", str(agent_dir), "host"]
|
|
226
|
-
)
|
|
227
|
-
assert "API keys" in result.output
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def test_unreachable_host_exits_nonzero(
|
|
231
|
-
monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
232
|
-
) -> None:
|
|
233
|
-
monkeypatch.setattr(cli, "run_cmd", FakeRun(ssh_rc=255))
|
|
234
|
-
result = CliRunner().invoke(
|
|
235
|
-
cli.main, ["--config", "--local-dir", str(agent_dir), "host"]
|
|
236
|
-
)
|
|
237
|
-
assert result.exit_code == 1
|
|
238
|
-
assert "unreachable" in result.output
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def test_rsync_failure_exits_nonzero(
|
|
242
|
-
monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
243
|
-
) -> None:
|
|
244
|
-
monkeypatch.setattr(cli, "run_cmd", FakeRun(rsync_rc=23))
|
|
245
|
-
result = CliRunner().invoke(
|
|
246
|
-
cli.main, ["--config", "--local-dir", str(agent_dir), "host"]
|
|
247
|
-
)
|
|
248
|
-
assert result.exit_code == 1
|
|
249
|
-
assert "FAILED" in result.output
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
@pytest.mark.parametrize(
|
|
253
|
-
("output", "expected"),
|
|
254
|
-
[
|
|
255
|
-
(">f+++++++ a\n>f+++++++ b\n", (2, 0)),
|
|
256
|
-
("*deleting stale.txt\n>f+++++++++ new.txt\n", (1, 1)),
|
|
257
|
-
(".d..t...... extensions/\n", (0, 0)),
|
|
258
|
-
("", (0, 0)),
|
|
259
|
-
],
|
|
260
|
-
)
|
|
261
|
-
def test_summarize(output: str, expected: tuple[int, int]) -> None:
|
|
262
|
-
assert cli.summarize(output) == expected
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def test_local_dir_env_override(
|
|
266
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
267
|
-
) -> None:
|
|
268
|
-
monkeypatch.setenv("PI_CODING_AGENT_DIR", str(tmp_path))
|
|
269
|
-
assert cli.local_agent_dir() == tmp_path
|
|
270
|
-
assert cli.local_agent_dir("/explicit") == Path("/explicit")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|