Files
emanuele-f e4338843d0 weblate.py: consider locale up-to-date when files match
Skip update and bump status when the locale file is identical
on weblate/master and HEAD. Also bump status when all pending
commits are already in master.
2026-02-11 18:59:13 +01:00

328 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright 2026 - Emanuele Faranda
#
# Cherry-pick translation updates from Weblate and squash consecutive
# commits by the same author.
#
# Usage:
# tools/weblate.py status Show pending translation commits
# tools/weblate.py update Update all tracked locales
# tools/weblate.py update <locale> Update a single locale (e.g. "ru")
import subprocess
import sys
import os
import re
REMOTE = "weblate"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
STATUS_FILE = os.path.join(SCRIPT_DIR, "weblate_status")
BUILD_GRADLE = os.path.join(REPO_ROOT, "app", "build.gradle")
STRINGS_BASE = "app/src/main/res"
def die(msg):
print(f"Error: {msg}", file=sys.stderr)
sys.exit(1)
def git(*args, check=True, stdin=None):
result = subprocess.run(["git"] + list(args),
input=stdin, capture_output=True, text=True)
if check and (result.returncode != 0):
die(f"git {' '.join(args)}\n{result.stderr.strip()}")
return result
def locale_path(locale):
return f"{STRINGS_BASE}/values-{locale}/strings.xml"
# ---------------------------------------------------------------------------
# Status file: one "locale=commit_hash" per line
# ---------------------------------------------------------------------------
def load_status():
status = {}
if os.path.exists(STATUS_FILE):
with open(STATUS_FILE) as f:
for line in f:
line = line.strip()
if (not line) or line.startswith("#"):
continue
locale, commit = line.split("=", 1)
status[locale] = commit
return status
def save_status(status):
with open(STATUS_FILE, "w") as f:
for locale in sorted(status):
f.write(f"{locale}={status[locale]}\n")
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def is_on_master(commit):
"""True if *commit* is already reachable from HEAD."""
return git("merge-base", "--is-ancestor", commit, "HEAD",
check=False).returncode == 0
def get_pending_commits(locale, last_commit):
"""Commits on weblate/master after *last_commit* that touch *locale*."""
path = locale_path(locale)
args = ["log", "--format=%H", "--reverse", "--no-merges"]
if last_commit:
args += ["--ancestry-path", f"{last_commit}..{REMOTE}/master"]
else:
args.append(f"{REMOTE}/master")
args += ["--", path]
out = git(*args).stdout.strip()
return [h for h in out.split("\n") if h]
def get_commit_info(commit):
"""Return (name, email, date, message) for *commit*."""
out = git("log", "-1", "--format=%an\n%ae\n%aI\n%s", commit).stdout.strip()
name, email, date, message = out.split("\n", 3)
return name, email, date, message
def has_staged_changes():
return git("diff", "--cached", "--quiet", check=False).returncode != 0
def locale_exists_on_disk(locale):
return os.path.isdir(os.path.join(STRINGS_BASE, f"values-{locale}"))
def count_strings(content, translatable_only=False):
"""Count <string name= entries in an Android strings XML."""
if translatable_only:
return len(re.findall(r'<string name="[^"]*"(?![^>]*translatable="false")', content))
return len(re.findall(r'<string name="', content))
def translation_pct(locale):
"""Return translation percentage string for a locale on weblate/master."""
en_path = f"{STRINGS_BASE}/values/strings.xml"
path = locale_path(locale)
en_content = git("show", f"{REMOTE}/master:{en_path}").stdout
locale_result = git("show", f"{REMOTE}/master:{path}", check=False)
if locale_result.returncode != 0:
return "0%"
total = count_strings(en_content, translatable_only=True)
if total == 0:
return "0%"
translated = count_strings(locale_result.stdout)
return f"{100 * translated // total}%"
def locale_in_sync(locale):
"""True if the locale file is identical on weblate/master and HEAD."""
path = locale_path(locale)
return git("diff", "--quiet", f"{REMOTE}/master", "HEAD", "--", path,
check=False).returncode == 0
def working_tree_clean():
return git("diff", "--quiet", "--", STRINGS_BASE, check=False).returncode == 0 and \
git("diff", "--cached", "--quiet", "--", STRINGS_BASE, check=False).returncode == 0
def get_supported_locales():
"""Non-English locales from resourceConfigurations in build.gradle."""
with open(BUILD_GRADLE) as f:
content = f.read()
locales = re.findall(r'"([a-z]{2}(?:-r[A-Z]{2})?)"',
content.split("resourceConfigurations")[1].split("]")[0])
locales = [l for l in locales if l != "en"]
return sorted(locales)
# ---------------------------------------------------------------------------
# Grouping consecutive commits by author
# ---------------------------------------------------------------------------
def group_by_author(commits):
"""Return list of groups: {name, email, date, commits, message}."""
groups = []
for commit in commits:
name, email, date, message = get_commit_info(commit)
if groups and (groups[-1]["email"] == email):
groups[-1]["commits"].append(commit)
groups[-1]["date"] = date
groups[-1]["message"] = message
else:
groups.append({
"name": name,
"email": email,
"date": date,
"commits": [commit],
"message": message,
})
return groups
# ---------------------------------------------------------------------------
# Applying translation changes
# ---------------------------------------------------------------------------
def apply_group(group, path):
"""Apply a group's translation changes via 3-way merge.
Computes a diff spanning from the parent of the first commit to the
last commit in the group, then applies it with --3way so that
master-only changes (e.g. removed strings) are preserved.
"""
first = group["commits"][0]
last = group["commits"][-1]
first_parent = git("rev-parse", f"{first}^").stdout.strip()
patch = git("diff", "--full-index", first_parent, last, "--", path).stdout
if not patch.strip():
return
result = git("apply", "--3way", check=False, stdin=patch)
if result.returncode != 0:
die(f"Failed to apply changes for {path} "
f"({first[:12]}..{last[:12]}):\n{result.stderr.strip()}")
def verify_locale(locale):
"""Verify that the locale file matches the one on weblate/master."""
path = locale_path(locale)
weblate_content = git("show", f"{REMOTE}/master:{path}").stdout
with open(path) as f:
our_content = f.read()
if our_content != weblate_content:
die(f"locale '{locale}' differs from {REMOTE}/master after update — "
"manual fix required")
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_status():
git("fetch", REMOTE)
status = load_status()
supported = get_supported_locales()
for locale in supported:
pct = translation_pct(locale)
last = status.get(locale)
if last:
pending = get_pending_commits(locale, last)
translation = [c for c in pending if not is_on_master(c)]
if translation and not locale_in_sync(locale):
print(f" {locale} ({pct}): {len(translation)} pending translation commit(s)")
else:
print(f" {locale} ({pct}): up-to-date")
else:
pending = get_pending_commits(locale, None)
if not pending:
continue
translation = [c for c in pending if not is_on_master(c)]
if (not translation) or locale_in_sync(locale):
continue
if locale_exists_on_disk(locale):
print(f" {locale} ({pct}): NOT TRACKED (exists on disk, "
f"{len(translation)} commit(s) — add to status file)")
else:
print(f" {locale} ({pct}): NEW ({len(translation)} commit(s))")
def update_locale(locale, status):
last = status.get(locale)
path = locale_path(locale)
if last is None:
if locale_exists_on_disk(locale):
die(f"locale '{locale}' exists on disk but is not in the "
"status file — add it manually first")
all_pending = get_pending_commits(locale, last)
if not all_pending:
print(f" {locale}: up-to-date")
return
pending = [c for c in all_pending if not is_on_master(c)]
skipped = len(all_pending) - len(pending)
if not pending:
print(f" {locale}: {skipped} commit(s) already in master, bumping status")
status[locale] = all_pending[-1]
save_status(status)
return
if locale_in_sync(locale):
print(f" {locale}: already in sync, bumping status")
status[locale] = pending[-1]
save_status(status)
return
extra = f" ({skipped} already in master)" if skipped else ""
print(f" {locale}: {len(pending)} pending commit(s){extra}")
locale_dir = os.path.join(STRINGS_BASE, f"values-{locale}")
os.makedirs(locale_dir, exist_ok=True)
groups = group_by_author(pending)
for group in groups:
apply_group(group, path)
git("add", path)
if not has_staged_changes():
n = len(group["commits"])
print(f" Skipping {n} commit(s) by {group['name']} (no diff)")
continue
author = f"{group['name']} <{group['email']}>"
message = group["message"]
git("commit", f"--author={author}", f"--date={group['date']}", "-m", message)
n = len(group["commits"])
squash_note = f" (squashed {n} commits)" if (n > 1) else ""
print(f" Committed: {message} by {group['name']}{squash_note}")
verify_locale(locale)
# track the last weblate-side commit (not a master commit) so the
# range for the next run stays on the weblate lineage
status[locale] = pending[-1]
save_status(status)
git("add", STATUS_FILE)
if has_staged_changes():
git("commit", "--amend", "--no-edit")
print(f" {locale}: done")
def cmd_update(target_locale=None):
if not working_tree_clean():
die("working tree has uncommitted changes — commit or stash first")
git("fetch", REMOTE)
status = load_status()
supported = get_supported_locales()
if target_locale:
if target_locale not in supported:
die(f"locale '{target_locale}' is not in resourceConfigurations")
locales = [target_locale]
else:
locales = supported
for locale in locales:
update_locale(locale, status)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def usage():
print("Usage: weblate.py <status|update> [locale]")
sys.exit(1)
def main():
if len(sys.argv) < 2:
usage()
cmd = sys.argv[1]
if cmd == "status":
cmd_status()
elif cmd == "update":
target = sys.argv[2] if (len(sys.argv) > 2) else None
cmd_update(target)
else:
usage()
if __name__ == "__main__":
main()