Skip to content

Commit 3590558

Browse files
potiukLee-Wjscheffl
authored
Add script to automatically check completness of translations (#51269)
* Add script to automatically check completness of translations For now we do not want to run it in in CI and error - because likely we will have many of those missing translations. But it might be useful for individual owners to check the status of their translation and also can be used to track overall missing translation status. Update airflow-core/src/airflow/ui/src/i18n/check_translations_completness.py Co-authored-by: Wei Lee <weilee.rx@gmail.com> * Update airflow-core/src/airflow/ui/src/i18n/check_translations_completeness.py Co-authored-by: Jens Scheffler <95105677+jscheffl@users.noreply.github.com> --------- Co-authored-by: Wei Lee <weilee.rx@gmail.com> Co-authored-by: Jens Scheffler <95105677+jscheffl@users.noreply.github.com>
1 parent aff9148 commit 3590558

9 files changed

Lines changed: 460 additions & 2 deletions

File tree

.pre-commit-config.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,14 @@ repos:
13961396
files: ^airflow-core/src/airflow/api_fastapi/.*\.py$|^airflow-core/src/airflow/api_fastapi/auth/managers/simple/.*\.py$|^providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/.*\.py$
13971397
exclude: ^airflow-core/src/airflow/api_fastapi/execution_api/.*
13981398
additional_dependencies: ['rich>=12.4.4', 'openapi-spec-validator>=0.7.1']
1399+
- id: check-i18n-json
1400+
name: Check i18n files validity
1401+
description: Check i18n files are valid json and have no TODOs
1402+
language: python
1403+
files: ^airflow-core/src/airflow/ui/src/i18n/locales/.*\.json
1404+
entry: ./scripts/ci/pre_commit/check_i18n_json.py
1405+
additional_dependencies: ['rich>=12.4.4']
1406+
pass_filenames: false
13991407
- id: ts-compile-format-lint-ui
14001408
name: Compile / format / lint UI
14011409
description: TS types generation / ESLint / Prettier new UI files
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
Checking completeness of i18n files:
21+
------------------------------------
22+
23+
All files:
24+
25+
```bash
26+
uv run ./check_translations_completeness.py
27+
```
28+
29+
Files for specific languages:
30+
31+
```bash
32+
uv run ./check_translations_completeness.py --language <language_code>
33+
```
34+
35+
Where `<language_code>` is the code of the language you want to check, e.g., `en`, `fr`, `de`, etc.
36+
37+
Adding missing translations (with `TODO: translate` prefix):
38+
39+
```bash
40+
uv run ./check_translations_completeness.py --language <language_code> --add-missing
41+
```
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
#!/usr/bin/env python3
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
19+
# /// script
20+
# requires-python = ">=3.11"
21+
# dependencies = [
22+
# "rich",
23+
# "rich-click",
24+
# ]
25+
# ///
26+
from __future__ import annotations
27+
28+
import json
29+
import sys
30+
from pathlib import Path
31+
from typing import Any, NamedTuple
32+
33+
import rich_click as click
34+
from rich import print
35+
from rich.console import Console
36+
from rich.panel import Panel
37+
from rich.table import Table
38+
39+
click.rich_click.MAX_WIDTH = 120
40+
click.rich_click.USE_RICH_MARKUP = True
41+
42+
LOCALES_DIR = Path(__file__).parent / "locales"
43+
44+
45+
class LocaleSummary(NamedTuple):
46+
"""
47+
Summary of missing and extra translation keys for a file, per locale.
48+
49+
Attributes:
50+
missing_keys: A dictionary mapping locale codes to lists of missing translation keys.
51+
extra: A dictionary mapping locale codes to lists of extra translation keys.
52+
"""
53+
54+
missing_keys: dict[str, list[str]]
55+
extra_keys: dict[str, list[str]]
56+
57+
58+
class LocaleFiles(NamedTuple):
59+
"""
60+
Represents a locale and its list of translation files.
61+
62+
Attributes:
63+
locale: The locale code (e.g., 'en', 'pl').
64+
files: A list of translation file names for the locale.
65+
"""
66+
67+
locale: str
68+
files: list[str]
69+
70+
71+
class LocaleKeySet(NamedTuple):
72+
"""
73+
Represents a locale and its set of translation keys for a file (or None if file missing).
74+
75+
Attributes:
76+
locale: The locale code (e.g., 'en', 'pl').
77+
keys: A set of translation keys for the file, or None if the file is missing for this locale.
78+
"""
79+
80+
locale: str
81+
keys: set[str] | None
82+
83+
84+
def get_locale_files() -> list[LocaleFiles]:
85+
return [
86+
LocaleFiles(
87+
locale=locale_dir.name, files=[f.name for f in locale_dir.iterdir() if f.suffix == ".json"]
88+
)
89+
for locale_dir in LOCALES_DIR.iterdir()
90+
if locale_dir.is_dir()
91+
]
92+
93+
94+
def load_json(filepath: Path) -> Any:
95+
return json.loads(filepath.read_text(encoding="utf-8"))
96+
97+
98+
def flatten_keys(d: dict, prefix: str = "") -> list[str]:
99+
keys: list[str] = []
100+
for k, v in d.items():
101+
full_key = f"{prefix}.{k}" if prefix else k
102+
if isinstance(v, dict):
103+
keys.extend(flatten_keys(v, full_key))
104+
else:
105+
keys.append(full_key)
106+
return keys
107+
108+
109+
def compare_keys(locale_files: list[LocaleFiles]) -> dict[str, LocaleSummary]:
110+
"""
111+
Compare all non-English locales with English locale only.
112+
113+
Returns a summary dict: filename -> LocaleSummary(missing, extra)
114+
"""
115+
all_files: set[str] = set()
116+
for lf in locale_files:
117+
all_files.update(lf.files)
118+
summary: dict[str, LocaleSummary] = {}
119+
for filename in all_files:
120+
key_sets: list[LocaleKeySet] = []
121+
for lf in locale_files:
122+
if filename in lf.files:
123+
path = LOCALES_DIR / lf.locale / filename
124+
try:
125+
data = load_json(path)
126+
keys = set(flatten_keys(data))
127+
except Exception as e:
128+
print(f"Error loading {path}: {e}")
129+
keys = set()
130+
else:
131+
keys = None
132+
key_sets.append(LocaleKeySet(locale=lf.locale, keys=keys))
133+
keys_by_locale = {ks.locale: ks.keys for ks in key_sets}
134+
en_keys = keys_by_locale.get("en", set()) or set()
135+
missing_keys: dict[str, list[str]] = {}
136+
extra_keys: dict[str, list[str]] = {}
137+
for ks in key_sets:
138+
if ks.locale == "en":
139+
continue
140+
if ks.keys is None:
141+
missing_keys[ks.locale] = list(en_keys)
142+
extra_keys[ks.locale] = []
143+
else:
144+
missing_keys[ks.locale] = list(en_keys - ks.keys)
145+
extra_keys[ks.locale] = list(ks.keys - en_keys)
146+
summary[filename] = LocaleSummary(missing_keys=missing_keys, extra_keys=extra_keys)
147+
return summary
148+
149+
150+
def print_locale_file_table(
151+
locale_files: list[LocaleFiles], console: Console, language: str | None = None
152+
) -> None:
153+
if language and language == "en":
154+
return
155+
console.print("[bold underline]Locales and their files:[/bold underline]", style="cyan")
156+
table = Table(show_header=True, header_style="bold magenta")
157+
table.add_column("Locale")
158+
table.add_column("Files")
159+
filtered = (
160+
locale_files if language is None else [lf for lf in locale_files if lf.locale in (language, "en")]
161+
)
162+
for lf in filtered:
163+
table.add_row(lf.locale, ", ".join(lf.files))
164+
console.print(table)
165+
166+
167+
def print_file_set_differences(
168+
locale_files: list[LocaleFiles], console: Console, language: str | None = None
169+
) -> bool:
170+
if language and language == "en":
171+
return False
172+
filtered = (
173+
locale_files if language is None else [lf for lf in locale_files if lf.locale in (language, "en")]
174+
)
175+
all_file_sets = {lf.locale: set(lf.files) for lf in filtered}
176+
file_sets: list[set[str]] = list(all_file_sets.values())
177+
found_difference = False
178+
if len(file_sets) > 1 and any(file_sets[0] != fs for fs in file_sets[1:]):
179+
found_difference = True
180+
console.print("[bold red]Error: Locales have different sets of translation files![/bold red]")
181+
for locale, files_set in all_file_sets.items():
182+
console.print(f"[yellow]{locale}[/yellow]: {sorted(list(files_set))}")
183+
return found_difference
184+
185+
186+
def print_language_summary(
187+
locale_files: list[LocaleFiles], summary: dict[str, LocaleSummary], console: Console
188+
) -> bool:
189+
found_difference = False
190+
missing_in_en: dict[str, dict[str, set[str]]] = {}
191+
for lf in locale_files:
192+
locale = lf.locale
193+
file_missing: dict[str, list[str]] = {}
194+
file_extra: dict[str, list[str]] = {}
195+
for filename, diff in summary.items():
196+
missing_keys = diff.missing_keys.get(locale, [])
197+
extra_keys = diff.extra_keys.get(locale, [])
198+
if missing_keys:
199+
file_missing[filename] = missing_keys
200+
if extra_keys:
201+
file_extra[filename] = extra_keys
202+
if locale != "en":
203+
for k in extra_keys:
204+
missing_in_en.setdefault(filename, {}).setdefault(k, set()).add(locale)
205+
if file_missing or file_extra:
206+
if locale == "en":
207+
continue
208+
console.print(Panel(f"[bold yellow]{locale}[/bold yellow]", style="blue"))
209+
if file_missing:
210+
found_difference = True
211+
console.print("[red] Missing keys (need to be added):[/red]")
212+
for filename, keys in file_missing.items():
213+
console.print(f" [magenta]{filename}[/magenta]:")
214+
for k in keys:
215+
console.print(f" [yellow]{k}[/yellow]")
216+
if file_extra:
217+
found_difference = True
218+
console.print("[red] Extra keys (need to be removed/updated):[/red]")
219+
for filename, keys in file_extra.items():
220+
console.print(f" [magenta]{filename}[/magenta]:")
221+
for k in keys:
222+
console.print(f" [yellow]{k}[/yellow]")
223+
return found_difference
224+
225+
226+
def get_outdated_entries_for_language(
227+
summary: dict[str, LocaleSummary], language: str
228+
) -> dict[str, list[str]]:
229+
"""Return a dict of filename -> list of outdated/old keys for the given language (present in other languages, missing in the given language)."""
230+
outdated: dict[str, list[str]] = {}
231+
for filename, diff in summary.items():
232+
missing = diff.missing_keys.get(language, [])
233+
if missing:
234+
outdated[filename] = list(missing)
235+
return outdated
236+
237+
238+
@click.command()
239+
@click.option(
240+
"--language", "-l", default=None, help="Show summary for a single language (e.g. en, de, pl, etc.)"
241+
)
242+
@click.option(
243+
"--add-missing",
244+
is_flag=True,
245+
default=False,
246+
help="Add missing translations for the selected language, prefixed with 'TODO: translate:'.",
247+
)
248+
def cli(language: str | None = None, add_missing: bool = False):
249+
locale_files = get_locale_files()
250+
console = Console(force_terminal=True, color_system="auto")
251+
print_locale_file_table(locale_files, console, language)
252+
found_difference = print_file_set_differences(locale_files, console, language)
253+
summary = compare_keys(locale_files)
254+
console.print("\n[bold underline]Summary of differences by language:[/bold underline]", style="cyan")
255+
if language:
256+
locales = [lf.locale for lf in locale_files]
257+
if language not in locales:
258+
console.print(f"[red]Language '{language}' not found among locales: {', '.join(locales)}[/red]")
259+
sys.exit(2)
260+
if language == "en":
261+
console.print("[bold red]Cannot check completeness for English language![/bold red]")
262+
sys.exit(2)
263+
else:
264+
filtered_summary = {}
265+
for filename, diff in summary.items():
266+
filtered_summary[filename] = LocaleSummary(
267+
missing_keys={language: diff.missing_keys.get(language, [])},
268+
extra_keys={language: diff.extra_keys.get(language, [])},
269+
)
270+
lang_diff = print_language_summary(
271+
[lf for lf in locale_files if lf.locale == language], filtered_summary, console
272+
)
273+
found_difference = found_difference or lang_diff
274+
if add_missing:
275+
add_missing_translations(language, filtered_summary, console)
276+
else:
277+
lang_diff = print_language_summary(locale_files, summary, console)
278+
found_difference = found_difference or lang_diff
279+
if not found_difference:
280+
console.print("[green]All translations are complete and consistent![/green]")
281+
if found_difference:
282+
sys.exit(1)
283+
284+
285+
def add_missing_translations(language: str, summary: dict[str, LocaleSummary], console: Console):
286+
"""
287+
Add missing translations for the selected language.
288+
289+
It does it by copying them from English and prefixing with 'TODO: translate:'.
290+
"""
291+
for filename, diff in summary.items():
292+
missing_keys = diff.missing_keys.get(language, [])
293+
if not missing_keys:
294+
continue
295+
en_path = LOCALES_DIR / "en" / filename
296+
lang_path = LOCALES_DIR / language / filename
297+
try:
298+
en_data = load_json(en_path)
299+
except Exception as e:
300+
console.print(f"[red]Failed to load English file {en_path}: {e}[/red]")
301+
continue
302+
try:
303+
lang_data = load_json(lang_path)
304+
except Exception as e:
305+
console.print(f"[red]Failed to load {language} file {language}: {e}[/red]")
306+
sys.exit(1)
307+
308+
# Helper to recursively add missing keys
309+
def add_keys(src, dst, prefix=""):
310+
for k, v in src.items():
311+
full_key = f"{prefix}.{k}" if prefix else k
312+
if full_key in missing_keys:
313+
if isinstance(v, dict):
314+
dst[k] = {}
315+
add_keys(v, dst[k], full_key)
316+
else:
317+
dst[k] = f"TODO: translate: {v}"
318+
else:
319+
if isinstance(v, dict):
320+
if k not in dst or not isinstance(dst[k], dict):
321+
dst[k] = {}
322+
add_keys(v, dst[k], full_key)
323+
324+
add_keys(en_data, lang_data)
325+
# Write back to file, preserving order
326+
lang_path.parent.mkdir(parents=True, exist_ok=True)
327+
with open(lang_path, "w", encoding="utf-8") as f:
328+
json.dump(lang_data, f, ensure_ascii=False, indent=2, sort_keys=True)
329+
f.write("\n") # Ensure newline at the end of the file
330+
console.print(f"[green]Added missing translations to {lang_path}[/green]")
331+
332+
333+
if __name__ == "__main__":
334+
cli()

0 commit comments

Comments
 (0)