pi-sync-cli 0.4.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.4.0 → pi_sync_cli-0.5.0}/PKG-INFO +42 -14
- {pi_sync_cli-0.4.0 → pi_sync_cli-0.5.0}/README.md +41 -13
- {pi_sync_cli-0.4.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.4.0 → pi_sync_cli-0.5.0}/src/pi_sync/cli.py +213 -2
- {pi_sync_cli-0.4.0 → pi_sync_cli-0.5.0}/tests/test_cli.py +265 -1
- {pi_sync_cli-0.4.0 → pi_sync_cli-0.5.0}/uv.lock +1 -1
- pi_sync_cli-0.4.0/src/pi_sync/__init__.py +0 -3
- {pi_sync_cli-0.4.0 → pi_sync_cli-0.5.0}/.github/workflows/publish.yml +0 -0
- {pi_sync_cli-0.4.0 → pi_sync_cli-0.5.0}/.gitignore +0 -0
- {pi_sync_cli-0.4.0 → pi_sync_cli-0.5.0}/LICENSE +0 -0
- {pi_sync_cli-0.4.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
|
|
@@ -145,12 +180,9 @@ without regenerating anything.
|
|
|
145
180
|
toggles), so two hosts pushing it will overwrite each other's local
|
|
146
181
|
preferences. Sync it when you change `packages`, not reflexively — and note the
|
|
147
182
|
overwritten copy is kept as `settings.json.backup` on the receiving host.
|
|
148
|
-
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
directory, because mirroring means "match exactly" — which also sidesteps an
|
|
152
|
-
openrsync bug where backing up a file it deletes fails with
|
|
153
|
-
`fchownat: Operation not permitted`.
|
|
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".
|
|
154
186
|
- Extensions that write runtime files inside their own directory (logs,
|
|
155
187
|
checkpoints) get those files synced too, and each host's copy is overwritten by
|
|
156
188
|
whichever side pushed last — exclude them with `-x '*/logs/*'`.
|
|
@@ -160,10 +192,6 @@ trampoline between hosts. `--delete` suppresses backups for the mirrored
|
|
|
160
192
|
in the environment where you can.
|
|
161
193
|
- Remote paths go through the host's shell, so `~` expands there as usual.
|
|
162
194
|
|
|
163
|
-
##
|
|
195
|
+
## Developing
|
|
164
196
|
|
|
165
|
-
|
|
166
|
-
uv sync
|
|
167
|
-
uv run pytest
|
|
168
|
-
uv run pi-sync --help
|
|
169
|
-
```
|
|
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
|
|
@@ -128,12 +163,9 @@ without regenerating anything.
|
|
|
128
163
|
toggles), so two hosts pushing it will overwrite each other's local
|
|
129
164
|
preferences. Sync it when you change `packages`, not reflexively — and note the
|
|
130
165
|
overwritten copy is kept as `settings.json.backup` on the receiving host.
|
|
131
|
-
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
directory, because mirroring means "match exactly" — which also sidesteps an
|
|
135
|
-
openrsync bug where backing up a file it deletes fails with
|
|
136
|
-
`fchownat: Operation not permitted`.
|
|
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".
|
|
137
169
|
- Extensions that write runtime files inside their own directory (logs,
|
|
138
170
|
checkpoints) get those files synced too, and each host's copy is overwritten by
|
|
139
171
|
whichever side pushed last — exclude them with `-x '*/logs/*'`.
|
|
@@ -143,10 +175,6 @@ trampoline between hosts. `--delete` suppresses backups for the mirrored
|
|
|
143
175
|
in the environment where you can.
|
|
144
176
|
- Remote paths go through the host's shell, so `~` expands there as usual.
|
|
145
177
|
|
|
146
|
-
##
|
|
178
|
+
## Developing
|
|
147
179
|
|
|
148
|
-
|
|
149
|
-
uv sync
|
|
150
|
-
uv run pytest
|
|
151
|
-
uv run pi-sync --help
|
|
152
|
-
```
|
|
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,12 +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
|
|
19
21
|
from dataclasses import dataclass
|
|
22
|
+
from importlib import metadata
|
|
20
23
|
from pathlib import Path
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
from urllib.request import urlopen
|
|
21
26
|
|
|
22
27
|
import click
|
|
23
28
|
from click.shell_completion import CompletionItem
|
|
@@ -360,8 +365,192 @@ def sync_host(
|
|
|
360
365
|
return ok
|
|
361
366
|
|
|
362
367
|
|
|
363
|
-
|
|
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"]})
|
|
364
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
|
+
)
|
|
365
554
|
@click.argument(
|
|
366
555
|
"targets",
|
|
367
556
|
nargs=-1,
|
|
@@ -411,6 +600,12 @@ def sync_host(
|
|
|
411
600
|
is_flag=True,
|
|
412
601
|
help="Uninstall pi from the host instead of syncing (keeps ~/.pi/agent).",
|
|
413
602
|
)
|
|
603
|
+
@click.option(
|
|
604
|
+
"--update-pi",
|
|
605
|
+
"update_pi",
|
|
606
|
+
is_flag=True,
|
|
607
|
+
help="Update pi on each host before syncing.",
|
|
608
|
+
)
|
|
414
609
|
@click.option(
|
|
415
610
|
"--local-dir",
|
|
416
611
|
default=None,
|
|
@@ -437,6 +632,7 @@ def main(
|
|
|
437
632
|
excludes: tuple[str, ...],
|
|
438
633
|
install_: bool,
|
|
439
634
|
uninstall_: bool,
|
|
635
|
+
update_pi: bool,
|
|
440
636
|
local_dir: str | None,
|
|
441
637
|
remote_dir: str,
|
|
442
638
|
verbose: bool,
|
|
@@ -447,6 +643,7 @@ def main(
|
|
|
447
643
|
pi-sync tinfoil # push config + extensions
|
|
448
644
|
pi-sync --config laptop # just models.json and settings.json
|
|
449
645
|
pi-sync --pull --all tinfoil # fetch the host's config back
|
|
646
|
+
pi-sync update # update pi-sync itself
|
|
450
647
|
"""
|
|
451
648
|
groups = {
|
|
452
649
|
group
|
|
@@ -512,6 +709,20 @@ def main(
|
|
|
512
709
|
if not ensure_pi(target, install_, remote_dir):
|
|
513
710
|
failed = True
|
|
514
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)")
|
|
515
726
|
if not sync_host(
|
|
516
727
|
target,
|
|
517
728
|
items,
|
|
@@ -530,4 +741,4 @@ def main(
|
|
|
530
741
|
|
|
531
742
|
|
|
532
743
|
if __name__ == "__main__":
|
|
533
|
-
|
|
744
|
+
app()
|
|
@@ -745,6 +745,270 @@ class TestSshHostDetails:
|
|
|
745
745
|
def test_version_option_reports_the_installed_version() -> None:
|
|
746
746
|
from importlib.metadata import version
|
|
747
747
|
|
|
748
|
-
result = CliRunner().invoke(cli.
|
|
748
|
+
result = CliRunner().invoke(cli.app, ["--version"])
|
|
749
749
|
assert result.exit_code == 0, result.output
|
|
750
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
|