pix-cli 0.1.0__tar.gz → 0.1.2__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.
Files changed (29) hide show
  1. pix_cli-0.1.2/LICENSE +21 -0
  2. pix_cli-0.1.2/PKG-INFO +250 -0
  3. pix_cli-0.1.2/README.md +222 -0
  4. pix_cli-0.1.2/README.zh-CN.md +220 -0
  5. {pix_cli-0.1.0 → pix_cli-0.1.2}/docs/architecture.md +5 -4
  6. {pix_cli-0.1.0 → pix_cli-0.1.2}/docs/decisions.md +2 -2
  7. pix_cli-0.1.2/pyproject.toml +58 -0
  8. {pix_cli-0.1.0 → pix_cli-0.1.2}/src/pix/config.py +13 -25
  9. {pix_cli-0.1.0 → pix_cli-0.1.2}/src/pix/logger.py +19 -0
  10. {pix_cli-0.1.0 → pix_cli-0.1.2}/src/pix/shrink.py +4 -1
  11. {pix_cli-0.1.0 → pix_cli-0.1.2}/tests/test_cli.py +31 -3
  12. {pix_cli-0.1.0 → pix_cli-0.1.2}/uv.lock +1 -1
  13. pix_cli-0.1.0/PKG-INFO +0 -40
  14. pix_cli-0.1.0/README.md +0 -31
  15. pix_cli-0.1.0/pyproject.toml +0 -30
  16. {pix_cli-0.1.0 → pix_cli-0.1.2}/.github/workflows/ci.yml +0 -0
  17. {pix_cli-0.1.0 → pix_cli-0.1.2}/.github/workflows/publish.yml +0 -0
  18. {pix_cli-0.1.0 → pix_cli-0.1.2}/.gitignore +0 -0
  19. {pix_cli-0.1.0 → pix_cli-0.1.2}/.python-version +0 -0
  20. {pix_cli-0.1.0 → pix_cli-0.1.2}/AGENTS.md +0 -0
  21. {pix_cli-0.1.0 → pix_cli-0.1.2}/docs/development.md +0 -0
  22. {pix_cli-0.1.0 → pix_cli-0.1.2}/docs/roadmap.md +0 -0
  23. {pix_cli-0.1.0 → pix_cli-0.1.2}/src/pix/__init__.py +0 -0
  24. {pix_cli-0.1.0 → pix_cli-0.1.2}/src/pix/cli.py +0 -0
  25. {pix_cli-0.1.0 → pix_cli-0.1.2}/src/pix/default_config.toml +0 -0
  26. {pix_cli-0.1.0 → pix_cli-0.1.2}/src/pix/doctor.py +0 -0
  27. {pix_cli-0.1.0 → pix_cli-0.1.2}/src/pix/py.typed +0 -0
  28. {pix_cli-0.1.0 → pix_cli-0.1.2}/src/pix/rclone.py +0 -0
  29. {pix_cli-0.1.0 → pix_cli-0.1.2}/src/pix/sync.py +0 -0
