Skip to content

Commit e9ca7cd

Browse files
feat(l10n): manifest contract and catalog diff engine
Adds the review-manifest JSON Schema -- the contract for the data passed from the untrusted PR diff job to the trusted comment job -- and pot_diff.py, which computes a translator-meaningful diff between two gettext catalogs (keyed on msgctxt + msgid + plural, ignoring source-location and header churn) and assembles a manifest conforming to that schema. Includes tests.
1 parent e0a80d4 commit e9ca7cd

4 files changed

Lines changed: 476 additions & 0 deletions

File tree

l10n/automation/pot_diff.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
"""Structured diff between gettext catalogs (.pot/.po) for the l10n automation.
2+
3+
Produces the review manifest consumed by the trusted follow-up comment job.
4+
Entries are keyed on (msgctxt, msgid); a change to a string's plural form is
5+
reported as a change to that entry. Source-location churn and header metadata
6+
are ignored, so they never show up as noise.
7+
8+
The manifest reports the source strings a PR changes: the catalog is extracted
9+
from the base branch source and from the PR head source, and the two are
10+
diffed. The committed messages.pot is never consulted, so the result is correct
11+
regardless of whether it is up to date (it is maintained automatically by the
12+
post-merge sync, not by hand).
13+
14+
Run as a script to emit a manifest JSON validated by
15+
``l10n/automation/schema/review-manifest.schema.json``::
16+
17+
python l10n/automation/pot_diff.py \
18+
--base base.pot --head head.pot \
19+
--repository owner/name --pr-number 123 \
20+
--head-sha <sha> --base-ref dev --out manifest.json
21+
"""
22+
23+
import argparse
24+
import json
25+
import os
26+
from datetime import datetime, timezone
27+
from typing import Dict, List, Optional, Tuple
28+
29+
SCHEMA_VERSION = "1.0"
30+
STREAM = "messages-pot"
31+
32+
# Cap how many individual entries we serialize per bucket. Counts stay exact;
33+
# only the listed examples are capped (manifest.*.truncated flags this).
34+
MAX_ENTRIES = 50
35+
36+
# Catalog key: (context, singular msgid). The context disambiguates identical
37+
# source strings, exactly as gettext does.
38+
Key = Tuple[Optional[str], str]
39+
40+
41+
def load_catalog(path: Optional[str]) -> Dict[Key, dict]:
42+
"""Load a .pot/.po into a dict keyed by (msgctxt, msgid).
43+
44+
A missing or empty path yields an empty catalog so the very first run (no
45+
prior .pot) and deleted files degrade gracefully rather than crashing.
46+
"""
47+
if not path or not os.path.exists(path) or os.path.getsize(path) == 0:
48+
return {}
49+
50+
# Imported lazily so importing this module (e.g. for tests on hosts without
51+
# Babel) does not hard-fail; callers that actually parse need Babel present.
52+
from babel.messages.pofile import read_po
53+
54+
with open(path, "rb") as fh:
55+
catalog = read_po(fh)
56+
57+
out: Dict[Key, dict] = {}
58+
for message in catalog:
59+
# The header message has an empty id; skip it.
60+
if not message.id:
61+
continue
62+
if isinstance(message.id, (list, tuple)):
63+
singular = message.id[0]
64+
plural_text: Optional[str] = message.id[1] if len(message.id) > 1 else None
65+
is_plural = len(message.id) > 1
66+
else:
67+
singular = message.id
68+
plural_text = None
69+
is_plural = False
70+
key: Key = (message.context, singular)
71+
out[key] = {
72+
"msgid": singular,
73+
"msgctxt": message.context,
74+
"plural": is_plural,
75+
"_plural_text": plural_text,
76+
}
77+
return out
78+
79+
80+
def _entry(record: dict) -> dict:
81+
# The manifest entry shape, kept to just the translator-facing fields.
82+
return {"msgid": record["msgid"], "msgctxt": record["msgctxt"], "plural": record["plural"]}
83+
84+
85+
def _sorted_keys(keys) -> List[Key]:
86+
# Deterministic ordering: by context (None first), then msgid.
87+
return sorted(keys, key=lambda k: (k[0] is not None, k[0] or "", k[1]))
88+
89+
90+
def diff_catalogs(base: Dict[Key, dict], head: Dict[Key, dict]) -> dict:
91+
"""Compute added/removed/changed between two catalogs.
92+
93+
* added -- keys present in ``head`` but not ``base``
94+
* removed -- keys present in ``base`` but not ``head``
95+
* changed -- keys in both whose plural form (presence or text) differs,
96+
i.e. what translators must now provide changed
97+
"""
98+
base_keys = set(base)
99+
head_keys = set(head)
100+
101+
added = [_entry(head[k]) for k in _sorted_keys(head_keys - base_keys)]
102+
removed = [_entry(base[k]) for k in _sorted_keys(base_keys - head_keys)]
103+
104+
changed: List[dict] = []
105+
for k in _sorted_keys(base_keys & head_keys):
106+
b, h = base[k], head[k]
107+
if b["plural"] != h["plural"] or b.get("_plural_text") != h.get("_plural_text"):
108+
changed.append(_entry(h))
109+
110+
counts = {
111+
"added": len(added),
112+
"removed": len(removed),
113+
"changed": len(changed),
114+
"total_base": len(base),
115+
"total_head": len(head),
116+
}
117+
truncated = any(len(b) > MAX_ENTRIES for b in (added, removed, changed))
118+
return {
119+
"added": added[:MAX_ENTRIES],
120+
"removed": removed[:MAX_ENTRIES],
121+
"changed": changed[:MAX_ENTRIES],
122+
"counts": counts,
123+
"truncated": truncated,
124+
}
125+
126+
127+
def build_manifest(
128+
*,
129+
base: Optional[str],
130+
head: Optional[str],
131+
repository: str,
132+
pr_number: int,
133+
head_sha: str,
134+
base_ref: str,
135+
base_sha: Optional[str] = None,
136+
regen_ok: bool = True,
137+
generated_at: Optional[str] = None,
138+
) -> dict:
139+
"""Assemble the review manifest from the base and head catalogs.
140+
141+
``base`` and ``head`` are catalogs freshly extracted from the base branch
142+
source and the PR head source respectively, so ``source_strings`` reflects
143+
exactly the source strings this PR changes.
144+
"""
145+
source_strings = diff_catalogs(load_catalog(base), load_catalog(head))
146+
147+
notes: List[str] = []
148+
if not regen_ok:
149+
notes.append("messages.pot could not be fully regenerated from source; "
150+
"the impact below may be incomplete.")
151+
152+
return {
153+
"schema_version": SCHEMA_VERSION,
154+
"stream": STREAM,
155+
"generated_at": generated_at or datetime.now(timezone.utc)
156+
.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
157+
"repository": repository,
158+
"pr": {
159+
"number": int(pr_number),
160+
"head_sha": head_sha,
161+
"base_sha": base_sha,
162+
"base_ref": base_ref,
163+
},
164+
"regen_ok": bool(regen_ok),
165+
"source_strings": source_strings,
166+
"notes": notes,
167+
}
168+
169+
170+
def _parse_args(argv=None) -> argparse.Namespace:
171+
p = argparse.ArgumentParser(description="Build the l10n review manifest.")
172+
p.add_argument("--base", help="messages.pot extracted from the base branch source")
173+
p.add_argument("--head", help="messages.pot extracted from the PR head source")
174+
p.add_argument("--repository", required=True)
175+
p.add_argument("--pr-number", type=int, required=True)
176+
p.add_argument("--head-sha", required=True)
177+
p.add_argument("--base-ref", required=True)
178+
p.add_argument("--base-sha", default=None)
179+
p.add_argument("--regen-ok", choices=["true", "false"], default="true",
180+
help="whether extraction succeeded for both base and head")
181+
p.add_argument("--out", default="-", help="output path, or - for stdout")
182+
return p.parse_args(argv)
183+
184+
185+
def main(argv=None) -> int:
186+
args = _parse_args(argv)
187+
manifest = build_manifest(
188+
base=args.base,
189+
head=args.head,
190+
repository=args.repository,
191+
pr_number=args.pr_number,
192+
head_sha=args.head_sha,
193+
base_ref=args.base_ref,
194+
base_sha=args.base_sha,
195+
regen_ok=(args.regen_ok == "true"),
196+
)
197+
payload = json.dumps(manifest, indent=2, ensure_ascii=False)
198+
if args.out == "-":
199+
print(payload)
200+
else:
201+
with open(args.out, "w", encoding="utf-8") as fh:
202+
fh.write(payload + "\n")
203+
return 0
204+
205+
206+
if __name__ == "__main__": # pragma: no cover
207+
raise SystemExit(main())
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://seedsigner.com/l10n/schema/review-manifest.schema.json",
4+
"title": "SeedSigner l10n review manifest",
5+
"description": "Structured, machine-validated summary produced by the read-only PR diff job and consumed by the trusted follow-up comment job. The producing job runs untrusted PR code, so this manifest is treated as untrusted data: the consumer validates it against this schema and re-derives the rendered comment from these fields rather than trusting any pre-rendered text.",
6+
"type": "object",
7+
"additionalProperties": false,
8+
"required": [
9+
"schema_version",
10+
"stream",
11+
"generated_at",
12+
"repository",
13+
"pr",
14+
"regen_ok",
15+
"source_strings"
16+
],
17+
"properties": {
18+
"schema_version": {
19+
"description": "Manifest format version. Consumers must reject versions they do not understand.",
20+
"const": "1.0"
21+
},
22+
"stream": {
23+
"description": "Rolling-stream identifier this manifest belongs to.",
24+
"type": "string",
25+
"enum": ["messages-pot"]
26+
},
27+
"generated_at": {
28+
"description": "UTC timestamp when the manifest was produced.",
29+
"type": "string",
30+
"format": "date-time"
31+
},
32+
"repository": {
33+
"description": "owner/name of the repository the PR targets.",
34+
"type": "string",
35+
"pattern": "^[^/\\s]+/[^/\\s]+$"
36+
},
37+
"pr": {
38+
"type": "object",
39+
"additionalProperties": false,
40+
"required": ["number", "head_sha", "base_ref"],
41+
"properties": {
42+
"number": {
43+
"description": "Pull request number. The consumer MUST cross-check that this PR's authoritative head SHA equals the triggering workflow_run head SHA before acting on it.",
44+
"type": "integer",
45+
"minimum": 1
46+
},
47+
"head_sha": {
48+
"description": "Head commit SHA the diff was computed against.",
49+
"type": "string",
50+
"pattern": "^[0-9a-f]{7,40}$"
51+
},
52+
"base_sha": {
53+
"type": ["string", "null"],
54+
"pattern": "^[0-9a-f]{7,40}$"
55+
},
56+
"base_ref": {
57+
"description": "Base branch name (e.g. dev).",
58+
"type": "string",
59+
"minLength": 1
60+
}
61+
}
62+
},
63+
"regen_ok": {
64+
"description": "True when messages.pot was successfully regenerated from both the base and head source. False means the diff below may be incomplete.",
65+
"type": "boolean"
66+
},
67+
"source_strings": {
68+
"description": "Translation impact of this PR: source strings added/removed/changed, computed by extracting the catalog from the base branch source and from the PR head source and diffing the two.",
69+
"$ref": "#/$defs/diff"
70+
},
71+
"notes": {
72+
"type": "array",
73+
"maxItems": 20,
74+
"items": { "type": "string", "maxLength": 2000 }
75+
}
76+
},
77+
"$defs": {
78+
"entry": {
79+
"type": "object",
80+
"additionalProperties": false,
81+
"required": ["msgid", "msgctxt", "plural"],
82+
"properties": {
83+
"msgid": { "type": "string", "maxLength": 10000 },
84+
"msgctxt": { "type": ["string", "null"], "maxLength": 10000 },
85+
"plural": {
86+
"description": "True when the message defines a plural form.",
87+
"type": "boolean"
88+
}
89+
}
90+
},
91+
"counts": {
92+
"type": "object",
93+
"additionalProperties": false,
94+
"required": ["added", "removed", "changed", "total_base", "total_head"],
95+
"properties": {
96+
"added": { "type": "integer", "minimum": 0 },
97+
"removed": { "type": "integer", "minimum": 0 },
98+
"changed": { "type": "integer", "minimum": 0 },
99+
"total_base": { "type": "integer", "minimum": 0 },
100+
"total_head": { "type": "integer", "minimum": 0 }
101+
}
102+
},
103+
"diff": {
104+
"type": "object",
105+
"additionalProperties": false,
106+
"required": ["added", "removed", "changed", "counts", "truncated"],
107+
"properties": {
108+
"added": { "type": "array", "maxItems": 50, "items": { "$ref": "#/$defs/entry" } },
109+
"removed": { "type": "array", "maxItems": 50, "items": { "$ref": "#/$defs/entry" } },
110+
"changed": { "type": "array", "maxItems": 50, "items": { "$ref": "#/$defs/entry" } },
111+
"counts": { "$ref": "#/$defs/counts" },
112+
"truncated": {
113+
"description": "True when the added/removed/changed arrays were capped and do not list every entry (counts remain exact).",
114+
"type": "boolean"
115+
}
116+
}
117+
}
118+
}
119+
}

