pi-sync-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pi_sync_cli-0.1.0/.github/workflows/publish.yml +77 -0
- pi_sync_cli-0.1.0/.gitignore +218 -0
- pi_sync_cli-0.1.0/LICENSE +21 -0
- pi_sync_cli-0.1.0/PKG-INFO +92 -0
- pi_sync_cli-0.1.0/README.md +75 -0
- pi_sync_cli-0.1.0/pyproject.toml +33 -0
- pi_sync_cli-0.1.0/src/pi_sync/__init__.py +3 -0
- pi_sync_cli-0.1.0/src/pi_sync/cli.py +268 -0
- pi_sync_cli-0.1.0/tests/test_cli.py +270 -0
- pi_sync_cli-0.1.0/uv.lock +169 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Actions are pinned to commit SHAs because the publish job holds PyPI
|
|
3
|
+
# credentials: checkout v7.0.1 | setup-uv v10.1.0 | artifact v7.0.1/v8.0.1
|
|
4
|
+
name: publish
|
|
5
|
+
|
|
6
|
+
"on":
|
|
7
|
+
push:
|
|
8
|
+
tags:
|
|
9
|
+
- "v[0-9]+.[0-9]+.[0-9]+"
|
|
10
|
+
- "v[0-9]+.[0-9]+.[0-9]+[ab][0-9]+"
|
|
11
|
+
- "v[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
build:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
steps:
|
|
20
|
+
- name: Checkout
|
|
21
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
22
|
+
with:
|
|
23
|
+
persist-credentials: false
|
|
24
|
+
|
|
25
|
+
- name: Install uv
|
|
26
|
+
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4
|
|
27
|
+
with:
|
|
28
|
+
enable-cache: false
|
|
29
|
+
|
|
30
|
+
- name: Check tag matches pyproject version
|
|
31
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
32
|
+
run: |
|
|
33
|
+
version="$(uv version --short)"
|
|
34
|
+
if [ "${GITHUB_REF_NAME#v}" != "$version" ]; then
|
|
35
|
+
echo "::error::tag ${GITHUB_REF_NAME} != pyproject ${version}"
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
- name: Build
|
|
40
|
+
run: uv build
|
|
41
|
+
|
|
42
|
+
- name: Smoke test the distributions' console script
|
|
43
|
+
run: |
|
|
44
|
+
uv run --isolated --no-project --with dist/*.whl \
|
|
45
|
+
pi-sync --help > /dev/null
|
|
46
|
+
uv run --isolated --no-project --with dist/*.tar.gz \
|
|
47
|
+
pi-sync --help > /dev/null
|
|
48
|
+
|
|
49
|
+
- name: Upload distributions
|
|
50
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
|
51
|
+
with:
|
|
52
|
+
name: dist
|
|
53
|
+
path: dist/
|
|
54
|
+
|
|
55
|
+
publish:
|
|
56
|
+
needs: build
|
|
57
|
+
runs-on: ubuntu-latest
|
|
58
|
+
# PyPI's trusted publisher must name this environment.
|
|
59
|
+
environment:
|
|
60
|
+
name: pypi
|
|
61
|
+
permissions:
|
|
62
|
+
# id-token is granted here only, never to the build job.
|
|
63
|
+
id-token: write
|
|
64
|
+
steps:
|
|
65
|
+
- name: Install uv
|
|
66
|
+
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4
|
|
67
|
+
with:
|
|
68
|
+
enable-cache: false
|
|
69
|
+
|
|
70
|
+
- name: Download distributions
|
|
71
|
+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
|
72
|
+
with:
|
|
73
|
+
name: dist
|
|
74
|
+
path: dist/
|
|
75
|
+
|
|
76
|
+
- name: Publish to PyPI
|
|
77
|
+
run: uv publish --trusted-publishing always
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sayan Goswami
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pi-sync-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sync pi agent config and extensions between hosts over rsync
|
|
5
|
+
Project-URL: Repository, https://github.com/say4n/pi-sync
|
|
6
|
+
Project-URL: Issues, https://github.com/say4n/pi-sync/issues
|
|
7
|
+
Author: Sayan Goswami
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent-config,dotfiles,pi,rsync,sync
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: System :: Archiving :: Mirroring
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: click>=8.1
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# pi-sync
|
|
19
|
+
|
|
20
|
+
Sync pi agent config between hosts over rsync, using your existing ssh config
|
|
21
|
+
for routing (so `~/.ssh/config` aliases just work).
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi-sync tinfoil # push config + extensions
|
|
25
|
+
pi-sync --config laptop # only models.json and settings.json
|
|
26
|
+
pi-sync --pull --all tinfoil # fetch the host's config back
|
|
27
|
+
pi-sync --dry-run --all a b # preview against two hosts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# the PyPI package is pi-sync-cli; it installs the `pi-sync` command
|
|
34
|
+
pipx install pi-sync-cli
|
|
35
|
+
pipx install git+ssh://git@github.com/say4n/pi-sync # from source (needs access)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.10+. `uv tool install` works in place of `pipx install`.
|
|
39
|
+
|
|
40
|
+
## What syncs
|
|
41
|
+
|
|
42
|
+
Only the declarative parts of the agent dir:
|
|
43
|
+
|
|
44
|
+
| Group | Files |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `--config` | `models.json`, `settings.json` |
|
|
47
|
+
| `--extensions` | `extensions/` |
|
|
48
|
+
| `--auth` | `auth.json` — secrets, opt-in, warns on push |
|
|
49
|
+
|
|
50
|
+
`--all` is `--config` + `--extensions` (also the default when no flag is given).
|
|
51
|
+
|
|
52
|
+
Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
53
|
+
`models-store.json` (regenerated from the pi.dev catalog), `ayu/`, `bin/`,
|
|
54
|
+
`trust.json`.
|
|
55
|
+
|
|
56
|
+
## Flags
|
|
57
|
+
|
|
58
|
+
| Flag | Effect |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `--all` / `--config` / `--extensions` / `--auth` | what to sync |
|
|
61
|
+
| `--pull` | host → local instead of local → host |
|
|
62
|
+
| `--delete` | mirror `extensions/` exactly (deletes extras on the destination) |
|
|
63
|
+
| `--dry-run` | report changes, copy nothing |
|
|
64
|
+
| `-x, --exclude PATTERN` | skip matching files (repeatable) |
|
|
65
|
+
| `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
|
|
66
|
+
| `--remote-dir` | default `~/.pi/agent` |
|
|
67
|
+
| `-v` / `--verbose` | print each rsync command and its output |
|
|
68
|
+
|
|
69
|
+
Multiple hosts are accepted: `pi-sync a b c`. Exits non-zero if any host is
|
|
70
|
+
unreachable or any transfer fails.
|
|
71
|
+
|
|
72
|
+
## Caveats
|
|
73
|
+
|
|
74
|
+
- `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
|
|
75
|
+
toggles), so two hosts pushing it will overwrite each other's local
|
|
76
|
+
preferences. Sync it when you change `packages`, not reflexively.
|
|
77
|
+
- Extensions that write runtime files inside their own directory (logs,
|
|
78
|
+
checkpoints) get those files synced too, and each host's copy is overwritten by
|
|
79
|
+
whichever side pushed last — exclude them with `-x '*/logs/*'`.
|
|
80
|
+
- Extension versions are whatever each host has installed; pin them in
|
|
81
|
+
`settings.json` (`npm:pi-lens@1.2.3`) if you need hosts identical.
|
|
82
|
+
- `--auth` copies API keys in the clear. Prefer `OPENCODE_API_KEY` (and friends)
|
|
83
|
+
in the environment where you can.
|
|
84
|
+
- Remote paths go through the host's shell, so `~` expands there as usual.
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv sync
|
|
90
|
+
uv run pytest
|
|
91
|
+
uv run pi-sync --help
|
|
92
|
+
```
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# pi-sync
|
|
2
|
+
|
|
3
|
+
Sync pi agent config between hosts over rsync, using your existing ssh config
|
|
4
|
+
for routing (so `~/.ssh/config` aliases just work).
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pi-sync tinfoil # push config + extensions
|
|
8
|
+
pi-sync --config laptop # only models.json and settings.json
|
|
9
|
+
pi-sync --pull --all tinfoil # fetch the host's config back
|
|
10
|
+
pi-sync --dry-run --all a b # preview against two hosts
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# the PyPI package is pi-sync-cli; it installs the `pi-sync` command
|
|
17
|
+
pipx install pi-sync-cli
|
|
18
|
+
pipx install git+ssh://git@github.com/say4n/pi-sync # from source (needs access)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Python 3.10+. `uv tool install` works in place of `pipx install`.
|
|
22
|
+
|
|
23
|
+
## What syncs
|
|
24
|
+
|
|
25
|
+
Only the declarative parts of the agent dir:
|
|
26
|
+
|
|
27
|
+
| Group | Files |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `--config` | `models.json`, `settings.json` |
|
|
30
|
+
| `--extensions` | `extensions/` |
|
|
31
|
+
| `--auth` | `auth.json` — secrets, opt-in, warns on push |
|
|
32
|
+
|
|
33
|
+
`--all` is `--config` + `--extensions` (also the default when no flag is given).
|
|
34
|
+
|
|
35
|
+
Host-local state is deliberately never touched: `sessions/`, `npm/`,
|
|
36
|
+
`models-store.json` (regenerated from the pi.dev catalog), `ayu/`, `bin/`,
|
|
37
|
+
`trust.json`.
|
|
38
|
+
|
|
39
|
+
## Flags
|
|
40
|
+
|
|
41
|
+
| Flag | Effect |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `--all` / `--config` / `--extensions` / `--auth` | what to sync |
|
|
44
|
+
| `--pull` | host → local instead of local → host |
|
|
45
|
+
| `--delete` | mirror `extensions/` exactly (deletes extras on the destination) |
|
|
46
|
+
| `--dry-run` | report changes, copy nothing |
|
|
47
|
+
| `-x, --exclude PATTERN` | skip matching files (repeatable) |
|
|
48
|
+
| `--local-dir` | default `$PI_CODING_AGENT_DIR` or `~/.pi/agent` |
|
|
49
|
+
| `--remote-dir` | default `~/.pi/agent` |
|
|
50
|
+
| `-v` / `--verbose` | print each rsync command and its output |
|
|
51
|
+
|
|
52
|
+
Multiple hosts are accepted: `pi-sync a b c`. Exits non-zero if any host is
|
|
53
|
+
unreachable or any transfer fails.
|
|
54
|
+
|
|
55
|
+
## Caveats
|
|
56
|
+
|
|
57
|
+
- `settings.json` is machine-written by pi (`lastChangelogVersion` bumps, UI
|
|
58
|
+
toggles), so two hosts pushing it will overwrite each other's local
|
|
59
|
+
preferences. Sync it when you change `packages`, not reflexively.
|
|
60
|
+
- Extensions that write runtime files inside their own directory (logs,
|
|
61
|
+
checkpoints) get those files synced too, and each host's copy is overwritten by
|
|
62
|
+
whichever side pushed last — exclude them with `-x '*/logs/*'`.
|
|
63
|
+
- Extension versions are whatever each host has installed; pin them in
|
|
64
|
+
`settings.json` (`npm:pi-lens@1.2.3`) if you need hosts identical.
|
|
65
|
+
- `--auth` copies API keys in the clear. Prefer `OPENCODE_API_KEY` (and friends)
|
|
66
|
+
in the environment where you can.
|
|
67
|
+
- Remote paths go through the host's shell, so `~` expands there as usual.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv sync
|
|
73
|
+
uv run pytest
|
|
74
|
+
uv run pi-sync --help
|
|
75
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pi-sync-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Sync pi agent config and extensions between hosts over rsync"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Sayan Goswami" }]
|
|
10
|
+
keywords = ["pi", "rsync", "dotfiles", "sync", "agent-config"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: System :: Archiving :: Mirroring",
|
|
15
|
+
]
|
|
16
|
+
dependencies = ["click>=8.1"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Repository = "https://github.com/say4n/pi-sync"
|
|
20
|
+
Issues = "https://github.com/say4n/pi-sync/issues"
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
pi-sync = "pi_sync.cli:main"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = ["pytest>=8"]
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/pi_sync"]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""pi-sync — push or pull pi agent config between hosts over rsync.
|
|
2
|
+
|
|
3
|
+
Only the declarative parts of the agent dir are syncable:
|
|
4
|
+
|
|
5
|
+
config models.json, settings.json (hand-written, portable)
|
|
6
|
+
extensions extensions/ (your extension code)
|
|
7
|
+
auth auth.json (secrets, opt-in)
|
|
8
|
+
|
|
9
|
+
Host-local state (sessions/, npm/, models-store.json, ayu/, bin/, trust.json)
|
|
10
|
+
is deliberately never touched.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
DEFAULT_AGENT_DIR = "~/.pi/agent"
|
|
23
|
+
DIRECTORY_ITEMS = frozenset({"extensions"})
|
|
24
|
+
GROUPS: dict[str, tuple[str, ...]] = {
|
|
25
|
+
"config": ("models.json", "settings.json"),
|
|
26
|
+
"extensions": ("extensions",),
|
|
27
|
+
"auth": ("auth.json",),
|
|
28
|
+
}
|
|
29
|
+
# Order groups are synced in, so output is stable.
|
|
30
|
+
GROUP_ORDER = ("config", "extensions", "auth")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def local_agent_dir(override: str | None = None) -> Path:
|
|
34
|
+
"""Local agent dir: explicit flag, else $PI_CODING_AGENT_DIR, else ~/.pi/agent."""
|
|
35
|
+
raw = override or os.environ.get("PI_CODING_AGENT_DIR") or DEFAULT_AGENT_DIR
|
|
36
|
+
return Path(raw).expanduser()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def select_items(groups: set[str]) -> list[str]:
|
|
40
|
+
return [item for group in GROUP_ORDER if group in groups for item in GROUPS[group]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def rsync_argv(
|
|
44
|
+
rel: str,
|
|
45
|
+
target: str,
|
|
46
|
+
local_dir: Path,
|
|
47
|
+
remote_dir: str,
|
|
48
|
+
*,
|
|
49
|
+
pull: bool = False,
|
|
50
|
+
delete: bool = False,
|
|
51
|
+
dry_run: bool = False,
|
|
52
|
+
excludes: Sequence[str] = (),
|
|
53
|
+
) -> list[str]:
|
|
54
|
+
"""One rsync invocation for one item, in the requested direction."""
|
|
55
|
+
remote = f"{target}:{remote_dir.rstrip('/')}/{rel}"
|
|
56
|
+
local = str(local_dir / rel)
|
|
57
|
+
if rel in DIRECTORY_ITEMS:
|
|
58
|
+
remote += "/" # copy contents, not the directory itself
|
|
59
|
+
local += "/"
|
|
60
|
+
argv = ["rsync", "-az", "-i"]
|
|
61
|
+
argv += [f"--exclude={pattern}" for pattern in excludes]
|
|
62
|
+
if delete and rel in DIRECTORY_ITEMS:
|
|
63
|
+
argv.append("--delete-during")
|
|
64
|
+
if dry_run:
|
|
65
|
+
argv.append("-n")
|
|
66
|
+
argv += [remote, local] if pull else [local, remote]
|
|
67
|
+
return argv
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_cmd(argv: list[str]) -> subprocess.CompletedProcess[str]:
|
|
71
|
+
return subprocess.run(argv, capture_output=True, text=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def summarize(output: str) -> tuple[int, int]:
|
|
75
|
+
"""Count transferred files and deletions from rsync --itemize-changes output."""
|
|
76
|
+
transferred = deleted = 0
|
|
77
|
+
for line in output.splitlines():
|
|
78
|
+
if line.startswith("*deleting"):
|
|
79
|
+
deleted += 1
|
|
80
|
+
elif line[:1] in ("<", ">"):
|
|
81
|
+
transferred += 1
|
|
82
|
+
return transferred, deleted
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_ssh(target: str) -> str | None:
|
|
86
|
+
"""Return an error message if the host is unreachable, else None."""
|
|
87
|
+
proc = run_cmd(["ssh", "-o", "ConnectTimeout=10", target, "true"])
|
|
88
|
+
if proc.returncode == 0:
|
|
89
|
+
return None
|
|
90
|
+
detail = (proc.stderr or proc.stdout or "ssh failed").strip().splitlines()
|
|
91
|
+
return detail[0] if detail else "ssh failed"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def sync_host(
|
|
95
|
+
target: str,
|
|
96
|
+
items: list[str],
|
|
97
|
+
local_dir: Path,
|
|
98
|
+
remote_dir: str,
|
|
99
|
+
*,
|
|
100
|
+
pull: bool,
|
|
101
|
+
delete: bool,
|
|
102
|
+
dry_run: bool,
|
|
103
|
+
verbose: bool,
|
|
104
|
+
excludes: Sequence[str] = (),
|
|
105
|
+
) -> bool:
|
|
106
|
+
click.secho(f"→ {target}", bold=True)
|
|
107
|
+
ok = True
|
|
108
|
+
for rel in items:
|
|
109
|
+
if not pull and not (local_dir / rel).exists():
|
|
110
|
+
click.secho(f" {rel:<14} skipped (not found locally)", fg="yellow")
|
|
111
|
+
continue
|
|
112
|
+
argv = rsync_argv(
|
|
113
|
+
rel,
|
|
114
|
+
target,
|
|
115
|
+
local_dir,
|
|
116
|
+
remote_dir,
|
|
117
|
+
pull=pull,
|
|
118
|
+
delete=delete,
|
|
119
|
+
dry_run=dry_run,
|
|
120
|
+
excludes=excludes,
|
|
121
|
+
)
|
|
122
|
+
if verbose:
|
|
123
|
+
click.echo(f" $ {' '.join(argv)}")
|
|
124
|
+
proc = run_cmd(argv)
|
|
125
|
+
if verbose and proc.stdout:
|
|
126
|
+
click.echo(
|
|
127
|
+
"".join(f" {line}\n" for line in proc.stdout.splitlines()), nl=False
|
|
128
|
+
)
|
|
129
|
+
if proc.returncode != 0:
|
|
130
|
+
ok = False
|
|
131
|
+
click.secho(f" {rel:<14} FAILED", fg="red")
|
|
132
|
+
for line in (proc.stderr or proc.stdout).strip().splitlines()[:5]:
|
|
133
|
+
click.echo(f" {line}")
|
|
134
|
+
continue
|
|
135
|
+
transferred, deleted = summarize(proc.stdout)
|
|
136
|
+
if not transferred and not deleted:
|
|
137
|
+
click.secho(f" {rel:<14} already in sync")
|
|
138
|
+
continue
|
|
139
|
+
parts = []
|
|
140
|
+
if transferred:
|
|
141
|
+
parts.append(f"{transferred} file{'s' if transferred != 1 else ''}")
|
|
142
|
+
if deleted:
|
|
143
|
+
parts.append(f"{deleted} deleted")
|
|
144
|
+
click.secho(
|
|
145
|
+
f" {rel:<14} {'would copy' if dry_run else 'copied'} {', '.join(parts)}"
|
|
146
|
+
)
|
|
147
|
+
return ok
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
151
|
+
@click.argument("targets", nargs=-1, required=True, metavar="[USER@]HOST...")
|
|
152
|
+
@click.option(
|
|
153
|
+
"--all", "all_", is_flag=True, help="Sync config and extensions (the default)."
|
|
154
|
+
)
|
|
155
|
+
@click.option(
|
|
156
|
+
"--config", "config_", is_flag=True, help="Sync models.json and settings.json."
|
|
157
|
+
)
|
|
158
|
+
@click.option(
|
|
159
|
+
"--extensions", "extensions_", is_flag=True, help="Sync the extensions/ directory."
|
|
160
|
+
)
|
|
161
|
+
@click.option(
|
|
162
|
+
"--auth", "auth_", is_flag=True, help="Sync auth.json (contains API keys)."
|
|
163
|
+
)
|
|
164
|
+
@click.option("--pull", is_flag=True, help="Copy host → local instead of local → host.")
|
|
165
|
+
@click.option(
|
|
166
|
+
"--delete",
|
|
167
|
+
"delete_",
|
|
168
|
+
is_flag=True,
|
|
169
|
+
help="Mirror extensions/ exactly, deleting files absent from the source.",
|
|
170
|
+
)
|
|
171
|
+
@click.option(
|
|
172
|
+
"--dry-run", is_flag=True, help="Report changes without copying anything."
|
|
173
|
+
)
|
|
174
|
+
@click.option(
|
|
175
|
+
"-x",
|
|
176
|
+
"--exclude",
|
|
177
|
+
"excludes",
|
|
178
|
+
multiple=True,
|
|
179
|
+
metavar="PATTERN",
|
|
180
|
+
help="rsync exclude pattern, e.g. '*/logs/*' (repeatable).",
|
|
181
|
+
)
|
|
182
|
+
@click.option(
|
|
183
|
+
"--local-dir",
|
|
184
|
+
default=None,
|
|
185
|
+
help="Local agent dir (default: $PI_CODING_AGENT_DIR or ~/.pi/agent).",
|
|
186
|
+
)
|
|
187
|
+
@click.option(
|
|
188
|
+
"--remote-dir",
|
|
189
|
+
default=DEFAULT_AGENT_DIR,
|
|
190
|
+
show_default=True,
|
|
191
|
+
help="Agent dir on the host.",
|
|
192
|
+
)
|
|
193
|
+
@click.option(
|
|
194
|
+
"-v", "--verbose", is_flag=True, help="Print each rsync command and its output."
|
|
195
|
+
)
|
|
196
|
+
def main(
|
|
197
|
+
targets: tuple[str, ...],
|
|
198
|
+
all_: bool,
|
|
199
|
+
config_: bool,
|
|
200
|
+
extensions_: bool,
|
|
201
|
+
auth_: bool,
|
|
202
|
+
pull: bool,
|
|
203
|
+
delete_: bool,
|
|
204
|
+
dry_run: bool,
|
|
205
|
+
excludes: tuple[str, ...],
|
|
206
|
+
local_dir: str | None,
|
|
207
|
+
remote_dir: str,
|
|
208
|
+
verbose: bool,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Sync pi agent config to one or more hosts, using your ssh config for routing.
|
|
211
|
+
|
|
212
|
+
\b
|
|
213
|
+
pi-sync tinfoil # push config + extensions
|
|
214
|
+
pi-sync --config laptop # just models.json and settings.json
|
|
215
|
+
pi-sync --pull --all tinfoil # fetch the host's config back
|
|
216
|
+
"""
|
|
217
|
+
groups = {
|
|
218
|
+
group
|
|
219
|
+
for group, enabled in (
|
|
220
|
+
("config", config_ or all_),
|
|
221
|
+
("extensions", extensions_ or all_),
|
|
222
|
+
("auth", auth_),
|
|
223
|
+
)
|
|
224
|
+
if enabled
|
|
225
|
+
}
|
|
226
|
+
if not groups:
|
|
227
|
+
groups = {"config", "extensions"}
|
|
228
|
+
items = select_items(groups)
|
|
229
|
+
|
|
230
|
+
if auth_ and not pull:
|
|
231
|
+
click.secho(
|
|
232
|
+
"! auth.json contains API keys and will be copied to the host", fg="yellow"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
agent_dir = local_agent_dir(local_dir)
|
|
236
|
+
click.echo(
|
|
237
|
+
f"{'pulling' if pull else 'pushing'} {', '.join(items)} "
|
|
238
|
+
f"{'from' if pull else 'to'} {len(targets)} host(s)\n"
|
|
239
|
+
f"local: {agent_dir}\nremote: {remote_dir}\n"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
failed = False
|
|
243
|
+
for raw_target in targets:
|
|
244
|
+
target = raw_target.rstrip(":")
|
|
245
|
+
error = check_ssh(target)
|
|
246
|
+
if error:
|
|
247
|
+
failed = True
|
|
248
|
+
click.secho(f"→ {target}\n unreachable: {error}", fg="red")
|
|
249
|
+
continue
|
|
250
|
+
if not sync_host(
|
|
251
|
+
target,
|
|
252
|
+
items,
|
|
253
|
+
agent_dir,
|
|
254
|
+
remote_dir,
|
|
255
|
+
pull=pull,
|
|
256
|
+
delete=delete_,
|
|
257
|
+
dry_run=dry_run,
|
|
258
|
+
verbose=verbose,
|
|
259
|
+
excludes=excludes,
|
|
260
|
+
):
|
|
261
|
+
failed = True
|
|
262
|
+
|
|
263
|
+
if failed:
|
|
264
|
+
raise SystemExit(1)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
main()
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Tests for pi-sync. No ssh or rsync process is ever executed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from click.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from pi_sync import cli
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeRun:
|
|
15
|
+
"""Records every command; replays canned itemize output for rsync."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
rsync_stdout: str = ">f+++++++ models.json\n",
|
|
20
|
+
rsync_rc: int = 0,
|
|
21
|
+
ssh_rc: int = 0,
|
|
22
|
+
):
|
|
23
|
+
self.calls: list[list[str]] = []
|
|
24
|
+
self.rsync_stdout = rsync_stdout
|
|
25
|
+
self.rsync_rc = rsync_rc
|
|
26
|
+
self.ssh_rc = ssh_rc
|
|
27
|
+
|
|
28
|
+
def __call__(self, argv: list[str]) -> subprocess.CompletedProcess[str]:
|
|
29
|
+
self.calls.append(argv)
|
|
30
|
+
if argv[0] == "ssh":
|
|
31
|
+
return subprocess.CompletedProcess(
|
|
32
|
+
argv, self.ssh_rc, "", "" if self.ssh_rc == 0 else "ssh: connect failed"
|
|
33
|
+
)
|
|
34
|
+
return subprocess.CompletedProcess(argv, self.rsync_rc, self.rsync_stdout, "")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def fake(monkeypatch: pytest.MonkeyPatch) -> FakeRun:
|
|
39
|
+
runner = FakeRun()
|
|
40
|
+
monkeypatch.setattr(cli, "run_cmd", runner)
|
|
41
|
+
return runner
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def agent_dir(tmp_path: Path) -> Path:
|
|
46
|
+
"""A fake local agent dir with every syncable item present."""
|
|
47
|
+
(tmp_path / "models.json").write_text("{}")
|
|
48
|
+
(tmp_path / "settings.json").write_text("{}")
|
|
49
|
+
(tmp_path / "auth.json").write_text("{}")
|
|
50
|
+
(tmp_path / "extensions").mkdir()
|
|
51
|
+
return tmp_path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def rsync_calls(fake: FakeRun) -> list[list[str]]:
|
|
55
|
+
return [call for call in fake.calls if call[0] == "rsync"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sources(fake: FakeRun) -> list[str]:
|
|
59
|
+
"""The local-side path of each rsync call, for push invocations."""
|
|
60
|
+
return [Path(call[-2]).name for call in rsync_calls(fake)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.parametrize(
|
|
64
|
+
("groups", "expected"),
|
|
65
|
+
[
|
|
66
|
+
({"config"}, ["models.json", "settings.json"]),
|
|
67
|
+
({"extensions"}, ["extensions"]),
|
|
68
|
+
({"auth"}, ["auth.json"]),
|
|
69
|
+
({"config", "extensions"}, ["models.json", "settings.json", "extensions"]),
|
|
70
|
+
({"config", "auth"}, ["models.json", "settings.json", "auth.json"]),
|
|
71
|
+
(set(), []),
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
def test_select_items(groups: set[str], expected: list[str]) -> None:
|
|
75
|
+
assert cli.select_items(groups) == expected
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.parametrize(
|
|
79
|
+
("argv_kwargs", "expected"),
|
|
80
|
+
[
|
|
81
|
+
({}, ["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"]),
|
|
82
|
+
(
|
|
83
|
+
{"pull": True},
|
|
84
|
+
["rsync", "-az", "-i", "host:~/.pi/agent/models.json", "/a/models.json"],
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
{"dry_run": True},
|
|
88
|
+
[
|
|
89
|
+
"rsync",
|
|
90
|
+
"-az",
|
|
91
|
+
"-i",
|
|
92
|
+
"-n",
|
|
93
|
+
"/a/models.json",
|
|
94
|
+
"host:~/.pi/agent/models.json",
|
|
95
|
+
],
|
|
96
|
+
),
|
|
97
|
+
(
|
|
98
|
+
{"delete": True},
|
|
99
|
+
["rsync", "-az", "-i", "/a/models.json", "host:~/.pi/agent/models.json"],
|
|
100
|
+
),
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
def test_rsync_argv_files(argv_kwargs: dict, expected: list[str]) -> None:
|
|
104
|
+
assert (
|
|
105
|
+
cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent", **argv_kwargs)
|
|
106
|
+
== expected
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_rsync_argv_directory_gets_trailing_slashes() -> None:
|
|
111
|
+
argv = cli.rsync_argv("extensions", "host", Path("/a"), "~/.pi/agent")
|
|
112
|
+
assert argv[-2:] == ["/a/extensions/", "host:~/.pi/agent/extensions/"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_rsync_argv_delete_only_for_directories() -> None:
|
|
116
|
+
assert "--delete-during" in cli.rsync_argv(
|
|
117
|
+
"extensions", "host", Path("/a"), "~/.pi/agent", delete=True
|
|
118
|
+
)
|
|
119
|
+
assert "--delete-during" not in cli.rsync_argv(
|
|
120
|
+
"models.json", "host", Path("/a"), "~/.pi/agent", delete=True
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_rsync_argv_excludes() -> None:
|
|
125
|
+
argv = cli.rsync_argv(
|
|
126
|
+
"extensions", "host", Path("/a"), "~/.pi/agent", excludes=("*/logs/*",)
|
|
127
|
+
)
|
|
128
|
+
assert "--exclude=*/logs/*" in argv
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_exclude_flag_passes_through(fake: FakeRun, agent_dir: Path) -> None:
|
|
132
|
+
CliRunner().invoke(
|
|
133
|
+
cli.main,
|
|
134
|
+
["--extensions", "-x", "*/logs/*", "--local-dir", str(agent_dir), "host"],
|
|
135
|
+
)
|
|
136
|
+
calls = rsync_calls(fake)
|
|
137
|
+
assert calls and all("--exclude=*/logs/*" in call for call in calls)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_remote_dir_trailing_slash_is_not_doubled() -> None:
|
|
141
|
+
argv = cli.rsync_argv("models.json", "host", Path("/a"), "~/.pi/agent/")
|
|
142
|
+
assert argv[-1] == "host:~/.pi/agent/models.json"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.parametrize(
|
|
146
|
+
("flags", "expected"),
|
|
147
|
+
[
|
|
148
|
+
([], ["models.json", "settings.json", "extensions"]),
|
|
149
|
+
(["--all"], ["models.json", "settings.json", "extensions"]),
|
|
150
|
+
(["--config"], ["models.json", "settings.json"]),
|
|
151
|
+
(["--extensions"], ["extensions"]),
|
|
152
|
+
(["--config", "--auth"], ["models.json", "settings.json", "auth.json"]),
|
|
153
|
+
(
|
|
154
|
+
["--all", "--auth"],
|
|
155
|
+
["models.json", "settings.json", "extensions", "auth.json"],
|
|
156
|
+
),
|
|
157
|
+
],
|
|
158
|
+
)
|
|
159
|
+
def test_flags_select_items(
|
|
160
|
+
fake: FakeRun, agent_dir: Path, flags: list[str], expected: list[str]
|
|
161
|
+
) -> None:
|
|
162
|
+
result = CliRunner().invoke(
|
|
163
|
+
cli.main, [*flags, "--local-dir", str(agent_dir), "host"]
|
|
164
|
+
)
|
|
165
|
+
assert result.exit_code == 0, result.output
|
|
166
|
+
assert sources(fake) == expected
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_ssh_preflight_runs_before_rsync(fake: FakeRun, agent_dir: Path) -> None:
|
|
170
|
+
CliRunner().invoke(cli.main, ["--local-dir", str(agent_dir), "host"])
|
|
171
|
+
assert fake.calls[0] == ["ssh", "-o", "ConnectTimeout=10", "host", "true"]
|
|
172
|
+
assert fake.calls[1][0] == "rsync"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_missing_local_item_is_skipped(fake: FakeRun, tmp_path: Path) -> None:
|
|
176
|
+
(tmp_path / "models.json").write_text("{}")
|
|
177
|
+
result = CliRunner().invoke(
|
|
178
|
+
cli.main, ["--all", "--local-dir", str(tmp_path), "host"]
|
|
179
|
+
)
|
|
180
|
+
assert result.exit_code == 0
|
|
181
|
+
assert sources(fake) == ["models.json"]
|
|
182
|
+
assert "skipped" in result.output
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_pull_does_not_require_local_files(fake: FakeRun, tmp_path: Path) -> None:
|
|
186
|
+
result = CliRunner().invoke(
|
|
187
|
+
cli.main, ["--all", "--pull", "--local-dir", str(tmp_path), "host"]
|
|
188
|
+
)
|
|
189
|
+
assert result.exit_code == 0
|
|
190
|
+
assert sources(fake) == [
|
|
191
|
+
"models.json",
|
|
192
|
+
"settings.json",
|
|
193
|
+
"extensions",
|
|
194
|
+
] # remote paths, pulled from
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_default_remote_dir(fake: FakeRun, agent_dir: Path) -> None:
|
|
198
|
+
CliRunner().invoke(cli.main, ["--config", "--local-dir", str(agent_dir), "host"])
|
|
199
|
+
assert all("host:~/.pi/agent/" in call[-1] for call in rsync_calls(fake))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_remote_dir_override(fake: FakeRun, agent_dir: Path) -> None:
|
|
203
|
+
CliRunner().invoke(
|
|
204
|
+
cli.main,
|
|
205
|
+
["--config", "--local-dir", str(agent_dir), "--remote-dir", "/srv/pi", "host"],
|
|
206
|
+
)
|
|
207
|
+
assert all("host:/srv/pi/" in call[-1] for call in rsync_calls(fake))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_trailing_colon_is_stripped(fake: FakeRun, agent_dir: Path) -> None:
|
|
211
|
+
CliRunner().invoke(
|
|
212
|
+
cli.main, ["--config", "--local-dir", str(agent_dir), "sayan@host:"]
|
|
213
|
+
)
|
|
214
|
+
assert all(call[-1].startswith("sayan@host:") for call in rsync_calls(fake))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_multiple_hosts(fake: FakeRun, agent_dir: Path) -> None:
|
|
218
|
+
CliRunner().invoke(cli.main, ["--config", "--local-dir", str(agent_dir), "a", "b"])
|
|
219
|
+
targets = {call[-1].split(":")[0] for call in rsync_calls(fake)}
|
|
220
|
+
assert targets == {"a", "b"}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_auth_warns_about_secrets(fake: FakeRun, agent_dir: Path) -> None:
|
|
224
|
+
result = CliRunner().invoke(
|
|
225
|
+
cli.main, ["--auth", "--local-dir", str(agent_dir), "host"]
|
|
226
|
+
)
|
|
227
|
+
assert "API keys" in result.output
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_unreachable_host_exits_nonzero(
|
|
231
|
+
monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
232
|
+
) -> None:
|
|
233
|
+
monkeypatch.setattr(cli, "run_cmd", FakeRun(ssh_rc=255))
|
|
234
|
+
result = CliRunner().invoke(
|
|
235
|
+
cli.main, ["--config", "--local-dir", str(agent_dir), "host"]
|
|
236
|
+
)
|
|
237
|
+
assert result.exit_code == 1
|
|
238
|
+
assert "unreachable" in result.output
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_rsync_failure_exits_nonzero(
|
|
242
|
+
monkeypatch: pytest.MonkeyPatch, agent_dir: Path
|
|
243
|
+
) -> None:
|
|
244
|
+
monkeypatch.setattr(cli, "run_cmd", FakeRun(rsync_rc=23))
|
|
245
|
+
result = CliRunner().invoke(
|
|
246
|
+
cli.main, ["--config", "--local-dir", str(agent_dir), "host"]
|
|
247
|
+
)
|
|
248
|
+
assert result.exit_code == 1
|
|
249
|
+
assert "FAILED" in result.output
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@pytest.mark.parametrize(
|
|
253
|
+
("output", "expected"),
|
|
254
|
+
[
|
|
255
|
+
(">f+++++++ a\n>f+++++++ b\n", (2, 0)),
|
|
256
|
+
("*deleting stale.txt\n>f+++++++++ new.txt\n", (1, 1)),
|
|
257
|
+
(".d..t...... extensions/\n", (0, 0)),
|
|
258
|
+
("", (0, 0)),
|
|
259
|
+
],
|
|
260
|
+
)
|
|
261
|
+
def test_summarize(output: str, expected: tuple[int, int]) -> None:
|
|
262
|
+
assert cli.summarize(output) == expected
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_local_dir_env_override(
|
|
266
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
267
|
+
) -> None:
|
|
268
|
+
monkeypatch.setenv("PI_CODING_AGENT_DIR", str(tmp_path))
|
|
269
|
+
assert cli.local_agent_dir() == tmp_path
|
|
270
|
+
assert cli.local_agent_dir("/explicit") == Path("/explicit")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.10"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "click"
|
|
7
|
+
version = "8.5.0"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "colorama"
|
|
16
|
+
version = "0.4.6"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
19
|
+
wheels = [
|
|
20
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[[package]]
|
|
24
|
+
name = "exceptiongroup"
|
|
25
|
+
version = "1.3.1"
|
|
26
|
+
source = { registry = "https://pypi.org/simple" }
|
|
27
|
+
dependencies = [
|
|
28
|
+
{ name = "typing-extensions" },
|
|
29
|
+
]
|
|
30
|
+
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
|
31
|
+
wheels = [
|
|
32
|
+
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[[package]]
|
|
36
|
+
name = "iniconfig"
|
|
37
|
+
version = "2.3.0"
|
|
38
|
+
source = { registry = "https://pypi.org/simple" }
|
|
39
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
40
|
+
wheels = [
|
|
41
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[[package]]
|
|
45
|
+
name = "packaging"
|
|
46
|
+
version = "26.3"
|
|
47
|
+
source = { registry = "https://pypi.org/simple" }
|
|
48
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
|
49
|
+
wheels = [
|
|
50
|
+
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[[package]]
|
|
54
|
+
name = "pi-sync-cli"
|
|
55
|
+
version = "0.1.0"
|
|
56
|
+
source = { editable = "." }
|
|
57
|
+
dependencies = [
|
|
58
|
+
{ name = "click" },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[package.dev-dependencies]
|
|
62
|
+
dev = [
|
|
63
|
+
{ name = "pytest" },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[package.metadata]
|
|
67
|
+
requires-dist = [{ name = "click", specifier = ">=8.1" }]
|
|
68
|
+
|
|
69
|
+
[package.metadata.requires-dev]
|
|
70
|
+
dev = [{ name = "pytest", specifier = ">=8" }]
|
|
71
|
+
|
|
72
|
+
[[package]]
|
|
73
|
+
name = "pluggy"
|
|
74
|
+
version = "1.6.0"
|
|
75
|
+
source = { registry = "https://pypi.org/simple" }
|
|
76
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
77
|
+
wheels = [
|
|
78
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
[[package]]
|
|
82
|
+
name = "pygments"
|
|
83
|
+
version = "2.21.0"
|
|
84
|
+
source = { registry = "https://pypi.org/simple" }
|
|
85
|
+
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
|
86
|
+
wheels = [
|
|
87
|
+
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
[[package]]
|
|
91
|
+
name = "pytest"
|
|
92
|
+
version = "9.1.1"
|
|
93
|
+
source = { registry = "https://pypi.org/simple" }
|
|
94
|
+
dependencies = [
|
|
95
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
96
|
+
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
|
97
|
+
{ name = "iniconfig" },
|
|
98
|
+
{ name = "packaging" },
|
|
99
|
+
{ name = "pluggy" },
|
|
100
|
+
{ name = "pygments" },
|
|
101
|
+
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
|
102
|
+
]
|
|
103
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
|
104
|
+
wheels = [
|
|
105
|
+
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
[[package]]
|
|
109
|
+
name = "tomli"
|
|
110
|
+
version = "2.4.1"
|
|
111
|
+
source = { registry = "https://pypi.org/simple" }
|
|
112
|
+
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
|
113
|
+
wheels = [
|
|
114
|
+
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
|
115
|
+
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
|
116
|
+
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
|
117
|
+
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
|
118
|
+
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
|
119
|
+
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
|
120
|
+
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
|
121
|
+
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
|
122
|
+
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
|
123
|
+
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
|
124
|
+
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
|
125
|
+
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
|
126
|
+
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
|
127
|
+
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
|
128
|
+
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
|
129
|
+
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
|
130
|
+
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
|
131
|
+
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
|
132
|
+
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
|
133
|
+
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
|
134
|
+
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
|
135
|
+
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
|
136
|
+
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
|
137
|
+
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
|
138
|
+
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
|
139
|
+
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
|
140
|
+
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
|
141
|
+
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
|
142
|
+
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
|
143
|
+
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
|
144
|
+
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
|
145
|
+
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
|
146
|
+
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
|
147
|
+
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
|
148
|
+
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
|
149
|
+
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
|
150
|
+
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
|
151
|
+
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
|
152
|
+
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
|
153
|
+
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
|
154
|
+
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
|
155
|
+
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
|
156
|
+
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
|
157
|
+
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
|
158
|
+
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
|
159
|
+
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
[[package]]
|
|
163
|
+
name = "typing-extensions"
|
|
164
|
+
version = "4.16.0"
|
|
165
|
+
source = { registry = "https://pypi.org/simple" }
|
|
166
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
|
167
|
+
wheels = [
|
|
168
|
+
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
|
169
|
+
]
|