Files
Termind/Scripts/normalize-string-catalogs.py
T

142 lines
5.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Keep shared, manually maintained string catalogs stable in Xcode and Git.
Only extractionState and JSON serialization change. In particular, a stale entry
is retained with all of its translations: one target's extraction cannot prove
that a string in a catalog shared by multiple targets is unused.
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import stat
import sys
import tempfile
RESOURCES = Path(__file__).resolve().parents[1] / "Frameworks" / "Resources"
DEFAULT_CATALOGS = (
RESOURCES / "Localizable.xcstrings",
RESOURCES / "InfoPlist.xcstrings",
)
def unique_object(pairs: list[tuple[str, object]]) -> dict:
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate JSON key: {key!r}")
result[key] = value
return result
def reject_constant(value: str) -> None:
raise ValueError(f"invalid JSON constant: {value}")
def load_catalog(contents: bytes) -> dict:
catalog = json.loads(
contents.decode("utf-8"),
object_pairs_hook=unique_object,
parse_constant=reject_constant,
)
if not isinstance(catalog, dict):
raise ValueError("catalog must be a JSON object")
for field in ("sourceLanguage", "version"):
if not isinstance(catalog.get(field), str) or not catalog[field]:
raise ValueError(f"{field} must be a nonempty string")
if not isinstance(catalog.get("strings"), dict):
raise ValueError("strings must be a JSON object")
for key, entry in catalog["strings"].items():
if not isinstance(entry, dict):
raise ValueError(f"string entry {key!r} must be a JSON object")
return catalog
def serialize(value: object, depth: int = 0) -> str:
"""Match xcstringstool's Unicode ordering, whitespace and unescaped slashes."""
indent = " " * depth
child_indent = indent + " "
if isinstance(value, dict):
members = [
f"{child_indent}{json.dumps(key, ensure_ascii=False)} : {serialize(value[key], depth + 1)}"
for key in sorted(value)
]
return "{\n" + ",\n".join(members) + "\n" + indent + "}"
if isinstance(value, list):
members = [child_indent + serialize(item, depth + 1) for item in value]
return "[\n" + ",\n".join(members) + "\n" + indent + "]"
return json.dumps(value, ensure_ascii=False, allow_nan=False)
def normalize(contents: bytes) -> bytes:
catalog = load_catalog(contents)
for entry in catalog["strings"].values():
entry["extractionState"] = "manual"
# Xcode does not append a newline to string catalogs.
return serialize(catalog).encode("utf-8")
def replace_catalog(path: Path, contents: bytes) -> None:
"""Replace atomically so an interrupted write cannot truncate translations."""
mode = stat.S_IMODE(path.stat().st_mode)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as output:
output.write(contents)
temporary.chmod(mode)
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--check", action="store_true", help="report drift without changing any files")
mode.add_argument("--write", action="store_true", help="normalize extraction state and Xcode formatting")
parser.add_argument("catalogs", nargs="*", type=Path, help="catalogs (default: Termind's two shared catalogs)")
arguments = parser.parse_args()
pending = []
invalid = False
# Validate the entire batch before writing any files. Duplicate JSON keys
# must fail here rather than silently discarding a translation while parsing.
for path in arguments.catalogs or DEFAULT_CATALOGS:
try:
original = path.read_bytes()
normalized = normalize(original)
if normalized != original:
pending.append((path, normalized))
except (OSError, ValueError) as error:
print(f"{path}: {error}", file=sys.stderr)
invalid = True
if invalid:
return 1
if arguments.check:
for path, _ in pending:
print(f"{path}: expected manual extraction state and canonical Xcode formatting", file=sys.stderr)
if pending:
print("Run Scripts/normalize-string-catalogs.py --write [catalog ...] to normalize.", file=sys.stderr)
return 1
print("String catalogs use manual extraction state and canonical Xcode formatting.")
return 0
for path, contents in pending:
try:
replace_catalog(path, contents)
except OSError as error:
print(f"{path}: {error}", file=sys.stderr)
return 1
print(f"Normalized {path}")
return 0
if __name__ == "__main__":
sys.exit(main())