Files
sousa-gecko/python/l10n/test_fluent_migrations/fmt.py
T
Francesco Lodolo cf7ef37d65 Bug 2043911 - Provide summary of warnings/errors when testing fluent migrations, r=eemeli,bolsson
Produce errors for
- Migrated strings that have differences beyond capitalization changes.
- Strings that are included in the recipe but don't appear in the migrated content.
- Attempts to migrate a string to the same ID in the same file.

Produce warnings/info for
- Strings missing from the migrated file, but not declared in the migration.
- Capitalization only differences.

Differential Revision: https://phabricator.services.mozilla.com/D303689
2026-06-02 06:17:32 +00:00

421 lines
15 KiB
Python

import importlib.util
import logging
import os
import platform
import re
import shutil
import stat
import sys
from collections.abc import Iterable
from datetime import datetime, timedelta
from difflib import unified_diff
from subprocess import check_call
from compare_locales.merge import merge_channels
from compare_locales.paths.configparser import TOMLParser
from compare_locales.paths.files import ProjectFiles
from fluent.migrate.repo_client import RepoClient, git
from fluent.migrate.transforms import Source
from fluent.migrate.util import fold
from fluent.migrate.validator import Validator
from fluent.syntax import FluentParser, FluentSerializer
from fluent.syntax import ast as FTL
from mach.util import get_state_dir
from mozpack.path import join, normpath
L10N_SOURCE_NAME = "l10n-source"
L10N_SOURCE_REPO = "https://github.com/mozilla-l10n/firefox-l10n-source.git"
PULL_AFTER = timedelta(days=2)
def handle_rmtree_error(func, path, exc_info):
"""
Custom error handler for shutil.rmtree().
Attempts to change file permissions if a permission error occurs.
"""
if func == os.unlink and isinstance(exc_info[0], PermissionError):
print(
f"Permission error encountered for: {path}. Attempting to change permissions."
)
try:
os.chmod(path, stat.S_IWRITE) # Make the file writable
func(path) # Retry the removal
except Exception as e:
print(f"Failed to remove {path} even after changing permissions: {e}")
raise # Re-raise the original exception if retry fails
else:
raise exc_info[0].with_traceback(exc_info[1], exc_info[2])
def remove_readonly(func, path, _):
"Clear the readonly bit and reattempt the removal"
os.chmod(path, stat.S_IWRITE)
func(path)
def inspect_migration(path):
"""Validate recipe and extract some metadata."""
return Validator.validate(path)
def prepare_directories(cmd):
"""
Ensure object dir exists,
and that repo dir has a relatively up-to-date clone of l10n-source or gecko-strings.
We run this once per mach invocation, for all tested migrations.
"""
obj_dir = join(cmd.topobjdir, "python", "l10n")
if not os.path.exists(obj_dir):
os.makedirs(obj_dir)
repo_dir = join(get_state_dir(), L10N_SOURCE_NAME)
marker = join(repo_dir, ".git", "l10n_pull_marker")
try:
last_pull = datetime.fromtimestamp(os.stat(marker).st_mtime)
skip_clone = datetime.now() < last_pull + PULL_AFTER
except OSError:
skip_clone = False
if not skip_clone:
if os.path.exists(repo_dir):
check_call(["git", "pull", L10N_SOURCE_REPO], cwd=repo_dir)
else:
check_call(["git", "clone", L10N_SOURCE_REPO, repo_dir])
with open(marker, "w") as fh:
fh.flush()
return obj_dir, repo_dir
def diff_resources(left_path, right_path):
parser = FluentParser(with_spans=False)
serializer = FluentSerializer(with_junk=True)
lines = []
for p in (left_path, right_path):
with open(p, encoding="utf-8") as fh:
res = parser.parse(fh.read())
lines.append(serializer.serialize(res).splitlines(True))
sys.stdout.writelines(
chunk for chunk in unified_diff(lines[0], lines[1], left_path, right_path)
)
def entries_by_id(text):
"""Map each message/term identifier to its serialized form.
Anything that isn't a message or term (comments, junk) is
skipped.
"""
parser = FluentParser(with_spans=False)
serializer = FluentSerializer()
entries = {}
for entry in parser.parse(text).body:
if isinstance(entry, FTL.Message):
id = entry.id.name
elif isinstance(entry, FTL.Term):
id = "-" + entry.id.name
else:
continue
entries[id] = serializer.serialize_entry(entry)
return entries
def source_deps(node):
"""Collect the `(path, key)` of every Source transform within `node`."""
def add(acc, cur):
if isinstance(cur, Source):
acc.add((cur.path, cur.key))
return acc
return fold(add, node, set())
def declared_targets(recipe_path):
"""Collect the messages declared in the recipe.
The recipe is run against a context that only records `add_transforms`
calls, capturing every declared message even when the actual migration
for a message fails, never reaching the output.
Returns `(targets, self_migrations)` where `targets` maps each target (and
reference) path to the set of declared message/term identifiers, and
`self_migrations` lists the `(target_path, id)` whose transform copies
the message, partially or entirely, from the same ID in the same file.
Cannot just check the output for the latter, since the migration will be
a no-op (the correct result will be in the file).
"""
targets = {}
self_migrations = []
class RecordingContext:
def add_transforms(self, target, reference, transforms):
ids = set()
for node in transforms:
id = "-" + node.id.name if isinstance(node, FTL.Term) else node.id.name
ids.add(id)
for path, key in source_deps(node):
if path == target and key.split(".", 1)[0] == id:
self_migrations.append((target, id))
break
for key in (target, reference):
targets.setdefault(key, set()).update(ids)
spec = importlib.util.spec_from_file_location(
"fluent_migration_recipe", recipe_path
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module.migrate(RecordingContext())
return targets, self_migrations
# Bit flags OR'd together into the return value of a migration test, so the
# exit status records which kinds of failure occurred, not just that one did.
ERR_REFERENCE_PATH = 1 # A reference path was not normalized
ERR_SELF_MIGRATION = 2 # A message is migrated from the same ID in the same file
ERR_NOT_MIGRATED = 4 # A declared message is missing from the migrated output
ERR_MISMATCH = 8 # A migrated message differs from the reference beyond capitalization
ERR_COMMIT_MESSAGE = 16 # Missing/wrong bug number, or a commit without "part {index}"
def summarize_diff(report, ref, ref_path, out_path, declared):
"""Collect findings from the diff between the migrated output and reference.
`declared` is the set of message identifiers included in the recipe and
it is used to classify these differences:
- Messages that differ but aren't declared by the recipe are reported first
as "can be ignored" (e.g. unrelated pending changes in quarantine, or
brand new strings that cannot be migrated).
- Messages in the recipe missing from the output are reported as errors:
the recipe meant to migrate them but didn't, likely because of an error.
- Declared messages present in the output but differing from the reference
only in capitalization are reported as warnings, since that is often
acceptable; any other difference is reported as an error.
- Attempt to migrate an ID to the same ID in the same file is reported
as an error.
Findings are appended to `report` for later rendering rather than logged
inline. Returns a bitmask of the error cases encountered: `ERR_NOT_MIGRATED`
if a declared message failed to migrate, `ERR_MISMATCH` if a migrated
message differs from the reference beyond capitalization, 0 if neither.
"""
with open(ref_path, encoding="utf-8") as fh:
ref_entries = entries_by_id(fh.read())
with open(out_path, encoding="utf-8") as fh:
out_entries = entries_by_id(fh.read())
diff_ids = {
id
for id in set(ref_entries) | set(out_entries)
if ref_entries.get(id) != out_entries.get(id)
}
ignored = sorted(diff_ids - declared)
missing = sorted(id for id in declared if id not in out_entries)
mismatched = sorted(id for id in declared & diff_ids if id in out_entries)
rv = 0
if ignored:
report.append((
logging.INFO,
{"file": ref, "ids": ", ".join(ignored)},
"{file}: the following messages differ but are not part of the "
"migration recipe, so they can be ignored: {ids}",
))
for id in missing:
rv |= ERR_NOT_MIGRATED
report.append((
logging.ERROR,
{"file": ref, "id": id},
"{file}: message {id} is part of the migration recipe but was not "
"migrated; the recipe likely has errors",
))
for id in mismatched:
if id not in ref_entries:
report.append((
logging.WARN,
{"file": ref, "id": id},
"{file}: migrated message {id} is not present in the reference",
))
elif ref_entries[id].casefold() == out_entries[id].casefold():
report.append((
logging.WARN,
{"file": ref, "id": id},
"{file}: migrated message {id} differs from the reference only "
"in capitalization",
))
else:
rv |= ERR_MISMATCH
report.append((
logging.ERROR,
{"file": ref, "id": id},
"{file}: migrated message {id} differs from the reference",
))
return rv
def render_report(report):
"""Print collected findings as a single summary at the end of the run.
Findings are ordered by severity, ignorable ones first and errors last, so
the most important messages end up closest to the prompt. Each finding is a
`(level, fields, template)` tuple. The summary is printed to stdout, the
same stream as the diffs, so it stays grouped after them even when output is
piped or redirected (mach's logger writes to a separate stream).
"""
if not report:
return
labels = {
logging.INFO: (0, "INFO"),
logging.WARNING: (1, "WARNING"),
logging.ERROR: (2, "ERROR"),
}
print("\nFluent migration test summary:")
for level, fields, template in sorted(
report, key=lambda f: (labels.get(f[0], (1,))[0], f[1].get("file", ""), f[2])
):
label = labels.get(level, (1, "INFO"))[1]
print(f" {label}: {template.format(**fields)}")
def test_migration(
cmd,
obj_dir: str,
repo_dir: str,
report: list,
to_test: list[str],
references: Iterable[str],
):
"""Test the given recipe.
This creates a workdir by l10n-merging gecko-strings and the m-c source,
to mimic gecko-strings after the patch to test landed.
It then runs the recipe with a gecko-strings clone as localization, both
dry and wet.
It inspects the generated commits, and shows a diff between the merged
reference and the generated content.
The diff is intended to be visually inspected. Some changes might be
expected, in particular when formatting of the en-US strings is different.
"""
rv = 0
migration_name = os.path.splitext(os.path.split(to_test)[1])[0]
work_dir = join(obj_dir, migration_name)
paths = os.path.normpath(to_test).split(os.sep)
# Migration modules should be in a sub-folder of l10n.
migration_module = (
".".join(paths[paths.index("l10n") + 1 : -1]) + "." + migration_name
)
if os.path.exists(work_dir):
# in python 3.12+ we can use onexc=
pyver = platform.python_version()
major, minor, _ = pyver.split(".")
# 3.12 deprecated onerror and introduced onexc.
if int(major) >= 3 and int(minor) >= 12:
shutil.rmtree(work_dir, onexc=remove_readonly)
else:
shutil.rmtree(work_dir, onerror=handle_rmtree_error)
os.makedirs(join(work_dir, "reference"))
l10n_toml = join(cmd.topsrcdir, cmd.substs["MOZ_BUILD_APP"], "locales", "l10n.toml")
pc = TOMLParser().parse(l10n_toml, env={"l10n_base": work_dir})
pc.set_locales(["reference"])
files = ProjectFiles("reference", [pc])
ref_root = join(work_dir, "reference")
for ref in references:
if ref != normpath(ref):
report.append((
logging.ERROR,
{"file": to_test, "ref": ref},
'Reference path "{ref}" needs to be normalized for {file}',
))
rv |= ERR_REFERENCE_PATH
continue
full_ref = join(ref_root, ref)
m = files.match(full_ref)
if m is None:
raise ValueError("Bad reference path: " + ref)
m_c_path = m[1]
g_s_path = join(work_dir, L10N_SOURCE_NAME, ref)
resources = [
b"" if not os.path.exists(f) else open(f, "rb").read()
for f in (g_s_path, m_c_path)
]
ref_dir = os.path.dirname(full_ref)
if not os.path.exists(ref_dir):
os.makedirs(ref_dir)
open(full_ref, "wb").write(merge_channels(ref, resources))
l10n_root = join(work_dir, "en-US")
git(work_dir, "clone", repo_dir, l10n_root)
client = RepoClient(l10n_root)
old_tip = client.head()
run_migration = [
cmd._virtualenv_manager.python_path,
"-m",
"fluent.migrate.tool",
"--lang",
"en-US",
"--reference-dir",
ref_root,
"--localization-dir",
l10n_root,
"--dry-run",
migration_module,
]
cmd.run_process(run_migration, cwd=work_dir, line_handler=print)
# drop --dry-run
run_migration.pop(-2)
cmd.run_process(run_migration, cwd=work_dir, line_handler=print)
tip = client.head()
try:
targets, self_migrations = declared_targets(to_test)
except Exception as e:
report.append((
logging.ERROR,
{"file": to_test, "error": str(e)},
"Could not inspect declared targets for {file}: {error}",
))
targets, self_migrations = {}, []
for target_path, id in self_migrations:
rv |= ERR_SELF_MIGRATION
report.append((
logging.ERROR,
{"file": target_path, "id": id},
"{file}: message {id} is migrated from itself (same ID in the same file)",
))
if old_tip == tip:
report.append((
logging.WARN,
{"file": to_test},
"No migration applied for {file}",
))
return rv
for ref in references:
ref_path = join(ref_root, ref)
out_path = join(l10n_root, ref)
diff_resources(ref_path, out_path)
rv |= summarize_diff(report, ref, ref_path, out_path, targets.get(ref, set()))
messages = client.log(old_tip, tip)
bug = re.search("[0-9]{5,}", migration_name)
# Just check first message for bug number, they're all following the same pattern
if bug is None or bug.group() not in messages[0]:
rv |= ERR_COMMIT_MESSAGE
report.append((
logging.ERROR,
{"file": to_test},
"Missing or wrong bug number for {file}",
))
if any(f"part {n + 1}" not in msg for n, msg in enumerate(messages)):
rv |= ERR_COMMIT_MESSAGE
report.append((
logging.ERROR,
{"file": to_test},
'Commit messages should have "part {{index}}" for {file}',
))
return rv