pix-cli 0.1.1__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.
- {pix_cli-0.1.1 → pix_cli-0.1.2}/PKG-INFO +1 -1
- {pix_cli-0.1.1 → pix_cli-0.1.2}/docs/architecture.md +5 -4
- {pix_cli-0.1.1 → pix_cli-0.1.2}/docs/decisions.md +2 -2
- {pix_cli-0.1.1 → pix_cli-0.1.2}/pyproject.toml +1 -1
- {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/config.py +13 -25
- {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/logger.py +19 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/shrink.py +4 -1
- {pix_cli-0.1.1 → pix_cli-0.1.2}/tests/test_cli.py +31 -3
- {pix_cli-0.1.1 → pix_cli-0.1.2}/uv.lock +1 -1
- {pix_cli-0.1.1 → pix_cli-0.1.2}/.github/workflows/ci.yml +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/.github/workflows/publish.yml +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/.gitignore +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/.python-version +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/AGENTS.md +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/LICENSE +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/README.md +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/README.zh-CN.md +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/docs/development.md +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/docs/roadmap.md +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/__init__.py +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/cli.py +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/default_config.toml +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/doctor.py +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/py.typed +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/rclone.py +0 -0
- {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/sync.py +0 -0
|
@@ -51,8 +51,8 @@ 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 探测、版本检查与执行
|
|
@@ -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
|
-
-
|
|
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
|
-
|
|
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”挡住。
|
|
@@ -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
|
|
@@ -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.
|
|
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"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|