pix-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ python-version: ["3.11", "3.14"]
13
+ env:
14
+ UV_PYTHON: ${{ matrix.python-version }}
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v5
18
+ - run: uv sync --dev
19
+ - run: uv run pytest
@@ -0,0 +1,43 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v[0-9]+.[0-9]+.[0-9]+"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ steps:
14
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
15
+ with:
16
+ persist-credentials: false
17
+ - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
18
+ with:
19
+ enable-cache: false
20
+ - run: uv build
21
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
22
+ with:
23
+ name: dist
24
+ path: dist/
25
+
26
+ publish:
27
+ needs: build
28
+ runs-on: ubuntu-latest
29
+ environment:
30
+ name: pypi
31
+ url: https://pypi.org/p/pix-cli
32
+ permissions:
33
+ id-token: write
34
+ steps:
35
+ - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
36
+ with:
37
+ enable-cache: false
38
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
39
+ with:
40
+ name: dist
41
+ path: dist/
42
+ - uses: astral-sh/attest-action@39b6ce43875c196a579e31635b11aa2a2036384a # v0.0.7
43
+ - run: uv publish
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Test caches
13
+ .pytest_cache/
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,40 @@
1
+ # AGENTS.md
2
+
3
+ `pix` 是批量图片压缩(AVIF)与 rclone 图床同步 CLI。已实现 `shrink` / `backup` / `publish` / `doctor` / `config init`;行为规格见 `docs/architecture.md`。
4
+
5
+ ## 文档
6
+
7
+ | 文件 | 内容 |
8
+ |---|---|
9
+ | [docs/architecture.md](docs/architecture.md) | 行为规格(唯一事实来源) |
10
+ | [docs/development.md](docs/development.md) | 环境、测试、构建、排障 |
11
+ | [docs/decisions.md](docs/decisions.md) | 技术决策与原因 |
12
+ | [docs/roadmap.md](docs/roadmap.md) | 当前目标与待办 |
13
+
14
+ 实现前先读 `architecture.md`;对选型有疑问读 `decisions.md`。
15
+
16
+ ## 命令
17
+
18
+ ```bash
19
+ uv sync
20
+ uv run pytest
21
+ uv run pix ...
22
+ uv tool install --editable ~/Projects/pix
23
+ uv build
24
+ ```
25
+
26
+ ## 必须遵守
27
+
28
+ - 兼容 Python 3.11。
29
+ - 仅标准库 + `pyvips[binary]`,不引入 click/rich/colorama。
30
+ - 拆分 `cli.py` 前先读 `docs/decisions.md` D5,不按行数拆分。
31
+ - `pyvips` 只在 `shrink` / `doctor` 内延迟导入。
32
+ - 不通过 shell 调用 `vips` 或 `rclone`。
33
+ - `-q` 是 quality,`--quiet` 是静默。
34
+ - 退出码 0/1/2,日志走 stderr;`backup` / `publish` 透传 rclone 退出码。
35
+ - `backup` / `publish` 使用 `rclone bisync`;首次运行必须显式 `--resync`,不要自动加。它们是双向镜像,不是单向备份。
36
+ - `--max-delete` 默认 10(删除百分比,rclone bisync 语义);`backup` / `publish` 源为空时默认拒绝,`--allow-empty` 加 `--resync` 才放行。
37
+ - 修改排除项后必须提示用户先 `--resync`;`--exclude` 是配置在前、命令行追加并去重。
38
+ - 增量状态固定在 `OUT/.pix/state.json`,每 200 个成功文件刷新并在 `finally` 落盘;`publish` 必须排除 `.pix/**`。
39
+ - `shrink` 必须拒绝目标冲突,`--prune` 必须拒绝空源,非 dry-run 必须持有 `OUT/.pix/lock`。
40
+ - `publish` 源必须至少有一个 `.avif`;`base_url` 必须以 `http://` 或 `https://` 开头。
pix_cli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,40 @@
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
+ ```
@@ -0,0 +1,31 @@
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
+ ```
@@ -0,0 +1,360 @@
1
+ # pix 架构与行为规格
2
+
3
+ > 本文是 `pix` 行为规格的唯一事实来源。选型理由见 [decisions.md](decisions.md),开发与测试见 [development.md](development.md),未完成工作见 [roadmap.md](roadmap.md)。
4
+
5
+ ## 1. 定位与边界
6
+
7
+ `pix` 是独立的图床流水线 CLI:
8
+
9
+ - 把本地图片目录增量压缩为 AVIF。
10
+ - 把原始图片目录备份到 OneDrive 等 rclone 兼容云盘。
11
+ - 把压缩结果双向同步到 Cloudflare R2 等 rclone 兼容图床。
12
+
13
+ `pix` 只处理用户显式传入的本地路径和 rclone 远端,不感知项目类型或版本控制状态。所有路径和远端都由命令行或用户级配置提供。
14
+
15
+ 目标:
16
+
17
+ - 提供独立的跨平台 CLI 工具 `pix`。
18
+ - 递归批量压缩图片为 AVIF,重复运行时只处理变化的部分。
19
+ - 通过 rclone bisync 与云盘双向同步。
20
+ - 可通过 `uv tool install` 安装,Linux 为 `~/.local/bin/pix`,Windows 为 `pix.exe`。
21
+
22
+ ### 范围
23
+
24
+ 第一版做:
25
+
26
+ - `pix shrink`:递归扫描图片,按宽度等比缩放并输出 AVIF,带增量状态。
27
+ - `pix backup`:把原始图片目录与 rclone 远端双向同步。
28
+ - `pix publish`:把压缩目录与图床远端双向同步,可选输出公开 URL 清单。
29
+ - `pix doctor`:检查运行依赖、rclone bisync 能力和配置。
30
+ - `pix config init`:把带注释的默认配置写到默认路径,供用户修改。
31
+ - 可选的用户默认配置 `~/.config/pix/config.toml`。
32
+ - 单元测试和基本集成测试。
33
+
34
+ 第一版不做:
35
+
36
+ - 一键流水线 `pix run`。
37
+ - 图片裁剪(crop)。
38
+ - 并发压缩(`--jobs` / `ThreadPoolExecutor`)。
39
+ - 自动 `--resync`。
40
+ - 远端可达性探测。
41
+ - 彩色/富文本日志。
42
+
43
+ ## 2. 技术栈与模块结构
44
+
45
+ - Python `>=3.11`。
46
+ - 仅标准库 + `pyvips[binary]`。
47
+ - 运行时依赖外部 `rclone >= 1.66`(bisync 稳定版),由 `backup` / `publish` 调用。
48
+
49
+ ```text
50
+ src/pix/
51
+ ├── __init__.py # 包版本
52
+ ├── py.typed
53
+ ├── cli.py # 组合根:parser、main、子命令分派
54
+ ├── config.py # 默认值、TOML 读取与校验、parse_size、config init
55
+ ├── default_config.toml # config init 写入的模板(随包分发)
56
+ ├── logger.py # stderr 日志
57
+ ├── shrink.py # shrink 子命令
58
+ ├── rclone.py # rclone 探测、版本检查与执行
59
+ ├── sync.py # backup / publish 子命令
60
+ └── doctor.py # doctor 子命令
61
+ tests/
62
+ └── test_cli.py
63
+ ```
64
+
65
+ 入口点:`pix = "pix.cli:main"`。`main()` 返回 `int`,`__main__` 中 `raise SystemExit(main())`。
66
+
67
+ `cli.py` 只承担参数解析、子命令分派和组合根职责;低层模块不反向依赖它。拆分理由见 [decisions.md](decisions.md) D27。
68
+
69
+ 函数边界:
70
+
71
+ | 模块 | 函数 | 职责 |
72
+ |---|---|---|
73
+ | `cli` | `main(argv: list[str] \| None = None) -> int` | 解析全局参数,分派子命令,返回退出码 |
74
+ | `cli` | `build_parser() -> argparse.ArgumentParser` | 构建主 parser 和子命令参数 |
75
+ | `config` | `load_config(path: Path) -> dict` | 读取 TOML;默认路径不存在时返回空覆盖 |
76
+ | `config` | `load_settings(path: Path \| None) -> dict` | 合并默认值并校验 |
77
+ | `config` | `parse_size(value: str \| int) -> int` | 尺寸字符串转字节 |
78
+ | `config` | `init_config(args) -> int` | `config init` 子命令:写入默认配置 |
79
+ | `shrink` | `shrink(args, settings, log) -> int` | `shrink` 子命令 |
80
+ | `sync` | `backup(args, settings, log) -> int` | `backup` 子命令 |
81
+ | `sync` | `publish(args, settings, log) -> int` | `publish` 子命令 |
82
+ | `doctor` | `doctor(args, settings, log) -> int` | `doctor` 子命令 |
83
+ | `rclone` | `run_rclone(cmd: list[str]) -> int` | 调用 rclone 并返回其退出码 |
84
+
85
+ 依赖方向:
86
+
87
+ ```text
88
+ pix.cli ──> pix.config / pix.logger / pix.shrink / pix.sync / pix.doctor
89
+ pix.shrink ──> pix.config / pix.logger / pyvips(仅函数内延迟导入)
90
+ pix.sync ──> pix.config / pix.logger / pix.rclone
91
+ pix.doctor ──> pix.config / pix.rclone / pix.shrink(提示文本)/ pyvips(延迟导入)
92
+ pix.rclone ──> subprocess
93
+ ```
94
+
95
+ ## 3. CLI 契约
96
+
97
+ ```bash
98
+ pix [--config PATH] [--version] {shrink,backup,publish,doctor,config}
99
+
100
+ pix shrink -s SRC -d OUT
101
+ [-w 1920] [-q 85] [-e 5] [-m 0]
102
+ [--force] [--prune] [--allow-empty] [--dry-run] [-v|--quiet]
103
+
104
+ pix backup -s SRC -d REMOTE
105
+ [--exclude GLOB ...] [--max-delete PERCENT] [--allow-empty] [--resync]
106
+ [--dry-run] [-v|--quiet]
107
+
108
+ pix publish -s OUT -d REMOTE
109
+ [--base-url URL] [--urls|--no-urls] [--exclude GLOB ...]
110
+ [--max-delete PERCENT] [--allow-empty] [--resync]
111
+ [--dry-run] [-v|--quiet]
112
+
113
+ pix doctor
114
+
115
+ pix config init [--force]
116
+ ```
117
+
118
+ 约定:
119
+
120
+ - `--config` 属于主 parser,写在子命令之前。
121
+ - `--version` 输出 `pix <version>` 后以 0 退出。
122
+ - `-s` 为源,`-d` 为目标。
123
+ - `shrink` 的 `-d` 是本地目录;`backup` / `publish` 的 `-d` 是 rclone 路径,可以是 remote,也可以是本地目录。
124
+ - `-q` 只表示 quality;安静模式使用 `--quiet`,不提供短选项。
125
+ - `-m/--min-size` 表示“小于该大小的源文件跳过”,`0` 表示不限制;支持 `500K` / `1M` / `2G`,按 1024 进制。
126
+ - `-v` 与 `--quiet` 互斥。
127
+ - `--dry-run` 不产生本地写入;`backup` / `publish` 中原样传给 rclone。与 `shrink --prune` 同用时只列出待删除项。
128
+ - `--exclude` 可重复。配置中的排除项在前,命令行排除项追加在后,保留顺序并去重。
129
+ - `--max-delete` 只用于 `backup` / `publish`,按删除百分比限制(rclone bisync 语义,不是数量),默认 `10`(即 10%);`0` 禁止删除,`-1` 不限制,最大 `100`。超过比例时 rclone 拒绝运行。
130
+ - `--allow-empty` 只用于 `shrink --prune`、`backup` 和 `publish`:允许源为空时继续执行破坏性操作。默认拒绝;`backup` / `publish` 还必须同时带 `--resync`。
131
+ - `--resync` 只用于 `backup` / `publish`,原样传给 rclone。
132
+ - `--force` 只用于 `shrink`。
133
+ - `--prune` 只用于 `shrink`,默认关闭。
134
+ - `--base-url` / `--urls` / `--no-urls` 只用于 `publish`;`--no-urls` 可临时关闭配置中的 `[publish].urls = true`。
135
+
136
+ 参数校验在配置合并之后执行,配置中的非法值同样以退出码 2 拒绝:
137
+
138
+ - `width` 为正整数。
139
+ - `quality` 为 1–100。
140
+ - `effort` 为 0–9。
141
+ - `min_size` 为非负整数,或可解析为字节数的字符串(如 `500K`、`1M`、`2G`)。
142
+ - `max_delete` 为整数且位于 `-1..100`。
143
+ - `publish.urls` 为布尔值;开启时必须能解析出 `base_url`。
144
+ - `base_url` 必须以 `http://` 或 `https://` 开头;否则退出 2。
145
+
146
+ ## 4. 配置
147
+
148
+ 默认配置路径:
149
+
150
+ - Linux/macOS:`$XDG_CONFIG_HOME/pix/config.toml`,未设置时为 `~/.config/pix/config.toml`。
151
+ - Windows:`%APPDATA%\pix\config.toml`。
152
+
153
+ 文件不存在时使用内置默认值。
154
+
155
+ ```toml
156
+ [shrink]
157
+ width = 1920
158
+ quality = 85
159
+ effort = 5
160
+ min_size = 0
161
+ include = ["jpg", "jpeg", "png", "webp"]
162
+
163
+ [backup]
164
+ exclude = []
165
+ max_delete = 10
166
+
167
+ [publish]
168
+ base_url = ""
169
+ urls = false
170
+ exclude = []
171
+ max_delete = 10
172
+
173
+ [rclone]
174
+ binary = "rclone"
175
+ ```
176
+
177
+ 优先级:**命令行 > pix 配置 > 内置默认**。
178
+
179
+ 合并时只有显式出现在命令行上的参数才覆盖配置值,因此 argparse 的默认值必须是 `None` 或等价哨兵,不能直接写内置默认值,否则会永久压过配置文件。
180
+
181
+ `--exclude` 是例外:它不覆盖配置,而是追加。合并顺序为配置项在前、命令行项在后,保留顺序并去重,避免加上一个排除项时丢掉另一个。
182
+
183
+ `--no-urls` 是显式命令行值,优先级高于 `[publish].urls = true`;`--max-delete` 同样按“命令行 > 配置 > 默认 10”合并。
184
+
185
+ `--config` 显式指向的文件不存在时视为配置错误,退出 2;默认路径不存在则静默使用内置默认。
186
+
187
+ 生成配置:`pix config init` 把内置模板写到 `--config` 指定路径(未指定时为默认路径),自动创建父目录,成功后打印路径并以 0 退出。目标已存在时拒绝覆盖并退出 2,提示使用 `--force`。该命令不读取现有配置,因此在 `load_settings` 之前分派,`--config` 指向尚不存在的文件也能创建。
188
+
189
+ `include` 中的扩展名不带点,匹配时大小写不敏感。
190
+
191
+ 远端 `-d` 只由命令行提供,不放进配置,避免一条命令在未指定目标时误同步到错误位置。
192
+
193
+ ## 5. shrink 行为
194
+
195
+ 1. 校验 `source` 是目录,`dest` 可创建。
196
+ 2. 非 `--dry-run` 时创建 `OUT/.pix/lock`(`O_CREAT|O_EXCL`);已存在则报错退出 1,提示可能有另一个 `shrink` 在运行,或需要手动删除陈旧锁。退出时在 `finally` 中删除。
197
+ 3. 状态文件固定为 `OUT/.pix/state.json`。
198
+ 4. 递归扫描 `include` 中的扩展名,大小写不敏感,按相对路径排序后处理。
199
+ 5. 在编码前建立目标路径映射。两个源文件映射到同一目标(例如 `a.jpg` 与 `a.jpeg`,或 `a.png` 与 `a.PNG`)时,列出全部冲突并以 2 退出,不编码、不写状态。
200
+ 6. 目标路径保持相对目录结构,扩展名替换为 `.avif`。
201
+ 7. 跳过规则(全部满足时跳过):
202
+ - `min_size > 0` 且源文件小于该值(严格小于);
203
+ - 状态中有该相对路径的记录;
204
+ - 状态顶层参数指纹与当前 `width:quality:effort` 一致;
205
+ - 目标存在;
206
+ - 源 `size` / `mtime_ns` 与记录一致;
207
+ - 目标 `size` 与记录一致。目标 `mtime_ns` 只用于诊断,不参与跳过判断,避免外部 `touch` 或同步工具改变时间戳后触发重编码。
208
+ 8. `--force` 忽略上述跳过规则,重编码所有源文件。
209
+ 9. 写临时文件后原子替换,避免中断留下半截 AVIF:
210
+
211
+ ```python
212
+ tmp = dest.with_name(f".{dest.name}.{os.getpid()}.tmp.avif")
213
+ # encode...
214
+ os.replace(tmp, dest)
215
+ ```
216
+
217
+ 临时文件必须以 `.avif` 结尾,确保 libvips 选择 `heifsave`。失败时清理临时文件。
218
+
219
+ 10. 编码调用:
220
+
221
+ ```python
222
+ img = pyvips.Image.thumbnail(str(src), width, size="down", linear=True)
223
+ img.write_to_file(str(tmp), Q=quality, keep="none", effort=effort)
224
+ ```
225
+
226
+ `size="down"` 保证不放大小图。
227
+
228
+ 11. 单个文件失败不中断整批,记录错误后继续;最后汇总,只要有失败就返回 1。
229
+ 12. `--dry-run` 只打印将要转换的文件(每行一个,输出到 stdout),不创建目录、不编码、不写状态、不建锁。与 `--prune` 同用时,每行输出 `prune <相对路径>`,表示将要删除,但绝不删除。
230
+ 13. 汇总输出:转换、跳过(状态命中)、跳过(太小)、跳过(空源保护)、失败、删除。
231
+ 14. 状态在编码过程中每成功 200 个文件刷新一次(原子写),并在正常结束或 `KeyboardInterrupt` 时通过 `finally` 再写一次,避免 Ctrl-C 丢掉整批进度。写入失败记录错误并返回 1,下次运行会重新编码未记录的文件。
232
+ 15. 状态损坏、版本不识别或 JSON 解析失败时输出警告,按空状态全量重建,不中止。
233
+
234
+ ### 状态文件
235
+
236
+ ```json
237
+ {
238
+ "version": 1,
239
+ "params": "w=1920;q=85;e=5",
240
+ "files": {
241
+ "sub/a.png": {
242
+ "src_size": 123456,
243
+ "src_mtime_ns": 1699999999999999999,
244
+ "out_size": 45678,
245
+ "out_mtime_ns": 1699999999999999999
246
+ }
247
+ }
248
+ }
249
+ ```
250
+
251
+ - 键为源文件相对路径,统一使用 `/` 分隔。
252
+ - 参数变化时 `params` 不匹配,整批重编码。
253
+ - 只有成功写出的文件才进入 `files`。
254
+ - `out_mtime_ns` 只用于诊断,不参与跳过判断。
255
+ - 状态文件本身不会被扫描或上传。
256
+
257
+ ### prune
258
+
259
+ `--prune` 在正常扫描后执行:
260
+
261
+ - 状态中存在、但源文件已不存在的条目,删除对应输出文件并移除条目。
262
+ - 状态中不存在、但输出目录里多出的文件不处理。
263
+ - 扫描结果为 0 个源图片、而状态中仍有条目时,视为源未挂载或路径错误:不删除任何文件并返回 1,除非显式传入 `--allow-empty`。`--dry-run` 同样返回 1,但只打印、不删除。
264
+ - `--dry-run` 与 `--prune` 同用时,只把待删除项按 `prune <相对路径>` 输出到 stdout,不删除。
265
+ - 删除失败记录错误,最后返回 1。
266
+ - 默认关闭,避免误删。
267
+
268
+ ## 6. backup 行为
269
+
270
+ `backup` 把原始图片目录与远端做 **rclone bisync 双向同步**。它是双向镜像,不是单向备份。
271
+
272
+ 命令拼装:
273
+
274
+ ```python
275
+ cmd = [rclone, "bisync", str(source), dest]
276
+ for pattern in excludes:
277
+ cmd += ["--exclude", pattern]
278
+ cmd += ["--max-delete", str(max_delete)]
279
+ if resync:
280
+ cmd.append("--resync")
281
+ if dry_run:
282
+ cmd.append("--dry-run")
283
+ cmd.append("--progress" if verbose else "--quiet")
284
+ ```
285
+
286
+ - 首次运行必须显式加 `--resync`。`pix` 不自动添加,避免两边都有内容时悄悄覆盖一侧。
287
+ - 不加 `--resync` 且 bisync 基线不存在时,rclone 会报错,`pix` 原样返回其退出码。
288
+ - `-v` 映射 `--progress`,默认映射 `--quiet`。
289
+ - `--exclude` 按配置在前、命令行在后合并,逐条传给 rclone;`pix` 不内置任何备份排除规则。
290
+ - **修改排除项后必须先 `--resync`**:rclone 要求过滤器变更后重建基线,否则被排除的文件可能被当作删除传播。先跑 `--dry-run`。
291
+ - `--max-delete` 默认 `10`,是删除百分比;一次运行要删除的比例超过 10% 时 rclone 会拒绝。确认要大量删除时显式调高,或传 `-1` 取消限制。
292
+ - 双向同步意味着删除会传播:本地删除会同步到远端,远端删除也会同步到本地。首次操作建议先 `--dry-run`。
293
+ - `-s` 必须是已存在的本地目录。源为空时默认拒绝并退出 2;只有显式 `--allow-empty` 且带 `--resync` 时才允许。
294
+ - 启动时执行 `rclone version`,要求 `>= 1.66`;rclone 缺失或版本过旧时输出错误并返回 1。
295
+ - bisync 的基线由 rclone 自己维护在 `~/.cache/rclone/bisync/`,`pix` 不读写、不清理。
296
+ - 使用 `subprocess.run(cmd)`,不经 shell,直接返回 rclone 的退出码。
297
+
298
+ ## 7. publish 行为
299
+
300
+ `publish` 把 shrink 输出目录与图床远端做 **rclone bisync 双向同步**,参数与 `backup` 相同,另加:
301
+
302
+ - `-s` 必须是已存在的本地目录,且至少包含一个 `.avif`。源为空时默认退出 2;只有显式 `--allow-empty` 且带 `--resync` 时才允许。
303
+ - 固定追加 `--exclude .pix/**`,防止状态文件被同步到图床。配置排除项随后,命令行排除项最后,去重。
304
+ - `--max-delete` 与 `backup` 相同,默认 `10`,按删除百分比计。
305
+ - `--base-url` 优先取命令行,其次取 `[publish].base_url`。值必须以 `http://` 或 `https://` 开头,否则退出 2。
306
+ - `--urls` / `--no-urls` 优先取命令行,其次取 `[publish].urls`,默认关闭;`--no-urls` 可临时关闭配置中的 `true`。
307
+ - 开启 URL 清单但没有可用的 `base_url` 时,在调用 rclone 前以退出码 2 拒绝。
308
+ - URL 清单只在 bisync 成功(退出码 0)且不是 `--dry-run` 时输出。
309
+ - URL 清单输出到 stdout,JSON 对象,键为相对 OUT 的路径,值为公开 URL,按路径排序:
310
+
311
+ ```json
312
+ {
313
+ "2024/a.avif": "https://img.example.com/2024/a.avif"
314
+ }
315
+ ```
316
+
317
+ - 生成清单时递归收集 OUT 下所有 `.avif`(大小写不敏感),路径段用 `urllib.parse.quote(..., safe="/")` 编码。
318
+ - `base_url` 末尾的 `/` 在拼接时去掉。
319
+ - `--dry-run` 不生成 URL 清单。
320
+ - 修改排除项后必须先 `--resync`,规则同 `backup`。
321
+ - 双向同步同样会传播删除,首次操作建议先 `--dry-run`。
322
+
323
+ ## 8. doctor
324
+
325
+ 输出示例(报告输出到 stdout):
326
+
327
+ ```text
328
+ pix 0.1.0
329
+ python 3.14.9 (linux)
330
+ vips 8.18.7 via pyvips 3.2.0 (API mode)
331
+ rclone 1.67.0 (/home/uzvg/.local/bin/rclone) bisync ok
332
+ config /home/uzvg/.config/pix/config.toml (ok)
333
+ ```
334
+
335
+ 检查项与退出行为:
336
+
337
+ - 缺少 pyvips/libvips:打印安装提示(文案见 [development.md](development.md#7-缺少-libvips-时)),退出 1。
338
+ - 缺少 rclone:提示官方下载地址或 `winget search rclone`,退出 1。
339
+ - rclone 版本低于 1.66:提示升级,退出 1。`backup` / `publish` 启动时也执行同样的版本检查,不依赖用户先跑 `doctor`。
340
+ - 配置文件解析失败:指出文件和错误位置,退出 2。
341
+ - 配置文件不存在:显示 `missing (using defaults)`,属于正常状态,退出 0。
342
+ - 多个问题同时存在时,配置错误(2)优先于依赖缺失(1)。
343
+
344
+ ## 9. 日志与退出码
345
+
346
+ - 默认:阶段信息和汇总,输出到 stderr。
347
+ - `-v`:输出每个文件的处理/跳过原因,以及 rclone 的进度。
348
+ - `--quiet`:只输出错误。
349
+ - `doctor` 报告输出到 stdout;`--dry-run` 的计划文件列表和 `--urls` 的 JSON 输出到 stdout。
350
+ - 退出码:`0` 成功,`1` 运行失败,`2` 参数/配置错误。
351
+ - 例外:`backup` / `publish` 直接返回 rclone 的退出码,不归一化;调用方按非零即失败处理。
352
+ - 不引入 colorama、rich、click;仅标准库 + pyvips。
353
+
354
+ ## 10. 跨平台约定
355
+
356
+ - 路径处理使用 `pathlib`。
357
+ - 不通过 shell 执行外部命令,Windows 盘符和反斜杠不被 shell 解释。
358
+ - 安装后的命令名为 `pix`(Linux)或 `pix.exe`(Windows)。
359
+ - AVIF 输出统一使用 `.avif` 后缀。
360
+ - 状态文件使用 UTF-8 和 `/` 分隔的相对路径,保证跨平台可迁移。