l10n/automation/tests/conftest.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Make the l10n automation modules importable when these tests run.
2+
3+
These tests are intentionally outside the project's configured `testpaths`
4+
(["tests"]), so they are not collected by the main pytest run. Run them with:
5+
6+
python -m pytest l10n/automation/tests
7+
"""
8+
9+
import os
10+
import sys
11+
12+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13+
14+
_HEADER = 'msgid ""\nmsgstr ""\n"Content-Type: text/plain; charset=UTF-8\\n"\n\n'
15+
16+
17+
def write_pot(path, entries):
18+
"""Write a minimal .pot file to path. entries: list of {id, plural?, ctx?}."""
19+
chunks = [_HEADER]
20+
for e in entries:
21+
block = ""
22+
if e.get("ctx"):
23+
block += f'msgctxt "{e["ctx"]}"\n'
24+
block += f'msgid "{e["id"]}"\n'
25+
if e.get("plural"):
26+
block += f'msgid_plural "{e["plural"]}"\n'
27+
block += 'msgstr[0] ""\nmsgstr[1] ""\n'
28+
else:
29+
block += 'msgstr ""\n'
30+
chunks.append(block + "\n")
31+
path.write_text("".join(chunks), encoding="utf-8")
32+
return str(path)

0 commit comments

Comments
 (0)