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.
Files changed (26) hide show
  1. {pix_cli-0.1.1 → pix_cli-0.1.2}/PKG-INFO +1 -1
  2. {pix_cli-0.1.1 → pix_cli-0.1.2}/docs/architecture.md +5 -4
  3. {pix_cli-0.1.1 → pix_cli-0.1.2}/docs/decisions.md +2 -2
  4. {pix_cli-0.1.1 → pix_cli-0.1.2}/pyproject.toml +1 -1
  5. {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/config.py +13 -25
  6. {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/logger.py +19 -0
  7. {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/shrink.py +4 -1
  8. {pix_cli-0.1.1 → pix_cli-0.1.2}/tests/test_cli.py +31 -3
  9. {pix_cli-0.1.1 → pix_cli-0.1.2}/uv.lock +1 -1
  10. {pix_cli-0.1.1 → pix_cli-0.1.2}/.github/workflows/ci.yml +0 -0
  11. {pix_cli-0.1.1 → pix_cli-0.1.2}/.github/workflows/publish.yml +0 -0
  12. {pix_cli-0.1.1 → pix_cli-0.1.2}/.gitignore +0 -0
  13. {pix_cli-0.1.1 → pix_cli-0.1.2}/.python-version +0 -0
  14. {pix_cli-0.1.1 → pix_cli-0.1.2}/AGENTS.md +0 -0
  15. {pix_cli-0.1.1 → pix_cli-0.1.2}/LICENSE +0 -0
  16. {pix_cli-0.1.1 → pix_cli-0.1.2}/README.md +0 -0
  17. {pix_cli-0.1.1 → pix_cli-0.1.2}/README.zh-CN.md +0 -0
  18. {pix_cli-0.1.1 → pix_cli-0.1.2}/docs/development.md +0 -0
  19. {pix_cli-0.1.1 → pix_cli-0.1.2}/docs/roadmap.md +0 -0
  20. {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/__init__.py +0 -0
  21. {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/cli.py +0 -0
  22. {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/default_config.toml +0 -0
  23. {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/doctor.py +0 -0
  24. {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/py.typed +0 -0
  25. {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/rclone.py +0 -0
  26. {pix_cli-0.1.1 → pix_cli-0.1.2}/src/pix/sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pix-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
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
@@ -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”挡住。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pix-cli"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Batch AVIF shrink and image-host sync CLI"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -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"] },
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