pix_cli-0.1.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 uzvg
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.
pix_cli-0.1.2/PKG-INFO ADDED
@@ -0,0 +1,250 @@
1
+ Metadata-Version: 2.5
2
+ Name: pix-cli
3
+ Version: 0.1.2
4
+ Summary: Batch AVIF shrink and image-host sync CLI
5
+ Project-URL: Homepage, https://github.com/uzvg/pix
6
+ Project-URL: Repository, https://github.com/uzvg/pix
7
+ Project-URL: Issues, https://github.com/uzvg/pix/issues
8
+ Project-URL: Changelog, https://github.com/uzvg/pix/releases
9
+ Author-email: uzvg <uzvg@qq.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: avif,cli,image-compression,image-host,rclone
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
24
+ Classifier: Topic :: System :: Archiving :: Backup
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: pyvips[binary]>=3.2
27
+ Description-Content-Type: text/markdown
28
+
29
+ # pix
30
+
31
+ **Batch AVIF shrink and image-host sync CLI.**
32
+
33
+ `pix` incrementally compresses local image directories to AVIF, mirrors originals to cloud storage, and publishes compressed images to an image host. Compression uses pyvips; sync is delegated to rclone.
34
+
35
+ [中文](https://github.com/uzvg/pix/blob/main/README.zh-CN.md) · [Architecture](https://github.com/uzvg/pix/blob/main/docs/architecture.md) · [Development](https://github.com/uzvg/pix/blob/main/docs/development.md) · [Decisions](https://github.com/uzvg/pix/blob/main/docs/decisions.md) · [Roadmap](https://github.com/uzvg/pix/blob/main/docs/roadmap.md)
36
+
37
+ > Status: core commands are implemented and tested. Real R2 and Windows smoke tests are still pending; see the [roadmap](https://github.com/uzvg/pix/blob/main/docs/roadmap.md).
38
+
39
+ ## What pix does
40
+
41
+ | Command | Purpose |
42
+ |---|---|
43
+ | `pix shrink` | Recursively compress images to AVIF, re-encoding only changed files |
44
+ | `pix backup` | Bidirectionally mirror an original photo directory to a cloud drive |
45
+ | `pix publish` | Bidirectionally mirror AVIF output to an image host; optionally emit public URLs |
46
+ | `pix doctor` | Check Python, libvips, rclone and configuration |
47
+ | `pix config init` | Write a commented default configuration file |
48
+
49
+ `pix` is deliberately narrow:
50
+
51
+ - It is not an rclone replacement and does not manage credentials.
52
+ - It does not read repository config or care about version control.
53
+ - It has no one-shot `pix run`; you chain the commands yourself.
54
+
55
+ ## How it works
56
+
57
+ ```text
58
+ photos/ ── shrink (pyvips) ──> out/*.avif + out/.pix/state.json
59
+ │
60
+ └── publish (rclone bisync) ──> r2:images
61
+ photos/ ── backup (rclone bisync) ────────────────────────────────> onedrive:photos
62
+ ```
63
+
64
+ | Command | What actually runs | Who does the work |
65
+ |---|---|---|
66
+ | `shrink` | pyvips thumbnail + AVIF encode | pix |
67
+ | `backup` | `rclone bisync SRC REMOTE` | pix composes arguments, rclone executes |
68
+ | `publish` | `rclone bisync OUT REMOTE`, excluding `.pix/**` | pix + rclone |
69
+ | `doctor` / `config init` | Dependency checks / config scaffold | pix |
70
+
71
+ **Sync is rclone.** `backup` and `publish` only compose `rclone bisync` arguments; rclone handles authentication, transfers and conflict handling. Configure remotes with `rclone config` before using them. `pix` never reads or writes `rclone.conf`.
72
+
73
+ `backup` / `publish` are **bidirectional mirrors**, not one-way backups. Deletions propagate in both directions.
74
+
75
+ ## Requirements
76
+
77
+ - Python 3.11+
78
+ - [uv](https://docs.astral.sh/uv/)
79
+ - rclone >= 1.66 for `backup` / `publish`
80
+ - At least one remote already configured in rclone
81
+
82
+ `pyvips[binary]` bundles libvips, so no separate vips installation is required.
83
+
84
+ ## Install
85
+
86
+ Not yet published on PyPI; install from source:
87
+
88
+ ```bash
89
+ git clone https://github.com/uzvg/pix.git
90
+ cd pix
91
+ uv tool install --editable .
92
+ pix --version
93
+ ```
94
+
95
+ Linux installs `~/.local/bin/pix`; Windows installs `%USERPROFILE%\.local\bin\pix.exe`. An editable install picks up source changes immediately. Reinstall with `uv tool install --force --editable .`, or remove it with `uv tool uninstall pix-cli`.
96
+
97
+ ## Configure rclone
98
+
99
+ Only needed for `backup` / `publish`:
100
+
101
+ ```bash
102
+ rclone config # create a remote, for example onedrive or r2
103
+ rclone lsd onedrive: # verify that it works
104
+ ```
105
+
106
+ rclone stores credentials in `~/.config/rclone/rclone.conf` (Windows: `%APPDATA%\rclone\rclone.conf`). `pix` passes the remote name through and does not store or validate credentials.
107
+
108
+ ## Configure pix (optional)
109
+
110
+ Default configuration paths:
111
+
112
+ - Linux/macOS: `$XDG_CONFIG_HOME/pix/config.toml`, or `~/.config/pix/config.toml`
113
+ - Windows: `%APPDATA%\pix\config.toml`
114
+
115
+ If the file is missing, built-in defaults are used. Generate a commented file with:
116
+
117
+ ```bash
118
+ pix config init
119
+ ```
120
+
121
+ Existing files are not overwritten; pass `--force` to replace one. Use `--config PATH` before the subcommand to point at another file.
122
+
123
+ Common keys:
124
+
125
+ | Section | Key | Default | Meaning |
126
+ |---|---|---|---|
127
+ | `[shrink]` | `width` | `1920` | Longest edge in pixels |
128
+ | | `quality` | `85` | AVIF quality, 1–100 |
129
+ | | `effort` | `5` | libvips effort, 0–9 |
130
+ | | `min_size` | `0` | Skip sources smaller than this; accepts `500K`, `1M`, `2G` |
131
+ | | `include` | `jpg`, `jpeg`, `png`, `webp` | Extensions to scan, without dots |
132
+ | `[backup]` | `exclude` | `[]` | rclone exclude globs; command-line values are appended |
133
+ | | `max_delete` | `10` | Delete limit as a percentage; `-1` disables, `0` forbids |
134
+ | `[publish]` | `base_url` | `""` | Public URL prefix; must start with `http://` or `https://` |
135
+ | | `urls` | `false` | Emit a JSON URL manifest |
136
+ | | `exclude` | `[]` | Same as backup |
137
+ | | `max_delete` | `10` | Same as backup |
138
+ | `[rclone]` | `binary` | `rclone` | rclone executable name or path |
139
+
140
+ Precedence is **command line > pix config > built-in defaults**. `--exclude` is appended to config excludes rather than replacing them. `-q` means quality; silence is `--quiet` (no short form).
141
+
142
+ ## Quick start
143
+
144
+ 1. Check the environment:
145
+
146
+ ```bash
147
+ pix doctor
148
+ ```
149
+
150
+ 2. Compress images:
151
+
152
+ ```bash
153
+ pix shrink -s ~/Pictures -d ~/Pictures-avif --dry-run
154
+ pix shrink -s ~/Pictures -d ~/Pictures-avif -w 1920 -q 85
155
+ ```
156
+
157
+ The first run encodes everything; later runs skip unchanged files. State lives in `OUT/.pix/state.json`. Changing `width`, `quality` or `effort` triggers a full re-encode. `--dry-run` only prints the plan.
158
+
159
+ 3. Back up originals:
160
+
161
+ ```bash
162
+ pix backup -s ~/Pictures -d onedrive:photos --dry-run
163
+ pix backup -s ~/Pictures -d onedrive:photos --resync
164
+ ```
165
+
166
+ `--resync` is required on the first run. Always preview with `--dry-run` first.
167
+
168
+ 4. Publish the AVIF output:
169
+
170
+ ```bash
171
+ pix publish -s ~/Pictures-avif -d r2:images \
172
+ --base-url https://img.example.com --urls > urls.json
173
+ ```
174
+
175
+ `publish` requires at least one `.avif` in the source and always excludes `.pix/**`. The URL manifest is printed to stdout as JSON only after a successful, non-dry-run sync.
176
+
177
+ 5. Re-run later:
178
+
179
+ ```bash
180
+ pix shrink -s ~/Pictures -d ~/Pictures-avif
181
+ pix backup -s ~/Pictures -d onedrive:photos
182
+ pix publish -s ~/Pictures-avif -d r2:images --urls
183
+ ```
184
+
185
+ Only changes are encoded and transferred.
186
+
187
+ ## Command cheat sheet
188
+
189
+ | Task | Command |
190
+ |---|---|
191
+ | Preview compression | `pix shrink -s SRC -d OUT --dry-run` |
192
+ | Re-encode everything | `pix shrink -s SRC -d OUT --force` |
193
+ | Remove outputs whose source is gone | `pix shrink -s SRC -d OUT --prune` |
194
+ | Preview a sync | `pix backup -s SRC -d REMOTE --dry-run` |
195
+ | First run, or after changing excludes | `pix backup -s SRC -d REMOTE --resync` |
196
+ | Emit a URL manifest | `pix publish -s OUT -d REMOTE --base-url https://img.example.com --urls` |
197
+ | Diagnose the environment | `pix doctor` |
198
+ | Generate a config file | `pix config init` |
199
+
200
+ ## Safety rules
201
+
202
+ | Situation | What happens | Protection |
203
+ |---|---|---|
204
+ | Deletions on either side | Propagated in both directions; accidental deletion is irreversible | Preview with `--dry-run`; default `--max-delete 10` (percentage) |
205
+ | First sync | rclone has no baseline and errors | Add `--resync` explicitly |
206
+ | Excludes changed | The old baseline may treat excluded files as deletions | Run with `--resync` again after changing excludes |
207
+ | Empty source | `backup` / `publish` refuse; `shrink --prune` also refuses | Use `--allow-empty` (plus `--resync` for sync) only when intentional |
208
+ | `a.jpg` and `a.jpeg` both exist | Both map to the same output | Detected before encoding; exits 2 |
209
+ | Two concurrent shrinks | They would write the same state and outputs | `OUT/.pix/lock` blocks the second process |
210
+ | State lost or corrupt | Warning, then a full rebuild | Only causes re-encoding; outputs stay valid |
211
+
212
+ ## Output and exit codes
213
+
214
+ - Logs and progress go to stderr.
215
+ - stdout carries `doctor` reports, `--dry-run` plans and `--urls` JSON.
216
+ - Exit codes: `0` success, `1` runtime failure, `2` argument or configuration error.
217
+ - `backup` / `publish` return rclone's exit code unchanged.
218
+
219
+ URL manifest example:
220
+
221
+ ```json
222
+ {
223
+ "2024/a.avif": "https://img.example.com/2024/a.avif"
224
+ }
225
+ ```
226
+
227
+ ## Troubleshooting
228
+
229
+ | Symptom | Fix |
230
+ |---|---|
231
+ | `pyvips/libvips unavailable` | Reinstall to bring libvips: `uv tool install --force --editable /path/to/pix`; or install libvips manually (Linux: `apt install libvips42`, Windows: libvips release zip on `PATH`) |
232
+ | rclone is older than 1.66 | Upgrade rclone |
233
+ | bisync reports a missing baseline | Add `--resync` on the first run |
234
+ | `OUT/.pix/lock` already exists | Check for another `shrink`, then remove a stale lock |
235
+ | Configuration fails to parse | Run `pix doctor` to locate the file; fix the TOML or remove the file to fall back to defaults |
236
+ | `publish` says the source has no `.avif` | Run `pix shrink` first |
237
+ | URL-related error | `base_url` must start with `http://` or `https://`, and `--urls` requires it |
238
+
239
+ ## Development
240
+
241
+ ```bash
242
+ uv sync
243
+ uv run pytest
244
+ uv run pix --version
245
+ uv build
246
+ ```
247
+
248
+ The code must stay compatible with Python 3.11. See [development.md](https://github.com/uzvg/pix/blob/main/docs/development.md) for tests, packaging and release details. Behavior is specified in [architecture.md](https://github.com/uzvg/pix/blob/main/docs/architecture.md); rationale lives in [decisions.md](https://github.com/uzvg/pix/blob/main/docs/decisions.md).
249
+
250
+ The detailed docs are currently written in Chinese.
@@ -0,0 +1,222 @@
1
+ # pix
2
+
3
+ **Batch AVIF shrink and image-host sync CLI.**
4
+
5
+ `pix` incrementally compresses local image directories to AVIF, mirrors originals to cloud storage, and publishes compressed images to an image host. Compression uses pyvips; sync is delegated to rclone.
6
+
7
+ [中文](https://github.com/uzvg/pix/blob/main/README.zh-CN.md) · [Architecture](https://github.com/uzvg/pix/blob/main/docs/architecture.md) · [Development](https://github.com/uzvg/pix/blob/main/docs/development.md) · [Decisions](https://github.com/uzvg/pix/blob/main/docs/decisions.md) · [Roadmap](https://github.com/uzvg/pix/blob/main/docs/roadmap.md)
8
+
9
+ > Status: core commands are implemented and tested. Real R2 and Windows smoke tests are still pending; see the [roadmap](https://github.com/uzvg/pix/blob/main/docs/roadmap.md).
10
+
11
+ ## What pix does
12
+
13
+ | Command | Purpose |
14
+ |---|---|
15
+ | `pix shrink` | Recursively compress images to AVIF, re-encoding only changed files |
16
+ | `pix backup` | Bidirectionally mirror an original photo directory to a cloud drive |
17
+ | `pix publish` | Bidirectionally mirror AVIF output to an image host; optionally emit public URLs |
18
+ | `pix doctor` | Check Python, libvips, rclone and configuration |
19
+ | `pix config init` | Write a commented default configuration file |
20
+
21
+ `pix` is deliberately narrow:
22
+
23
+ - It is not an rclone replacement and does not manage credentials.
24
+ - It does not read repository config or care about version control.
25
+ - It has no one-shot `pix run`; you chain the commands yourself.
26
+
27
+ ## How it works
28
+
29
+ ```text
30
+ photos/ ── shrink (pyvips) ──> out/*.avif + out/.pix/state.json
31
+ │
32
+ └── publish (rclone bisync) ──> r2:images
33
+ photos/ ── backup (rclone bisync) ────────────────────────────────> onedrive:photos
34
+ ```
35
+
36
+ | Command | What actually runs | Who does the work |
37
+ |---|---|---|
38
+ | `shrink` | pyvips thumbnail + AVIF encode | pix |
39
+ | `backup` | `rclone bisync SRC REMOTE` | pix composes arguments, rclone executes |
40
+ | `publish` | `rclone bisync OUT REMOTE`, excluding `.pix/**` | pix + rclone |
41
+ | `doctor` / `config init` | Dependency checks / config scaffold | pix |
42
+
43
+ **Sync is rclone.** `backup` and `publish` only compose `rclone bisync` arguments; rclone handles authentication, transfers and conflict handling. Configure remotes with `rclone config` before using them. `pix` never reads or writes `rclone.conf`.
44
+
45
+ `backup` / `publish` are **bidirectional mirrors**, not one-way backups. Deletions propagate in both directions.
46
+
47
+ ## Requirements
48
+
49
+ - Python 3.11+
50
+ - [uv](https://docs.astral.sh/uv/)
51
+ - rclone >= 1.66 for `backup` / `publish`
52
+ - At least one remote already configured in rclone
53
+
54
+ `pyvips[binary]` bundles libvips, so no separate vips installation is required.
55
+
56
+ ## Install
57
+
58
+ Not yet published on PyPI; install from source:
59
+
60
+ ```bash
61
+ git clone https://github.com/uzvg/pix.git
62
+ cd pix
63
+ uv tool install --editable .
64
+ pix --version
65
+ ```
66
+
67
+ Linux installs `~/.local/bin/pix`; Windows installs `%USERPROFILE%\.local\bin\pix.exe`. An editable install picks up source changes immediately. Reinstall with `uv tool install --force --editable .`, or remove it with `uv tool uninstall pix-cli`.
68
+
69
+ ## Configure rclone
70
+
71
+ Only needed for `backup` / `publish`:
72
+
73
+ ```bash
74
+ rclone config # create a remote, for example onedrive or r2
75
+ rclone lsd onedrive: # verify that it works
76
+ ```
77
+
78
+ rclone stores credentials in `~/.config/rclone/rclone.conf` (Windows: `%APPDATA%\rclone\rclone.conf`). `pix` passes the remote name through and does not store or validate credentials.
79
+
80
+ ## Configure pix (optional)
81
+
82
+ Default configuration paths:
83
+
84
+ - Linux/macOS: `$XDG_CONFIG_HOME/pix/config.toml`, or `~/.config/pix/config.toml`
85
+ - Windows: `%APPDATA%\pix\config.toml`
86
+
87
+ If the file is missing, built-in defaults are used. Generate a commented file with:
88
+
89
+ ```bash
90
+ pix config init
91
+ ```
92
+
93
+ Existing files are not overwritten; pass `--force` to replace one. Use `--config PATH` before the subcommand to point at another file.
94
+
95
+ Common keys:
96
+
97
+ | Section | Key | Default | Meaning |
98
+ |---|---|---|---|
99
+ | `[shrink]` | `width` | `1920` | Longest edge in pixels |
100
+ | | `quality` | `85` | AVIF quality, 1–100 |
101
+ | | `effort` | `5` | libvips effort, 0–9 |
102
+ | | `min_size` | `0` | Skip sources smaller than this; accepts `500K`, `1M`, `2G` |
103
+ | | `include` | `jpg`, `jpeg`, `png`, `webp` | Extensions to scan, without dots |
104
+ | `[backup]` | `exclude` | `[]` | rclone exclude globs; command-line values are appended |
105
+ | | `max_delete` | `10` | Delete limit as a percentage; `-1` disables, `0` forbids |
106
+ | `[publish]` | `base_url` | `""` | Public URL prefix; must start with `http://` or `https://` |
107
+ | | `urls` | `false` | Emit a JSON URL manifest |
108
+ | | `exclude` | `[]` | Same as backup |
109
+ | | `max_delete` | `10` | Same as backup |
110
+ | `[rclone]` | `binary` | `rclone` | rclone executable name or path |
111
+
112
+ Precedence is **command line > pix config > built-in defaults**. `--exclude` is appended to config excludes rather than replacing them. `-q` means quality; silence is `--quiet` (no short form).
113
+
114
+ ## Quick start
115
+
116
+ 1. Check the environment:
117
+
118
+ ```bash
119
+ pix doctor
120
+ ```
121
+
122
+ 2. Compress images:
123
+
124
+ ```bash
125
+ pix shrink -s ~/Pictures -d ~/Pictures-avif --dry-run
126
+ pix shrink -s ~/Pictures -d ~/Pictures-avif -w 1920 -q 85
127
+ ```
128
+
129
+ The first run encodes everything; later runs skip unchanged files. State lives in `OUT/.pix/state.json`. Changing `width`, `quality` or `effort` triggers a full re-encode. `--dry-run` only prints the plan.
130
+
131
+ 3. Back up originals:
132
+
133
+ ```bash
134
+ pix backup -s ~/Pictures -d onedrive:photos --dry-run
135
+ pix backup -s ~/Pictures -d onedrive:photos --resync
136
+ ```
137
+
138
+ `--resync` is required on the first run. Always preview with `--dry-run` first.
139
+
140
+ 4. Publish the AVIF output:
141
+
142
+ ```bash
143
+ pix publish -s ~/Pictures-avif -d r2:images \
144
+ --base-url https://img.example.com --urls > urls.json
145
+ ```
146
+
147
+ `publish` requires at least one `.avif` in the source and always excludes `.pix/**`. The URL manifest is printed to stdout as JSON only after a successful, non-dry-run sync.
148
+
149
+ 5. Re-run later:
150
+
151
+ ```bash
152
+ pix shrink -s ~/Pictures -d ~/Pictures-avif
153
+ pix backup -s ~/Pictures -d onedrive:photos
154
+ pix publish -s ~/Pictures-avif -d r2:images --urls
155
+ ```
156
+
157
+ Only changes are encoded and transferred.
158
+
159
+ ## Command cheat sheet
160
+
161
+ | Task | Command |
162
+ |---|---|
163
+ | Preview compression | `pix shrink -s SRC -d OUT --dry-run` |
164
+ | Re-encode everything | `pix shrink -s SRC -d OUT --force` |
165
+ | Remove outputs whose source is gone | `pix shrink -s SRC -d OUT --prune` |
166
+ | Preview a sync | `pix backup -s SRC -d REMOTE --dry-run` |
167
+ | First run, or after changing excludes | `pix backup -s SRC -d REMOTE --resync` |
168
+ | Emit a URL manifest | `pix publish -s OUT -d REMOTE --base-url https://img.example.com --urls` |
169
+ | Diagnose the environment | `pix doctor` |
170
+ | Generate a config file | `pix config init` |
171
+
172
+ ## Safety rules
173
+
174
+ | Situation | What happens | Protection |
175
+ |---|---|---|
176
+ | Deletions on either side | Propagated in both directions; accidental deletion is irreversible | Preview with `--dry-run`; default `--max-delete 10` (percentage) |
177
+ | First sync | rclone has no baseline and errors | Add `--resync` explicitly |
178
+ | Excludes changed | The old baseline may treat excluded files as deletions | Run with `--resync` again after changing excludes |
179
+ | Empty source | `backup` / `publish` refuse; `shrink --prune` also refuses | Use `--allow-empty` (plus `--resync` for sync) only when intentional |
180
+ | `a.jpg` and `a.jpeg` both exist | Both map to the same output | Detected before encoding; exits 2 |
181
+ | Two concurrent shrinks | They would write the same state and outputs | `OUT/.pix/lock` blocks the second process |
182
+ | State lost or corrupt | Warning, then a full rebuild | Only causes re-encoding; outputs stay valid |
183
+
184
+ ## Output and exit codes
185
+
186
+ - Logs and progress go to stderr.
187
+ - stdout carries `doctor` reports, `--dry-run` plans and `--urls` JSON.
188
+ - Exit codes: `0` success, `1` runtime failure, `2` argument or configuration error.
189
+ - `backup` / `publish` return rclone's exit code unchanged.
190
+
191
+ URL manifest example:
192
+
193
+ ```json
194
+ {
195
+ "2024/a.avif": "https://img.example.com/2024/a.avif"
196
+ }
197
+ ```
198
+
199
+ ## Troubleshooting
200
+
201
+ | Symptom | Fix |
202
+ |---|---|
203
+ | `pyvips/libvips unavailable` | Reinstall to bring libvips: `uv tool install --force --editable /path/to/pix`; or install libvips manually (Linux: `apt install libvips42`, Windows: libvips release zip on `PATH`) |
204
+ | rclone is older than 1.66 | Upgrade rclone |
205
+ | bisync reports a missing baseline | Add `--resync` on the first run |
206
+ | `OUT/.pix/lock` already exists | Check for another `shrink`, then remove a stale lock |
207
+ | Configuration fails to parse | Run `pix doctor` to locate the file; fix the TOML or remove the file to fall back to defaults |
208
+ | `publish` says the source has no `.avif` | Run `pix shrink` first |
209
+ | URL-related error | `base_url` must start with `http://` or `https://`, and `--urls` requires it |
210
+
211
+ ## Development
212
+
213
+ ```bash
214
+ uv sync
215
+ uv run pytest
216
+ uv run pix --version
217
+ uv build
218
+ ```
219
+
220
+ The code must stay compatible with Python 3.11. See [development.md](https://github.com/uzvg/pix/blob/main/docs/development.md) for tests, packaging and release details. Behavior is specified in [architecture.md](https://github.com/uzvg/pix/blob/main/docs/architecture.md); rationale lives in [decisions.md](https://github.com/uzvg/pix/blob/main/docs/decisions.md).
221
+
222
+ The detailed docs are currently written in Chinese.
@@ -0,0 +1,220 @@
1
+ # pix
2
+
3
+ **批量 AVIF 压缩与图床同步 CLI。**
4
+
5
+ `pix` 把本地图片目录增量压缩为 AVIF,把原图双向镜像到云盘,并把压缩结果发布到图床。压缩使用 pyvips,同步交给 rclone。
6
+
7
+ [English](https://github.com/uzvg/pix/blob/main/README.md) · [行为规格](https://github.com/uzvg/pix/blob/main/docs/architecture.md) · [开发指南](https://github.com/uzvg/pix/blob/main/docs/development.md) · [技术决策](https://github.com/uzvg/pix/blob/main/docs/decisions.md) · [路线图](https://github.com/uzvg/pix/blob/main/docs/roadmap.md)
8
+
9
+ > 状态:核心命令已实现并通过测试;真实 R2 与 Windows 冒烟测试仍待完成,见[路线图](https://github.com/uzvg/pix/blob/main/docs/roadmap.md)。
10
+
11
+ ## pix 做什么
12
+
13
+ | 命令 | 作用 |
14
+ |---|---|
15
+ | `pix shrink` | 递归压缩图片为 AVIF,只重新编码变化的文件 |
16
+ | `pix backup` | 把原图目录与云盘做双向镜像 |
17
+ | `pix publish` | 把 AVIF 输出目录与图床做双向镜像,可选输出公开 URL |
18
+ | `pix doctor` | 检查 Python、libvips、rclone 与配置 |
19
+ | `pix config init` | 生成带注释的默认配置文件 |
20
+
21
+ `pix` 刻意保持边界清晰:
22
+
23
+ - 不是 rclone 替代品,不管理云盘凭据。
24
+ - 不读取仓库配置,不感知版本控制状态。
25
+ - 没有一键 `pix run`,命令由你自行组合。
26
+
27
+ ## 工作原理
28
+
29
+ ```text
30
+ photos/ ── shrink (pyvips) ──> out/*.avif + out/.pix/state.json
31
+ │
32
+ └── publish (rclone bisync) ──> r2:images
33
+ photos/ ── backup (rclone bisync) ────────────────────────────────> onedrive:photos
34
+ ```
35
+
36
+ | 命令 | 实际执行的内容 | 谁负责 |
37
+ |---|---|---|
38
+ | `shrink` | pyvips 缩放并编码 AVIF | pix |
39
+ | `backup` | `rclone bisync SRC REMOTE` | pix 拼参数,rclone 执行 |
40
+ | `publish` | `rclone bisync OUT REMOTE`,排除 `.pix/**` | pix + rclone |
41
+ | `doctor` / `config init` | 依赖检查 / 配置脚手架 | pix |
42
+
43
+ **同步由 rclone 执行。** `backup` / `publish` 只负责拼装 `rclone bisync` 参数;认证、传输和冲突处理都由 rclone 完成。使用前请先用 `rclone config` 配好 remote。`pix` 不读取也不写入 `rclone.conf`。
44
+
45
+ `backup` / `publish` 是**双向镜像**,不是单向备份。删除会双向传播。
46
+
47
+ ## 前置条件
48
+
49
+ - Python 3.11+
50
+ - [uv](https://docs.astral.sh/uv/)
51
+ - `backup` / `publish` 需要 rclone >= 1.66
52
+ - 已在 rclone 中配置好至少一个 remote
53
+
54
+ `pyvips[binary]` 自带 libvips,不需要单独安装 vips。
55
+
56
+ ## 安装
57
+
58
+ 尚未发布到 PyPI,从源码安装:
59
+
60
+ ```bash
61
+ git clone https://github.com/uzvg/pix.git
62
+ cd pix
63
+ uv tool install --editable .
64
+ pix --version
65
+ ```
66
+
67
+ Linux 安装到 `~/.local/bin/pix`,Windows 为 `%USERPROFILE%\.local\bin\pix.exe`。editable 安装修改源码后立即生效。重装用 `uv tool install --force --editable .`,卸载用 `uv tool uninstall pix-cli`。
68
+
69
+ ## 配置 rclone
70
+
71
+ 仅 `backup` / `publish` 需要:
72
+
73
+ ```bash
74
+ rclone config # 新建 remote,例如 onedrive 或 r2
75
+ rclone lsd onedrive: # 验证是否可用
76
+ ```
77
+
78
+ rclone 把凭据保存在 `~/.config/rclone/rclone.conf`(Windows:`%APPDATA%\rclone\rclone.conf`)。`pix` 只把 remote 名称透传给 rclone,不保存也不校验凭据。
79
+
80
+ ## 配置 pix(可选)
81
+
82
+ 默认配置路径:
83
+
84
+ - Linux/macOS:`$XDG_CONFIG_HOME/pix/config.toml`,未设置时为 `~/.config/pix/config.toml`
85
+ - Windows:`%APPDATA%\pix\config.toml`
86
+
87
+ 文件不存在时使用内置默认值。生成带注释的配置文件:
88
+
89
+ ```bash
90
+ pix config init
91
+ ```
92
+
93
+ 已有文件不会被覆盖;需要替换时加 `--force`。用 `--config PATH`(写在子命令之前)指向其他文件。
94
+
95
+ 常用配置项:
96
+
97
+ | 段 | 键 | 默认值 | 说明 |
98
+ |---|---|---|---|
99
+ | `[shrink]` | `width` | `1920` | 最长边像素 |
100
+ | | `quality` | `85` | AVIF 质量,1–100 |
101
+ | | `effort` | `5` | libvips effort,0–9 |
102
+ | | `min_size` | `0` | 小于该值的源文件跳过;支持 `500K`、`1M`、`2G` |
103
+ | | `include` | `jpg`、`jpeg`、`png`、`webp` | 扫描扩展名,不带点 |
104
+ | `[backup]` | `exclude` | `[]` | rclone 排除 glob;命令行项会追加 |
105
+ | | `max_delete` | `10` | 删除百分比上限;`-1` 不限制,`0` 禁止删除 |
106
+ | `[publish]` | `base_url` | `""` | 公开 URL 前缀,必须以 `http://` 或 `https://` 开头 |
107
+ | | `urls` | `false` | 是否输出 JSON URL 清单 |
108
+ | | `exclude` | `[]` | 同 `backup` |
109
+ | | `max_delete` | `10` | 同 `backup` |
110
+ | `[rclone]` | `binary` | `rclone` | rclone 可执行文件名或路径 |
111
+
112
+ 优先级为**命令行 > pix 配置 > 内置默认**。`--exclude` 是追加而非覆盖配置项。`-q` 表示 quality,静默模式用 `--quiet`(无短选项)。
113
+
114
+ ## 快速上手
115
+
116
+ 1. 检查环境:
117
+
118
+ ```bash
119
+ pix doctor
120
+ ```
121
+
122
+ 2. 压缩图片:
123
+
124
+ ```bash
125
+ pix shrink -s ~/Pictures -d ~/Pictures-avif --dry-run
126
+ pix shrink -s ~/Pictures -d ~/Pictures-avif -w 1920 -q 85
127
+ ```
128
+
129
+ 首次全量编码,之后跳过未变化的文件。状态保存在 `OUT/.pix/state.json`。修改 `width`、`quality` 或 `effort` 会触发全量重编码。`--dry-run` 只打印计划。
130
+
131
+ 3. 备份原图:
132
+
133
+ ```bash
134
+ pix backup -s ~/Pictures -d onedrive:photos --dry-run
135
+ pix backup -s ~/Pictures -d onedrive:photos --resync
136
+ ```
137
+
138
+ 首次运行必须显式加 `--resync`。建议先跑 `--dry-run` 预览。
139
+
140
+ 4. 发布 AVIF 输出:
141
+
142
+ ```bash
143
+ pix publish -s ~/Pictures-avif -d r2:images \
144
+ --base-url https://img.example.com --urls > urls.json
145
+ ```
146
+
147
+ `publish` 要求源目录至少有一个 `.avif`,并固定排除 `.pix/**`。URL 清单只在同步成功且不是 `--dry-run` 时输出到 stdout。
148
+
149
+ 5. 之后重复运行:
150
+
151
+ ```bash
152
+ pix shrink -s ~/Pictures -d ~/Pictures-avif
153
+ pix backup -s ~/Pictures -d onedrive:photos
154
+ pix publish -s ~/Pictures-avif -d r2:images --urls
155
+ ```
156
+
157
+ 只处理变化的部分。
158
+
159
+ ## 常用命令
160
+
161
+ | 场景 | 命令 |
162
+ |---|---|
163
+ | 预览压缩 | `pix shrink -s SRC -d OUT --dry-run` |
164
+ | 强制全部重压 | `pix shrink -s SRC -d OUT --force` |
165
+ | 删除源已消失的输出 | `pix shrink -s SRC -d OUT --prune` |
166
+ | 预览同步 | `pix backup -s SRC -d REMOTE --dry-run` |
167
+ | 首次运行或修改排除项后 | `pix backup -s SRC -d REMOTE --resync` |
168
+ | 输出 URL 清单 | `pix publish -s OUT -d REMOTE --base-url https://img.example.com --urls` |
169
+ | 诊断环境 | `pix doctor` |
170
+ | 生成配置文件 | `pix config init` |
171
+
172
+ ## 安全边界
173
+
174
+ | 情况 | 会发生什么 | 如何防护 |
175
+ |---|---|---|
176
+ | 任意一侧删除文件 | 删除双向传播,误删不可逆 | 先 `--dry-run`;默认 `--max-delete 10`(百分比) |
177
+ | 首次同步 | rclone 没有基线,直接报错 | 显式加 `--resync` |
178
+ | 修改排除项 | 旧基线可能把被排除的文件当成删除传播 | 修改后必须重新 `--resync` |
179
+ | 源目录为空 | `backup` / `publish` 默认拒绝;`shrink --prune` 同样拒绝 | 仅在确认要清空时用 `--allow-empty`(同步还需 `--resync`) |
180
+ | `a.jpg` 与 `a.jpeg` 同时存在 | 映射到同一输出 | 编码前检测并以 2 退出 |
181
+ | 同时运行两个 shrink | 会写同一份状态和输出 | `OUT/.pix/lock` 阻止第二个进程 |
182
+ | 状态文件丢失或损坏 | 告警后全量重建 | 只会重新编码,不会产生错误结果 |
183
+
184
+ ## 输出与退出码
185
+
186
+ - 日志和进度输出到 stderr。
187
+ - stdout 用于 `doctor` 报告、`--dry-run` 计划和 `--urls` JSON。
188
+ - 退出码:`0` 成功,`1` 运行失败,`2` 参数或配置错误。
189
+ - `backup` / `publish` 原样返回 rclone 的退出码。
190
+
191
+ URL 清单示例:
192
+
193
+ ```json
194
+ {
195
+ "2024/a.avif": "https://img.example.com/2024/a.avif"
196
+ }
197
+ ```
198
+
199
+ ## 故障排查
200
+
201
+ | 现象 | 处理 |
202
+ |---|---|
203
+ | 提示 `pyvips/libvips unavailable` | 重装以带入 libvips:`uv tool install --force --editable /path/to/pix`;或手动安装 libvips(Linux:`apt install libvips42`;Windows:下载 libvips release 压缩包并把 `bin` 加入 PATH) |
204
+ | rclone 版本低于 1.66 | 升级 rclone |
205
+ | bisync 报缺少基线 | 首次运行加 `--resync` |
206
+ | `OUT/.pix/lock` 已存在 | 确认没有其他 `shrink` 在运行,再删除陈旧锁 |
207
+ | 配置解析失败 | 运行 `pix doctor` 定位文件;修复 TOML,或删除配置改用默认值 |
208
+ | `publish` 提示源目录没有 `.avif` | 先运行 `pix shrink` |
209
+ | URL 相关报错 | `base_url` 必须以 `http://` 或 `https://` 开头,且 `--urls` 依赖它 |
210
+
211
+ ## 开发
212
+
213
+ ```bash
214
+ uv sync
215
+ uv run pytest
216
+ uv run pix --version
217
+ uv build
218
+ ```
219
+
220
+ 代码必须兼容 Python 3.11。测试、打包与发布细节见[开发指南](https://github.com/uzvg/pix/blob/main/docs/development.md)。行为规格见[architecture.md](https://github.com/uzvg/pix/blob/main/docs/architecture.md),选型原因见[decisions.md](https://github.com/uzvg/pix/blob/main/docs/decisions.md)。
@@ -51,8 +51,8 @@ src/pix/
51
51
  ├── __init__.py # 包版本
52
52
  ├── py.typed
53
53
  ├── cli.py # 组合根:parser、main、子命令分派
54
- ├── config.py # 默认值、TOML 读取与校验、parse_size、config init
55
- ├── default_config.toml # config init 写入的模板(随包分发)
54
+ ├── config.py # TOML 读取与校验、parse_size、config init
55
+ ├── default_config.toml # 默认值唯一来源,也是 config init 的模板(随包分发)
56
56
  ├── logger.py # stderr 日志
57
57
  ├── shrink.py # shrink 子命令
58
58
  ├── rclone.py # rclone 探测、版本检查与执行
@@ -150,7 +150,7 @@ pix config init [--force]
150
150
  - Linux/macOS:`$XDG_CONFIG_HOME/pix/config.toml`,未设置时为 `~/.config/pix/config.toml`。
151
151
  - Windows:`%APPDATA%\pix\config.toml`。
152
152
 
153
- 文件不存在时使用内置默认值。
153
+ 文件不存在时使用内置默认值(即 `src/pix/default_config.toml`)。
154
154
 
155
155
  ```toml
156
156
  [shrink]
@@ -230,6 +230,7 @@ binary = "rclone"
230
230
  13. 汇总输出:转换、跳过(状态命中)、跳过(太小)、跳过(空源保护)、失败、删除。
231
231
  14. 状态在编码过程中每成功 200 个文件刷新一次(原子写),并在正常结束或 `KeyboardInterrupt` 时通过 `finally` 再写一次,避免 Ctrl-C 丢掉整批进度。写入失败记录错误并返回 1,下次运行会重新编码未记录的文件。
232
232
  15. 状态损坏、版本不识别或 JSON 解析失败时输出警告,按空状态全量重建,不中止。
233
+ 16. 编码每个文件前在 stderr 输出进度 `processing <序号>/<总数>: <相对路径>`;终端下原地刷新,重定向时逐行输出,`--quiet` 抑制。日志与汇总会先结束进度行。
233
234
 
234
235
  ### 状态文件
235
236
 
@@ -343,7 +344,7 @@ config /home/uzvg/.config/pix/config.toml (ok)
343
344
 
344
345
  ## 9. 日志与退出码
345
346
 
346
- - 默认:阶段信息和汇总,输出到 stderr。
347
+ - 默认:阶段信息、编码进度和汇总,输出到 stderr。终端下进度原地刷新,重定向时逐行输出。
347
348
  - `-v`:输出每个文件的处理/跳过原因,以及 rclone 的进度。
348
349
  - `--quiet`:只输出错误。
349
350
  - `doctor` 报告输出到 stdout;`--dry-run` 的计划文件列表和 `--urls` 的 JSON 输出到 stdout。
@@ -172,6 +172,6 @@
172
172
 
173
173
  ## D28. 默认配置用内置模板生成
174
174
 
175
- **决策**:`pix config init` 写出随包分发的 `src/pix/default_config.toml`,而不是把 `DEFAULTS` 序列化成 TOML。目标文件已存在时拒绝覆盖并退出 2,`--force` 才覆盖;该命令在 `load_settings` 之前分派。
175
+ **决策**:随包分发的 `src/pix/default_config.toml` 是默认值的唯一来源:`load_settings` 通过 `load_defaults()` 解析它,`pix config init` 直接复制它(保留注释)。目标文件已存在时拒绝覆盖并退出 2,`--force` 才覆盖;该命令在 `load_settings` 之前分派。
176
176
 
177
- **原因**:Python 3.11 标准库只有 `tomllib`,没有 TOML 写入器;引入 `tomli-w` 违反“仅标准库 + pyvips”的约束。独立文件可以直接带注释,用户打开就能改,编辑器也能按 TOML 高亮;测试把模板解析后与 `DEFAULTS` 比对,防止两者漂移。拒绝覆盖保护用户已经修改过的配置,误覆盖的代价远高于多敲一个 `--force`。提前分派让 `--config` 指向新路径时也能创建工作,而不是被“config file not found”挡住。
177
+ **原因**:Python 3.11 标准库只有 `tomllib`,没有 TOML 写入器;引入 `tomli-w` 违反“仅标准库 + pyvips”的约束。独立文件可以直接带注释,用户打开就能改,编辑器也能按 TOML 高亮;默认值只在这一处维护,避免代码常量与模板漂移。拒绝覆盖保护用户已经修改过的配置,误覆盖的代价远高于多敲一个 `--force`。提前分派让 `--config` 指向新路径时也能创建工作,而不是被“config file not found”挡住。
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "pix-cli"
3
+ version = "0.1.2"
4
+ description = "Batch AVIF shrink and image-host sync CLI"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [
8
+ { name = "uzvg", email = "uzvg@qq.com" }
9
+ ]
10
+ keywords = [
11
+ "avif",
12
+ "image-compression",
13
+ "image-host",
14
+ "rclone",
15
+ "cli",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Environment :: Console",
20
+ "Intended Audience :: End Users/Desktop",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Topic :: Multimedia :: Graphics :: Graphics Conversion",
29
+ "Topic :: System :: Archiving :: Backup",
30
+ ]
31
+ requires-python = ">=3.11"
32
+ dependencies = [
33
+ "pyvips[binary]>=3.2",
34
+ ]
35
+
36
+ [project.scripts]
37
+ pix = "pix.cli:main"
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/uzvg/pix"
41
+ Repository = "https://github.com/uzvg/pix"
42
+ Issues = "https://github.com/uzvg/pix/issues"
43
+ Changelog = "https://github.com/uzvg/pix/releases"
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "pytest>=8",
48
+ ]
49
+
50
+ [build-system]
51
+ requires = ["hatchling"]
52
+ build-backend = "hatchling.build"
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["src/pix"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
- import copy
7
6
  import os
8
7
  import re
9
8
  import sys
@@ -11,29 +10,6 @@ import tomllib
11
10
  from pathlib import Path
12
11
  from typing import Any
13
12
 
14
- DEFAULTS: dict[str, Any] = {
15
- "shrink": {
16
- "width": 1920,
17
- "quality": 85,
18
- "effort": 5,
19
- "min_size": 0,
20
- "include": ["jpg", "jpeg", "png", "webp"],
21
- },
22
- "backup": {
23
- "exclude": [],
24
- "max_delete": 10,
25
- },
26
- "publish": {
27
- "base_url": "",
28
- "urls": False,
29
- "exclude": [],
30
- "max_delete": 10,
31
- },
32
- "rclone": {
33
- "binary": "rclone",
34
- },
35
- }
36
-
37
13
  _SIZE_RE = re.compile(r"^(\d+)([KMG]?)$", re.IGNORECASE)
38
14
  _SIZE_UNITS = {"": 1, "K": 1024, "M": 1024**2, "G": 1024**3}
39
15
 
@@ -42,6 +18,18 @@ class ConfigError(Exception):
42
18
  """Invalid or unreadable configuration."""
43
19
 
44
20
 
21
+ def load_defaults() -> dict[str, Any]:
22
+ """Read the packaged template, the single source of default values."""
23
+ path = Path(__file__).with_name("default_config.toml")
24
+ try:
25
+ with path.open("rb") as handle:
26
+ return tomllib.load(handle)
27
+ except OSError as exc:
28
+ raise ConfigError(f"{path}: {exc}") from exc
29
+ except tomllib.TOMLDecodeError as exc:
30
+ raise ConfigError(f"{path}: {exc}") from exc
31
+
32
+
45
33
  def parse_size(value: str | int) -> int:
46
34
  if isinstance(value, int) and not isinstance(value, bool):
47
35
  if value < 0:
@@ -97,7 +85,7 @@ def load_settings(explicit: Path | None = None) -> dict[str, Any]:
97
85
  if explicit is not None and not path.is_file():
98
86
  raise ConfigError(f"config file not found: {path}")
99
87
  overrides = load_config(path)
100
- settings = copy.deepcopy(DEFAULTS)
88
+ settings = load_defaults()
101
89
  _deep_update(settings, overrides)
102
90
  validate_settings(settings)
103
91
  return settings
@@ -9,18 +9,37 @@ class Logger:
9
9
  def __init__(self, verbose: bool = False, quiet: bool = False) -> None:
10
10
  self.verbose = verbose
11
11
  self.quiet = quiet
12
+ self._progress_active = False
13
+
14
+ def _end_progress(self) -> None:
15
+ if self._progress_active:
16
+ print(file=sys.stderr)
17
+ self._progress_active = False
12
18
 
13
19
  def info(self, message: str) -> None:
14
20
  if not self.quiet:
21
+ self._end_progress()
15
22
  print(message, file=sys.stderr)
16
23
 
17
24
  def detail(self, message: str) -> None:
18
25
  if self.verbose and not self.quiet:
26
+ self._end_progress()
19
27
  print(message, file=sys.stderr)
20
28
 
21
29
  def warning(self, message: str) -> None:
22
30
  if not self.quiet:
31
+ self._end_progress()
23
32
  print(f"pix: warning: {message}", file=sys.stderr)
24
33
 
25
34
  def error(self, message: str) -> None:
35
+ self._end_progress()
26
36
  print(f"pix: error: {message}", file=sys.stderr)
37
+
38
+ def progress(self, message: str) -> None:
39
+ if self.quiet:
40
+ return
41
+ if sys.stderr.isatty():
42
+ print(f"\r{message}\033[K", end="", file=sys.stderr, flush=True)
43
+ self._progress_active = True
44
+ else:
45
+ print(message, file=sys.stderr)
@@ -258,9 +258,10 @@ def shrink(args: argparse.Namespace, settings: dict[str, Any], log: Logger) -> i
258
258
  status = 0
259
259
  pyvips = None
260
260
  encoded: set[str] = set()
261
+ total = len(sources)
261
262
 
262
263
  try:
263
- for src in sources:
264
+ for index, src in enumerate(sources, 1):
264
265
  rel = src.relative_to(source).as_posix()
265
266
  try:
266
267
  src_stat = src.stat()
@@ -287,6 +288,8 @@ def shrink(args: argparse.Namespace, settings: dict[str, Any], log: Logger) -> i
287
288
  counters["converted"] += 1
288
289
  continue
289
290
 
291
+ log.progress(f"processing {index}/{total}: {rel}")
292
+
290
293
  if pyvips is None:
291
294
  try:
292
295
  import pyvips as pyvips_module
@@ -1,3 +1,4 @@
1
+ import io
1
2
  import json
2
3
  import os
3
4
  import tomllib
@@ -7,6 +8,7 @@ import pytest
7
8
 
8
9
  from pix import cli, doctor, rclone
9
10
  from pix import config as pix_config
11
+ from pix.logger import Logger
10
12
 
11
13
 
12
14
  def test_parse_size():
@@ -20,6 +22,22 @@ def test_parse_size():
20
22
  pix_config.parse_size(bad)
21
23
 
22
24
 
25
+ def test_logger_progress_rewrites_on_tty(monkeypatch):
26
+ class FakeStderr(io.StringIO):
27
+ def isatty(self):
28
+ return True
29
+
30
+ stream = FakeStderr()
31
+ monkeypatch.setattr("pix.logger.sys.stderr", stream)
32
+
33
+ log = Logger()
34
+ log.progress("first")
35
+ log.progress("second")
36
+ log.info("done")
37
+
38
+ assert stream.getvalue() == "\rfirst\033[K\rsecond\033[K\ndone\n"
39
+
40
+
23
41
  def test_version_exits_zero(capsys):
24
42
  assert cli.main(["--version"]) == 0
25
43
  assert capsys.readouterr().out.strip() == f"pix {cli.__version__}"
@@ -38,7 +56,7 @@ def test_config_priority_and_defaults(tmp_path: Path):
38
56
 
39
57
  assert settings["shrink"]["width"] == 1000
40
58
  assert settings["shrink"]["quality"] == 70
41
- assert settings["shrink"]["effort"] == pix_config.DEFAULTS["shrink"]["effort"]
59
+ assert settings["shrink"]["effort"] == pix_config.load_defaults()["shrink"]["effort"]
42
60
  assert settings["publish"]["max_delete"] == 10
43
61
 
44
62
 
@@ -74,7 +92,7 @@ def test_config_init_writes_defaults(tmp_path: Path, capsys):
74
92
  assert cli.main(["--config", str(config), "config", "init"]) == 0
75
93
 
76
94
  assert capsys.readouterr().out.strip() == f"wrote {config}"
77
- assert tomllib.loads(config.read_text(encoding="utf-8")) == pix_config.DEFAULTS
95
+ assert tomllib.loads(config.read_text(encoding="utf-8")) == pix_config.load_defaults()
78
96
  assert pix_config.load_settings(config)["shrink"]["width"] == 1920
79
97
 
80
98
 
@@ -94,7 +112,7 @@ def test_config_init_force_overwrites(tmp_path: Path, capsys):
94
112
 
95
113
  assert cli.main(["--config", str(config), "config", "init", "--force"]) == 0
96
114
 
97
- assert tomllib.loads(config.read_text(encoding="utf-8")) == pix_config.DEFAULTS
115
+ assert tomllib.loads(config.read_text(encoding="utf-8")) == pix_config.load_defaults()
98
116
  assert capsys.readouterr().out.strip() == f"wrote {config}"
99
117
 
100
118
 
@@ -142,6 +160,16 @@ def test_shrink_encodes_and_writes_state(tmp_path: Path):
142
160
  assert entry["out_size"] == output.stat().st_size
143
161
 
144
162
 
163
+ def test_shrink_reports_progress(tmp_path: Path, capsys):
164
+ source = tmp_path / "src"
165
+ dest = tmp_path / "out"
166
+ make_png(source / "a.png")
167
+
168
+ assert run_shrink(source, dest, "-w", "100") == 0
169
+
170
+ assert "processing 1/1: a.png" in capsys.readouterr().err
171
+
172
+
145
173
  def test_shrink_second_run_skips_unchanged(tmp_path: Path, capsys):
146
174
  source = tmp_path / "src"
147
175
  dest = tmp_path / "out"
@@ -129,7 +129,7 @@ wheels = [
129
129
 
130
130
  [[package]]
131
131
  name = "pix-cli"
132
- version = "0.1.0"
132
+ version = "0.1.1"
133
133
  source = { editable = "." }
134
134
  dependencies = [
135
135
  { name = "pyvips", extra = ["binary"] },
pix_cli-0.1.0/PKG-INFO DELETED
@@ -1,40 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: pix-cli
3
- Version: 0.1.0
4
- Summary: Batch AVIF shrink and image-host sync CLI
5
- Author-email: uzvg <uzvg@qq.com>
6
- Requires-Python: >=3.11
7
- Requires-Dist: pyvips[binary]>=3.2
8
- Description-Content-Type: text/markdown
9
-
10
- # pix
11
-
12
- Batch AVIF shrink and image-host sync CLI.
13
-
14
- `pix` 把本地图片目录增量压缩为 AVIF,再通过 rclone bisync 备份原图、发布压缩图到图床。只处理显式传入的路径和远端。
15
-
16
- ```bash
17
- pix shrink -s ./photos -d ./out -w 1920 -q 85
18
- pix backup -s ./photos -d onedrive:photos --resync
19
- pix publish -s ./out -d r2:images --base-url https://img.example.com --urls
20
- pix doctor
21
- pix config init
22
- ```
23
-
24
- `backup` / `publish` 是双向镜像,不是单向备份:删除会双向传播。默认 `--max-delete 10`(删除百分比,即 10%),超过该比例时 rclone 会拒绝;首次运行或修改排除项后必须显式 `--resync`,先跑 `--dry-run` 更安全。
25
-
26
- > Status: implementation complete; real R2 and Windows smoke tests pending (see roadmap).
27
-
28
- ## Docs
29
-
30
- - [Architecture](docs/architecture.md) — behavior spec
31
- - [Development](docs/development.md) — setup, tests, build
32
- - [Decisions](docs/decisions.md) — technical decisions and rationale
33
- - [Roadmap](docs/roadmap.md) — status and tasks
34
-
35
- ## Quick start
36
-
37
- ```bash
38
- uv sync
39
- uv run pytest
40
- ```
pix_cli-0.1.0/README.md DELETED
@@ -1,31 +0,0 @@
1
- # pix
2
-
3
- Batch AVIF shrink and image-host sync CLI.
4
-
5
- `pix` 把本地图片目录增量压缩为 AVIF,再通过 rclone bisync 备份原图、发布压缩图到图床。只处理显式传入的路径和远端。
6
-
7
- ```bash
8
- pix shrink -s ./photos -d ./out -w 1920 -q 85
9
- pix backup -s ./photos -d onedrive:photos --resync
10
- pix publish -s ./out -d r2:images --base-url https://img.example.com --urls
11
- pix doctor
12
- pix config init
13
- ```
14
-
15
- `backup` / `publish` 是双向镜像,不是单向备份:删除会双向传播。默认 `--max-delete 10`(删除百分比,即 10%),超过该比例时 rclone 会拒绝;首次运行或修改排除项后必须显式 `--resync`,先跑 `--dry-run` 更安全。
16
-
17
- > Status: implementation complete; real R2 and Windows smoke tests pending (see roadmap).
18
-
19
- ## Docs
20
-
21
- - [Architecture](docs/architecture.md) — behavior spec
22
- - [Development](docs/development.md) — setup, tests, build
23
- - [Decisions](docs/decisions.md) — technical decisions and rationale
24
- - [Roadmap](docs/roadmap.md) — status and tasks
25
-
26
- ## Quick start
27
-
28
- ```bash
29
- uv sync
30
- uv run pytest
31
- ```
@@ -1,30 +0,0 @@
1
- [project]
2
- name = "pix-cli"
3
- version = "0.1.0"
4
- description = "Batch AVIF shrink and image-host sync CLI"
5
- readme = "README.md"
6
- authors = [
7
- { name = "uzvg", email = "uzvg@qq.com" }
8
- ]
9
- requires-python = ">=3.11"
10
- dependencies = [
11
- "pyvips[binary]>=3.2",
12
- ]
13
-
14
- [project.scripts]
15
- pix = "pix.cli:main"
16
-
17
- [dependency-groups]
18
- dev = [
19
- "pytest>=8",
20
- ]
21
-
22
- [build-system]
23
- requires = ["hatchling"]
24
- build-backend = "hatchling.build"
25
-
26
- [tool.hatch.build.targets.wheel]
27
- packages = ["src/pix"]
28
-
29
- [tool.pytest.ini_options]
30
- testpaths = ["tests"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes