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.
@@ -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,3 @@
1
+ """Sync pi agent config and extensions between hosts over rsync."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
+ ]