pix-cli 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """pix package."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _package_version
5
+
6
+ try:
7
+ __version__ = _package_version("pix-cli")
8
+ except PackageNotFoundError: # pragma: no cover - only when running uninstalled
9
+ __version__ = "0.1.0"
pix/cli.py ADDED
@@ -0,0 +1,116 @@
1
+ """pix command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .config import ConfigError, init_config, load_settings
11
+ from .doctor import doctor
12
+ from .logger import Logger
13
+ from .shrink import shrink
14
+ from .sync import backup, publish
15
+
16
+
17
+ def _add_logging_args(parser: argparse.ArgumentParser) -> None:
18
+ group = parser.add_mutually_exclusive_group()
19
+ group.add_argument("-v", "--verbose", action="store_true", default=None)
20
+ group.add_argument("--quiet", action="store_true", default=None)
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(prog="pix", description="Batch AVIF shrink and image-host sync.")
25
+ parser.add_argument("--config", type=Path, default=None, metavar="PATH")
26
+ parser.add_argument("--version", action="version", version=f"pix {__version__}")
27
+ subparsers = parser.add_subparsers(dest="command", required=True)
28
+
29
+ shrink = subparsers.add_parser("shrink", help="compress images to AVIF")
30
+ shrink.add_argument("-s", "--source", type=Path, required=True)
31
+ shrink.add_argument("-d", "--dest", type=Path, required=True)
32
+ shrink.add_argument("-w", "--width", type=int, default=None)
33
+ shrink.add_argument("-q", "--quality", type=int, default=None)
34
+ shrink.add_argument("-e", "--effort", type=int, default=None)
35
+ shrink.add_argument("-m", "--min-size", type=str, default=None)
36
+ shrink.add_argument("--force", action="store_true")
37
+ shrink.add_argument("--prune", action="store_true")
38
+ shrink.add_argument("--allow-empty", action="store_true")
39
+ shrink.add_argument("--dry-run", action="store_true")
40
+ _add_logging_args(shrink)
41
+
42
+ backup = subparsers.add_parser("backup", help="bidirectionally mirror originals")
43
+ backup.add_argument("-s", "--source", type=Path, required=True)
44
+ backup.add_argument("-d", "--dest", required=True)
45
+ backup.add_argument("--exclude", action="append", default=None, metavar="GLOB")
46
+ backup.add_argument(
47
+ "--max-delete",
48
+ type=int,
49
+ default=None,
50
+ metavar="PERCENT",
51
+ help="abort if more than PERCENT%% of files are deleted (default: 10)",
52
+ )
53
+ backup.add_argument("--allow-empty", action="store_true")
54
+ backup.add_argument("--resync", action="store_true")
55
+ backup.add_argument("--dry-run", action="store_true")
56
+ _add_logging_args(backup)
57
+
58
+ publish = subparsers.add_parser("publish", help="bidirectionally mirror compressed images")
59
+ publish.add_argument("-s", "--source", type=Path, required=True)
60
+ publish.add_argument("-d", "--dest", required=True)
61
+ publish.add_argument("--base-url", default=None)
62
+ publish.add_argument("--urls", action=argparse.BooleanOptionalAction, default=None)
63
+ publish.add_argument("--exclude", action="append", default=None, metavar="GLOB")
64
+ publish.add_argument(
65
+ "--max-delete",
66
+ type=int,
67
+ default=None,
68
+ metavar="PERCENT",
69
+ help="abort if more than PERCENT%% of files are deleted (default: 10)",
70
+ )
71
+ publish.add_argument("--allow-empty", action="store_true")
72
+ publish.add_argument("--resync", action="store_true")
73
+ publish.add_argument("--dry-run", action="store_true")
74
+ _add_logging_args(publish)
75
+
76
+ subparsers.add_parser("doctor", help="check runtime dependencies and configuration")
77
+
78
+ config = subparsers.add_parser("config", help="manage the configuration file")
79
+ config_sub = config.add_subparsers(dest="config_command", required=True)
80
+ init = config_sub.add_parser("init", help="write the default config file")
81
+ init.add_argument("--force", action="store_true")
82
+
83
+ return parser
84
+
85
+
86
+ def main(argv: list[str] | None = None) -> int:
87
+ parser = build_parser()
88
+ try:
89
+ args = parser.parse_args(argv)
90
+ except SystemExit as exc: # --help/--version and argparse errors
91
+ return int(exc.code) if isinstance(exc.code, int) else 0
92
+
93
+ if args.command == "config":
94
+ return init_config(args)
95
+
96
+ try:
97
+ settings = load_settings(args.config)
98
+ except ConfigError as exc:
99
+ print(f"pix: error: {exc}", file=sys.stderr)
100
+ return 2
101
+
102
+ log = Logger(
103
+ verbose=bool(getattr(args, "verbose", False)),
104
+ quiet=bool(getattr(args, "quiet", False)),
105
+ )
106
+ handlers = {
107
+ "shrink": shrink,
108
+ "backup": backup,
109
+ "publish": publish,
110
+ "doctor": doctor,
111
+ }
112
+ return handlers[args.command](args, settings, log)
113
+
114
+
115
+ if __name__ == "__main__":
116
+ raise SystemExit(main())
pix/config.py ADDED
@@ -0,0 +1,172 @@
1
+ """Configuration loading and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import copy
7
+ import os
8
+ import re
9
+ import sys
10
+ import tomllib
11
+ from pathlib import Path
12
+ from typing import Any
13
+
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
+ _SIZE_RE = re.compile(r"^(\d+)([KMG]?)$", re.IGNORECASE)
38
+ _SIZE_UNITS = {"": 1, "K": 1024, "M": 1024**2, "G": 1024**3}
39
+
40
+
41
+ class ConfigError(Exception):
42
+ """Invalid or unreadable configuration."""
43
+
44
+
45
+ def parse_size(value: str | int) -> int:
46
+ if isinstance(value, int) and not isinstance(value, bool):
47
+ if value < 0:
48
+ raise ValueError("size must not be negative")
49
+ return value
50
+ if not isinstance(value, str):
51
+ raise ValueError(f"invalid size: {value!r}")
52
+ match = _SIZE_RE.fullmatch(value.strip())
53
+ if match is None:
54
+ raise ValueError(f"invalid size: {value!r}")
55
+ return int(match.group(1)) * _SIZE_UNITS[match.group(2).upper()]
56
+
57
+
58
+ def default_config_path() -> Path:
59
+ if os.name == "nt":
60
+ base = os.environ.get("APPDATA")
61
+ if base:
62
+ return Path(base) / "pix" / "config.toml"
63
+ return Path.home() / "AppData" / "Roaming" / "pix" / "config.toml"
64
+ base = os.environ.get("XDG_CONFIG_HOME")
65
+ if base:
66
+ return Path(base) / "pix" / "config.toml"
67
+ return Path.home() / ".config" / "pix" / "config.toml"
68
+
69
+
70
+ def load_config(path: Path) -> dict[str, Any]:
71
+ """Read a TOML config file; a missing file is an empty override."""
72
+ if not path.is_file():
73
+ return {}
74
+ try:
75
+ with path.open("rb") as handle:
76
+ data = tomllib.load(handle)
77
+ except OSError as exc:
78
+ raise ConfigError(f"{path}: {exc}") from exc
79
+ except tomllib.TOMLDecodeError as exc:
80
+ raise ConfigError(f"{path}: {exc}") from exc
81
+ if not isinstance(data, dict):
82
+ raise ConfigError(f"{path}: top level must be a table")
83
+ return data
84
+
85
+
86
+ def _deep_update(target: dict[str, Any], source: dict[str, Any]) -> None:
87
+ for key, value in source.items():
88
+ current = target.get(key)
89
+ if isinstance(current, dict) and isinstance(value, dict):
90
+ _deep_update(current, value)
91
+ else:
92
+ target[key] = value
93
+
94
+
95
+ def load_settings(explicit: Path | None = None) -> dict[str, Any]:
96
+ path = explicit if explicit is not None else default_config_path()
97
+ if explicit is not None and not path.is_file():
98
+ raise ConfigError(f"config file not found: {path}")
99
+ overrides = load_config(path)
100
+ settings = copy.deepcopy(DEFAULTS)
101
+ _deep_update(settings, overrides)
102
+ validate_settings(settings)
103
+ return settings
104
+
105
+
106
+ def require_int(name: str, value: Any, minimum: int, maximum: int | None = None) -> int:
107
+ if isinstance(value, bool) or not isinstance(value, int):
108
+ raise ConfigError(f"{name} must be an integer")
109
+ if value < minimum or (maximum is not None and value > maximum):
110
+ if maximum is None:
111
+ raise ConfigError(f"{name} must be >= {minimum}")
112
+ raise ConfigError(f"{name} must be between {minimum} and {maximum}")
113
+ return value
114
+
115
+
116
+ def _require_excludes(name: str, value: Any) -> list[str]:
117
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
118
+ raise ConfigError(f"{name} must be a list of strings")
119
+ return value
120
+
121
+
122
+ def validate_settings(settings: dict[str, Any]) -> None:
123
+ for section in ("shrink", "backup", "publish", "rclone"):
124
+ if not isinstance(settings.get(section), dict):
125
+ raise ConfigError(f"[{section}] must be a table")
126
+
127
+ shrink = settings["shrink"]
128
+ require_int("shrink.width", shrink["width"], 1)
129
+ require_int("shrink.quality", shrink["quality"], 1, 100)
130
+ require_int("shrink.effort", shrink["effort"], 0, 9)
131
+ try:
132
+ shrink["min_size"] = parse_size(shrink["min_size"])
133
+ except ValueError as exc:
134
+ raise ConfigError(f"shrink.min_size: {exc}") from exc
135
+
136
+ include = shrink["include"]
137
+ if not isinstance(include, list) or not all(
138
+ isinstance(item, str) and item and not item.startswith(".") for item in include
139
+ ):
140
+ raise ConfigError("shrink.include must be a list of extensions without dots")
141
+
142
+ for section in ("backup", "publish"):
143
+ _require_excludes(f"{section}.exclude", settings[section]["exclude"])
144
+ require_int(f"{section}.max_delete", settings[section]["max_delete"], -1, 100)
145
+
146
+ publish = settings["publish"]
147
+ if not isinstance(publish["base_url"], str):
148
+ raise ConfigError("publish.base_url must be a string")
149
+ if not isinstance(publish["urls"], bool):
150
+ raise ConfigError("publish.urls must be a boolean")
151
+
152
+ if not isinstance(settings["rclone"]["binary"], str) or not settings["rclone"]["binary"]:
153
+ raise ConfigError("rclone.binary must be a non-empty string")
154
+
155
+
156
+ def init_config(args: argparse.Namespace) -> int:
157
+ path = args.config if args.config is not None else default_config_path()
158
+ if path.exists() and not args.force:
159
+ print(
160
+ f"pix: error: config file already exists: {path} (use --force to overwrite)",
161
+ file=sys.stderr,
162
+ )
163
+ return 2
164
+ try:
165
+ template = Path(__file__).with_name("default_config.toml").read_text(encoding="utf-8")
166
+ path.parent.mkdir(parents=True, exist_ok=True)
167
+ path.write_text(template, encoding="utf-8")
168
+ except OSError as exc:
169
+ print(f"pix: error: {path}: {exc}", file=sys.stderr)
170
+ return 2
171
+ print(f"wrote {path}")
172
+ return 0
@@ -0,0 +1,32 @@
1
+ # pix configuration
2
+ # Command-line flags override these values.
3
+
4
+ [shrink]
5
+ # Longest edge in pixels.
6
+ width = 1920
7
+ # AVIF quality, 1-100.
8
+ quality = 85
9
+ # libvips effort, 0-9 (higher is slower).
10
+ effort = 5
11
+ # Skip sources smaller than this; accepts bytes or 0, 500K, 1M, 2G.
12
+ min_size = 0
13
+ # Extensions to scan, written without dots.
14
+ include = ["jpg", "jpeg", "png", "webp"]
15
+
16
+ [backup]
17
+ # rclone exclude globs; command-line --exclude is appended after these.
18
+ exclude = []
19
+ # Maximum percent of files rclone may delete (-1 disables, 0 forbids).
20
+ max_delete = 10
21
+
22
+ [publish]
23
+ # Public URL prefix; must start with http:// or https://.
24
+ base_url = ""
25
+ # Emit a JSON URL manifest.
26
+ urls = false
27
+ exclude = []
28
+ max_delete = 10
29
+
30
+ [rclone]
31
+ # rclone executable name or path.
32
+ binary = "rclone"
pix/doctor.py ADDED
@@ -0,0 +1,54 @@
1
+ """doctor subcommand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import platform
7
+ import shutil
8
+ import sys
9
+ from typing import Any
10
+
11
+ from . import __version__
12
+ from .config import default_config_path
13
+ from .logger import Logger
14
+ from .rclone import RCLONE_HINT, RCLONE_MIN, probe_rclone
15
+ from .shrink import PYVIPS_HINT
16
+
17
+
18
+ def doctor(args: argparse.Namespace, settings: dict[str, Any], log: Logger) -> int:
19
+ issues = 0
20
+ print(f"pix {__version__}")
21
+ print(f"python {platform.python_version()} ({platform.system().lower()})")
22
+
23
+ try:
24
+ import pyvips
25
+
26
+ libvips = ".".join(str(pyvips.version(index)) for index in range(3))
27
+ print(f"vips {libvips} via pyvips {pyvips.__version__} (API mode)")
28
+ except Exception as exc:
29
+ print("vips unavailable")
30
+ print(PYVIPS_HINT, file=sys.stderr)
31
+ print(f"pix: error: {exc}", file=sys.stderr)
32
+ issues = 1
33
+
34
+ binary = settings["rclone"]["binary"]
35
+ version, parsed, _error = probe_rclone(binary)
36
+ location = shutil.which(binary) or binary
37
+ if parsed is None:
38
+ print(f"rclone missing ({binary})")
39
+ print(RCLONE_HINT, file=sys.stderr)
40
+ issues = 1
41
+ elif parsed[:2] < RCLONE_MIN:
42
+ print(f"rclone {version} ({location}) too old; need >= 1.66")
43
+ print(RCLONE_HINT, file=sys.stderr)
44
+ issues = 1
45
+ else:
46
+ print(f"rclone {version} ({location}) bisync ok")
47
+
48
+ config_path = args.config if args.config is not None else default_config_path()
49
+ if config_path.is_file():
50
+ print(f"config {config_path} (ok)")
51
+ else:
52
+ print(f"config {config_path} (missing (using defaults))")
53
+
54
+ return 1 if issues else 0
pix/logger.py ADDED
@@ -0,0 +1,26 @@
1
+ """stderr logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ class Logger:
9
+ def __init__(self, verbose: bool = False, quiet: bool = False) -> None:
10
+ self.verbose = verbose
11
+ self.quiet = quiet
12
+
13
+ def info(self, message: str) -> None:
14
+ if not self.quiet:
15
+ print(message, file=sys.stderr)
16
+
17
+ def detail(self, message: str) -> None:
18
+ if self.verbose and not self.quiet:
19
+ print(message, file=sys.stderr)
20
+
21
+ def warning(self, message: str) -> None:
22
+ if not self.quiet:
23
+ print(f"pix: warning: {message}", file=sys.stderr)
24
+
25
+ def error(self, message: str) -> None:
26
+ print(f"pix: error: {message}", file=sys.stderr)
pix/py.typed ADDED
File without changes
pix/rclone.py ADDED
@@ -0,0 +1,72 @@
1
+ """rclone probing and execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from .logger import Logger
11
+
12
+ RCLONE_MIN = (1, 66)
13
+ RCLONE_HINT = (
14
+ "install rclone >= 1.66: https://rclone.org/downloads/ "
15
+ "(Windows: winget search rclone)"
16
+ )
17
+
18
+
19
+ def merge_excludes(*groups: list[str] | None) -> list[str]:
20
+ merged: list[str] = []
21
+ seen: set[str] = set()
22
+ for group in groups:
23
+ for item in group or []:
24
+ if item not in seen:
25
+ seen.add(item)
26
+ merged.append(item)
27
+ return merged
28
+
29
+
30
+ def has_files(path: Path) -> bool:
31
+ return any(item.is_file() for item in path.rglob("*"))
32
+
33
+
34
+ def probe_rclone(binary: str) -> tuple[str | None, tuple[int, int, int] | None, str | None]:
35
+ try:
36
+ result = subprocess.run([binary, "version"], capture_output=True, text=True)
37
+ except FileNotFoundError:
38
+ return None, None, f"rclone not found: {binary}"
39
+ except OSError as exc:
40
+ return None, None, f"cannot run rclone: {exc}"
41
+ output = f"{result.stdout or ''}\n{result.stderr or ''}".strip()
42
+ match = re.search(r"rclone v(\d+)\.(\d+)(?:\.(\d+))?", output)
43
+ if match is None:
44
+ if result.returncode != 0:
45
+ return None, None, f"rclone version failed with exit code {result.returncode}"
46
+ return None, None, "cannot parse rclone version"
47
+ parsed = (int(match.group(1)), int(match.group(2)), int(match.group(3) or 0))
48
+ return f"{parsed[0]}.{parsed[1]}.{parsed[2]}", parsed, None
49
+
50
+
51
+ def check_rclone(binary: str, log: Logger) -> str | None:
52
+ version, parsed, error = probe_rclone(binary)
53
+ if parsed is None:
54
+ log.error(error or "rclone unavailable")
55
+ log.error(RCLONE_HINT)
56
+ return None
57
+ if parsed[:2] < RCLONE_MIN:
58
+ log.error(f"rclone {version} is too old; need >= 1.66")
59
+ log.error(f"upgrade rclone: {RCLONE_HINT}")
60
+ return None
61
+ return version
62
+
63
+
64
+ def run_rclone(cmd: list[str]) -> int:
65
+ try:
66
+ return subprocess.run(cmd).returncode
67
+ except FileNotFoundError:
68
+ print(f"pix: error: rclone not found: {cmd[0]}", file=sys.stderr)
69
+ return 1
70
+ except OSError as exc:
71
+ print(f"pix: error: cannot run rclone: {exc}", file=sys.stderr)
72
+ return 1
pix/shrink.py ADDED
@@ -0,0 +1,368 @@
1
+ """shrink subcommand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .config import ConfigError, parse_size, require_int
13
+ from .logger import Logger
14
+
15
+ STATE_VERSION = 1
16
+ STATE_DIR = ".pix"
17
+
18
+ PYVIPS_HINT = """pyvips/libvips 不可用。
19
+
20
+ 推荐重装(会自带 libvips):
21
+ uv tool install --force pix-cli
22
+
23
+ 也可以手动安装 libvips:
24
+ Windows: 从 https://github.com/libvips/libvips/releases 下载
25
+ vips-dev-w64-web-*.zip,解压后把 <vips>\\bin 加入 PATH
26
+ Linux: sudo apt install libvips42
27
+
28
+ 注意:uv 只能安装 Python 包,不能单独安装原生 libvips。
29
+ pyvips[binary] 的作用就是把 libvips 一起打包进 Python 环境。"""
30
+
31
+
32
+ def _scan_sources(source: Path, include: list[str]) -> list[Path]:
33
+ allowed = {item.lower() for item in include}
34
+ paths = [
35
+ path
36
+ for path in source.rglob("*")
37
+ if path.is_file() and path.suffix.lower().lstrip(".") in allowed
38
+ ]
39
+ paths.sort(key=lambda path: path.relative_to(source).as_posix())
40
+ return paths
41
+
42
+
43
+ def _target_map(
44
+ sources: list[Path], source: Path, dest: Path
45
+ ) -> tuple[dict[Path, Path], dict[str, list[str]]]:
46
+ targets: dict[Path, Path] = {}
47
+ owners: dict[str, list[str]] = {}
48
+ for src in sources:
49
+ rel = src.relative_to(source)
50
+ out_rel = rel.with_suffix(".avif")
51
+ key = out_rel.as_posix()
52
+ owners.setdefault(key, []).append(rel.as_posix())
53
+ targets[src] = dest / out_rel
54
+ conflicts = {key: rels for key, rels in owners.items() if len(rels) > 1}
55
+ return targets, conflicts
56
+
57
+
58
+ def _load_state(path: Path, params: str, log: Logger) -> dict[str, Any]:
59
+ empty = {"version": STATE_VERSION, "params": params, "files": {}}
60
+ if not path.is_file():
61
+ return empty
62
+ try:
63
+ data = json.loads(path.read_text(encoding="utf-8"))
64
+ except (OSError, ValueError) as exc:
65
+ log.warning(f"cannot read state {path}: {exc}; rebuilding")
66
+ return empty
67
+ if (
68
+ not isinstance(data, dict)
69
+ or data.get("version") != STATE_VERSION
70
+ or not isinstance(data.get("files"), dict)
71
+ or not all(
72
+ isinstance(key, str) and isinstance(value, dict)
73
+ for key, value in data["files"].items()
74
+ )
75
+ ):
76
+ log.warning(f"unrecognized state {path}; rebuilding")
77
+ return empty
78
+ return {
79
+ "version": STATE_VERSION,
80
+ "params": data.get("params") if isinstance(data.get("params"), str) else "",
81
+ "files": data["files"],
82
+ }
83
+
84
+
85
+ def _save_state(path: Path, state: dict[str, Any]) -> None:
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
88
+ try:
89
+ tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
90
+ os.replace(tmp, path)
91
+ except OSError:
92
+ try:
93
+ tmp.unlink()
94
+ except OSError:
95
+ pass
96
+ raise
97
+
98
+
99
+ def _acquire_lock(path: Path) -> bool:
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ try:
102
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
103
+ except FileExistsError:
104
+ return False
105
+ try:
106
+ os.write(fd, f"{os.getpid()}\n".encode())
107
+ finally:
108
+ os.close(fd)
109
+ return True
110
+
111
+
112
+ def _release_lock(path: Path) -> None:
113
+ try:
114
+ path.unlink()
115
+ except FileNotFoundError:
116
+ pass
117
+
118
+
119
+ def _skip_reason(
120
+ src_stat: os.stat_result,
121
+ dest_path: Path,
122
+ entry: dict[str, Any] | None,
123
+ params_match: bool,
124
+ min_size: int,
125
+ ) -> str | None:
126
+ if min_size > 0 and src_stat.st_size < min_size:
127
+ return "small"
128
+ if entry is None or not params_match:
129
+ return None
130
+ try:
131
+ out_stat = dest_path.stat()
132
+ except OSError:
133
+ return None
134
+ if (
135
+ entry.get("src_size") == src_stat.st_size
136
+ and entry.get("src_mtime_ns") == src_stat.st_mtime_ns
137
+ and entry.get("out_size") == out_stat.st_size
138
+ ):
139
+ return "state"
140
+ return None
141
+
142
+
143
+ def _prune(
144
+ state: dict[str, Any],
145
+ source: Path,
146
+ dest: Path,
147
+ source_count: int,
148
+ dry_run: bool,
149
+ allow_empty: bool,
150
+ log: Logger,
151
+ ) -> tuple[int, int, int]:
152
+ files = state["files"]
153
+ if source_count == 0 and files:
154
+ if not allow_empty:
155
+ log.error(
156
+ "source has no images but state has entries; refusing to prune "
157
+ "(use --allow-empty to override)"
158
+ )
159
+ return 0, 0, len(files)
160
+ stale = sorted(rel for rel in files if not (source / rel).is_file())
161
+ deleted = 0
162
+ failed = 0
163
+ for rel in stale:
164
+ if dry_run:
165
+ print(f"prune {rel}")
166
+ deleted += 1
167
+ continue
168
+ out_path = dest / Path(rel).with_suffix(".avif")
169
+ try:
170
+ out_path.unlink()
171
+ except FileNotFoundError:
172
+ pass
173
+ except OSError as exc:
174
+ log.error(f"prune {rel}: {exc}")
175
+ failed += 1
176
+ continue
177
+ del files[rel]
178
+ deleted += 1
179
+ log.detail(f"pruned {rel}")
180
+ return deleted, failed, 0
181
+
182
+
183
+ def _log_summary(log: Logger, counters: dict[str, int]) -> None:
184
+ log.info(
185
+ "summary: converted={converted} skipped={state} too-small={small} "
186
+ "empty-protected={protected} failed={failed} deleted={deleted}".format(**counters)
187
+ )
188
+
189
+
190
+ def shrink(args: argparse.Namespace, settings: dict[str, Any], log: Logger) -> int:
191
+ config = settings["shrink"]
192
+ try:
193
+ width = require_int(
194
+ "width", args.width if args.width is not None else config["width"], 1
195
+ )
196
+ quality = require_int(
197
+ "quality", args.quality if args.quality is not None else config["quality"], 1, 100
198
+ )
199
+ effort = require_int(
200
+ "effort", args.effort if args.effort is not None else config["effort"], 0, 9
201
+ )
202
+ min_size = (
203
+ parse_size(args.min_size) if args.min_size is not None else config["min_size"]
204
+ )
205
+ except (ConfigError, ValueError) as exc:
206
+ log.error(str(exc))
207
+ return 2
208
+
209
+ source = args.source
210
+ dest = args.dest
211
+ if not source.is_dir():
212
+ log.error(f"source is not a directory: {source}")
213
+ return 2
214
+ if dest.exists() and not dest.is_dir():
215
+ log.error(f"dest is not a directory: {dest}")
216
+ return 2
217
+
218
+ try:
219
+ sources = _scan_sources(source, config["include"])
220
+ except OSError as exc:
221
+ log.error(f"cannot scan {source}: {exc}")
222
+ return 1
223
+ targets, conflicts = _target_map(sources, source, dest)
224
+ if conflicts:
225
+ log.error("multiple sources map to the same output:")
226
+ for key, rels in sorted(conflicts.items()):
227
+ print(f" {key} <- {', '.join(rels)}", file=sys.stderr)
228
+ return 2
229
+
230
+ params = f"w={width};q={quality};e={effort}"
231
+ state_file = dest / STATE_DIR / "state.json"
232
+ lock_file = dest / STATE_DIR / "lock"
233
+
234
+ if not args.dry_run:
235
+ try:
236
+ if not _acquire_lock(lock_file):
237
+ log.error(
238
+ f"another shrink appears to be running ({lock_file}); "
239
+ "remove the file if it is stale"
240
+ )
241
+ return 1
242
+ except OSError as exc:
243
+ log.error(f"cannot create lock {lock_file}: {exc}")
244
+ return 1
245
+
246
+ log.info(f"scanning {source}: {len(sources)} image(s)")
247
+ state = _load_state(state_file, params, log)
248
+ params_match = state["params"] == params
249
+ files = state["files"]
250
+ counters = {
251
+ "converted": 0,
252
+ "state": 0,
253
+ "small": 0,
254
+ "protected": 0,
255
+ "failed": 0,
256
+ "deleted": 0,
257
+ }
258
+ status = 0
259
+ pyvips = None
260
+ encoded: set[str] = set()
261
+
262
+ try:
263
+ for src in sources:
264
+ rel = src.relative_to(source).as_posix()
265
+ try:
266
+ src_stat = src.stat()
267
+ except OSError as exc:
268
+ log.error(f"{rel}: {exc}")
269
+ counters["failed"] += 1
270
+ status = 1
271
+ continue
272
+
273
+ reason = None if args.force else _skip_reason(
274
+ src_stat, targets[src], files.get(rel), params_match, min_size
275
+ )
276
+ if reason == "small":
277
+ counters["small"] += 1
278
+ log.detail(f"skip {rel} (too small)")
279
+ continue
280
+ if reason == "state":
281
+ counters["state"] += 1
282
+ log.detail(f"skip {rel} (unchanged)")
283
+ continue
284
+
285
+ if args.dry_run:
286
+ print(rel)
287
+ counters["converted"] += 1
288
+ continue
289
+
290
+ if pyvips is None:
291
+ try:
292
+ import pyvips as pyvips_module
293
+ except Exception as exc:
294
+ log.error(PYVIPS_HINT)
295
+ log.error(str(exc))
296
+ status = 1
297
+ break
298
+ pyvips = pyvips_module
299
+
300
+ dest_path = targets[src]
301
+ tmp = dest_path.with_name(f".{dest_path.name}.{os.getpid()}.tmp.avif")
302
+ try:
303
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
304
+ try:
305
+ img = pyvips.Image.thumbnail(str(src), width, size="down", linear=True)
306
+ img.write_to_file(str(tmp), Q=quality, keep="none", effort=effort)
307
+ os.replace(tmp, dest_path)
308
+ except Exception:
309
+ try:
310
+ tmp.unlink()
311
+ except OSError:
312
+ pass
313
+ raise
314
+ out_stat = dest_path.stat()
315
+ except Exception as exc:
316
+ log.error(f"{rel}: {exc}")
317
+ counters["failed"] += 1
318
+ status = 1
319
+ continue
320
+
321
+ files[rel] = {
322
+ "src_size": src_stat.st_size,
323
+ "src_mtime_ns": src_stat.st_mtime_ns,
324
+ "out_size": out_stat.st_size,
325
+ "out_mtime_ns": out_stat.st_mtime_ns,
326
+ }
327
+ encoded.add(rel)
328
+ counters["converted"] += 1
329
+ log.detail(f"encoded {rel}")
330
+ if counters["converted"] % 200 == 0:
331
+ try:
332
+ _save_state(state_file, state)
333
+ except OSError as exc:
334
+ log.error(f"cannot write state {state_file}: {exc}")
335
+ status = 1
336
+
337
+ if args.prune:
338
+ deleted, failed, protected = _prune(
339
+ state, source, dest, len(sources), args.dry_run, args.allow_empty, log
340
+ )
341
+ counters["deleted"] += deleted
342
+ counters["failed"] += failed
343
+ counters["protected"] += protected
344
+ if failed or protected:
345
+ status = 1
346
+
347
+ if not params_match and status == 0 and not args.dry_run:
348
+ # Only keep entries written under the new parameters; stale entries
349
+ # would otherwise be skipped after min_size or include changes.
350
+ state["files"] = {
351
+ rel: entry for rel, entry in state["files"].items() if rel in encoded
352
+ }
353
+ except KeyboardInterrupt:
354
+ log.error("interrupted")
355
+ status = 1
356
+ finally:
357
+ if not args.dry_run:
358
+ if params_match or status == 0:
359
+ state["params"] = params
360
+ try:
361
+ _save_state(state_file, state)
362
+ except OSError as exc:
363
+ log.error(f"cannot write state {state_file}: {exc}")
364
+ status = 1
365
+ _release_lock(lock_file)
366
+
367
+ _log_summary(log, counters)
368
+ return status
pix/sync.py ADDED
@@ -0,0 +1,142 @@
1
+ """backup and publish subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import quote
10
+
11
+ from .config import ConfigError, require_int
12
+ from .logger import Logger
13
+ from .rclone import check_rclone, has_files, merge_excludes, run_rclone
14
+
15
+
16
+ def backup(args: argparse.Namespace, settings: dict[str, Any], log: Logger) -> int:
17
+ config = settings["backup"]
18
+ source = args.source
19
+ if not source.is_dir():
20
+ log.error(f"source is not a directory: {source}")
21
+ return 2
22
+ try:
23
+ max_delete = require_int(
24
+ "max-delete",
25
+ args.max_delete if args.max_delete is not None else config["max_delete"],
26
+ -1,
27
+ 100,
28
+ )
29
+ except ConfigError as exc:
30
+ log.error(str(exc))
31
+ return 2
32
+
33
+ try:
34
+ has_files_found = has_files(source)
35
+ except OSError as exc:
36
+ log.error(f"cannot scan {source}: {exc}")
37
+ return 1
38
+ if not has_files_found and not (args.allow_empty and args.resync):
39
+ log.error("source is empty; use --allow-empty together with --resync to proceed")
40
+ return 2
41
+
42
+ binary = settings["rclone"]["binary"]
43
+ version = check_rclone(binary, log)
44
+ if version is None:
45
+ return 1
46
+
47
+ excludes = merge_excludes(config["exclude"], args.exclude)
48
+ cmd = [binary, "bisync", str(source), args.dest]
49
+ for pattern in excludes:
50
+ cmd += ["--exclude", pattern]
51
+ cmd += ["--max-delete", str(max_delete)]
52
+ if args.resync:
53
+ cmd.append("--resync")
54
+ if args.dry_run:
55
+ cmd.append("--dry-run")
56
+ cmd.append("--progress" if args.verbose else "--quiet")
57
+
58
+ log.info(f"bisync {source} <-> {args.dest} (rclone {version})")
59
+ log.detail(" ".join(cmd))
60
+ return run_rclone(cmd)
61
+
62
+
63
+ def _collect_avif(source: Path) -> list[Path]:
64
+ files = [
65
+ path for path in source.rglob("*") if path.is_file() and path.suffix.lower() == ".avif"
66
+ ]
67
+ files.sort(key=lambda path: path.relative_to(source).as_posix())
68
+ return files
69
+
70
+
71
+ def publish(args: argparse.Namespace, settings: dict[str, Any], log: Logger) -> int:
72
+ config = settings["publish"]
73
+ source = args.source
74
+ if not source.is_dir():
75
+ log.error(f"source is not a directory: {source}")
76
+ return 2
77
+ try:
78
+ max_delete = require_int(
79
+ "max-delete",
80
+ args.max_delete if args.max_delete is not None else config["max_delete"],
81
+ -1,
82
+ 100,
83
+ )
84
+ except ConfigError as exc:
85
+ log.error(str(exc))
86
+ return 2
87
+
88
+ try:
89
+ avifs = _collect_avif(source)
90
+ except OSError as exc:
91
+ log.error(f"cannot scan {source}: {exc}")
92
+ return 1
93
+ if not avifs and not (args.allow_empty and args.resync):
94
+ log.error(
95
+ "source contains no .avif files; use --allow-empty together with --resync "
96
+ "to proceed"
97
+ )
98
+ return 2
99
+
100
+ base_url = args.base_url if args.base_url is not None else config["base_url"]
101
+ urls = args.urls if args.urls is not None else config["urls"]
102
+ if base_url and not base_url.startswith(("http://", "https://")):
103
+ log.error("base-url must start with http:// or https://")
104
+ return 2
105
+ if urls and not base_url:
106
+ log.error("--urls requires a base URL (--base-url or [publish].base_url)")
107
+ return 2
108
+
109
+ binary = settings["rclone"]["binary"]
110
+ version = check_rclone(binary, log)
111
+ if version is None:
112
+ return 1
113
+
114
+ excludes = merge_excludes([".pix/**"], config["exclude"], args.exclude)
115
+ cmd = [binary, "bisync", str(source), args.dest]
116
+ for pattern in excludes:
117
+ cmd += ["--exclude", pattern]
118
+ cmd += ["--max-delete", str(max_delete)]
119
+ if args.resync:
120
+ cmd.append("--resync")
121
+ if args.dry_run:
122
+ cmd.append("--dry-run")
123
+ cmd.append("--progress" if args.verbose else "--quiet")
124
+
125
+ log.info(f"bisync {source} <-> {args.dest} (rclone {version})")
126
+ log.detail(" ".join(cmd))
127
+ code = run_rclone(cmd)
128
+ if code != 0 or args.dry_run or not urls:
129
+ return code
130
+
131
+ root = base_url.rstrip("/")
132
+ try:
133
+ files = _collect_avif(source)
134
+ except OSError as exc:
135
+ log.error(f"cannot scan {source}: {exc}")
136
+ return 1
137
+ manifest = {
138
+ rel: f"{root}/{quote(rel, safe='/')}"
139
+ for rel in (path.relative_to(source).as_posix() for path in files)
140
+ }
141
+ print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True))
142
+ return code
@@ -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,14 @@
1
+ pix/__init__.py,sha256=gRYT_Cw6vsRunTh7WKL9SZSCZE0wcOdWrecSDfDYgbg,290
2
+ pix/cli.py,sha256=vcrih_Kv8O4E0AC6dCkqd8qWS5rBZWwkiGfvy7QvoAk,4539
3
+ pix/config.py,sha256=FTbwpUlCJlBlm0V1yt5Zt1mu9v2uveckCBnioImXIhs,5872
4
+ pix/default_config.toml,sha256=q6y5sm_b90LGhRC-bQMd978qheSPO9Xx4abcmJqTIsE,765
5
+ pix/doctor.py,sha256=z2ZrS-dsVPh2grdFda4XRze-0xUcGEd4rwxBZY6rl18,1676
6
+ pix/logger.py,sha256=QUijBqc6CHFUKtF9L_1PcuTRCOS_FDt6eVzq3XD_Dik,704
7
+ pix/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pix/rclone.py,sha256=vvVReBo_XFHjmfoKBjPoFVUGaJxwe1zBGD4klLNpNgE,2290
9
+ pix/shrink.py,sha256=DcptFJhDZ5j8zZrd-iFUhPHPp4uuoQdEHimdCjh446Y,11686
10
+ pix/sync.py,sha256=yhlkl9OCCm0FsLwLNius7B9BNkpWVjevScNVcF6kKSg,4517
11
+ pix_cli-0.1.0.dist-info/METADATA,sha256=yB9ZwSA6v4mzPTgtHH9ETGt3eMqnbn8LOBWuyTpGZJo,1323
12
+ pix_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
13
+ pix_cli-0.1.0.dist-info/entry_points.txt,sha256=1TW3gP0Yaf19T624ac9xUG9x8tLkQgPnfWlj3yNgOsY,37
14
+ pix_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pix = pix.cli:main