|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Avoid redundant S3 uploads by aging byte-identical local files. |
| 3 | +
|
| 4 | +`aws s3 sync` uploads a local file when its size differs from the remote object or |
| 5 | +when the local modification time is newer. mdBook rebuilds refresh mtimes even |
| 6 | +when output bytes are unchanged, which causes unnecessary PutObject requests. |
| 7 | +
|
| 8 | +Given a ListObjectsV2 manifest, this script compares local MD5 digests with |
| 9 | +single-part S3 ETags. Byte-identical files are assigned an old mtime so the |
| 10 | +following `aws s3 sync --delete` skips them. New, changed, multipart, and |
| 11 | +otherwise unverifiable objects are left untouched and therefore upload normally. |
| 12 | +""" |
| 13 | + |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +import argparse |
| 17 | +import hashlib |
| 18 | +import json |
| 19 | +import os |
| 20 | +from pathlib import Path |
| 21 | +from typing import Any |
| 22 | + |
| 23 | +OLD_MTIME_SECONDS = 1 |
| 24 | + |
| 25 | + |
| 26 | +def file_md5(path: Path, chunk_size: int = 1024 * 1024) -> str: |
| 27 | + digest = hashlib.md5(usedforsecurity=False) |
| 28 | + with path.open("rb") as handle: |
| 29 | + for chunk in iter(lambda: handle.read(chunk_size), b""): |
| 30 | + digest.update(chunk) |
| 31 | + return digest.hexdigest() |
| 32 | + |
| 33 | + |
| 34 | +def load_remote_objects(manifest_path: Path, remote_prefix: str) -> dict[str, dict[str, Any]]: |
| 35 | + if remote_prefix.startswith("/"): |
| 36 | + raise ValueError("remote prefix must not start with '/'") |
| 37 | + if remote_prefix and not remote_prefix.endswith("/"): |
| 38 | + remote_prefix += "/" |
| 39 | + |
| 40 | + payload = json.loads(manifest_path.read_text(encoding="utf-8")) |
| 41 | + contents = payload.get("Contents", []) |
| 42 | + if contents is None: |
| 43 | + contents = [] |
| 44 | + if not isinstance(contents, list): |
| 45 | + raise ValueError("manifest Contents must be a list") |
| 46 | + |
| 47 | + objects: dict[str, dict[str, Any]] = {} |
| 48 | + for item in contents: |
| 49 | + if not isinstance(item, dict): |
| 50 | + continue |
| 51 | + key = str(item.get("Key") or "") |
| 52 | + if not key.startswith(remote_prefix): |
| 53 | + continue |
| 54 | + relative_key = key[len(remote_prefix):] |
| 55 | + if not relative_key or relative_key.endswith("/"): |
| 56 | + continue |
| 57 | + objects[relative_key] = item |
| 58 | + return objects |
| 59 | + |
| 60 | + |
| 61 | +def mark_unchanged_files(source: Path, remote: dict[str, dict[str, Any]]) -> dict[str, int]: |
| 62 | + if not source.is_dir(): |
| 63 | + raise ValueError(f"source is not a directory: {source}") |
| 64 | + |
| 65 | + stats = {"local_files": 0, "unchanged": 0, "upload_candidates": 0} |
| 66 | + for path in source.rglob("*"): |
| 67 | + if not path.is_file(): |
| 68 | + continue |
| 69 | + stats["local_files"] += 1 |
| 70 | + relative_key = path.relative_to(source).as_posix() |
| 71 | + item = remote.get(relative_key) |
| 72 | + if not item: |
| 73 | + stats["upload_candidates"] += 1 |
| 74 | + continue |
| 75 | + |
| 76 | + etag = str(item.get("ETag") or "").strip('"').lower() |
| 77 | + raw_size = item.get("Size") |
| 78 | + try: |
| 79 | + remote_size = int(raw_size) if raw_size is not None else -1 |
| 80 | + except (TypeError, ValueError): |
| 81 | + remote_size = -1 |
| 82 | + |
| 83 | + # A dashed ETag is normally multipart and is not the object's MD5. |
| 84 | + verifiable = len(etag) == 32 and "-" not in etag and all(c in "0123456789abcdef" for c in etag) |
| 85 | + if verifiable and remote_size == path.stat().st_size and file_md5(path) == etag: |
| 86 | + os.utime(path, (OLD_MTIME_SECONDS, OLD_MTIME_SECONDS), follow_symlinks=True) |
| 87 | + stats["unchanged"] += 1 |
| 88 | + else: |
| 89 | + stats["upload_candidates"] += 1 |
| 90 | + return stats |
| 91 | + |
| 92 | + |
| 93 | +def main() -> int: |
| 94 | + parser = argparse.ArgumentParser(description=__doc__) |
| 95 | + parser.add_argument("--source", type=Path, required=True) |
| 96 | + parser.add_argument("--manifest", type=Path, required=True) |
| 97 | + parser.add_argument("--remote-prefix", default="") |
| 98 | + args = parser.parse_args() |
| 99 | + |
| 100 | + remote = load_remote_objects(args.manifest, args.remote_prefix) |
| 101 | + stats = mark_unchanged_files(args.source, remote) |
| 102 | + stats["remote_objects"] = len(remote) |
| 103 | + print(json.dumps(stats, sort_keys=True)) |
| 104 | + return 0 |
| 105 | + |
| 106 | + |
| 107 | +if __name__ == "__main__": |
| 108 | + raise SystemExit(main()) |
0 commit comments