pix-cli 0.1.1__tar.gz → 0.1.3__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.
- {pix_cli-0.1.1 → pix_cli-0.1.3}/AGENTS.md +4 -4
- {pix_cli-0.1.1 → pix_cli-0.1.3}/PKG-INFO +20 -20
- {pix_cli-0.1.1 → pix_cli-0.1.3}/README.md +18 -18
- {pix_cli-0.1.1 → pix_cli-0.1.3}/README.zh-CN.md +18 -18
- {pix_cli-0.1.1 → pix_cli-0.1.3}/docs/architecture.md +26 -25
- {pix_cli-0.1.1 → pix_cli-0.1.3}/docs/decisions.md +19 -11
- {pix_cli-0.1.1 → pix_cli-0.1.3}/docs/development.md +9 -9
- {pix_cli-0.1.1 → pix_cli-0.1.3}/docs/roadmap.md +9 -9
- {pix_cli-0.1.1 → pix_cli-0.1.3}/pyproject.toml +2 -2
- {pix_cli-0.1.1 → pix_cli-0.1.3}/src/pix/__init__.py +1 -1
- {pix_cli-0.1.1 → pix_cli-0.1.3}/src/pix/cli.py +11 -11
- {pix_cli-0.1.1 → pix_cli-0.1.3}/src/pix/config.py +15 -27
- {pix_cli-0.1.1 → pix_cli-0.1.3}/src/pix/default_config.toml +1 -1
- {pix_cli-0.1.1 → pix_cli-0.1.3}/src/pix/logger.py +19 -0
- {pix_cli-0.1.1 → pix_cli-0.1.3}/src/pix/shrink.py +4 -1
- {pix_cli-0.1.1 → pix_cli-0.1.3}/src/pix/sync.py +3 -3
- {pix_cli-0.1.1 → pix_cli-0.1.3}/tests/test_cli.py +49 -21
- {pix_cli-0.1.1 → pix_cli-0.1.3}/uv.lock +1 -1
- {pix_cli-0.1.1 → pix_cli-0.1.3}/.github/workflows/ci.yml +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.3}/.github/workflows/publish.yml +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.3}/.gitignore +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.3}/.python-version +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.3}/LICENSE +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.3}/src/pix/doctor.py +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.3}/src/pix/py.typed +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.3}/src/pix/rclone.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AGENTS.md
|
|
2
2
|
|
|
3
|
-
`pix` 是批量图片压缩(AVIF)与 rclone 图床同步 CLI。已实现 `shrink` / `
|
|
3
|
+
`pix` 是批量图片压缩(AVIF)与 rclone 图床同步 CLI。已实现 `shrink` / `mirror` / `publish` / `doctor` / `config init`;行为规格见 `docs/architecture.md`。
|
|
4
4
|
|
|
5
5
|
## 文档
|
|
6
6
|
|
|
@@ -31,9 +31,9 @@ uv build
|
|
|
31
31
|
- `pyvips` 只在 `shrink` / `doctor` 内延迟导入。
|
|
32
32
|
- 不通过 shell 调用 `vips` 或 `rclone`。
|
|
33
33
|
- `-q` 是 quality,`--quiet` 是静默。
|
|
34
|
-
- 退出码 0/1/2,日志走 stderr;`
|
|
35
|
-
- `
|
|
36
|
-
- `--max-delete` 默认 10(删除百分比,rclone bisync 语义);`
|
|
34
|
+
- 退出码 0/1/2,日志走 stderr;`mirror` / `publish` 透传 rclone 退出码。
|
|
35
|
+
- `mirror` / `publish` 使用 `rclone bisync`;首次运行必须显式 `--resync`,不要自动加。它们是双向镜像,不是单向备份。
|
|
36
|
+
- `--max-delete` 默认 10(删除百分比,rclone bisync 语义);`mirror` / `publish` 源为空时默认拒绝,`--allow-empty` 加 `--resync` 才放行。
|
|
37
37
|
- 修改排除项后必须提示用户先 `--resync`;`--exclude` 是配置在前、命令行追加并去重。
|
|
38
38
|
- 增量状态固定在 `OUT/.pix/state.json`,每 200 个成功文件刷新并在 `finally` 落盘;`publish` 必须排除 `.pix/**`。
|
|
39
39
|
- `shrink` 必须拒绝目标冲突,`--prune` 必须拒绝空源,非 dry-run 必须持有 `OUT/.pix/lock`。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pix-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Batch AVIF shrink and image-host sync CLI
|
|
5
5
|
Project-URL: Homepage, https://github.com/uzvg/pix
|
|
6
6
|
Project-URL: Repository, https://github.com/uzvg/pix
|
|
@@ -21,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.14
|
|
23
23
|
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
|
|
24
|
-
Classifier: Topic :: System :: Archiving ::
|
|
24
|
+
Classifier: Topic :: System :: Archiving :: Mirroring
|
|
25
25
|
Requires-Python: >=3.11
|
|
26
26
|
Requires-Dist: pyvips[binary]>=3.2
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
@@ -41,7 +41,7 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
| Command | Purpose |
|
|
42
42
|
|---|---|
|
|
43
43
|
| `pix shrink` | Recursively compress images to AVIF, re-encoding only changed files |
|
|
44
|
-
| `pix
|
|
44
|
+
| `pix mirror` | Bidirectionally mirror an original photo directory to a cloud drive |
|
|
45
45
|
| `pix publish` | Bidirectionally mirror AVIF output to an image host; optionally emit public URLs |
|
|
46
46
|
| `pix doctor` | Check Python, libvips, rclone and configuration |
|
|
47
47
|
| `pix config init` | Write a commented default configuration file |
|
|
@@ -58,25 +58,25 @@ Description-Content-Type: text/markdown
|
|
|
58
58
|
photos/ ── shrink (pyvips) ──> out/*.avif + out/.pix/state.json
|
|
59
59
|
│
|
|
60
60
|
└── publish (rclone bisync) ──> r2:images
|
|
61
|
-
photos/ ──
|
|
61
|
+
photos/ ── mirror (rclone bisync) ────────────────────────────────> onedrive:photos
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
| Command | What actually runs | Who does the work |
|
|
65
65
|
|---|---|---|
|
|
66
66
|
| `shrink` | pyvips thumbnail + AVIF encode | pix |
|
|
67
|
-
| `
|
|
67
|
+
| `mirror` | `rclone bisync SRC REMOTE` | pix composes arguments, rclone executes |
|
|
68
68
|
| `publish` | `rclone bisync OUT REMOTE`, excluding `.pix/**` | pix + rclone |
|
|
69
69
|
| `doctor` / `config init` | Dependency checks / config scaffold | pix |
|
|
70
70
|
|
|
71
|
-
**Sync is rclone.** `
|
|
71
|
+
**Sync is rclone.** `mirror` 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
72
|
|
|
73
|
-
`
|
|
73
|
+
`mirror` / `publish` are **bidirectional mirrors**, not one-way copies. Deletions propagate in both directions.
|
|
74
74
|
|
|
75
75
|
## Requirements
|
|
76
76
|
|
|
77
77
|
- Python 3.11+
|
|
78
78
|
- [uv](https://docs.astral.sh/uv/)
|
|
79
|
-
- rclone >= 1.66 for `
|
|
79
|
+
- rclone >= 1.66 for `mirror` / `publish`
|
|
80
80
|
- At least one remote already configured in rclone
|
|
81
81
|
|
|
82
82
|
`pyvips[binary]` bundles libvips, so no separate vips installation is required.
|
|
@@ -96,7 +96,7 @@ Linux installs `~/.local/bin/pix`; Windows installs `%USERPROFILE%\.local\bin\pi
|
|
|
96
96
|
|
|
97
97
|
## Configure rclone
|
|
98
98
|
|
|
99
|
-
Only needed for `
|
|
99
|
+
Only needed for `mirror` / `publish`:
|
|
100
100
|
|
|
101
101
|
```bash
|
|
102
102
|
rclone config # create a remote, for example onedrive or r2
|
|
@@ -129,12 +129,12 @@ Common keys:
|
|
|
129
129
|
| | `effort` | `5` | libvips effort, 0–9 |
|
|
130
130
|
| | `min_size` | `0` | Skip sources smaller than this; accepts `500K`, `1M`, `2G` |
|
|
131
131
|
| | `include` | `jpg`, `jpeg`, `png`, `webp` | Extensions to scan, without dots |
|
|
132
|
-
| `[
|
|
132
|
+
| `[mirror]` | `exclude` | `[]` | rclone exclude globs; command-line values are appended |
|
|
133
133
|
| | `max_delete` | `10` | Delete limit as a percentage; `-1` disables, `0` forbids |
|
|
134
134
|
| `[publish]` | `base_url` | `""` | Public URL prefix; must start with `http://` or `https://` |
|
|
135
135
|
| | `urls` | `false` | Emit a JSON URL manifest |
|
|
136
|
-
| | `exclude` | `[]` | Same as
|
|
137
|
-
| | `max_delete` | `10` | Same as
|
|
136
|
+
| | `exclude` | `[]` | Same as mirror |
|
|
137
|
+
| | `max_delete` | `10` | Same as mirror |
|
|
138
138
|
| `[rclone]` | `binary` | `rclone` | rclone executable name or path |
|
|
139
139
|
|
|
140
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).
|
|
@@ -156,11 +156,11 @@ Precedence is **command line > pix config > built-in defaults**. `--exclude` is
|
|
|
156
156
|
|
|
157
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
158
|
|
|
159
|
-
3.
|
|
159
|
+
3. Mirror originals:
|
|
160
160
|
|
|
161
161
|
```bash
|
|
162
|
-
pix
|
|
163
|
-
pix
|
|
162
|
+
pix mirror -s ~/Pictures -d onedrive:photos --dry-run
|
|
163
|
+
pix mirror -s ~/Pictures -d onedrive:photos --resync
|
|
164
164
|
```
|
|
165
165
|
|
|
166
166
|
`--resync` is required on the first run. Always preview with `--dry-run` first.
|
|
@@ -178,7 +178,7 @@ Precedence is **command line > pix config > built-in defaults**. `--exclude` is
|
|
|
178
178
|
|
|
179
179
|
```bash
|
|
180
180
|
pix shrink -s ~/Pictures -d ~/Pictures-avif
|
|
181
|
-
pix
|
|
181
|
+
pix mirror -s ~/Pictures -d onedrive:photos
|
|
182
182
|
pix publish -s ~/Pictures-avif -d r2:images --urls
|
|
183
183
|
```
|
|
184
184
|
|
|
@@ -191,8 +191,8 @@ Precedence is **command line > pix config > built-in defaults**. `--exclude` is
|
|
|
191
191
|
| Preview compression | `pix shrink -s SRC -d OUT --dry-run` |
|
|
192
192
|
| Re-encode everything | `pix shrink -s SRC -d OUT --force` |
|
|
193
193
|
| Remove outputs whose source is gone | `pix shrink -s SRC -d OUT --prune` |
|
|
194
|
-
| Preview a sync | `pix
|
|
195
|
-
| First run, or after changing excludes | `pix
|
|
194
|
+
| Preview a sync | `pix mirror -s SRC -d REMOTE --dry-run` |
|
|
195
|
+
| First run, or after changing excludes | `pix mirror -s SRC -d REMOTE --resync` |
|
|
196
196
|
| Emit a URL manifest | `pix publish -s OUT -d REMOTE --base-url https://img.example.com --urls` |
|
|
197
197
|
| Diagnose the environment | `pix doctor` |
|
|
198
198
|
| Generate a config file | `pix config init` |
|
|
@@ -204,7 +204,7 @@ Precedence is **command line > pix config > built-in defaults**. `--exclude` is
|
|
|
204
204
|
| Deletions on either side | Propagated in both directions; accidental deletion is irreversible | Preview with `--dry-run`; default `--max-delete 10` (percentage) |
|
|
205
205
|
| First sync | rclone has no baseline and errors | Add `--resync` explicitly |
|
|
206
206
|
| Excludes changed | The old baseline may treat excluded files as deletions | Run with `--resync` again after changing excludes |
|
|
207
|
-
| Empty source | `
|
|
207
|
+
| Empty source | `mirror` / `publish` refuse; `shrink --prune` also refuses | Use `--allow-empty` (plus `--resync` for sync) only when intentional |
|
|
208
208
|
| `a.jpg` and `a.jpeg` both exist | Both map to the same output | Detected before encoding; exits 2 |
|
|
209
209
|
| Two concurrent shrinks | They would write the same state and outputs | `OUT/.pix/lock` blocks the second process |
|
|
210
210
|
| State lost or corrupt | Warning, then a full rebuild | Only causes re-encoding; outputs stay valid |
|
|
@@ -214,7 +214,7 @@ Precedence is **command line > pix config > built-in defaults**. `--exclude` is
|
|
|
214
214
|
- Logs and progress go to stderr.
|
|
215
215
|
- stdout carries `doctor` reports, `--dry-run` plans and `--urls` JSON.
|
|
216
216
|
- Exit codes: `0` success, `1` runtime failure, `2` argument or configuration error.
|
|
217
|
-
- `
|
|
217
|
+
- `mirror` / `publish` return rclone's exit code unchanged.
|
|
218
218
|
|
|
219
219
|
URL manifest example:
|
|
220
220
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| Command | Purpose |
|
|
14
14
|
|---|---|
|
|
15
15
|
| `pix shrink` | Recursively compress images to AVIF, re-encoding only changed files |
|
|
16
|
-
| `pix
|
|
16
|
+
| `pix mirror` | Bidirectionally mirror an original photo directory to a cloud drive |
|
|
17
17
|
| `pix publish` | Bidirectionally mirror AVIF output to an image host; optionally emit public URLs |
|
|
18
18
|
| `pix doctor` | Check Python, libvips, rclone and configuration |
|
|
19
19
|
| `pix config init` | Write a commented default configuration file |
|
|
@@ -30,25 +30,25 @@
|
|
|
30
30
|
photos/ ── shrink (pyvips) ──> out/*.avif + out/.pix/state.json
|
|
31
31
|
│
|
|
32
32
|
└── publish (rclone bisync) ──> r2:images
|
|
33
|
-
photos/ ──
|
|
33
|
+
photos/ ── mirror (rclone bisync) ────────────────────────────────> onedrive:photos
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
| Command | What actually runs | Who does the work |
|
|
37
37
|
|---|---|---|
|
|
38
38
|
| `shrink` | pyvips thumbnail + AVIF encode | pix |
|
|
39
|
-
| `
|
|
39
|
+
| `mirror` | `rclone bisync SRC REMOTE` | pix composes arguments, rclone executes |
|
|
40
40
|
| `publish` | `rclone bisync OUT REMOTE`, excluding `.pix/**` | pix + rclone |
|
|
41
41
|
| `doctor` / `config init` | Dependency checks / config scaffold | pix |
|
|
42
42
|
|
|
43
|
-
**Sync is rclone.** `
|
|
43
|
+
**Sync is rclone.** `mirror` 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
44
|
|
|
45
|
-
`
|
|
45
|
+
`mirror` / `publish` are **bidirectional mirrors**, not one-way copies. Deletions propagate in both directions.
|
|
46
46
|
|
|
47
47
|
## Requirements
|
|
48
48
|
|
|
49
49
|
- Python 3.11+
|
|
50
50
|
- [uv](https://docs.astral.sh/uv/)
|
|
51
|
-
- rclone >= 1.66 for `
|
|
51
|
+
- rclone >= 1.66 for `mirror` / `publish`
|
|
52
52
|
- At least one remote already configured in rclone
|
|
53
53
|
|
|
54
54
|
`pyvips[binary]` bundles libvips, so no separate vips installation is required.
|
|
@@ -68,7 +68,7 @@ Linux installs `~/.local/bin/pix`; Windows installs `%USERPROFILE%\.local\bin\pi
|
|
|
68
68
|
|
|
69
69
|
## Configure rclone
|
|
70
70
|
|
|
71
|
-
Only needed for `
|
|
71
|
+
Only needed for `mirror` / `publish`:
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
74
|
rclone config # create a remote, for example onedrive or r2
|
|
@@ -101,12 +101,12 @@ Common keys:
|
|
|
101
101
|
| | `effort` | `5` | libvips effort, 0–9 |
|
|
102
102
|
| | `min_size` | `0` | Skip sources smaller than this; accepts `500K`, `1M`, `2G` |
|
|
103
103
|
| | `include` | `jpg`, `jpeg`, `png`, `webp` | Extensions to scan, without dots |
|
|
104
|
-
| `[
|
|
104
|
+
| `[mirror]` | `exclude` | `[]` | rclone exclude globs; command-line values are appended |
|
|
105
105
|
| | `max_delete` | `10` | Delete limit as a percentage; `-1` disables, `0` forbids |
|
|
106
106
|
| `[publish]` | `base_url` | `""` | Public URL prefix; must start with `http://` or `https://` |
|
|
107
107
|
| | `urls` | `false` | Emit a JSON URL manifest |
|
|
108
|
-
| | `exclude` | `[]` | Same as
|
|
109
|
-
| | `max_delete` | `10` | Same as
|
|
108
|
+
| | `exclude` | `[]` | Same as mirror |
|
|
109
|
+
| | `max_delete` | `10` | Same as mirror |
|
|
110
110
|
| `[rclone]` | `binary` | `rclone` | rclone executable name or path |
|
|
111
111
|
|
|
112
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).
|
|
@@ -128,11 +128,11 @@ Precedence is **command line > pix config > built-in defaults**. `--exclude` is
|
|
|
128
128
|
|
|
129
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
130
|
|
|
131
|
-
3.
|
|
131
|
+
3. Mirror originals:
|
|
132
132
|
|
|
133
133
|
```bash
|
|
134
|
-
pix
|
|
135
|
-
pix
|
|
134
|
+
pix mirror -s ~/Pictures -d onedrive:photos --dry-run
|
|
135
|
+
pix mirror -s ~/Pictures -d onedrive:photos --resync
|
|
136
136
|
```
|
|
137
137
|
|
|
138
138
|
`--resync` is required on the first run. Always preview with `--dry-run` first.
|
|
@@ -150,7 +150,7 @@ Precedence is **command line > pix config > built-in defaults**. `--exclude` is
|
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
152
|
pix shrink -s ~/Pictures -d ~/Pictures-avif
|
|
153
|
-
pix
|
|
153
|
+
pix mirror -s ~/Pictures -d onedrive:photos
|
|
154
154
|
pix publish -s ~/Pictures-avif -d r2:images --urls
|
|
155
155
|
```
|
|
156
156
|
|
|
@@ -163,8 +163,8 @@ Precedence is **command line > pix config > built-in defaults**. `--exclude` is
|
|
|
163
163
|
| Preview compression | `pix shrink -s SRC -d OUT --dry-run` |
|
|
164
164
|
| Re-encode everything | `pix shrink -s SRC -d OUT --force` |
|
|
165
165
|
| Remove outputs whose source is gone | `pix shrink -s SRC -d OUT --prune` |
|
|
166
|
-
| Preview a sync | `pix
|
|
167
|
-
| First run, or after changing excludes | `pix
|
|
166
|
+
| Preview a sync | `pix mirror -s SRC -d REMOTE --dry-run` |
|
|
167
|
+
| First run, or after changing excludes | `pix mirror -s SRC -d REMOTE --resync` |
|
|
168
168
|
| Emit a URL manifest | `pix publish -s OUT -d REMOTE --base-url https://img.example.com --urls` |
|
|
169
169
|
| Diagnose the environment | `pix doctor` |
|
|
170
170
|
| Generate a config file | `pix config init` |
|
|
@@ -176,7 +176,7 @@ Precedence is **command line > pix config > built-in defaults**. `--exclude` is
|
|
|
176
176
|
| Deletions on either side | Propagated in both directions; accidental deletion is irreversible | Preview with `--dry-run`; default `--max-delete 10` (percentage) |
|
|
177
177
|
| First sync | rclone has no baseline and errors | Add `--resync` explicitly |
|
|
178
178
|
| Excludes changed | The old baseline may treat excluded files as deletions | Run with `--resync` again after changing excludes |
|
|
179
|
-
| Empty source | `
|
|
179
|
+
| Empty source | `mirror` / `publish` refuse; `shrink --prune` also refuses | Use `--allow-empty` (plus `--resync` for sync) only when intentional |
|
|
180
180
|
| `a.jpg` and `a.jpeg` both exist | Both map to the same output | Detected before encoding; exits 2 |
|
|
181
181
|
| Two concurrent shrinks | They would write the same state and outputs | `OUT/.pix/lock` blocks the second process |
|
|
182
182
|
| State lost or corrupt | Warning, then a full rebuild | Only causes re-encoding; outputs stay valid |
|
|
@@ -186,7 +186,7 @@ Precedence is **command line > pix config > built-in defaults**. `--exclude` is
|
|
|
186
186
|
- Logs and progress go to stderr.
|
|
187
187
|
- stdout carries `doctor` reports, `--dry-run` plans and `--urls` JSON.
|
|
188
188
|
- Exit codes: `0` success, `1` runtime failure, `2` argument or configuration error.
|
|
189
|
-
- `
|
|
189
|
+
- `mirror` / `publish` return rclone's exit code unchanged.
|
|
190
190
|
|
|
191
191
|
URL manifest example:
|
|
192
192
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| 命令 | 作用 |
|
|
14
14
|
|---|---|
|
|
15
15
|
| `pix shrink` | 递归压缩图片为 AVIF,只重新编码变化的文件 |
|
|
16
|
-
| `pix
|
|
16
|
+
| `pix mirror` | 把原图目录与云盘做双向镜像 |
|
|
17
17
|
| `pix publish` | 把 AVIF 输出目录与图床做双向镜像,可选输出公开 URL |
|
|
18
18
|
| `pix doctor` | 检查 Python、libvips、rclone 与配置 |
|
|
19
19
|
| `pix config init` | 生成带注释的默认配置文件 |
|
|
@@ -30,25 +30,25 @@
|
|
|
30
30
|
photos/ ── shrink (pyvips) ──> out/*.avif + out/.pix/state.json
|
|
31
31
|
│
|
|
32
32
|
└── publish (rclone bisync) ──> r2:images
|
|
33
|
-
photos/ ──
|
|
33
|
+
photos/ ── mirror (rclone bisync) ────────────────────────────────> onedrive:photos
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
| 命令 | 实际执行的内容 | 谁负责 |
|
|
37
37
|
|---|---|---|
|
|
38
38
|
| `shrink` | pyvips 缩放并编码 AVIF | pix |
|
|
39
|
-
| `
|
|
39
|
+
| `mirror` | `rclone bisync SRC REMOTE` | pix 拼参数,rclone 执行 |
|
|
40
40
|
| `publish` | `rclone bisync OUT REMOTE`,排除 `.pix/**` | pix + rclone |
|
|
41
41
|
| `doctor` / `config init` | 依赖检查 / 配置脚手架 | pix |
|
|
42
42
|
|
|
43
|
-
**同步由 rclone 执行。** `
|
|
43
|
+
**同步由 rclone 执行。** `mirror` / `publish` 只负责拼装 `rclone bisync` 参数;认证、传输和冲突处理都由 rclone 完成。使用前请先用 `rclone config` 配好 remote。`pix` 不读取也不写入 `rclone.conf`。
|
|
44
44
|
|
|
45
|
-
`
|
|
45
|
+
`mirror` / `publish` 是**双向镜像**,不是单向备份。删除会双向传播。
|
|
46
46
|
|
|
47
47
|
## 前置条件
|
|
48
48
|
|
|
49
49
|
- Python 3.11+
|
|
50
50
|
- [uv](https://docs.astral.sh/uv/)
|
|
51
|
-
- `
|
|
51
|
+
- `mirror` / `publish` 需要 rclone >= 1.66
|
|
52
52
|
- 已在 rclone 中配置好至少一个 remote
|
|
53
53
|
|
|
54
54
|
`pyvips[binary]` 自带 libvips,不需要单独安装 vips。
|
|
@@ -68,7 +68,7 @@ Linux 安装到 `~/.local/bin/pix`,Windows 为 `%USERPROFILE%\.local\bin\pix.e
|
|
|
68
68
|
|
|
69
69
|
## 配置 rclone
|
|
70
70
|
|
|
71
|
-
仅 `
|
|
71
|
+
仅 `mirror` / `publish` 需要:
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
74
|
rclone config # 新建 remote,例如 onedrive 或 r2
|
|
@@ -101,12 +101,12 @@ pix config init
|
|
|
101
101
|
| | `effort` | `5` | libvips effort,0–9 |
|
|
102
102
|
| | `min_size` | `0` | 小于该值的源文件跳过;支持 `500K`、`1M`、`2G` |
|
|
103
103
|
| | `include` | `jpg`、`jpeg`、`png`、`webp` | 扫描扩展名,不带点 |
|
|
104
|
-
| `[
|
|
104
|
+
| `[mirror]` | `exclude` | `[]` | rclone 排除 glob;命令行项会追加 |
|
|
105
105
|
| | `max_delete` | `10` | 删除百分比上限;`-1` 不限制,`0` 禁止删除 |
|
|
106
106
|
| `[publish]` | `base_url` | `""` | 公开 URL 前缀,必须以 `http://` 或 `https://` 开头 |
|
|
107
107
|
| | `urls` | `false` | 是否输出 JSON URL 清单 |
|
|
108
|
-
| | `exclude` | `[]` | 同 `
|
|
109
|
-
| | `max_delete` | `10` | 同 `
|
|
108
|
+
| | `exclude` | `[]` | 同 `mirror` |
|
|
109
|
+
| | `max_delete` | `10` | 同 `mirror` |
|
|
110
110
|
| `[rclone]` | `binary` | `rclone` | rclone 可执行文件名或路径 |
|
|
111
111
|
|
|
112
112
|
优先级为**命令行 > pix 配置 > 内置默认**。`--exclude` 是追加而非覆盖配置项。`-q` 表示 quality,静默模式用 `--quiet`(无短选项)。
|
|
@@ -128,11 +128,11 @@ pix config init
|
|
|
128
128
|
|
|
129
129
|
首次全量编码,之后跳过未变化的文件。状态保存在 `OUT/.pix/state.json`。修改 `width`、`quality` 或 `effort` 会触发全量重编码。`--dry-run` 只打印计划。
|
|
130
130
|
|
|
131
|
-
3.
|
|
131
|
+
3. 镜像原图:
|
|
132
132
|
|
|
133
133
|
```bash
|
|
134
|
-
pix
|
|
135
|
-
pix
|
|
134
|
+
pix mirror -s ~/Pictures -d onedrive:photos --dry-run
|
|
135
|
+
pix mirror -s ~/Pictures -d onedrive:photos --resync
|
|
136
136
|
```
|
|
137
137
|
|
|
138
138
|
首次运行必须显式加 `--resync`。建议先跑 `--dry-run` 预览。
|
|
@@ -150,7 +150,7 @@ pix config init
|
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
152
|
pix shrink -s ~/Pictures -d ~/Pictures-avif
|
|
153
|
-
pix
|
|
153
|
+
pix mirror -s ~/Pictures -d onedrive:photos
|
|
154
154
|
pix publish -s ~/Pictures-avif -d r2:images --urls
|
|
155
155
|
```
|
|
156
156
|
|
|
@@ -163,8 +163,8 @@ pix config init
|
|
|
163
163
|
| 预览压缩 | `pix shrink -s SRC -d OUT --dry-run` |
|
|
164
164
|
| 强制全部重压 | `pix shrink -s SRC -d OUT --force` |
|
|
165
165
|
| 删除源已消失的输出 | `pix shrink -s SRC -d OUT --prune` |
|
|
166
|
-
| 预览同步 | `pix
|
|
167
|
-
| 首次运行或修改排除项后 | `pix
|
|
166
|
+
| 预览同步 | `pix mirror -s SRC -d REMOTE --dry-run` |
|
|
167
|
+
| 首次运行或修改排除项后 | `pix mirror -s SRC -d REMOTE --resync` |
|
|
168
168
|
| 输出 URL 清单 | `pix publish -s OUT -d REMOTE --base-url https://img.example.com --urls` |
|
|
169
169
|
| 诊断环境 | `pix doctor` |
|
|
170
170
|
| 生成配置文件 | `pix config init` |
|
|
@@ -176,7 +176,7 @@ pix config init
|
|
|
176
176
|
| 任意一侧删除文件 | 删除双向传播,误删不可逆 | 先 `--dry-run`;默认 `--max-delete 10`(百分比) |
|
|
177
177
|
| 首次同步 | rclone 没有基线,直接报错 | 显式加 `--resync` |
|
|
178
178
|
| 修改排除项 | 旧基线可能把被排除的文件当成删除传播 | 修改后必须重新 `--resync` |
|
|
179
|
-
| 源目录为空 | `
|
|
179
|
+
| 源目录为空 | `mirror` / `publish` 默认拒绝;`shrink --prune` 同样拒绝 | 仅在确认要清空时用 `--allow-empty`(同步还需 `--resync`) |
|
|
180
180
|
| `a.jpg` 与 `a.jpeg` 同时存在 | 映射到同一输出 | 编码前检测并以 2 退出 |
|
|
181
181
|
| 同时运行两个 shrink | 会写同一份状态和输出 | `OUT/.pix/lock` 阻止第二个进程 |
|
|
182
182
|
| 状态文件丢失或损坏 | 告警后全量重建 | 只会重新编码,不会产生错误结果 |
|
|
@@ -186,7 +186,7 @@ pix config init
|
|
|
186
186
|
- 日志和进度输出到 stderr。
|
|
187
187
|
- stdout 用于 `doctor` 报告、`--dry-run` 计划和 `--urls` JSON。
|
|
188
188
|
- 退出码:`0` 成功,`1` 运行失败,`2` 参数或配置错误。
|
|
189
|
-
- `
|
|
189
|
+
- `mirror` / `publish` 原样返回 rclone 的退出码。
|
|
190
190
|
|
|
191
191
|
URL 清单示例:
|
|
192
192
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
`pix` 是独立的图床流水线 CLI:
|
|
8
8
|
|
|
9
9
|
- 把本地图片目录增量压缩为 AVIF。
|
|
10
|
-
-
|
|
10
|
+
- 把原始图片目录双向镜像到 OneDrive 等 rclone 兼容云盘。
|
|
11
11
|
- 把压缩结果双向同步到 Cloudflare R2 等 rclone 兼容图床。
|
|
12
12
|
|
|
13
13
|
`pix` 只处理用户显式传入的本地路径和 rclone 远端,不感知项目类型或版本控制状态。所有路径和远端都由命令行或用户级配置提供。
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
第一版做:
|
|
25
25
|
|
|
26
26
|
- `pix shrink`:递归扫描图片,按宽度等比缩放并输出 AVIF,带增量状态。
|
|
27
|
-
- `pix
|
|
27
|
+
- `pix mirror`:把原始图片目录与 rclone 远端双向同步。
|
|
28
28
|
- `pix publish`:把压缩目录与图床远端双向同步,可选输出公开 URL 清单。
|
|
29
29
|
- `pix doctor`:检查运行依赖、rclone bisync 能力和配置。
|
|
30
30
|
- `pix config init`:把带注释的默认配置写到默认路径,供用户修改。
|
|
@@ -44,19 +44,19 @@
|
|
|
44
44
|
|
|
45
45
|
- Python `>=3.11`。
|
|
46
46
|
- 仅标准库 + `pyvips[binary]`。
|
|
47
|
-
- 运行时依赖外部 `rclone >= 1.66`(bisync 稳定版),由 `
|
|
47
|
+
- 运行时依赖外部 `rclone >= 1.66`(bisync 稳定版),由 `mirror` / `publish` 调用。
|
|
48
48
|
|
|
49
49
|
```text
|
|
50
50
|
src/pix/
|
|
51
51
|
├── __init__.py # 包版本
|
|
52
52
|
├── py.typed
|
|
53
53
|
├── cli.py # 组合根:parser、main、子命令分派
|
|
54
|
-
├── config.py #
|
|
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 探测、版本检查与执行
|
|
59
|
-
├── sync.py #
|
|
59
|
+
├── sync.py # mirror / publish 子命令
|
|
60
60
|
└── doctor.py # doctor 子命令
|
|
61
61
|
tests/
|
|
62
62
|
└── test_cli.py
|
|
@@ -77,7 +77,7 @@ tests/
|
|
|
77
77
|
| `config` | `parse_size(value: str \| int) -> int` | 尺寸字符串转字节 |
|
|
78
78
|
| `config` | `init_config(args) -> int` | `config init` 子命令:写入默认配置 |
|
|
79
79
|
| `shrink` | `shrink(args, settings, log) -> int` | `shrink` 子命令 |
|
|
80
|
-
| `sync` | `
|
|
80
|
+
| `sync` | `mirror(args, settings, log) -> int` | `mirror` 子命令 |
|
|
81
81
|
| `sync` | `publish(args, settings, log) -> int` | `publish` 子命令 |
|
|
82
82
|
| `doctor` | `doctor(args, settings, log) -> int` | `doctor` 子命令 |
|
|
83
83
|
| `rclone` | `run_rclone(cmd: list[str]) -> int` | 调用 rclone 并返回其退出码 |
|
|
@@ -95,13 +95,13 @@ pix.rclone ──> subprocess
|
|
|
95
95
|
## 3. CLI 契约
|
|
96
96
|
|
|
97
97
|
```bash
|
|
98
|
-
pix [--config PATH] [--version] {shrink,
|
|
98
|
+
pix [--config PATH] [--version] {shrink,mirror,publish,doctor,config}
|
|
99
99
|
|
|
100
100
|
pix shrink -s SRC -d OUT
|
|
101
101
|
[-w 1920] [-q 85] [-e 5] [-m 0]
|
|
102
102
|
[--force] [--prune] [--allow-empty] [--dry-run] [-v|--quiet]
|
|
103
103
|
|
|
104
|
-
pix
|
|
104
|
+
pix mirror -s SRC -d REMOTE
|
|
105
105
|
[--exclude GLOB ...] [--max-delete PERCENT] [--allow-empty] [--resync]
|
|
106
106
|
[--dry-run] [-v|--quiet]
|
|
107
107
|
|
|
@@ -120,15 +120,15 @@ pix config init [--force]
|
|
|
120
120
|
- `--config` 属于主 parser,写在子命令之前。
|
|
121
121
|
- `--version` 输出 `pix <version>` 后以 0 退出。
|
|
122
122
|
- `-s` 为源,`-d` 为目标。
|
|
123
|
-
- `shrink` 的 `-d` 是本地目录;`
|
|
123
|
+
- `shrink` 的 `-d` 是本地目录;`mirror` / `publish` 的 `-d` 是 rclone 路径,可以是 remote,也可以是本地目录。
|
|
124
124
|
- `-q` 只表示 quality;安静模式使用 `--quiet`,不提供短选项。
|
|
125
125
|
- `-m/--min-size` 表示“小于该大小的源文件跳过”,`0` 表示不限制;支持 `500K` / `1M` / `2G`,按 1024 进制。
|
|
126
126
|
- `-v` 与 `--quiet` 互斥。
|
|
127
|
-
- `--dry-run` 不产生本地写入;`
|
|
127
|
+
- `--dry-run` 不产生本地写入;`mirror` / `publish` 中原样传给 rclone。与 `shrink --prune` 同用时只列出待删除项。
|
|
128
128
|
- `--exclude` 可重复。配置中的排除项在前,命令行排除项追加在后,保留顺序并去重。
|
|
129
|
-
- `--max-delete` 只用于 `
|
|
130
|
-
- `--allow-empty` 只用于 `shrink --prune`、`
|
|
131
|
-
- `--resync` 只用于 `
|
|
129
|
+
- `--max-delete` 只用于 `mirror` / `publish`,按删除百分比限制(rclone bisync 语义,不是数量),默认 `10`(即 10%);`0` 禁止删除,`-1` 不限制,最大 `100`。超过比例时 rclone 拒绝运行。
|
|
130
|
+
- `--allow-empty` 只用于 `shrink --prune`、`mirror` 和 `publish`:允许源为空时继续执行破坏性操作。默认拒绝;`mirror` / `publish` 还必须同时带 `--resync`。
|
|
131
|
+
- `--resync` 只用于 `mirror` / `publish`,原样传给 rclone。
|
|
132
132
|
- `--force` 只用于 `shrink`。
|
|
133
133
|
- `--prune` 只用于 `shrink`,默认关闭。
|
|
134
134
|
- `--base-url` / `--urls` / `--no-urls` 只用于 `publish`;`--no-urls` 可临时关闭配置中的 `[publish].urls = true`。
|
|
@@ -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]
|
|
@@ -160,7 +160,7 @@ effort = 5
|
|
|
160
160
|
min_size = 0
|
|
161
161
|
include = ["jpg", "jpeg", "png", "webp"]
|
|
162
162
|
|
|
163
|
-
[
|
|
163
|
+
[mirror]
|
|
164
164
|
exclude = []
|
|
165
165
|
max_delete = 10
|
|
166
166
|
|
|
@@ -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
|
|
|
@@ -265,9 +266,9 @@ binary = "rclone"
|
|
|
265
266
|
- 删除失败记录错误,最后返回 1。
|
|
266
267
|
- 默认关闭,避免误删。
|
|
267
268
|
|
|
268
|
-
## 6.
|
|
269
|
+
## 6. mirror 行为
|
|
269
270
|
|
|
270
|
-
`
|
|
271
|
+
`mirror` 把原始图片目录与远端做 **rclone bisync 双向同步**。它是双向镜像,不是单向备份。
|
|
271
272
|
|
|
272
273
|
命令拼装:
|
|
273
274
|
|
|
@@ -286,7 +287,7 @@ cmd.append("--progress" if verbose else "--quiet")
|
|
|
286
287
|
- 首次运行必须显式加 `--resync`。`pix` 不自动添加,避免两边都有内容时悄悄覆盖一侧。
|
|
287
288
|
- 不加 `--resync` 且 bisync 基线不存在时,rclone 会报错,`pix` 原样返回其退出码。
|
|
288
289
|
- `-v` 映射 `--progress`,默认映射 `--quiet`。
|
|
289
|
-
- `--exclude` 按配置在前、命令行在后合并,逐条传给 rclone;`pix`
|
|
290
|
+
- `--exclude` 按配置在前、命令行在后合并,逐条传给 rclone;`pix` 不内置任何镜像排除规则。
|
|
290
291
|
- **修改排除项后必须先 `--resync`**:rclone 要求过滤器变更后重建基线,否则被排除的文件可能被当作删除传播。先跑 `--dry-run`。
|
|
291
292
|
- `--max-delete` 默认 `10`,是删除百分比;一次运行要删除的比例超过 10% 时 rclone 会拒绝。确认要大量删除时显式调高,或传 `-1` 取消限制。
|
|
292
293
|
- 双向同步意味着删除会传播:本地删除会同步到远端,远端删除也会同步到本地。首次操作建议先 `--dry-run`。
|
|
@@ -297,11 +298,11 @@ cmd.append("--progress" if verbose else "--quiet")
|
|
|
297
298
|
|
|
298
299
|
## 7. publish 行为
|
|
299
300
|
|
|
300
|
-
`publish` 把 shrink 输出目录与图床远端做 **rclone bisync 双向同步**,参数与 `
|
|
301
|
+
`publish` 把 shrink 输出目录与图床远端做 **rclone bisync 双向同步**,参数与 `mirror` 相同,另加:
|
|
301
302
|
|
|
302
303
|
- `-s` 必须是已存在的本地目录,且至少包含一个 `.avif`。源为空时默认退出 2;只有显式 `--allow-empty` 且带 `--resync` 时才允许。
|
|
303
304
|
- 固定追加 `--exclude .pix/**`,防止状态文件被同步到图床。配置排除项随后,命令行排除项最后,去重。
|
|
304
|
-
- `--max-delete` 与 `
|
|
305
|
+
- `--max-delete` 与 `mirror` 相同,默认 `10`,按删除百分比计。
|
|
305
306
|
- `--base-url` 优先取命令行,其次取 `[publish].base_url`。值必须以 `http://` 或 `https://` 开头,否则退出 2。
|
|
306
307
|
- `--urls` / `--no-urls` 优先取命令行,其次取 `[publish].urls`,默认关闭;`--no-urls` 可临时关闭配置中的 `true`。
|
|
307
308
|
- 开启 URL 清单但没有可用的 `base_url` 时,在调用 rclone 前以退出码 2 拒绝。
|
|
@@ -317,7 +318,7 @@ cmd.append("--progress" if verbose else "--quiet")
|
|
|
317
318
|
- 生成清单时递归收集 OUT 下所有 `.avif`(大小写不敏感),路径段用 `urllib.parse.quote(..., safe="/")` 编码。
|
|
318
319
|
- `base_url` 末尾的 `/` 在拼接时去掉。
|
|
319
320
|
- `--dry-run` 不生成 URL 清单。
|
|
320
|
-
- 修改排除项后必须先 `--resync`,规则同 `
|
|
321
|
+
- 修改排除项后必须先 `--resync`,规则同 `mirror`。
|
|
321
322
|
- 双向同步同样会传播删除,首次操作建议先 `--dry-run`。
|
|
322
323
|
|
|
323
324
|
## 8. doctor
|
|
@@ -336,19 +337,19 @@ config /home/uzvg/.config/pix/config.toml (ok)
|
|
|
336
337
|
|
|
337
338
|
- 缺少 pyvips/libvips:打印安装提示(文案见 [development.md](development.md#7-缺少-libvips-时)),退出 1。
|
|
338
339
|
- 缺少 rclone:提示官方下载地址或 `winget search rclone`,退出 1。
|
|
339
|
-
- rclone 版本低于 1.66:提示升级,退出 1。`
|
|
340
|
+
- rclone 版本低于 1.66:提示升级,退出 1。`mirror` / `publish` 启动时也执行同样的版本检查,不依赖用户先跑 `doctor`。
|
|
340
341
|
- 配置文件解析失败:指出文件和错误位置,退出 2。
|
|
341
342
|
- 配置文件不存在:显示 `missing (using defaults)`,属于正常状态,退出 0。
|
|
342
343
|
- 多个问题同时存在时,配置错误(2)优先于依赖缺失(1)。
|
|
343
344
|
|
|
344
345
|
## 9. 日志与退出码
|
|
345
346
|
|
|
346
|
-
-
|
|
347
|
+
- 默认:阶段信息、编码进度和汇总,输出到 stderr。终端下进度原地刷新,重定向时逐行输出。
|
|
347
348
|
- `-v`:输出每个文件的处理/跳过原因,以及 rclone 的进度。
|
|
348
349
|
- `--quiet`:只输出错误。
|
|
349
350
|
- `doctor` 报告输出到 stdout;`--dry-run` 的计划文件列表和 `--urls` 的 JSON 输出到 stdout。
|
|
350
351
|
- 退出码:`0` 成功,`1` 运行失败,`2` 参数/配置错误。
|
|
351
|
-
- 例外:`
|
|
352
|
+
- 例外:`mirror` / `publish` 直接返回 rclone 的退出码,不归一化;调用方按非零即失败处理。
|
|
352
353
|
- 不引入 colorama、rich、click;仅标准库 + pyvips。
|
|
353
354
|
|
|
354
355
|
## 10. 跨平台约定
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
**决策**:`import pyvips` 只出现在 `shrink` / `doctor` 内部。
|
|
40
40
|
|
|
41
|
-
**原因**:`
|
|
41
|
+
**原因**:`mirror` / `publish` 不需要图像后端;缺少 libvips 时同步仍应可用。导入失败只在真正需要图像能力时提示。
|
|
42
42
|
|
|
43
43
|
## D7. 配置优先级:命令行 > 配置文件 > 内置默认
|
|
44
44
|
|
|
@@ -66,15 +66,15 @@
|
|
|
66
66
|
|
|
67
67
|
## D11. 使用 rclone bisync 双向同步
|
|
68
68
|
|
|
69
|
-
**决策**:`
|
|
69
|
+
**决策**:`mirror` / `publish` 都调用 `rclone bisync`,不使用单向 `copy` 或 `sync`。
|
|
70
70
|
|
|
71
71
|
**原因**:两边都可能新增、修改或删除文件,单向同步会让另一侧的改动被覆盖或永远无法回流。
|
|
72
72
|
|
|
73
|
-
**影响**:`
|
|
73
|
+
**影响**:`mirror` / `publish` 是双向镜像,不是单向备份;删除会双向传播,误删不可逆。用 `--dry-run` 预览、`--resync` 显式重建基线,并默认 `--max-delete 10`(rclone bisync 的删除百分比,`0` 禁止删除,`-1` 不限制)来控制风险;`pix` 不自动 resync。修改排除项后也必须先 `--resync`。
|
|
74
74
|
|
|
75
75
|
## D12. 不内置 rclone 排除规则
|
|
76
76
|
|
|
77
|
-
**决策**:`pix`
|
|
77
|
+
**决策**:`pix` 不硬编码镜像排除项;排除全部由 `--exclude` 传入。配置排除项在前,命令行排除项追加在后并去重。唯一例外是 `publish` 固定排除 `.pix/**`。
|
|
78
78
|
|
|
79
79
|
**原因**:排除项属于调用方的同步策略。`.pix/**` 是 `pix` 自己的状态文件,不属于用户内容,必须排除,否则会被传到图床。合并而不是覆盖,避免加一条规则时丢掉另一条。
|
|
80
80
|
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
|
|
87
87
|
## D14. `subprocess.run(list)`,退出码透传
|
|
88
88
|
|
|
89
|
-
**决策**:不经过 shell,`
|
|
89
|
+
**决策**:不经过 shell,`mirror` / `publish` 直接返回 rclone 的退出码。
|
|
90
90
|
|
|
91
91
|
**原因**:避免 shell 引号和 Windows 路径转义问题;透传退出码便于脚本判断 bisync 的具体失败原因。这是全局 `0/1/2` 约定的明确例外。
|
|
92
92
|
|
|
@@ -128,11 +128,11 @@
|
|
|
128
128
|
|
|
129
129
|
**影响**:首次运行没有状态,会全量编码一次;状态丢失只会导致重编码,不会产生错误结果。
|
|
130
130
|
|
|
131
|
-
## D21.
|
|
131
|
+
## D21. mirror 与 publish 分离
|
|
132
132
|
|
|
133
133
|
**决策**:两个子命令,而不是一个带模式开关的 `sync`。
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
**原因**:镜像原图和发布压缩图的目标、源、默认排除不同。分开后每个命令只有一个明确意图,也不需要 `--delete` 这类容易误用的开关。
|
|
136
136
|
|
|
137
137
|
## D22. URL 清单默认关闭
|
|
138
138
|
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
|
|
149
149
|
## D24. 破坏性操作先拒绝空输入与目标冲突
|
|
150
150
|
|
|
151
|
-
**决策**:`shrink --prune` 扫描到 0 个源图片但状态仍有条目时拒绝删除;`
|
|
151
|
+
**决策**:`shrink --prune` 扫描到 0 个源图片但状态仍有条目时拒绝删除;`mirror` / `publish` 源为空时要求显式 `--allow-empty` 且同时带 `--resync`;两个源文件映射到同一输出时退出 2。
|
|
152
152
|
|
|
153
153
|
**原因**:空目录最常见的来源是挂载失败、路径写错或上游失败,而不是用户真的想删除全部。目标冲突(如 `a.jpg` 与 `a.jpeg`)会让后一个编码静默覆盖前一个。这些情况都应在写入或删除前拒绝。
|
|
154
154
|
|
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
|
|
161
161
|
## D26. rclone 版本在命令入口检查
|
|
162
162
|
|
|
163
|
-
**决策**:`
|
|
163
|
+
**决策**:`mirror` / `publish` 启动时执行 `rclone version` 并要求 `>= 1.66`;`doctor` 保留同样的检查作为诊断。
|
|
164
164
|
|
|
165
165
|
**原因**:旧版 rclone 的 bisync 参数或行为不同,只靠 `doctor` 会让直接运行同步命令的用户遇到晦涩错误。版本检查放在真正调用 rclone 之前,失败信息才可操作。
|
|
166
166
|
|
|
@@ -172,6 +172,14 @@
|
|
|
172
172
|
|
|
173
173
|
## D28. 默认配置用内置模板生成
|
|
174
174
|
|
|
175
|
-
|
|
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
|
|
177
|
+
**原因**:Python 3.11 标准库只有 `tomllib`,没有 TOML 写入器;引入 `tomli-w` 违反“仅标准库 + pyvips”的约束。独立文件可以直接带注释,用户打开就能改,编辑器也能按 TOML 高亮;默认值只在这一处维护,避免代码常量与模板漂移。拒绝覆盖保护用户已经修改过的配置,误覆盖的代价远高于多敲一个 `--force`。提前分派让 `--config` 指向新路径时也能创建工作,而不是被“config file not found”挡住。
|
|
178
|
+
|
|
179
|
+
## D29. `backup` 改名为 `mirror`
|
|
180
|
+
|
|
181
|
+
**决策**:原 `backup` 子命令改名为 `mirror`,配置节 `[backup]` 同步改为 `[mirror]`,`publish` 保持不变;不保留旧名别名。
|
|
182
|
+
|
|
183
|
+
**原因**:`backup` 暗示单向、只增、可回滚,但实现是 `rclone bisync` 双向镜像,删除会双向传播且不可逆,命令名本身就在误导用户。`mirror` 准确描述机制。`publish` 描述的是目的,不隐含“远端只增”,取消发布时删除传播符合直觉,因此保留。处于测试阶段且未发布,改名成本最低。
|
|
184
|
+
|
|
185
|
+
**影响**:破坏性改名,不提供旧命令名或旧配置节兼容。
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
- [uv](https://docs.astral.sh/uv/)。
|
|
8
8
|
- Python `>=3.11`。仓库的 `.python-version` 和本地虚拟环境是 3.14,但代码必须兼容 3.11,不能使用 3.12+ 语法。
|
|
9
9
|
- 图像后端 `pyvips[binary]` 由依赖自动安装,不需要单独安装 libvips。
|
|
10
|
-
- `
|
|
10
|
+
- `mirror` / `publish` 需要外部 `rclone >= 1.66`;单元测试不需要安装真实 rclone。
|
|
11
11
|
|
|
12
12
|
## 2. 常用命令
|
|
13
13
|
|
|
@@ -55,16 +55,16 @@ editable 安装后修改源码立即生效。升级依赖后可用 `uv tool inst
|
|
|
55
55
|
| 原子写入 | 编码失败时临时文件被清理 |
|
|
56
56
|
| 并发锁 | 第二个 `shrink` 遇到 `OUT/.pix/lock` 时返回 1 |
|
|
57
57
|
| `min_size` 类型 | 配置写整数 `0` 或字符串 `"500K"` 均可解析 |
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
58
|
+
| `mirror` 命令拼装 | `bisync`、`--exclude`、`--max-delete`、`--resync`、`--dry-run`、`-v` / `--quiet` |
|
|
59
|
+
| `mirror` / `publish` 空源 | 默认退出 2;`--allow-empty` 加 `--resync` 才放行 |
|
|
60
60
|
| `publish` 命令拼装 | 固定带 `--exclude .pix/**`,配置排除项随后,命令行追加 |
|
|
61
61
|
| `publish` 无 AVIF | 源目录没有 `.avif` 时退出 2,不调用 rclone |
|
|
62
62
|
| `publish --urls` | 输出排序后的 JSON;路径段正确编码 |
|
|
63
63
|
| `--urls` / `--no-urls` | 配置 `urls = true` 时 `--no-urls` 能单次关闭 |
|
|
64
64
|
| `--urls` 缺少 base_url | 退出 2,且不调用 rclone |
|
|
65
65
|
| `base_url` 协议 | 非 `http(s)://` 时退出 2 |
|
|
66
|
-
| 退出码透传 | mock 返回非零时 `
|
|
67
|
-
| rclone 缺失/过旧 | `
|
|
66
|
+
| 退出码透传 | mock 返回非零时 `mirror` / `publish` 原样返回 |
|
|
67
|
+
| rclone 缺失/过旧 | `mirror` / `publish` 启动时检查版本,失败返回 1 |
|
|
68
68
|
| 真实 shrink | 生成小 PNG,运行 `pix shrink`,断言输出存在且宽度正确 |
|
|
69
69
|
|
|
70
70
|
mtime 测试用 `os.utime` 控制时间,避免依赖真实文件系统精度。状态测试直接读写 `.pix/state.json`,不要依赖真实 rclone。
|
|
@@ -111,7 +111,7 @@ vips thumbnail src 'out.avif[Q=85,strip,effort=5]' 1920 --size down --linear
|
|
|
111
111
|
- `thumbnail(..., size="down", linear=True)` 的默认旋转行为与 CLI 一致。
|
|
112
112
|
- libvips 8.18.7 没有 `avifsave` action;AVIF 通过 `heifsave`(`.avif` 后缀自动选择)保存。
|
|
113
113
|
- `effort` 是有效参数。
|
|
114
|
-
- `import pyvips` 延迟到 `shrink` / `doctor` 内部,保证 `
|
|
114
|
+
- `import pyvips` 延迟到 `shrink` / `doctor` 内部,保证 `mirror` / `publish` 在图像后端缺失时仍可用。
|
|
115
115
|
|
|
116
116
|
复验方法:对同一张真实图片分别用上述两种方式生成 AVIF,比较文件大小和 MD5/内容哈希。
|
|
117
117
|
|
|
@@ -136,11 +136,11 @@ pyvips/libvips 不可用。
|
|
|
136
136
|
|
|
137
137
|
## 8. rclone 与 bisync
|
|
138
138
|
|
|
139
|
-
- 需要 `rclone >= 1.66`。`
|
|
140
|
-
- 首次运行 `pix
|
|
139
|
+
- 需要 `rclone >= 1.66`。`mirror` / `publish` 启动时解析 `rclone version` 并检查;`doctor` 保留同样的检查。
|
|
140
|
+
- 首次运行 `pix mirror` / `pix publish` 必须加 `--resync`,否则 rclone 会因缺少基线报错。
|
|
141
141
|
- 修改 `--exclude` 或配置排除项后,必须重新带 `--resync` 运行,否则被排除的文件可能被当作删除传播。
|
|
142
142
|
- `--max-delete` 默认 `10`,是 rclone bisync 的删除百分比(不是数量);`0` 禁止删除,`-1` 不限制。超过比例时 rclone 拒绝执行,这是默认保护,不要静默改成 `-1`。
|
|
143
|
-
- `
|
|
143
|
+
- `mirror` / `publish` 是双向镜像,不是单向备份,删除会双向传播。手动验证时先用 `--dry-run`,再在临时目录里测试。
|
|
144
144
|
- 源目录为空时默认拒绝;确实要清空远端时才用 `--allow-empty`(`publish` 还需 `--resync`)。
|
|
145
145
|
- bisync 基线在 `~/.cache/rclone/bisync/`,由 rclone 管理;排查问题时可以用 `rclone bisync --help` 查看当前版本的参数。
|
|
146
146
|
- 单元测试只断言 argv,不访问网络,也不需要真实 remote。
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
截至 2026-11-04:
|
|
8
8
|
|
|
9
|
-
- 四个子命令 `shrink` / `
|
|
9
|
+
- 四个子命令 `shrink` / `mirror` / `publish` / `doctor` 已实现,`uv run pytest` 通过。
|
|
10
10
|
- `pix config init` 可生成带注释的默认配置;`--force` 覆盖已有文件。
|
|
11
11
|
- 真实 rclone 的本地目录端到端(首次 `--resync`、二次增量、双向删除)已验证;`pix shrink` 与 `vips thumbnail` 的 AVIF 字节一致性已验证。真实 R2 与 Windows 原生库仍待手工验证。
|
|
12
12
|
- 文档已按新定位重写,并完成 2026-11-04 审计修复。
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
## M0. 文档重写(完成)
|
|
16
16
|
|
|
17
17
|
- [x] 移除旧项目的全部残留内容。
|
|
18
|
-
- [x] 定义 `shrink` / `
|
|
18
|
+
- [x] 定义 `shrink` / `mirror` / `publish` / `doctor` 四个子命令。
|
|
19
19
|
- [x] 定义 `.pix/state.json` 增量状态。
|
|
20
20
|
- [x] 定义 bisync 双向同步与 `--resync` 规则。
|
|
21
21
|
- [x] 更新 README、AGENTS.md、pyproject 描述。
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
按 2026-11-04 审计逐项修正规格与脚手架:
|
|
26
26
|
|
|
27
27
|
- [x] `--prune` 空源保护与 `--allow-empty`。
|
|
28
|
-
- [x] `
|
|
28
|
+
- [x] `mirror` 明确为双向镜像,新增 `--max-delete`(默认 10%,rclone bisync 的删除百分比)。
|
|
29
29
|
- [x] 目标路径冲突检测(`a.jpg` / `a.jpeg` 等)。
|
|
30
30
|
- [x] 状态每 200 个文件刷新,`finally` 落盘;`out_mtime_ns` 退出跳过条件。
|
|
31
31
|
- [x] `publish` 源校验:必须是含 `.avif` 的目录。
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
- [x] `min_size` 接受整数或尺寸字符串。
|
|
37
37
|
- [x] 修改排除项后必须 `--resync`。
|
|
38
38
|
- [x] 删除模板死代码;拆分规则改为按职责拆分。
|
|
39
|
-
- [x] rclone 版本在 `
|
|
39
|
+
- [x] rclone 版本在 `mirror` / `publish` 入口检查。
|
|
40
40
|
- [x] `shrink` 单实例锁 `OUT/.pix/lock`。
|
|
41
41
|
- [x] `base_url` 必须带 `http(s)://`。
|
|
42
42
|
|
|
43
43
|
## M1. CLI 骨架
|
|
44
44
|
|
|
45
|
-
- [x] argparse 主 parser + `shrink` / `
|
|
45
|
+
- [x] argparse 主 parser + `shrink` / `mirror` / `publish` / `doctor` 子命令。
|
|
46
46
|
- [x] 全局 `--config`、`--version`。
|
|
47
47
|
- [x] 配置加载与三层合并,argparse 默认值使用哨兵。
|
|
48
48
|
- [x] 日志级别:默认、`-v`、`--quiet`。
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
- 改参数后全量重编码;改单个源文件只重编码该文件。
|
|
75
75
|
- 中断或编码失败不会留下半截 AVIF。
|
|
76
76
|
|
|
77
|
-
## M3.
|
|
77
|
+
## M3. mirror
|
|
78
78
|
|
|
79
79
|
- [x] rclone 路径解析与本地源目录校验;空源默认拒绝,`--allow-empty` 加 `--resync` 才放行。
|
|
80
80
|
- [x] bisync 命令拼装:`--exclude`(合并去重)、`--max-delete`(默认 10%,百分比)、`--resync`、`--dry-run`、`-v` / `--quiet`。
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
|
|
94
94
|
- [x] bisync 命令固定追加 `--exclude .pix/**`,配置排除项随后,命令行追加。
|
|
95
95
|
- [x] `publish` 源必须是含至少一个 `.avif` 的目录;空源需 `--allow-empty` 加 `--resync`。
|
|
96
|
-
- [x] `--max-delete` 默认 10%,与 `
|
|
96
|
+
- [x] `--max-delete` 默认 10%,与 `mirror` 一致。
|
|
97
97
|
- [x] `--base-url` / `[publish].base_url` 合并,协议必须是 `http(s)://`。
|
|
98
98
|
- [x] `--urls` / `--no-urls` 合并与 JSON 输出,路径段编码正确。
|
|
99
99
|
- [x] 缺少 base_url 时退出 2,且在调用 rclone 前拒绝。
|
|
@@ -122,7 +122,7 @@
|
|
|
122
122
|
|
|
123
123
|
## Backlog(第一版不做)
|
|
124
124
|
|
|
125
|
-
- 一键流水线 `pix run`(shrink →
|
|
125
|
+
- 一键流水线 `pix run`(shrink → mirror → publish)。
|
|
126
126
|
- 自动 `--resync`。
|
|
127
127
|
- 远端可达性探测。
|
|
128
128
|
- 图片裁剪(crop)。
|
|
@@ -135,7 +135,7 @@
|
|
|
135
135
|
| 风险 | 处理 |
|
|
136
136
|
|---|---|
|
|
137
137
|
| bisync 双向删除不可逆 | 默认 `--max-delete 10`(10% 删除比例);显式 `--resync`;先 `--dry-run` |
|
|
138
|
-
| 空目录/挂载失败被当成“删除全部” | `--prune` 空源拒绝;`
|
|
138
|
+
| 空目录/挂载失败被当成“删除全部” | `--prune` 空源拒绝;`mirror` / `publish` 空源需 `--allow-empty` |
|
|
139
139
|
| `a.jpg` 与 `a.jpeg` 争用同一输出 | 编码前冲突检测,退出 2 |
|
|
140
140
|
| 修改排除项后未 resync 导致误删 | 文档与错误提示要求先 `--resync` |
|
|
141
141
|
| `pyvips[binary]` 与系统 libvips 版本不一致导致 AVIF 差异 | 已在 8.18.7 验证字节一致;升级 libvips 后重跑抽样对比 |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pix-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
description = "Batch AVIF shrink and image-host sync CLI"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
"Programming Language :: Python :: 3.13",
|
|
27
27
|
"Programming Language :: Python :: 3.14",
|
|
28
28
|
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
|
|
29
|
-
"Topic :: System :: Archiving ::
|
|
29
|
+
"Topic :: System :: Archiving :: Mirroring",
|
|
30
30
|
]
|
|
31
31
|
requires-python = ">=3.11"
|
|
32
32
|
dependencies = [
|
|
@@ -11,7 +11,7 @@ from .config import ConfigError, init_config, load_settings
|
|
|
11
11
|
from .doctor import doctor
|
|
12
12
|
from .logger import Logger
|
|
13
13
|
from .shrink import shrink
|
|
14
|
-
from .sync import
|
|
14
|
+
from .sync import mirror, publish
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def _add_logging_args(parser: argparse.ArgumentParser) -> None:
|
|
@@ -39,21 +39,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
39
39
|
shrink.add_argument("--dry-run", action="store_true")
|
|
40
40
|
_add_logging_args(shrink)
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
mirror = subparsers.add_parser("mirror", help="bidirectionally mirror originals")
|
|
43
|
+
mirror.add_argument("-s", "--source", type=Path, required=True)
|
|
44
|
+
mirror.add_argument("-d", "--dest", required=True)
|
|
45
|
+
mirror.add_argument("--exclude", action="append", default=None, metavar="GLOB")
|
|
46
|
+
mirror.add_argument(
|
|
47
47
|
"--max-delete",
|
|
48
48
|
type=int,
|
|
49
49
|
default=None,
|
|
50
50
|
metavar="PERCENT",
|
|
51
51
|
help="abort if more than PERCENT%% of files are deleted (default: 10)",
|
|
52
52
|
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
_add_logging_args(
|
|
53
|
+
mirror.add_argument("--allow-empty", action="store_true")
|
|
54
|
+
mirror.add_argument("--resync", action="store_true")
|
|
55
|
+
mirror.add_argument("--dry-run", action="store_true")
|
|
56
|
+
_add_logging_args(mirror)
|
|
57
57
|
|
|
58
58
|
publish = subparsers.add_parser("publish", help="bidirectionally mirror compressed images")
|
|
59
59
|
publish.add_argument("-s", "--source", type=Path, required=True)
|
|
@@ -105,7 +105,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
105
105
|
)
|
|
106
106
|
handlers = {
|
|
107
107
|
"shrink": shrink,
|
|
108
|
-
"
|
|
108
|
+
"mirror": mirror,
|
|
109
109
|
"publish": publish,
|
|
110
110
|
"doctor": doctor,
|
|
111
111
|
}
|
|
@@ -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 =
|
|
88
|
+
settings = load_defaults()
|
|
101
89
|
_deep_update(settings, overrides)
|
|
102
90
|
validate_settings(settings)
|
|
103
91
|
return settings
|
|
@@ -120,7 +108,7 @@ def _require_excludes(name: str, value: Any) -> list[str]:
|
|
|
120
108
|
|
|
121
109
|
|
|
122
110
|
def validate_settings(settings: dict[str, Any]) -> None:
|
|
123
|
-
for section in ("shrink", "
|
|
111
|
+
for section in ("shrink", "mirror", "publish", "rclone"):
|
|
124
112
|
if not isinstance(settings.get(section), dict):
|
|
125
113
|
raise ConfigError(f"[{section}] must be a table")
|
|
126
114
|
|
|
@@ -139,7 +127,7 @@ def validate_settings(settings: dict[str, Any]) -> None:
|
|
|
139
127
|
):
|
|
140
128
|
raise ConfigError("shrink.include must be a list of extensions without dots")
|
|
141
129
|
|
|
142
|
-
for section in ("
|
|
130
|
+
for section in ("mirror", "publish"):
|
|
143
131
|
_require_excludes(f"{section}.exclude", settings[section]["exclude"])
|
|
144
132
|
require_int(f"{section}.max_delete", settings[section]["max_delete"], -1, 100)
|
|
145
133
|
|
|
@@ -13,7 +13,7 @@ min_size = 0
|
|
|
13
13
|
# Extensions to scan, written without dots.
|
|
14
14
|
include = ["jpg", "jpeg", "png", "webp"]
|
|
15
15
|
|
|
16
|
-
[
|
|
16
|
+
[mirror]
|
|
17
17
|
# rclone exclude globs; command-line --exclude is appended after these.
|
|
18
18
|
exclude = []
|
|
19
19
|
# Maximum percent of files rclone may delete (-1 disables, 0 forbids).
|
|
@@ -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,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""mirror and publish subcommands."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -13,8 +13,8 @@ from .logger import Logger
|
|
|
13
13
|
from .rclone import check_rclone, has_files, merge_excludes, run_rclone
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
config = settings["
|
|
16
|
+
def mirror(args: argparse.Namespace, settings: dict[str, Any], log: Logger) -> int:
|
|
17
|
+
config = settings["mirror"]
|
|
18
18
|
source = args.source
|
|
19
19
|
if not source.is_dir():
|
|
20
20
|
log.error(f"source is not a directory: {source}")
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -379,11 +407,11 @@ def make_source(tmp_path: Path) -> Path:
|
|
|
379
407
|
return source
|
|
380
408
|
|
|
381
409
|
|
|
382
|
-
def
|
|
410
|
+
def test_mirror_builds_bisync_command(tmp_path: Path, monkeypatch):
|
|
383
411
|
source = make_source(tmp_path)
|
|
384
412
|
config = tmp_path / "config.toml"
|
|
385
413
|
config.write_text(
|
|
386
|
-
'[
|
|
414
|
+
'[mirror]\nexclude = ["cfg", "dup"]\nmax_delete = 9\n', encoding="utf-8"
|
|
387
415
|
)
|
|
388
416
|
calls = fake_rclone(monkeypatch)
|
|
389
417
|
|
|
@@ -391,7 +419,7 @@ def test_backup_builds_bisync_command(tmp_path: Path, monkeypatch):
|
|
|
391
419
|
[
|
|
392
420
|
"--config",
|
|
393
421
|
str(config),
|
|
394
|
-
"
|
|
422
|
+
"mirror",
|
|
395
423
|
"-s",
|
|
396
424
|
str(source),
|
|
397
425
|
"-d",
|
|
@@ -422,39 +450,39 @@ def test_backup_builds_bisync_command(tmp_path: Path, monkeypatch):
|
|
|
422
450
|
]
|
|
423
451
|
|
|
424
452
|
|
|
425
|
-
def
|
|
453
|
+
def test_mirror_defaults_to_quiet_and_max_delete_ten(tmp_path: Path, monkeypatch):
|
|
426
454
|
source = make_source(tmp_path)
|
|
427
455
|
calls = fake_rclone(monkeypatch)
|
|
428
456
|
|
|
429
|
-
assert cli.main(["
|
|
457
|
+
assert cli.main(["mirror", "-s", str(source), "-d", "remote:photos"]) == 0
|
|
430
458
|
|
|
431
459
|
assert calls[1][-3:] == ["--max-delete", "10", "--quiet"]
|
|
432
460
|
|
|
433
461
|
|
|
434
|
-
def
|
|
462
|
+
def test_mirror_empty_source_refuses(tmp_path: Path, monkeypatch, capsys):
|
|
435
463
|
source = tmp_path / "src"
|
|
436
464
|
source.mkdir()
|
|
437
465
|
calls = fake_rclone(monkeypatch)
|
|
438
466
|
|
|
439
|
-
assert cli.main(["
|
|
467
|
+
assert cli.main(["mirror", "-s", str(source), "-d", "remote:photos"]) == 2
|
|
440
468
|
|
|
441
469
|
assert calls == []
|
|
442
470
|
assert "source is empty" in capsys.readouterr().err
|
|
443
471
|
|
|
444
472
|
|
|
445
|
-
def
|
|
473
|
+
def test_mirror_allow_empty_requires_resync(tmp_path: Path, monkeypatch):
|
|
446
474
|
source = tmp_path / "src"
|
|
447
475
|
source.mkdir()
|
|
448
476
|
calls = fake_rclone(monkeypatch)
|
|
449
477
|
|
|
450
478
|
assert cli.main(
|
|
451
|
-
["
|
|
479
|
+
["mirror", "-s", str(source), "-d", "remote:photos", "--allow-empty"]
|
|
452
480
|
) == 2
|
|
453
481
|
assert calls == []
|
|
454
482
|
|
|
455
483
|
assert cli.main(
|
|
456
484
|
[
|
|
457
|
-
"
|
|
485
|
+
"mirror",
|
|
458
486
|
"-s",
|
|
459
487
|
str(source),
|
|
460
488
|
"-d",
|
|
@@ -466,38 +494,38 @@ def test_backup_allow_empty_requires_resync(tmp_path: Path, monkeypatch):
|
|
|
466
494
|
assert "--resync" in calls[1]
|
|
467
495
|
|
|
468
496
|
|
|
469
|
-
def
|
|
497
|
+
def test_mirror_dry_run_and_passthrough_exit_code(tmp_path: Path, monkeypatch):
|
|
470
498
|
source = make_source(tmp_path)
|
|
471
499
|
calls = fake_rclone(monkeypatch, bisync_code=7)
|
|
472
500
|
|
|
473
501
|
assert cli.main(
|
|
474
|
-
["
|
|
502
|
+
["mirror", "-s", str(source), "-d", "remote:photos", "--dry-run", "--quiet"]
|
|
475
503
|
) == 7
|
|
476
504
|
|
|
477
505
|
assert calls[1][-2:] == ["--dry-run", "--quiet"]
|
|
478
506
|
|
|
479
507
|
|
|
480
|
-
def
|
|
508
|
+
def test_mirror_rejects_old_or_missing_rclone(tmp_path: Path, monkeypatch, capsys):
|
|
481
509
|
source = make_source(tmp_path)
|
|
482
510
|
|
|
483
511
|
fake_rclone(monkeypatch, version="rclone v1.65.2\n")
|
|
484
|
-
assert cli.main(["
|
|
512
|
+
assert cli.main(["mirror", "-s", str(source), "-d", "remote:photos"]) == 1
|
|
485
513
|
assert "too old" in capsys.readouterr().err
|
|
486
514
|
|
|
487
515
|
fake_rclone(monkeypatch, version=FileNotFoundError())
|
|
488
|
-
assert cli.main(["
|
|
516
|
+
assert cli.main(["mirror", "-s", str(source), "-d", "remote:photos"]) == 1
|
|
489
517
|
assert "not found" in capsys.readouterr().err
|
|
490
518
|
|
|
491
519
|
|
|
492
|
-
def
|
|
520
|
+
def test_mirror_rejects_invalid_max_delete(tmp_path: Path, monkeypatch):
|
|
493
521
|
source = make_source(tmp_path)
|
|
494
522
|
calls = fake_rclone(monkeypatch)
|
|
495
523
|
|
|
496
524
|
assert cli.main(
|
|
497
|
-
["
|
|
525
|
+
["mirror", "-s", str(source), "-d", "remote:photos", "--max-delete", "-2"]
|
|
498
526
|
) == 2
|
|
499
527
|
assert cli.main(
|
|
500
|
-
["
|
|
528
|
+
["mirror", "-s", str(source), "-d", "remote:photos", "--max-delete", "101"]
|
|
501
529
|
) == 2
|
|
502
530
|
assert calls == []
|
|
503
531
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|