pi-sync-cli 0.3.0__tar.gz → 0.5.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.5.0/AGENTS.md +62 -0
- pi_sync_cli-0.5.0/DEVELOPMENT.md +119 -0
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.5.0}/PKG-INFO +44 -9
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.5.0}/README.md +43 -8
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.5.0}/pyproject.toml +2 -2
- pi_sync_cli-0.5.0/src/pi_sync/__init__.py +5 -0
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.5.0}/src/pi_sync/cli.py +280 -16
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.5.0}/tests/test_cli.py +391 -9
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.5.0}/uv.lock +1 -1
- pi_sync_cli-0.3.0/src/pi_sync/__init__.py +0 -3
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.5.0}/.github/workflows/publish.yml +0 -0
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.5.0}/.gitignore +0 -0
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.5.0}/LICENSE +0 -0
- {pi_sync_cli-0.3.0 → pi_sync_cli-0.5.0}/pyrightconfig.json +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Working notes for AI agents (and humans) changing this repo. User-facing
|
|
4
|
+
documentation lives in [README.md](README.md); layout, tooling and the release
|
|
5
|
+
process live in [DEVELOPMENT.md](DEVELOPMENT.md).
|
|
6
|
+
|
|
7
|
+
## What this is
|
|
8
|
+
|
|
9
|
+
pi-sync: a single click module that rsyncs pi agent config between hosts.
|
|
10
|
+
`src/pi_sync/cli.py` is the whole program and `tests/test_cli.py` the whole
|
|
11
|
+
suite, so a change is one file plus its tests.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv sync # create/refresh .venv and uv.lock
|
|
17
|
+
uv run pytest # the suite (fast, offline)
|
|
18
|
+
uv run pi-sync --help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Invariants — please don't "fix" these
|
|
22
|
+
|
|
23
|
+
- **Entry point is `pi_sync.cli:app`**, a click group with a default-command
|
|
24
|
+
fallback: `DefaultGroup.parse_args` prepends `sync` when the first token is not
|
|
25
|
+
a subcommand, and `SyncCommand.format_usage` keeps help reading
|
|
26
|
+
`pi-sync [OPTIONS] ...`. The sync function stays named `main` because the tests
|
|
27
|
+
invoke `cli.main` directly.
|
|
28
|
+
- **No `__version__`.** The version comes from distribution metadata; adding one
|
|
29
|
+
back reintroduces the drift that removal fixed.
|
|
30
|
+
- **Tests never spawn anything.** `FakeRun` records argv and replays output;
|
|
31
|
+
extend it instead of calling `subprocess`. That guarantee is deliberate.
|
|
32
|
+
- **Host probes are POSIX sh piped to `ssh host sh -s`**, because some hosts run
|
|
33
|
+
fish and `for …; do … done` is a syntax error there.
|
|
34
|
+
- **Never trust pi's installer exit status.** Its "do nothing" menu choice exits
|
|
35
|
+
0, so `install_pi` re-probes the host. Keep new install/uninstall paths
|
|
36
|
+
verification-based.
|
|
37
|
+
- **`update` resolves the distribution from its own environment**; do not
|
|
38
|
+
hardcode `pi-sync-cli`, since an older install can coexist under `pi-sync` and
|
|
39
|
+
a hardcoded name would upgrade the wrong virtualenv.
|
|
40
|
+
- **`--dry-run` must not mutate anything**, including installing or uninstalling
|
|
41
|
+
pi on a host.
|
|
42
|
+
|
|
43
|
+
## Conventions
|
|
44
|
+
|
|
45
|
+
- Commit messages: all lowercase, conventional prefix (`fix:`, `feat:`, `chore:`,
|
|
46
|
+
`ci:`, `docs:`).
|
|
47
|
+
- Run `uv run pytest` and `lens_diagnostics mode=all` before declaring work done;
|
|
48
|
+
fix blockers rather than reporting around them.
|
|
49
|
+
- pi-lens reformats files on write and sometimes *after* a commit. Check
|
|
50
|
+
`git status` before finishing and commit formatter churn separately.
|
|
51
|
+
- `.pi/tasks/` is gitignored local task output — never commit it.
|
|
52
|
+
- Never commit secrets. `auth.json` is host-local and only travels with an
|
|
53
|
+
explicit `--auth`.
|
|
54
|
+
|
|
55
|
+
## Working against real hosts
|
|
56
|
+
|
|
57
|
+
- This Mac drives everything; `tinfoil` and the Raspberry Pi (`pi`) are real
|
|
58
|
+
hosts and can be offline.
|
|
59
|
+
- Prefer `--dry-run` when demonstrating. A real push overwrites host config — a
|
|
60
|
+
`.backup` is kept, but don't be cavalier about it.
|
|
61
|
+
- `~/.ssh/config` is protected: read it through code that extracts host aliases,
|
|
62
|
+
and don't dump its contents into output or a commit.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Developing pi-sync
|
|
2
|
+
|
|
3
|
+
## Layout
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
src/pi_sync/cli.py the whole CLI: probing, rsync, completions, self-update
|
|
7
|
+
tests/test_cli.py unit tests (no ssh or rsync process is ever executed)
|
|
8
|
+
pyproject.toml click dependency, console script, uv dev group
|
|
9
|
+
pyrightconfig.json points pyright at .venv (see "Tooling" below)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Three design anchors worth understanding before editing:
|
|
13
|
+
|
|
14
|
+
- **The console script points at `pi_sync.cli:app`**, a click *group*.
|
|
15
|
+
`DefaultGroup.parse_args` prepends `sync` when the first argument is not a
|
|
16
|
+
subcommand, which is what lets `pi-sync tinfoil`, `pi-sync --config tinfoil`
|
|
17
|
+
and `pi-sync update` all work. `SyncCommand.format_usage` exists so help reads
|
|
18
|
+
`pi-sync [OPTIONS] [USER@]HOST...` instead of `pi-sync sync [OPTIONS] ...`.
|
|
19
|
+
`main` is the sync subcommand and keeps that name because the tests drive it
|
|
20
|
+
directly.
|
|
21
|
+
- **The version comes from distribution metadata** (`@click.version_option`), so
|
|
22
|
+
there is no `__version__` to fall out of step with the release.
|
|
23
|
+
- **`update` resolves the distribution from its own environment**
|
|
24
|
+
(`importlib.metadata.packages_distributions()`) rather than hardcoding a name.
|
|
25
|
+
An older install can coexist under the pre-rename name `pi-sync`, and a
|
|
26
|
+
hardcoded name would upgrade the wrong virtualenv — or nothing.
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv sync # create/refresh .venv and uv.lock
|
|
32
|
+
uv run pytest # the suite
|
|
33
|
+
uv run pi-sync --help
|
|
34
|
+
uv build # wheel + sdist into dist/
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Tests
|
|
38
|
+
|
|
39
|
+
`FakeRun` records every command and replays canned output, so the suite needs no
|
|
40
|
+
network, no hosts, and no real transfers. When you add a command, extend
|
|
41
|
+
`FakeRun` rather than reaching for `subprocess` — the guarantee that nothing is
|
|
42
|
+
executed is what keeps the suite fast and safe.
|
|
43
|
+
|
|
44
|
+
The one path that cannot be unit-tested is the interactive installer handover,
|
|
45
|
+
because it depends on a real terminal. It was verified out-of-band with a PTY
|
|
46
|
+
harness that attaches a pseudo-terminal, feeds a keystroke, and asserts the
|
|
47
|
+
installer's `/dev/tty` menu reached the terminal and that the keypress reached
|
|
48
|
+
the remote host.
|
|
49
|
+
|
|
50
|
+
## Tooling
|
|
51
|
+
|
|
52
|
+
`pi-lens` runs ruff format/lint as files are written, and sometimes reformats
|
|
53
|
+
*after* a commit — check `git status` before finishing, and commit formatter
|
|
54
|
+
churn on its own.
|
|
55
|
+
|
|
56
|
+
`pyrightconfig.json` sets `venvPath`/`venv` because pyright does not pick up a
|
|
57
|
+
uv-created venv on its own here. Measured: without it, `Import "pytest" could
|
|
58
|
+
not be resolved`; with it, zero errors. A long-lived pyright language server
|
|
59
|
+
that started before the venv existed will keep reporting the stale result until
|
|
60
|
+
it is restarted.
|
|
61
|
+
|
|
62
|
+
## Releasing
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv version --bump minor # or patch; also updates uv.lock
|
|
66
|
+
git commit -am "chore: release X.Y.Z"
|
|
67
|
+
git push origin main
|
|
68
|
+
git tag -a vX.Y.Z -m vX.Y.Z
|
|
69
|
+
git push origin vX.Y.Z # publishing happens on the tag
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`.github/workflows/publish.yml` then builds, smoke-tests both artifacts *through
|
|
73
|
+
the console script*, and publishes with OIDC trusted publishing — no token is
|
|
74
|
+
stored anywhere. It refuses to publish when the tag does not match the version in
|
|
75
|
+
`pyproject.toml`.
|
|
76
|
+
|
|
77
|
+
Verify a release:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
gh run view <run-id> --log | grep "Uploading pi_"
|
|
81
|
+
curl -s https://pypi.org/pypi/pi-sync-cli/X.Y.Z/json # per-version: immediate
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Expect **CDN lag** on the aggregate endpoints. Measured on 0.4.0: the
|
|
85
|
+
per-version endpoint answered immediately, `/pypi/pi-sync-cli/json` took ~45s,
|
|
86
|
+
and the simple index took ~40s. Until the simple index flips,
|
|
87
|
+
`uvx --from pi-sync-cli==X.Y.Z` fails with "no version … requirements are
|
|
88
|
+
unsatisfiable", which looks like a broken release but is not. Re-tagging never
|
|
89
|
+
helps (PyPI rejects duplicate versions); `workflow_dispatch` re-runs the job
|
|
90
|
+
without a new tag.
|
|
91
|
+
|
|
92
|
+
## PyPI notes
|
|
93
|
+
|
|
94
|
+
The distribution is `pi-sync-cli`; the command is `pi-sync`. Plain `pi-sync` is
|
|
95
|
+
permanently unavailable: PyPI compares names with punctuation stripped, and
|
|
96
|
+
`pisync` already exists (an unrelated rsync backup script).
|
|
97
|
+
|
|
98
|
+
Trusted publisher fields: project `pi-sync-cli`, owner `say4n`, repository
|
|
99
|
+
`pi-sync`, workflow `publish.yml`, environment `pypi`. A pending publisher
|
|
100
|
+
reserves nothing until first use — it is invalidated if someone else registers
|
|
101
|
+
the name in the meantime.
|
|
102
|
+
|
|
103
|
+
## Platform gotchas found the hard way
|
|
104
|
+
|
|
105
|
+
- macOS ships **openrsync**, which rejects rsync 3 flags. Only flags verified
|
|
106
|
+
against it are used; `--ignore-missing-args`, `--human-readable` and
|
|
107
|
+
`--mkpath` are not available.
|
|
108
|
+
- `--backup` with `--delete` fails on openrsync acting as *receiver*
|
|
109
|
+
(`fchownat: Operation not permitted`, exit 23) but works fine with rsync 3.x,
|
|
110
|
+
so backups are suppressed whenever `--delete` is in play.
|
|
111
|
+
- rsync's default check compares size and mtime at 1-second granularity: two
|
|
112
|
+
files created in the same second with equal sizes look "already in sync". Use
|
|
113
|
+
distinct mtimes when testing backups, or the test proves nothing.
|
|
114
|
+
- Remote shells are **fish** on some hosts, so probes are POSIX sh piped over
|
|
115
|
+
stdin (`ssh host sh -s`), never a shell loop in the command string
|
|
116
|
+
(`for …; do … done` is a syntax error there).
|
|
117
|
+
- pi's installer reads `/dev/tty`, not stdin, so capturing its output makes its
|
|
118
|
+
prompts invisible while it waits for a keypress. Interactive installs must
|
|
119
|
+
stream; unattended ones must close stdin so a prompt fails fast.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pi-sync-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
@@ -25,6 +25,7 @@ pi-sync tinfoil # push config + extensions
|
|
|
25
25
|
pi-sync --config laptop # only models.json and settings.json
|
|
26
26
|
pi-sync --pull --all tinfoil # fetch the host's config back
|
|
27
27
|
pi-sync --dry-run --all a b # preview against two hosts
|
|
28
|
+
pi-sync update # update pi-sync itself
|
|
28
29
|
```
|
|
29
30
|
|
|
30
31
|
## Install
|
|
@@ -64,6 +65,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
64
65
|
| `-x, --exclude PATTERN` | skip matching files (repeatable) |
|
|
65
66
|
| `--install` | install pi on hosts that lack it, without prompting |
|
|
66
67
|
| `--uninstall` | remove pi from the host instead of syncing (config is kept) |
|
|
68
|
+
| `--update-pi` | update pi on each host before syncing |
|
|
67
69
|
| `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
|
|
68
70
|
| `--remote-dir` | default `~/.pi/agent` |
|
|
69
71
|
| `-v` / `--verbose` | print each rsync command and its output |
|
|
@@ -71,6 +73,9 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
71
73
|
Multiple hosts are accepted: `pi-sync a b c`. Exits non-zero if any host is
|
|
72
74
|
unreachable or any transfer fails.
|
|
73
75
|
|
|
76
|
+
Anything pi-sync overwrites on the destination is kept beside it as
|
|
77
|
+
`<name>.backup`.
|
|
78
|
+
|
|
74
79
|
## Host preflight
|
|
75
80
|
|
|
76
81
|
Each host gets one ssh probe that reports reachability and pi's location in the
|
|
@@ -99,6 +104,30 @@ The probe checks `command -v pi` plus the usual install locations
|
|
|
99
104
|
`/usr/local/bin`), because a non-interactive ssh session does not source the
|
|
100
105
|
host's shell init — on a linuxbrew host `command -v pi` alone misses it.
|
|
101
106
|
|
|
107
|
+
## Updating
|
|
108
|
+
|
|
109
|
+
`pi-sync update` upgrades this tool through whichever installer owns it —
|
|
110
|
+
`pipx upgrade`, `uv tool upgrade`, or `pip install --upgrade` — and reports the
|
|
111
|
+
version it moved from and to. `--check` reports without changing anything.
|
|
112
|
+
Running from a source checkout it tells you to `git pull` instead, and from an
|
|
113
|
+
ephemeral `uvx --from …` environment it explains that there is nothing to
|
|
114
|
+
upgrade.
|
|
115
|
+
|
|
116
|
+
`pi-sync --update-pi <hosts>` runs pi's own updater (`pi update --self`) on each
|
|
117
|
+
host before syncing, so the fleet does not drift:
|
|
118
|
+
|
|
119
|
+
```console
|
|
120
|
+
$ pi-sync --update-pi tinfoil
|
|
121
|
+
→ tinfoil
|
|
122
|
+
pi 0.85.1 → 0.86.0
|
|
123
|
+
models.json already in sync
|
|
124
|
+
settings.json already in sync
|
|
125
|
+
extensions already in sync
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`update` is a reserved word — a host with that alias is still reachable as
|
|
129
|
+
`user@update`.
|
|
130
|
+
|
|
102
131
|
## Uninstalling
|
|
103
132
|
|
|
104
133
|
`--uninstall` removes pi from the host instead of syncing. It runs
|
|
@@ -117,7 +146,13 @@ uninstall fails, that is usually a managed install
|
|
|
117
146
|
## Shell completions
|
|
118
147
|
|
|
119
148
|
Host arguments complete from `~/.ssh/config`, following `Include` directives and
|
|
120
|
-
skipping wildcard entries:
|
|
149
|
+
skipping wildcard entries. zsh and fish also show where each alias points:
|
|
150
|
+
|
|
151
|
+
```console
|
|
152
|
+
$ pi-sync t<TAB>
|
|
153
|
+
tinfoil tinfoil@tinfoil.sayan.page
|
|
154
|
+
tinfoil-proxy notdebian@100.98.241.11
|
|
155
|
+
```
|
|
121
156
|
|
|
122
157
|
```bash
|
|
123
158
|
# bash
|
|
@@ -143,7 +178,11 @@ without regenerating anything.
|
|
|
143
178
|
|
|
144
179
|
- `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
|
|
145
180
|
toggles), so two hosts pushing it will overwrite each other's local
|
|
146
|
-
preferences. Sync it when you change `packages`, not reflexively
|
|
181
|
+
preferences. Sync it when you change `packages`, not reflexively — and note the
|
|
182
|
+
overwritten copy is kept as `settings.json.backup` on the receiving host.
|
|
183
|
+
- `*.backup` files are never synced, so those copies stay host-local and never
|
|
184
|
+
trampoline between hosts. `--delete` suppresses backups for the mirrored
|
|
185
|
+
directory, because mirroring means "match exactly".
|
|
147
186
|
- Extensions that write runtime files inside their own directory (logs,
|
|
148
187
|
checkpoints) get those files synced too, and each host's copy is overwritten by
|
|
149
188
|
whichever side pushed last — exclude them with `-x '*/logs/*'`.
|
|
@@ -153,10 +192,6 @@ without regenerating anything.
|
|
|
153
192
|
in the environment where you can.
|
|
154
193
|
- Remote paths go through the host's shell, so `~` expands there as usual.
|
|
155
194
|
|
|
156
|
-
##
|
|
195
|
+
## Developing
|
|
157
196
|
|
|
158
|
-
|
|
159
|
-
uv sync
|
|
160
|
-
uv run pytest
|
|
161
|
-
uv run pi-sync --help
|
|
162
|
-
```
|
|
197
|
+
See [DEVELOPMENT.md](DEVELOPMENT.md) for the layout, tests and release process.
|
|
@@ -8,6 +8,7 @@ pi-sync tinfoil # push config + extensions
|
|
|
8
8
|
pi-sync --config laptop # only models.json and settings.json
|
|
9
9
|
pi-sync --pull --all tinfoil # fetch the host's config back
|
|
10
10
|
pi-sync --dry-run --all a b # preview against two hosts
|
|
11
|
+
pi-sync update # update pi-sync itself
|
|
11
12
|
```
|
|
12
13
|
|
|
13
14
|
## Install
|
|
@@ -47,6 +48,7 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
47
48
|
| `-x, --exclude PATTERN` | skip matching files (repeatable) |
|
|
48
49
|
| `--install` | install pi on hosts that lack it, without prompting |
|
|
49
50
|
| `--uninstall` | remove pi from the host instead of syncing (config is kept) |
|
|
51
|
+
| `--update-pi` | update pi on each host before syncing |
|
|
50
52
|
| `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
|
|
51
53
|
| `--remote-dir` | default `~/.pi/agent` |
|
|
52
54
|
| `-v` / `--verbose` | print each rsync command and its output |
|
|
@@ -54,6 +56,9 @@ Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
|
54
56
|
Multiple hosts are accepted: `pi-sync a b c`. Exits non-zero if any host is
|
|
55
57
|
unreachable or any transfer fails.
|
|
56
58
|
|
|
59
|
+
Anything pi-sync overwrites on the destination is kept beside it as
|
|
60
|
+
`<name>.backup`.
|
|
61
|
+
|
|
57
62
|
## Host preflight
|
|
58
63
|
|
|
59
64
|
Each host gets one ssh probe that reports reachability and pi's location in the
|
|
@@ -82,6 +87,30 @@ The probe checks `command -v pi` plus the usual install locations
|
|
|
82
87
|
`/usr/local/bin`), because a non-interactive ssh session does not source the
|
|
83
88
|
host's shell init — on a linuxbrew host `command -v pi` alone misses it.
|
|
84
89
|
|
|
90
|
+
## Updating
|
|
91
|
+
|
|
92
|
+
`pi-sync update` upgrades this tool through whichever installer owns it —
|
|
93
|
+
`pipx upgrade`, `uv tool upgrade`, or `pip install --upgrade` — and reports the
|
|
94
|
+
version it moved from and to. `--check` reports without changing anything.
|
|
95
|
+
Running from a source checkout it tells you to `git pull` instead, and from an
|
|
96
|
+
ephemeral `uvx --from …` environment it explains that there is nothing to
|
|
97
|
+
upgrade.
|
|
98
|
+
|
|
99
|
+
`pi-sync --update-pi <hosts>` runs pi's own updater (`pi update --self`) on each
|
|
100
|
+
host before syncing, so the fleet does not drift:
|
|
101
|
+
|
|
102
|
+
```console
|
|
103
|
+
$ pi-sync --update-pi tinfoil
|
|
104
|
+
→ tinfoil
|
|
105
|
+
pi 0.85.1 → 0.86.0
|
|
106
|
+
models.json already in sync
|
|
107
|
+
settings.json already in sync
|
|
108
|
+
extensions already in sync
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`update` is a reserved word — a host with that alias is still reachable as
|
|
112
|
+
`user@update`.
|
|
113
|
+
|
|
85
114
|
## Uninstalling
|
|
86
115
|
|
|
87
116
|
`--uninstall` removes pi from the host instead of syncing. It runs
|
|
@@ -100,7 +129,13 @@ uninstall fails, that is usually a managed install
|
|
|
100
129
|
## Shell completions
|
|
101
130
|
|
|
102
131
|
Host arguments complete from `~/.ssh/config`, following `Include` directives and
|
|
103
|
-
skipping wildcard entries:
|
|
132
|
+
skipping wildcard entries. zsh and fish also show where each alias points:
|
|
133
|
+
|
|
134
|
+
```console
|
|
135
|
+
$ pi-sync t<TAB>
|
|
136
|
+
tinfoil tinfoil@tinfoil.sayan.page
|
|
137
|
+
tinfoil-proxy notdebian@100.98.241.11
|
|
138
|
+
```
|
|
104
139
|
|
|
105
140
|
```bash
|
|
106
141
|
# bash
|
|
@@ -126,7 +161,11 @@ without regenerating anything.
|
|
|
126
161
|
|
|
127
162
|
- `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
|
|
128
163
|
toggles), so two hosts pushing it will overwrite each other's local
|
|
129
|
-
preferences. Sync it when you change `packages`, not reflexively
|
|
164
|
+
preferences. Sync it when you change `packages`, not reflexively — and note the
|
|
165
|
+
overwritten copy is kept as `settings.json.backup` on the receiving host.
|
|
166
|
+
- `*.backup` files are never synced, so those copies stay host-local and never
|
|
167
|
+
trampoline between hosts. `--delete` suppresses backups for the mirrored
|
|
168
|
+
directory, because mirroring means "match exactly".
|
|
130
169
|
- Extensions that write runtime files inside their own directory (logs,
|
|
131
170
|
checkpoints) get those files synced too, and each host's copy is overwritten by
|
|
132
171
|
whichever side pushed last — exclude them with `-x '*/logs/*'`.
|
|
@@ -136,10 +175,6 @@ without regenerating anything.
|
|
|
136
175
|
in the environment where you can.
|
|
137
176
|
- Remote paths go through the host's shell, so `~` expands there as usual.
|
|
138
177
|
|
|
139
|
-
##
|
|
178
|
+
## Developing
|
|
140
179
|
|
|
141
|
-
|
|
142
|
-
uv sync
|
|
143
|
-
uv run pytest
|
|
144
|
-
uv run pi-sync --help
|
|
145
|
-
```
|
|
180
|
+
See [DEVELOPMENT.md](DEVELOPMENT.md) for the layout, tests and release process.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pi-sync-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.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"
|
|
@@ -20,7 +20,7 @@ Repository = "https://github.com/say4n/pi-sync"
|
|
|
20
20
|
Issues = "https://github.com/say4n/pi-sync/issues"
|
|
21
21
|
|
|
22
22
|
[project.scripts]
|
|
23
|
-
pi-sync = "pi_sync.cli:
|
|
23
|
+
pi-sync = "pi_sync.cli:app"
|
|
24
24
|
|
|
25
25
|
[dependency-groups]
|
|
26
26
|
dev = ["pytest>=8"]
|
|
@@ -12,11 +12,17 @@ is deliberately never touched.
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import json
|
|
15
16
|
import os
|
|
17
|
+
import re
|
|
16
18
|
import subprocess
|
|
17
19
|
import sys
|
|
18
20
|
from collections.abc import Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from importlib import metadata
|
|
19
23
|
from pathlib import Path
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
from urllib.request import urlopen
|
|
20
26
|
|
|
21
27
|
import click
|
|
22
28
|
from click.shell_completion import CompletionItem
|
|
@@ -54,10 +60,29 @@ def local_agent_dir(override: str | None = None) -> Path:
|
|
|
54
60
|
return Path(raw).expanduser()
|
|
55
61
|
|
|
56
62
|
|
|
57
|
-
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class SshHost:
|
|
65
|
+
"""A host alias, plus the details worth showing in shell completions."""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
hostname: str | None = None
|
|
69
|
+
user: str | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def detail(self) -> str:
|
|
73
|
+
if self.hostname and self.user:
|
|
74
|
+
return f"{self.user}@{self.hostname}"
|
|
75
|
+
return self.hostname or self.user or ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ssh_config_hosts(
|
|
58
79
|
config: str = SSH_CONFIG, _seen: frozenset[str] = frozenset()
|
|
59
|
-
) -> list[
|
|
60
|
-
"""
|
|
80
|
+
) -> list[SshHost]:
|
|
81
|
+
"""Hosts from an ssh config, following Include and skipping wildcards.
|
|
82
|
+
|
|
83
|
+
Directives bind to the Host line that precedes them, so the current block is
|
|
84
|
+
flushed when the next Host or Include appears.
|
|
85
|
+
"""
|
|
61
86
|
path = Path(config).expanduser()
|
|
62
87
|
if not path.is_file():
|
|
63
88
|
return []
|
|
@@ -65,7 +90,22 @@ def ssh_hosts(
|
|
|
65
90
|
if resolved in _seen:
|
|
66
91
|
return []
|
|
67
92
|
seen = _seen | {resolved}
|
|
68
|
-
hosts: list[
|
|
93
|
+
hosts: list[SshHost] = []
|
|
94
|
+
names: set[str] = set()
|
|
95
|
+
pending: list[str] = []
|
|
96
|
+
hostname: str | None = None
|
|
97
|
+
user: str | None = None
|
|
98
|
+
|
|
99
|
+
def add(entries: list[SshHost]) -> None:
|
|
100
|
+
for entry in entries:
|
|
101
|
+
if entry.name not in names:
|
|
102
|
+
names.add(entry.name)
|
|
103
|
+
hosts.append(entry)
|
|
104
|
+
|
|
105
|
+
def flush() -> None:
|
|
106
|
+
add([SshHost(name, hostname, user) for name in pending])
|
|
107
|
+
pending.clear()
|
|
108
|
+
|
|
69
109
|
for raw_line in path.read_text(errors="replace").splitlines():
|
|
70
110
|
line = raw_line.strip()
|
|
71
111
|
if not line or line.startswith("#"):
|
|
@@ -75,22 +115,32 @@ def ssh_hosts(
|
|
|
75
115
|
continue
|
|
76
116
|
keyword, rest = parts[0].lower(), parts[1].strip()
|
|
77
117
|
if keyword == "host":
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
118
|
+
flush()
|
|
119
|
+
hostname = user = None
|
|
120
|
+
pending += [t for t in rest.split() if not any(c in t for c in "*?!")]
|
|
121
|
+
elif keyword == "hostname":
|
|
122
|
+
hostname = rest.split()[0]
|
|
123
|
+
elif keyword == "user":
|
|
124
|
+
user = rest.split()[0]
|
|
82
125
|
elif keyword == "include":
|
|
126
|
+
flush()
|
|
83
127
|
for pattern in rest.split():
|
|
84
128
|
target = Path(pattern).expanduser()
|
|
85
129
|
if not target.is_absolute():
|
|
86
130
|
target = Path.home() / ".ssh" / target
|
|
87
131
|
for included in sorted(target.parent.glob(target.name)):
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
]
|
|
132
|
+
add(ssh_config_hosts(str(included), seen))
|
|
133
|
+
flush()
|
|
91
134
|
return hosts
|
|
92
135
|
|
|
93
136
|
|
|
137
|
+
def ssh_hosts(
|
|
138
|
+
config: str = SSH_CONFIG, _seen: frozenset[str] = frozenset()
|
|
139
|
+
) -> list[str]:
|
|
140
|
+
"""Host alias names from an ssh config (see ssh_config_hosts)."""
|
|
141
|
+
return [host.name for host in ssh_config_hosts(config, _seen)]
|
|
142
|
+
|
|
143
|
+
|
|
94
144
|
def complete_target(
|
|
95
145
|
ctx: click.Context, param: click.Parameter, incomplete: str
|
|
96
146
|
) -> list[CompletionItem]:
|
|
@@ -98,9 +148,9 @@ def complete_target(
|
|
|
98
148
|
prefix = incomplete.rpartition("@")[2]
|
|
99
149
|
lead = incomplete[: len(incomplete) - len(prefix)] if prefix else incomplete
|
|
100
150
|
return [
|
|
101
|
-
CompletionItem(f"{lead}{host}")
|
|
102
|
-
for host in
|
|
103
|
-
if host.startswith(prefix)
|
|
151
|
+
CompletionItem(f"{lead}{host.name}", help=host.detail)
|
|
152
|
+
for host in ssh_config_hosts()
|
|
153
|
+
if host.name.startswith(prefix)
|
|
104
154
|
]
|
|
105
155
|
|
|
106
156
|
|
|
@@ -128,7 +178,14 @@ def rsync_argv(
|
|
|
128
178
|
argv = ["rsync", "-az", "-i"]
|
|
129
179
|
argv += [f"--exclude={pattern}" for pattern in excludes]
|
|
130
180
|
if delete and rel in DIRECTORY_ITEMS:
|
|
181
|
+
# Mirroring wins over backups here: the destination is meant to match the
|
|
182
|
+
# source exactly, and openrsync (the rsync macOS ships) errors out when it
|
|
183
|
+
# has to back up a file it deletes.
|
|
131
184
|
argv.append("--delete-during")
|
|
185
|
+
else:
|
|
186
|
+
# Keep the destination's copy of whatever we replace, as <file>.backup.
|
|
187
|
+
argv += ["--backup", "--suffix=.backup"]
|
|
188
|
+
argv.append("--exclude=*.backup") # backups stay host-local
|
|
132
189
|
if dry_run:
|
|
133
190
|
argv.append("-n")
|
|
134
191
|
argv += [remote, local] if pull else [local, remote]
|
|
@@ -308,7 +365,192 @@ def sync_host(
|
|
|
308
365
|
return ok
|
|
309
366
|
|
|
310
367
|
|
|
311
|
-
|
|
368
|
+
PYPI_JSON = "https://pypi.org/pypi/{dist}/json"
|
|
369
|
+
EPHEMERAL_MARKERS = ("/uv/archive-", "/uv/environments-")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@dataclass(frozen=True)
|
|
373
|
+
class Install:
|
|
374
|
+
"""How the running copy of pi-sync got onto this machine."""
|
|
375
|
+
|
|
376
|
+
dist: str
|
|
377
|
+
version: str
|
|
378
|
+
kind: str
|
|
379
|
+
detail: str = ""
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def install_kind(direct_url: str, prefix: str) -> tuple[str, str]:
|
|
383
|
+
"""Classify an environment from its PEP 610 payload and its prefix.
|
|
384
|
+
|
|
385
|
+
The payload is parsed rather than pattern-matched: uv and pip disagree about
|
|
386
|
+
whitespace in `direct_url.json`, so a substring test silently misses one.
|
|
387
|
+
"""
|
|
388
|
+
try:
|
|
389
|
+
info = json.loads(direct_url) if direct_url else {}
|
|
390
|
+
except ValueError:
|
|
391
|
+
info = {}
|
|
392
|
+
if not isinstance(info, dict):
|
|
393
|
+
info = {}
|
|
394
|
+
dir_info = info.get("dir_info")
|
|
395
|
+
if isinstance(dir_info, dict) and dir_info.get("editable"):
|
|
396
|
+
return "editable", str(info.get("url", "")).removeprefix("file://")
|
|
397
|
+
if "/pipx/venvs/" in prefix:
|
|
398
|
+
return "pipx", prefix
|
|
399
|
+
if "/uv/tools/" in prefix:
|
|
400
|
+
return "uv-tool", prefix
|
|
401
|
+
if any(marker in prefix for marker in EPHEMERAL_MARKERS):
|
|
402
|
+
return "ephemeral", prefix
|
|
403
|
+
return "pip", prefix
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def running_install() -> Install:
|
|
407
|
+
"""Identify the install we are running *from*, not the one merely on PATH.
|
|
408
|
+
|
|
409
|
+
Two installs of this tool can coexist (an older distribution name and the
|
|
410
|
+
current one), so the distribution is resolved from this process's own
|
|
411
|
+
environment: a hardcoded package name would happily upgrade somebody else's
|
|
412
|
+
virtualenv, or none at all.
|
|
413
|
+
"""
|
|
414
|
+
found = metadata.packages_distributions().get("pi_sync") or ["pi-sync-cli"]
|
|
415
|
+
dist = metadata.distribution(found[0])
|
|
416
|
+
kind, detail = install_kind(dist.read_text("direct_url.json") or "", sys.prefix)
|
|
417
|
+
return Install(dist.metadata["Name"], dist.version, kind, detail)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def upgrade_argv(install: Install) -> list[str] | None:
|
|
421
|
+
"""The command that upgrades this install, or None when it is not managed."""
|
|
422
|
+
if install.kind == "pipx":
|
|
423
|
+
return ["pipx", "upgrade", install.dist]
|
|
424
|
+
if install.kind == "uv-tool":
|
|
425
|
+
return ["uv", "tool", "upgrade", install.dist]
|
|
426
|
+
if install.kind == "pip":
|
|
427
|
+
return [sys.executable, "-m", "pip", "install", "--upgrade", install.dist]
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def latest_version(dist: str) -> str | None:
|
|
432
|
+
"""Newest release on PyPI, or None when it cannot be determined."""
|
|
433
|
+
url = PYPI_JSON.format(dist=dist)
|
|
434
|
+
if urlparse(url).scheme != "https": # never fetch over anything else
|
|
435
|
+
return None
|
|
436
|
+
try:
|
|
437
|
+
with urlopen(url, timeout=10) as response: # noqa: S310 - scheme checked
|
|
438
|
+
return json.load(response)["info"]["version"]
|
|
439
|
+
except Exception: # offline, renamed project, unexpected payload
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def version_tuple(value: str) -> tuple[int, ...]:
|
|
444
|
+
"""The leading numeric release, ignoring any pre-release suffix."""
|
|
445
|
+
match = re.match(r"\d+(?:\.\d+)*", value.strip())
|
|
446
|
+
if not match:
|
|
447
|
+
return ()
|
|
448
|
+
try:
|
|
449
|
+
return tuple(int(part) for part in match.group(0).split("."))
|
|
450
|
+
except ValueError: # unreachable: the pattern only matches digits
|
|
451
|
+
return ()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def pi_version_on(target: str) -> str | None:
|
|
455
|
+
"""The host's pi version, or None when it cannot be read."""
|
|
456
|
+
proc = run_cmd(["ssh", target, "pi --version"])
|
|
457
|
+
text = (proc.stdout or "").strip()
|
|
458
|
+
return text if proc.returncode == 0 and text else None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def update_pi_on(target: str) -> str | None:
|
|
462
|
+
"""Run pi's own updater on the host. Returns an error message, or None."""
|
|
463
|
+
proc = run_cmd(["ssh", target, "pi update --self"])
|
|
464
|
+
if proc.returncode == 0:
|
|
465
|
+
return None
|
|
466
|
+
detail = ((proc.stderr or proc.stdout) or "").strip().splitlines()
|
|
467
|
+
return detail[-1] if detail else f"pi update exited {proc.returncode}"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class SyncCommand(click.Command):
|
|
471
|
+
"""`sync` is the default command, so its usage should not read `pi-sync sync`."""
|
|
472
|
+
|
|
473
|
+
def format_usage(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
474
|
+
pieces = " ".join(self.collect_usage_pieces(ctx))
|
|
475
|
+
program = ctx.find_root().info_name or "pi-sync"
|
|
476
|
+
formatter.write_usage(program, pieces, prefix=None)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class DefaultGroup(click.Group):
|
|
480
|
+
"""Group that treats the absence of a subcommand as `sync`.
|
|
481
|
+
|
|
482
|
+
This keeps `pi-sync host`, `pi-sync --config host` and `pi-sync update` all
|
|
483
|
+
working. The cost is that "update" is now a reserved word: a host with that
|
|
484
|
+
alias has to be reached as `user@update` or by renaming the alias.
|
|
485
|
+
|
|
486
|
+
The prepend happens before click parses the group's options, because that
|
|
487
|
+
parser rejects sync's flags (`No such option: --config`) long before
|
|
488
|
+
command resolution would get a chance to fall back.
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
def parse_args( # type: ignore[override]
|
|
492
|
+
self, ctx: click.Context, args: list[str]
|
|
493
|
+
) -> list[str]:
|
|
494
|
+
if args and args[0] not in self.commands and args[0] != "--version":
|
|
495
|
+
args = ["sync", *args]
|
|
496
|
+
return super().parse_args(ctx, args)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@click.group(cls=DefaultGroup, context_settings={"help_option_names": ["-h", "--help"]})
|
|
500
|
+
@click.version_option(package_name="pi-sync-cli")
|
|
501
|
+
def app() -> None:
|
|
502
|
+
"""Sync pi agent config across hosts, and manage pi itself."""
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@app.command("update", context_settings={"help_option_names": ["-h", "--help"]})
|
|
506
|
+
@click.option("--check", is_flag=True, help="Report what would happen; change nothing.")
|
|
507
|
+
def update_cmd(check: bool) -> None:
|
|
508
|
+
"""Update pi-sync itself to the latest release.
|
|
509
|
+
|
|
510
|
+
\b
|
|
511
|
+
pi-sync update # upgrades via pipx, uv, or pip — whichever installed it
|
|
512
|
+
pi-sync update --check # just report
|
|
513
|
+
"""
|
|
514
|
+
install = running_install()
|
|
515
|
+
click.echo(f"pi-sync {install.version} ({install.kind})")
|
|
516
|
+
if install.kind == "editable":
|
|
517
|
+
click.echo(
|
|
518
|
+
f"running from a checkout; update it with:\n git -C {install.detail} pull"
|
|
519
|
+
)
|
|
520
|
+
return
|
|
521
|
+
if install.kind == "ephemeral":
|
|
522
|
+
click.echo(
|
|
523
|
+
"running from an ephemeral uvx environment — nothing to update.\n"
|
|
524
|
+
"for a durable install: uv tool install pi-sync-cli"
|
|
525
|
+
)
|
|
526
|
+
return
|
|
527
|
+
argv = upgrade_argv(install)
|
|
528
|
+
if argv is None: # pragma: no cover - every kind above is handled earlier
|
|
529
|
+
click.secho("do not know how this copy was installed", fg="red")
|
|
530
|
+
raise SystemExit(1)
|
|
531
|
+
latest = latest_version(install.dist)
|
|
532
|
+
if latest is None:
|
|
533
|
+
click.secho(f"could not reach PyPI for {install.dist}", fg="red")
|
|
534
|
+
raise SystemExit(1)
|
|
535
|
+
if version_tuple(latest) <= version_tuple(install.version):
|
|
536
|
+
click.secho(f"already up to date (latest is {latest})", fg="green")
|
|
537
|
+
return
|
|
538
|
+
if check:
|
|
539
|
+
click.echo(f"would run: {' '.join(argv)}\n{install.version} → {latest}")
|
|
540
|
+
return
|
|
541
|
+
click.echo(f"upgrading {install.version} → {latest}...")
|
|
542
|
+
proc = run_cmd(argv, capture=False) # streamed: pipx/uv show their own progress
|
|
543
|
+
if proc.returncode:
|
|
544
|
+
click.secho(f"upgrade failed (exit {proc.returncode})", fg="red")
|
|
545
|
+
raise SystemExit(1)
|
|
546
|
+
click.secho(f"pi-sync {latest} installed", fg="green")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@app.command(
|
|
550
|
+
"sync",
|
|
551
|
+
cls=SyncCommand,
|
|
552
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
553
|
+
)
|
|
312
554
|
@click.argument(
|
|
313
555
|
"targets",
|
|
314
556
|
nargs=-1,
|
|
@@ -358,6 +600,12 @@ def sync_host(
|
|
|
358
600
|
is_flag=True,
|
|
359
601
|
help="Uninstall pi from the host instead of syncing (keeps ~/.pi/agent).",
|
|
360
602
|
)
|
|
603
|
+
@click.option(
|
|
604
|
+
"--update-pi",
|
|
605
|
+
"update_pi",
|
|
606
|
+
is_flag=True,
|
|
607
|
+
help="Update pi on each host before syncing.",
|
|
608
|
+
)
|
|
361
609
|
@click.option(
|
|
362
610
|
"--local-dir",
|
|
363
611
|
default=None,
|
|
@@ -384,6 +632,7 @@ def main(
|
|
|
384
632
|
excludes: tuple[str, ...],
|
|
385
633
|
install_: bool,
|
|
386
634
|
uninstall_: bool,
|
|
635
|
+
update_pi: bool,
|
|
387
636
|
local_dir: str | None,
|
|
388
637
|
remote_dir: str,
|
|
389
638
|
verbose: bool,
|
|
@@ -394,6 +643,7 @@ def main(
|
|
|
394
643
|
pi-sync tinfoil # push config + extensions
|
|
395
644
|
pi-sync --config laptop # just models.json and settings.json
|
|
396
645
|
pi-sync --pull --all tinfoil # fetch the host's config back
|
|
646
|
+
pi-sync update # update pi-sync itself
|
|
397
647
|
"""
|
|
398
648
|
groups = {
|
|
399
649
|
group
|
|
@@ -459,6 +709,20 @@ def main(
|
|
|
459
709
|
if not ensure_pi(target, install_, remote_dir):
|
|
460
710
|
failed = True
|
|
461
711
|
continue
|
|
712
|
+
if update_pi:
|
|
713
|
+
if dry_run:
|
|
714
|
+
click.secho(" would run: pi update --self")
|
|
715
|
+
else:
|
|
716
|
+
before = pi_version_on(target)
|
|
717
|
+
error = update_pi_on(target)
|
|
718
|
+
after = pi_version_on(target)
|
|
719
|
+
if error:
|
|
720
|
+
failed = True
|
|
721
|
+
click.secho(f" pi update failed: {error}", fg="red")
|
|
722
|
+
elif before and after and before != after:
|
|
723
|
+
click.secho(f" pi {before} → {after}", fg="green")
|
|
724
|
+
else:
|
|
725
|
+
click.secho(f" pi {after or 'unknown'} (already current)")
|
|
462
726
|
if not sync_host(
|
|
463
727
|
target,
|
|
464
728
|
items,
|
|
@@ -477,4 +741,4 @@ def main(
|
|
|
477
741
|
|
|
478
742
|
|
|
479
743
|
if __name__ == "__main__":
|
|
480
|
-
|
|
744
|
+
app()
|
|
@@ -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,314 @@ 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.app, ["--version"])
|
|
749
|
+
assert result.exit_code == 0, result.output
|
|
750
|
+
assert version("pi-sync-cli") in result.output
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
class TestGroupFallback:
|
|
754
|
+
"""`pi-sync update` and `pi-sync <hosts>` must both work."""
|
|
755
|
+
|
|
756
|
+
def test_hosts_still_route_to_sync(self, fake: FakeRun, agent_dir: Path) -> None:
|
|
757
|
+
result = CliRunner().invoke(
|
|
758
|
+
cli.app, ["--config", "--local-dir", str(agent_dir), "host"]
|
|
759
|
+
)
|
|
760
|
+
assert result.exit_code == 0, result.output
|
|
761
|
+
assert any(c[0] == "rsync" for c in fake.calls)
|
|
762
|
+
|
|
763
|
+
def test_bare_host_routes_to_sync(self, fake: FakeRun, agent_dir: Path) -> None:
|
|
764
|
+
result = CliRunner().invoke(cli.app, ["--local-dir", str(agent_dir), "host"])
|
|
765
|
+
assert result.exit_code == 0, result.output
|
|
766
|
+
assert any(c[0] == "rsync" for c in fake.calls)
|
|
767
|
+
|
|
768
|
+
def test_help_shows_sync_options_not_just_subcommands(self) -> None:
|
|
769
|
+
result = CliRunner().invoke(cli.app, ["--help"])
|
|
770
|
+
assert "[USER@]HOST" in result.output
|
|
771
|
+
|
|
772
|
+
def test_version_is_answered_by_the_group(self) -> None:
|
|
773
|
+
result = CliRunner().invoke(cli.app, ["--version"])
|
|
774
|
+
assert result.exit_code == 0, result.output
|
|
775
|
+
assert "version" in result.output
|
|
776
|
+
|
|
777
|
+
def test_update_is_not_swallowed_by_the_fallback(
|
|
778
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
779
|
+
) -> None:
|
|
780
|
+
monkeypatch.setattr(
|
|
781
|
+
cli,
|
|
782
|
+
"running_install",
|
|
783
|
+
lambda: cli.Install(
|
|
784
|
+
"pi-sync-cli", "0.4.0", "ephemeral", "/prefix/ephemeral"
|
|
785
|
+
),
|
|
786
|
+
)
|
|
787
|
+
result = CliRunner().invoke(cli.app, ["update"])
|
|
788
|
+
assert result.exit_code == 0, result.output
|
|
789
|
+
assert "ephemeral" in result.output
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
class TestUpdate:
|
|
793
|
+
@staticmethod
|
|
794
|
+
def install(
|
|
795
|
+
kind: str,
|
|
796
|
+
version: str = "0.4.0",
|
|
797
|
+
dist: str = "pi-sync-cli",
|
|
798
|
+
detail: str | None = None,
|
|
799
|
+
) -> cli.Install:
|
|
800
|
+
return cli.Install(dist, version, kind, detail or f"/prefix/{kind}")
|
|
801
|
+
|
|
802
|
+
def test_editable_checkout_points_at_git(
|
|
803
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
804
|
+
) -> None:
|
|
805
|
+
monkeypatch.setattr(
|
|
806
|
+
cli,
|
|
807
|
+
"running_install",
|
|
808
|
+
lambda: self.install("editable", detail="/opt/checkout"),
|
|
809
|
+
)
|
|
810
|
+
result = CliRunner().invoke(cli.app, ["update"])
|
|
811
|
+
assert result.exit_code == 0, result.output
|
|
812
|
+
assert "git -C /opt/checkout pull" in result.output
|
|
813
|
+
|
|
814
|
+
def test_ephemeral_run_suggests_a_durable_install(
|
|
815
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
816
|
+
) -> None:
|
|
817
|
+
monkeypatch.setattr(cli, "running_install", lambda: self.install("ephemeral"))
|
|
818
|
+
result = CliRunner().invoke(cli.app, ["update"])
|
|
819
|
+
assert result.exit_code == 0
|
|
820
|
+
assert "uv tool install pi-sync-cli" in result.output
|
|
821
|
+
|
|
822
|
+
def test_pipx_install_upgrades_through_pipx(
|
|
823
|
+
self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
|
|
824
|
+
) -> None:
|
|
825
|
+
monkeypatch.setattr(cli, "running_install", lambda: self.install("pipx"))
|
|
826
|
+
monkeypatch.setattr(cli, "latest_version", lambda dist: "0.5.0")
|
|
827
|
+
result = CliRunner().invoke(cli.app, ["update"])
|
|
828
|
+
assert result.exit_code == 0, result.output
|
|
829
|
+
assert ["pipx", "upgrade", "pi-sync-cli"] in fake.calls
|
|
830
|
+
assert "0.5.0 installed" in result.output
|
|
831
|
+
|
|
832
|
+
def test_uv_tool_install_upgrades_through_uv(
|
|
833
|
+
self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
|
|
834
|
+
) -> None:
|
|
835
|
+
monkeypatch.setattr(cli, "running_install", lambda: self.install("uv-tool"))
|
|
836
|
+
monkeypatch.setattr(cli, "latest_version", lambda dist: "0.5.0")
|
|
837
|
+
CliRunner().invoke(cli.app, ["update"])
|
|
838
|
+
assert ["uv", "tool", "upgrade", "pi-sync-cli"] in fake.calls
|
|
839
|
+
|
|
840
|
+
def test_old_distribution_name_is_used_as_is(
|
|
841
|
+
self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
|
|
842
|
+
) -> None:
|
|
843
|
+
"""A copy installed under the pre-rename name upgrades *that* package."""
|
|
844
|
+
monkeypatch.setattr(
|
|
845
|
+
cli, "running_install", lambda: self.install("pipx", dist="pi-sync")
|
|
846
|
+
)
|
|
847
|
+
monkeypatch.setattr(cli, "latest_version", lambda dist: "0.5.0")
|
|
848
|
+
CliRunner().invoke(cli.app, ["update"])
|
|
849
|
+
assert ["pipx", "upgrade", "pi-sync"] in fake.calls
|
|
850
|
+
|
|
851
|
+
def test_already_current_does_not_upgrade(
|
|
852
|
+
self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
|
|
853
|
+
) -> None:
|
|
854
|
+
monkeypatch.setattr(cli, "running_install", lambda: self.install("pipx"))
|
|
855
|
+
monkeypatch.setattr(cli, "latest_version", lambda dist: "0.4.0")
|
|
856
|
+
result = CliRunner().invoke(cli.app, ["update"])
|
|
857
|
+
assert result.exit_code == 0
|
|
858
|
+
assert "already up to date" in result.output
|
|
859
|
+
assert not any(c[0] in ("pipx", "uv") for c in fake.calls)
|
|
860
|
+
|
|
861
|
+
def test_newer_local_than_pypi_is_not_a_downgrade(
|
|
862
|
+
self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
|
|
863
|
+
) -> None:
|
|
864
|
+
monkeypatch.setattr(
|
|
865
|
+
cli, "running_install", lambda: self.install("pipx", version="0.9.0")
|
|
866
|
+
)
|
|
867
|
+
monkeypatch.setattr(cli, "latest_version", lambda dist: "0.4.0")
|
|
868
|
+
CliRunner().invoke(cli.app, ["update"])
|
|
869
|
+
assert not any(c[0] in ("pipx", "uv") for c in fake.calls)
|
|
870
|
+
|
|
871
|
+
def test_check_reports_without_upgrading(
|
|
872
|
+
self, fake: FakeRun, monkeypatch: pytest.MonkeyPatch
|
|
873
|
+
) -> None:
|
|
874
|
+
monkeypatch.setattr(cli, "running_install", lambda: self.install("pipx"))
|
|
875
|
+
monkeypatch.setattr(cli, "latest_version", lambda dist: "0.5.0")
|
|
876
|
+
result = CliRunner().invoke(cli.app, ["update", "--check"])
|
|
877
|
+
assert "would run: pipx upgrade pi-sync-cli" in result.output
|
|
878
|
+
assert not any(c[0] == "pipx" for c in fake.calls)
|
|
879
|
+
|
|
880
|
+
def test_unreachable_pypi_is_reported(
|
|
881
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
882
|
+
) -> None:
|
|
883
|
+
monkeypatch.setattr(cli, "running_install", lambda: self.install("pipx"))
|
|
884
|
+
monkeypatch.setattr(cli, "latest_version", lambda dist: None)
|
|
885
|
+
result = CliRunner().invoke(cli.app, ["update"])
|
|
886
|
+
assert result.exit_code == 1
|
|
887
|
+
assert "could not reach PyPI" in result.output
|
|
888
|
+
|
|
889
|
+
def test_failed_upgrade_exits_nonzero(
|
|
890
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
891
|
+
) -> None:
|
|
892
|
+
monkeypatch.setattr(cli, "running_install", lambda: self.install("pipx"))
|
|
893
|
+
monkeypatch.setattr(cli, "latest_version", lambda dist: "0.5.0")
|
|
894
|
+
monkeypatch.setattr(cli, "run_cmd", FakeRun(rsync_rc=1))
|
|
895
|
+
result = CliRunner().invoke(cli.app, ["update"])
|
|
896
|
+
assert result.exit_code == 1
|
|
897
|
+
assert "upgrade failed" in result.output
|
|
898
|
+
|
|
899
|
+
@pytest.mark.parametrize(
|
|
900
|
+
("latest", "current", "expected"),
|
|
901
|
+
[
|
|
902
|
+
("0.4.0", "0.4.0", False),
|
|
903
|
+
("0.5.0", "0.4.0", True),
|
|
904
|
+
("0.10.0", "0.9.0", True),
|
|
905
|
+
("0.4.0", "0.5.0", False),
|
|
906
|
+
("1.0.0rc1", "1.0.0", False),
|
|
907
|
+
],
|
|
908
|
+
)
|
|
909
|
+
def test_version_comparison(
|
|
910
|
+
self, latest: str, current: str, expected: bool
|
|
911
|
+
) -> None:
|
|
912
|
+
assert (cli.version_tuple(latest) > cli.version_tuple(current)) is expected
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
class TestUpdatePi:
|
|
916
|
+
def test_reports_the_version_change(
|
|
917
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
918
|
+
) -> None:
|
|
919
|
+
monkeypatch.setattr(cli, "run_cmd", FakeRun(pi_path="/usr/bin/pi"))
|
|
920
|
+
versions = iter(["0.85.1", "0.86.0"])
|
|
921
|
+
monkeypatch.setattr(cli, "pi_version_on", lambda target: next(versions))
|
|
922
|
+
monkeypatch.setattr(cli, "update_pi_on", lambda target: None)
|
|
923
|
+
result = CliRunner().invoke(
|
|
924
|
+
cli.main, ["--update-pi", "--local-dir", str(agent_dir), "host"]
|
|
925
|
+
)
|
|
926
|
+
assert result.exit_code == 0, result.output
|
|
927
|
+
assert "pi 0.85.1 → 0.86.0" in result.output
|
|
928
|
+
|
|
929
|
+
def test_updates_before_syncing(
|
|
930
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
931
|
+
) -> None:
|
|
932
|
+
fake = FakeRun(pi_path="/usr/bin/pi")
|
|
933
|
+
monkeypatch.setattr(cli, "run_cmd", fake)
|
|
934
|
+
monkeypatch.setattr(cli, "pi_version_on", lambda target: "0.85.1")
|
|
935
|
+
monkeypatch.setattr(cli, "update_pi_on", lambda target: None)
|
|
936
|
+
CliRunner().invoke(
|
|
937
|
+
cli.main, ["--update-pi", "--local-dir", str(agent_dir), "host"]
|
|
938
|
+
)
|
|
939
|
+
assert any(c[0] == "rsync" for c in fake.calls)
|
|
940
|
+
|
|
941
|
+
def test_failure_is_reported_and_fails_the_run(
|
|
942
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
943
|
+
) -> None:
|
|
944
|
+
monkeypatch.setattr(cli, "run_cmd", FakeRun(pi_path="/usr/bin/pi"))
|
|
945
|
+
monkeypatch.setattr(cli, "pi_version_on", lambda target: "0.85.1")
|
|
946
|
+
monkeypatch.setattr(cli, "update_pi_on", lambda target: "npm ERR! boom")
|
|
947
|
+
result = CliRunner().invoke(
|
|
948
|
+
cli.main, ["--update-pi", "--local-dir", str(agent_dir), "host"]
|
|
949
|
+
)
|
|
950
|
+
assert result.exit_code == 1
|
|
951
|
+
assert "pi update failed: npm ERR! boom" in result.output
|
|
952
|
+
|
|
953
|
+
def test_dry_run_only_reports(
|
|
954
|
+
self, monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
955
|
+
) -> None:
|
|
956
|
+
monkeypatch.setattr(cli, "run_cmd", FakeRun(pi_path="/usr/bin/pi"))
|
|
957
|
+
called: list[str] = []
|
|
958
|
+
monkeypatch.setattr(cli, "update_pi_on", lambda target: called.append(target))
|
|
959
|
+
result = CliRunner().invoke(
|
|
960
|
+
cli.main,
|
|
961
|
+
["--update-pi", "--dry-run", "--local-dir", str(agent_dir), "host"],
|
|
962
|
+
)
|
|
963
|
+
assert "would run: pi update --self" in result.output
|
|
964
|
+
assert called == []
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
class TestInstallDetection:
|
|
968
|
+
"""Installers disagree on direct_url.json whitespace; both must be understood."""
|
|
969
|
+
|
|
970
|
+
def test_uv_editable_payload(self) -> None:
|
|
971
|
+
payload = '{"url":"file:///Users/x/pi-sync","dir_info":{"editable":true}}'
|
|
972
|
+
assert cli.install_kind(payload, "/Users/x/pi-sync/.venv") == (
|
|
973
|
+
"editable",
|
|
974
|
+
"/Users/x/pi-sync",
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
def test_pip_editable_payload_with_spaces(self) -> None:
|
|
978
|
+
payload = '{"url": "file:///scratch/checkout", "dir_info": {"editable": true}}'
|
|
979
|
+
assert cli.install_kind(payload, "/x/.venv") == (
|
|
980
|
+
"editable",
|
|
981
|
+
"/scratch/checkout",
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
def test_pipx_prefix(self) -> None:
|
|
985
|
+
kind, detail = cli.install_kind("", "/home/x/.local/pipx/venvs/pi-sync-cli")
|
|
986
|
+
assert (kind, detail) == ("pipx", "/home/x/.local/pipx/venvs/pi-sync-cli")
|
|
987
|
+
|
|
988
|
+
def test_uv_tool_prefix(self) -> None:
|
|
989
|
+
prefix = "/home/x/.local/share/uv/tools/pi-sync-cli"
|
|
990
|
+
assert cli.install_kind("", prefix)[0] == "uv-tool"
|
|
991
|
+
|
|
992
|
+
def test_uvx_ephemeral_prefix(self) -> None:
|
|
993
|
+
assert (
|
|
994
|
+
cli.install_kind("", "/Users/x/.cache/uv/archive-v0/abc")[0] == "ephemeral"
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
def test_plain_venv_is_pip(self) -> None:
|
|
998
|
+
assert cli.install_kind("", "/Users/x/project/.venv")[0] == "pip"
|
|
999
|
+
|
|
1000
|
+
def test_unparsable_payload_falls_back(self) -> None:
|
|
1001
|
+
assert cli.install_kind("{not json", "/x/.venv")[0] == "pip"
|
|
1002
|
+
|
|
1003
|
+
def test_editable_wins_over_prefix(self) -> None:
|
|
1004
|
+
payload = '{"url":"file:///scratch/checkout","dir_info":{"editable":true}}'
|
|
1005
|
+
assert (
|
|
1006
|
+
cli.install_kind(payload, "/x/.local/pipx/venvs/pi-sync-cli")[0]
|
|
1007
|
+
== "editable"
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def test_help_usage_reads_as_the_program_not_the_subcommand() -> None:
|
|
1012
|
+
result = CliRunner().invoke(cli.app, ["--help"], prog_name="pi-sync")
|
|
1013
|
+
assert "Usage: pi-sync [OPTIONS] [USER@]HOST..." in result.output
|
|
1014
|
+
assert "pi-sync sync" not in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|