Skip to content

Commit e325717

Browse files
committed
Reduce PO diff noise by filtering source references
1 parent a1b943c commit e325717

3 files changed

Lines changed: 190 additions & 16 deletions

File tree

‎.github/workflows/update.yml‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ jobs:
6060
- name: Wrap catalog message files
6161
run: |
6262
powrap --modified
63-
- name: Ignore timestamp-only changes
64-
run: python scripts/translations.py restore-timestamp-only .
63+
- name: Normalize PO references and ignore timestamp-only changes
64+
run: python scripts/translations.py restore-timestamp-only --references .
6565
- name: Commit and push changes
6666
if: github.repository == 'python/python-docs-ja'
6767
run: |
@@ -108,7 +108,7 @@ jobs:
108108
- name: Install dependencies
109109
run: |
110110
sudo apt-get update -y && sudo apt-get install gettext -y
111-
pip install pomerge powrap
111+
pip install pomerge powrap 'polib>=1.2.0'
112112
113113
- name: Merge overwriting on stable release branch
114114
run: |
@@ -117,8 +117,8 @@ jobs:
117117
- name: Wrap catalog message files
118118
run: |
119119
powrap --modified -C "${{ matrix.branch }}"
120-
- name: Ignore timestamp-only changes
121-
run: python "${{ env.CPYTHON_BRANCH }}/scripts/translations.py" restore-timestamp-only "${{ matrix.branch }}"
120+
- name: Normalize PO references and ignore timestamp-only changes
121+
run: python "${{ env.CPYTHON_BRANCH }}/scripts/translations.py" restore-timestamp-only --references "${{ matrix.branch }}"
122122

123123
- name: Commit and push changes
124124
if: github.repository == 'python/python-docs-ja'

‎scripts/translations.py‎

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,33 +27,95 @@ def without_po_timestamps(content):
2727
return content[:header.start()] + normalized + content[header.end():]
2828

2929

30-
def restore_timestamp_only_changes(root):
30+
def po_references(content):
31+
"""Locate reference comments without reserializing the PO file."""
32+
import polib
33+
34+
lines = content.splitlines(keepends=True)
35+
encoding = polib.detect_encoding(content)
36+
entries = polib.pofile(content.decode(encoding), encoding=encoding)
37+
references = {}
38+
for index, entry in enumerate(entries):
39+
if entry.obsolete:
40+
continue
41+
end = entries[index + 1].linenum - 1 if index + 1 < len(entries) else len(lines)
42+
positions = [
43+
pos for pos in range(entry.linenum - 1, end)
44+
if lines[pos].startswith(b"#:")
45+
]
46+
key = (entry.msgctxt, entry.msgid, entry.msgid_plural)
47+
references[key] = (entry, positions)
48+
return lines, references
49+
50+
51+
def normalize_po_references(original, incoming):
52+
"""Drop references for new/changed messages; ignore line-number updates."""
53+
old_lines, old_references = po_references(original)
54+
lines, references = po_references(incoming)
55+
for key, (entry, positions) in references.items():
56+
if not positions:
57+
continue
58+
old_entry, old_positions = old_references.get(key, (None, []))
59+
if old_entry is None:
60+
changed = True
61+
else:
62+
changed = (entry.msgstr, entry.msgstr_plural) != (old_entry.msgstr, old_entry.msgstr_plural)
63+
if changed or (old_entry is not None and not old_positions):
64+
for pos in positions:
65+
lines[pos] = b''
66+
continue
67+
# Keep added/removed references and file renames for unchanged translations.
68+
if not old_positions or (
69+
[path for path, _ in entry.occurrences]
70+
!= [path for path, _ in old_entry.occurrences]
71+
):
72+
continue
73+
lines[positions[0]] = b"".join(old_lines[pos] for pos in old_positions)
74+
for pos in positions[1:]:
75+
lines[pos] = b""
76+
return b"".join(lines)
77+
78+
79+
def restore_timestamp_only_changes(root, *, normalize_references=False):
3180
"""Restore PO files whose unstaged changes only affect header dates.
3281
33-
Run after wrapping, so formatting differences in downloaded or merged files
34-
do not prevent timestamp-only changes from being recognized.
82+
Optionally drop references for new/changed messages and restore other line numbers.
83+
Run after wrapping so formatting differences do not mask date-only changes.
3584
"""
3685
result = subprocess.run(
3786
["git", "-C", str(root), "diff", "--name-only", "-z", "--", "*.po"],
3887
check=True, stdout=subprocess.PIPE,
3988
)
40-
for name in result.stdout.split(b"\0"):
89+
names = result.stdout.split(b"\0")
90+
untracked = set()
91+
if normalize_references:
92+
untracked = set(subprocess.run(
93+
["git", "-C", str(root), "ls-files", "--others", "--exclude-standard",
94+
"-z", "--", "*.po"], check=True, stdout=subprocess.PIPE,
95+
).stdout.split(b"\0"))
96+
names.extend(sorted(untracked))
97+
for name in names:
4198
if not name:
4299
continue
43100
path = os.fsdecode(name)
44101
target = root / path
45102
if not target.is_file():
46103
continue
47-
original = subprocess.run(
104+
original = b"" if name in untracked else subprocess.run(
48105
["git", "-C", str(root), "show", f":{path}"],
49106
check=True, stdout=subprocess.PIPE,
50107
).stdout
51-
if (
52-
without_po_timestamps(target.read_bytes())
53-
== without_po_timestamps(original)
54-
):
55-
target.write_bytes(original)
108+
incoming = target.read_bytes()
109+
restored = incoming
110+
if normalize_references:
111+
restored = normalize_po_references(original, restored)
112+
if restored != incoming:
113+
print(f"Normalized PO references: {path}")
114+
if without_po_timestamps(restored) == without_po_timestamps(original):
115+
restored = original
56116
print(f"Ignored timestamp-only change: {path}")
117+
if restored != incoming:
118+
target.write_bytes(restored)
57119

58120

59121
def active_catalogs(config_path):
@@ -183,14 +245,18 @@ def main():
183245
merge_parser.add_argument("destination", type=Path)
184246
restore_parser = commands.add_parser("restore-timestamp-only")
185247
restore_parser.add_argument("root", type=Path)
248+
restore_parser.add_argument(
249+
"--references", action="store_true",
250+
help="drop references for new/changed messages and ignore other line-number updates",
251+
)
186252
args = parser.parse_args()
187253
if args.command == "sync":
188254
sync(args.source, args.destination, args.language,
189255
skip_missing_resources=args.skip_missing_resources)
190256
elif args.command == "merge":
191257
merge(args.source, args.destination)
192258
else:
193-
restore_timestamp_only_changes(args.root)
259+
restore_timestamp_only_changes(args.root, normalize_references=args.references)
194260

195261

196262
if __name__ == "__main__":

‎tests/test_translations.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,114 @@ def test_restore_command_handles_multiple_files_and_explicit_root(self):
150150
self.assertEqual((self.destination / "translated.po").read_bytes(), translated)
151151
self.assertEqual((self.destination / "library/with space.po").read_bytes(), DATED_PO)
152152

153+
def test_normalize_references_and_dates_before_staging(self):
154+
for newline in (b"\n", b"\r\n"):
155+
with self.subTest(newline=newline):
156+
before = DATED_PO.replace(b"\n", newline)
157+
changed = before.replace(b"example.rst:10", b"example.rst:200")
158+
changed = changed.replace(b"2026-09-29", b"2026-10-24")
159+
for name in ("unchanged.po", "translated.po", "deleted.po"):
160+
(self.destination / name).write_bytes(before)
161+
self.track()
162+
(self.destination / "unchanged.po").write_bytes(changed)
163+
(self.destination / "translated.po").write_bytes(
164+
changed.replace('翻訳'.encode(), '新しい訳'.encode())
165+
)
166+
(self.destination / "deleted.po").unlink()
167+
new = self.destination / ("new-crlf.po" if newline == b"\r\n" else "new-lf.po")
168+
new.write_bytes(changed)
169+
subprocess.run(
170+
[sys.executable, translations.__file__, "restore-timestamp-only",
171+
"--references", str(self.destination)],
172+
check=True, stdout=subprocess.PIPE, cwd=self.source,
173+
)
174+
self.assertEqual((self.destination / "unchanged.po").read_bytes(), before)
175+
expected = before.replace('翻訳'.encode(), '新しい訳'.encode())
176+
expected = expected.replace(b"2026-09-29", b"2026-10-24")
177+
expected = expected.replace(b"#: example.rst:10" + newline, b"")
178+
self.assertEqual((self.destination / "translated.po").read_bytes(), expected)
179+
self.assertEqual(new.read_bytes(), changed.replace(b"#: example.rst:200" + newline, b""))
180+
self.assertFalse((self.destination / "deleted.po").exists())
181+
self.track()
182+
staged = subprocess.check_output(
183+
["git", "-C", str(self.destination), "show", ":translated.po"]
184+
)
185+
self.assertEqual(staged, expected)
186+
187+
def test_normalize_references_preserves_other_entry_changes(self):
188+
changed = DATED_PO.replace(b"example.rst:10", b"example.rst:200")
189+
for old, new, action in [
190+
(b'msgid "example"', b'msgid "new source"', "drop"),
191+
(b'msgid "example"', b'msgctxt "new context"\nmsgid "example"', "drop"),
192+
(b"example.rst:200", b"renamed.rst:200", "keep"),
193+
(b"example.rst:200", b"example.rst:200 other.rst:30", "keep"),
194+
(b'#: example.rst:200\n', b'', "keep"),
195+
(b'msgid "example"', b'#, fuzzy\nmsgid "example"', "restore"),
196+
(b'msgid "example"', b'#. new comment\nmsgid "example"', "restore"),
197+
('msgstr "翻訳"'.encode(), 'msgstr "新しい訳"'.encode(), "drop"),
198+
]:
199+
with self.subTest(change=new):
200+
incoming = changed.replace(old, new)
201+
expected = incoming
202+
if action == "drop":
203+
expected = incoming.replace(b"#: example.rst:200\n", b"")
204+
elif action == "restore":
205+
expected = incoming.replace(b"example.rst:200", b"example.rst:10")
206+
self.assertEqual(translations.normalize_po_references(DATED_PO, incoming),
207+
expected)
208+
209+
def test_restore_line_numbers_matches_context_plural_and_reordered_entries(self):
210+
header = DATED_PO.split(b"#: example.rst:10")[0]
211+
first = (b'#: one.rst:10 two.rst:20\nmsgctxt "first"\n'
212+
b'msgid "same"\nmsgstr "first translation"\n\n')
213+
second = (b'#: one.rst:30\nmsgctxt "second"\n'
214+
b'msgid "same"\nmsgid_plural "others"\nmsgstr[0] "plural"\n\n')
215+
added = b'#: new.rst:100\nmsgid "new"\nmsgstr ""\n\n'
216+
obsolete = b'#~ msgid "old"\n#~ msgstr "old translation"\n'
217+
updated_first = first.replace(b"one.rst:10 two.rst:20", b"one.rst:100\n#: two.rst:200")
218+
updated_first = updated_first.replace(b'msgid "same"', b'msgid ""\n"sa"\n"me"')
219+
updated_second = second.replace(b"one.rst:30", b"one.rst:300")
220+
incoming = header + added + updated_second + updated_first + obsolete
221+
expected_first = updated_first.replace(b"one.rst:100\n#: two.rst:200", b"one.rst:10 two.rst:20")
222+
self.assertEqual(
223+
translations.normalize_po_references(header + first + second + obsolete, incoming),
224+
header + added.replace(b"#: new.rst:100\n", b"") + second + expected_first + obsolete,
225+
)
226+
# A new plural message with a translation has its references removed.
227+
updated_second = updated_second.replace(b'"others"', b'"new plural"')
228+
self.assertEqual(
229+
translations.normalize_po_references(header + second, header + updated_second),
230+
header + updated_second.replace(b"#: one.rst:300\n", b""),
231+
)
232+
233+
def test_untranslated_source_changes_drop_references(self):
234+
before = DATED_PO.replace('msgstr "翻訳"'.encode(), b'msgstr ""')
235+
for original, incoming in [
236+
(b"", before),
237+
(before, before.replace(b'msgid "example"', b'msgid "new source"')),
238+
(before, before.replace(b'msgid "example"',
239+
b'msgctxt "new context"\nmsgid "example"')),
240+
(before, before.replace(b'msgid "example"\nmsgstr ""\n',
241+
b'msgid "example"\nmsgid_plural "examples"\nmsgstr[0] ""\n')),
242+
]:
243+
with self.subTest(incoming=incoming):
244+
expected = incoming.replace(b"#: example.rst:10\n", b"")
245+
normalized = translations.normalize_po_references(original, incoming)
246+
self.assertEqual(normalized, expected)
247+
self.assertEqual(translations.normalize_po_references(normalized, incoming), expected)
248+
249+
def test_removed_references_do_not_return_on_next_download(self):
250+
updated = DATED_PO.replace('翻訳'.encode(), '新しい訳'.encode())
251+
updated = updated.replace(b"#: example.rst:10\n", b"#: example.rst:200\n#: other.rst:300\n")
252+
expected = updated.replace(b"#: example.rst:200\n#: other.rst:300\n", b"")
253+
normalized = translations.normalize_po_references(DATED_PO, updated)
254+
self.assertEqual(normalized, expected)
255+
self.assertEqual(translations.normalize_po_references(normalized, updated), expected)
256+
# Translation text resembling a reference is not a comment.
257+
literal = updated.replace('新しい訳'.encode(), b"#: example.rst:200")
258+
self.assertIn(b'msgstr "#: example.rst:200"',
259+
translations.normalize_po_references(normalized, literal))
260+
153261
def test_sync_removes_old_path_and_preserves_untracked_files(self):
154262
catalogs = ["builtins/functions.po", "library/builtins.po", "bugs.po"]
155263
write_config(self.source, catalogs)

0 commit comments

Comments
 (0)