You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by po...@apache.org on 2025/06/01 16:49:42 UTC
(airflow) branch main updated: Add script to automatically check completness of translations (#51269)
This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 35905587a47 Add script to automatically check completness of translations (#51269)
35905587a47 is described below
commit 35905587a47e451f5cbd71014c2d914ff91206d8
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Sun Jun 1 18:49:35 2025 +0200
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 <we...@gmail.com>
* Update airflow-core/src/airflow/ui/src/i18n/check_translations_completeness.py
Co-authored-by: Jens Scheffler <95...@users.noreply.github.com>
---------
Co-authored-by: Wei Lee <we...@gmail.com>
Co-authored-by: Jens Scheffler <95...@users.noreply.github.com>
---
.pre-commit-config.yaml | 8 +
airflow-core/src/airflow/ui/src/i18n/README.md | 41 +++
.../ui/src/i18n/check_translations_completeness.py | 334 +++++++++++++++++++++
contributing-docs/08_static_code_checks.rst | 2 +
dev/breeze/doc/images/output_static-checks.svg | 2 +-
dev/breeze/doc/images/output_static-checks.txt | 2 +-
dev/breeze/src/airflow_breeze/pre_commit_ids.py | 1 +
devel-common/src/docs/utils/conf_constants.py | 1 +
scripts/ci/pre_commit/check_i18n_json.py | 71 +++++
9 files changed, 460 insertions(+), 2 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7590cfa6adf..21463a31ad9 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1396,6 +1396,14 @@ repos:
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$
exclude: ^airflow-core/src/airflow/api_fastapi/execution_api/.*
additional_dependencies: ['rich>=12.4.4', 'openapi-spec-validator>=0.7.1']
+ - id: check-i18n-json
+ name: Check i18n files validity
+ description: Check i18n files are valid json and have no TODOs
+ language: python
+ files: ^airflow-core/src/airflow/ui/src/i18n/locales/.*\.json
+ entry: ./scripts/ci/pre_commit/check_i18n_json.py
+ additional_dependencies: ['rich>=12.4.4']
+ pass_filenames: false
- id: ts-compile-format-lint-ui
name: Compile / format / lint UI
description: TS types generation / ESLint / Prettier new UI files
diff --git a/airflow-core/src/airflow/ui/src/i18n/README.md b/airflow-core/src/airflow/ui/src/i18n/README.md
new file mode 100644
index 00000000000..c5a0c1c89ed
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/i18n/README.md
@@ -0,0 +1,41 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+Checking completeness of i18n files:
+------------------------------------
+
+All files:
+
+```bash
+uv run ./check_translations_completeness.py
+```
+
+Files for specific languages:
+
+```bash
+uv run ./check_translations_completeness.py --language <language_code>
+```
+
+Where `<language_code>` is the code of the language you want to check, e.g., `en`, `fr`, `de`, etc.
+
+Adding missing translations (with `TODO: translate` prefix):
+
+```bash
+uv run ./check_translations_completeness.py --language <language_code> --add-missing
+```
diff --git a/airflow-core/src/airflow/ui/src/i18n/check_translations_completeness.py b/airflow-core/src/airflow/ui/src/i18n/check_translations_completeness.py
new file mode 100755
index 00000000000..d462db66d05
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/i18n/check_translations_completeness.py
@@ -0,0 +1,334 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+# "rich",
+# "rich-click",
+# ]
+# ///
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+from typing import Any, NamedTuple
+
+import rich_click as click
+from rich import print
+from rich.console import Console
+from rich.panel import Panel
+from rich.table import Table
+
+click.rich_click.MAX_WIDTH = 120
+click.rich_click.USE_RICH_MARKUP = True
+
+LOCALES_DIR = Path(__file__).parent / "locales"
+
+
+class LocaleSummary(NamedTuple):
+ """
+ Summary of missing and extra translation keys for a file, per locale.
+
+ Attributes:
+ missing_keys: A dictionary mapping locale codes to lists of missing translation keys.
+ extra: A dictionary mapping locale codes to lists of extra translation keys.
+ """
+
+ missing_keys: dict[str, list[str]]
+ extra_keys: dict[str, list[str]]
+
+
+class LocaleFiles(NamedTuple):
+ """
+ Represents a locale and its list of translation files.
+
+ Attributes:
+ locale: The locale code (e.g., 'en', 'pl').
+ files: A list of translation file names for the locale.
+ """
+
+ locale: str
+ files: list[str]
+
+
+class LocaleKeySet(NamedTuple):
+ """
+ Represents a locale and its set of translation keys for a file (or None if file missing).
+
+ Attributes:
+ locale: The locale code (e.g., 'en', 'pl').
+ keys: A set of translation keys for the file, or None if the file is missing for this locale.
+ """
+
+ locale: str
+ keys: set[str] | None
+
+
+def get_locale_files() -> list[LocaleFiles]:
+ return [
+ LocaleFiles(
+ locale=locale_dir.name, files=[f.name for f in locale_dir.iterdir() if f.suffix == ".json"]
+ )
+ for locale_dir in LOCALES_DIR.iterdir()
+ if locale_dir.is_dir()
+ ]
+
+
+def load_json(filepath: Path) -> Any:
+ return json.loads(filepath.read_text(encoding="utf-8"))
+
+
+def flatten_keys(d: dict, prefix: str = "") -> list[str]:
+ keys: list[str] = []
+ for k, v in d.items():
+ full_key = f"{prefix}.{k}" if prefix else k
+ if isinstance(v, dict):
+ keys.extend(flatten_keys(v, full_key))
+ else:
+ keys.append(full_key)
+ return keys
+
+
+def compare_keys(locale_files: list[LocaleFiles]) -> dict[str, LocaleSummary]:
+ """
+ Compare all non-English locales with English locale only.
+
+ Returns a summary dict: filename -> LocaleSummary(missing, extra)
+ """
+ all_files: set[str] = set()
+ for lf in locale_files:
+ all_files.update(lf.files)
+ summary: dict[str, LocaleSummary] = {}
+ for filename in all_files:
+ key_sets: list[LocaleKeySet] = []
+ for lf in locale_files:
+ if filename in lf.files:
+ path = LOCALES_DIR / lf.locale / filename
+ try:
+ data = load_json(path)
+ keys = set(flatten_keys(data))
+ except Exception as e:
+ print(f"Error loading {path}: {e}")
+ keys = set()
+ else:
+ keys = None
+ key_sets.append(LocaleKeySet(locale=lf.locale, keys=keys))
+ keys_by_locale = {ks.locale: ks.keys for ks in key_sets}
+ en_keys = keys_by_locale.get("en", set()) or set()
+ missing_keys: dict[str, list[str]] = {}
+ extra_keys: dict[str, list[str]] = {}
+ for ks in key_sets:
+ if ks.locale == "en":
+ continue
+ if ks.keys is None:
+ missing_keys[ks.locale] = list(en_keys)
+ extra_keys[ks.locale] = []
+ else:
+ missing_keys[ks.locale] = list(en_keys - ks.keys)
+ extra_keys[ks.locale] = list(ks.keys - en_keys)
+ summary[filename] = LocaleSummary(missing_keys=missing_keys, extra_keys=extra_keys)
+ return summary
+
+
+def print_locale_file_table(
+ locale_files: list[LocaleFiles], console: Console, language: str | None = None
+) -> None:
+ if language and language == "en":
+ return
+ console.print("[bold underline]Locales and their files:[/bold underline]", style="cyan")
+ table = Table(show_header=True, header_style="bold magenta")
+ table.add_column("Locale")
+ table.add_column("Files")
+ filtered = (
+ locale_files if language is None else [lf for lf in locale_files if lf.locale in (language, "en")]
+ )
+ for lf in filtered:
+ table.add_row(lf.locale, ", ".join(lf.files))
+ console.print(table)
+
+
+def print_file_set_differences(
+ locale_files: list[LocaleFiles], console: Console, language: str | None = None
+) -> bool:
+ if language and language == "en":
+ return False
+ filtered = (
+ locale_files if language is None else [lf for lf in locale_files if lf.locale in (language, "en")]
+ )
+ all_file_sets = {lf.locale: set(lf.files) for lf in filtered}
+ file_sets: list[set[str]] = list(all_file_sets.values())
+ found_difference = False
+ if len(file_sets) > 1 and any(file_sets[0] != fs for fs in file_sets[1:]):
+ found_difference = True
+ console.print("[bold red]Error: Locales have different sets of translation files![/bold red]")
+ for locale, files_set in all_file_sets.items():
+ console.print(f"[yellow]{locale}[/yellow]: {sorted(list(files_set))}")
+ return found_difference
+
+
+def print_language_summary(
+ locale_files: list[LocaleFiles], summary: dict[str, LocaleSummary], console: Console
+) -> bool:
+ found_difference = False
+ missing_in_en: dict[str, dict[str, set[str]]] = {}
+ for lf in locale_files:
+ locale = lf.locale
+ file_missing: dict[str, list[str]] = {}
+ file_extra: dict[str, list[str]] = {}
+ for filename, diff in summary.items():
+ missing_keys = diff.missing_keys.get(locale, [])
+ extra_keys = diff.extra_keys.get(locale, [])
+ if missing_keys:
+ file_missing[filename] = missing_keys
+ if extra_keys:
+ file_extra[filename] = extra_keys
+ if locale != "en":
+ for k in extra_keys:
+ missing_in_en.setdefault(filename, {}).setdefault(k, set()).add(locale)
+ if file_missing or file_extra:
+ if locale == "en":
+ continue
+ console.print(Panel(f"[bold yellow]{locale}[/bold yellow]", style="blue"))
+ if file_missing:
+ found_difference = True
+ console.print("[red] Missing keys (need to be added):[/red]")
+ for filename, keys in file_missing.items():
+ console.print(f" [magenta]{filename}[/magenta]:")
+ for k in keys:
+ console.print(f" [yellow]{k}[/yellow]")
+ if file_extra:
+ found_difference = True
+ console.print("[red] Extra keys (need to be removed/updated):[/red]")
+ for filename, keys in file_extra.items():
+ console.print(f" [magenta]{filename}[/magenta]:")
+ for k in keys:
+ console.print(f" [yellow]{k}[/yellow]")
+ return found_difference
+
+
+def get_outdated_entries_for_language(
+ summary: dict[str, LocaleSummary], language: str
+) -> dict[str, list[str]]:
+ """Return a dict of filename -> list of outdated/old keys for the given language (present in other languages, missing in the given language)."""
+ outdated: dict[str, list[str]] = {}
+ for filename, diff in summary.items():
+ missing = diff.missing_keys.get(language, [])
+ if missing:
+ outdated[filename] = list(missing)
+ return outdated
+
+
+@click.command()
+@click.option(
+ "--language", "-l", default=None, help="Show summary for a single language (e.g. en, de, pl, etc.)"
+)
+@click.option(
+ "--add-missing",
+ is_flag=True,
+ default=False,
+ help="Add missing translations for the selected language, prefixed with 'TODO: translate:'.",
+)
+def cli(language: str | None = None, add_missing: bool = False):
+ locale_files = get_locale_files()
+ console = Console(force_terminal=True, color_system="auto")
+ print_locale_file_table(locale_files, console, language)
+ found_difference = print_file_set_differences(locale_files, console, language)
+ summary = compare_keys(locale_files)
+ console.print("\n[bold underline]Summary of differences by language:[/bold underline]", style="cyan")
+ if language:
+ locales = [lf.locale for lf in locale_files]
+ if language not in locales:
+ console.print(f"[red]Language '{language}' not found among locales: {', '.join(locales)}[/red]")
+ sys.exit(2)
+ if language == "en":
+ console.print("[bold red]Cannot check completeness for English language![/bold red]")
+ sys.exit(2)
+ else:
+ filtered_summary = {}
+ for filename, diff in summary.items():
+ filtered_summary[filename] = LocaleSummary(
+ missing_keys={language: diff.missing_keys.get(language, [])},
+ extra_keys={language: diff.extra_keys.get(language, [])},
+ )
+ lang_diff = print_language_summary(
+ [lf for lf in locale_files if lf.locale == language], filtered_summary, console
+ )
+ found_difference = found_difference or lang_diff
+ if add_missing:
+ add_missing_translations(language, filtered_summary, console)
+ else:
+ lang_diff = print_language_summary(locale_files, summary, console)
+ found_difference = found_difference or lang_diff
+ if not found_difference:
+ console.print("[green]All translations are complete and consistent![/green]")
+ if found_difference:
+ sys.exit(1)
+
+
+def add_missing_translations(language: str, summary: dict[str, LocaleSummary], console: Console):
+ """
+ Add missing translations for the selected language.
+
+ It does it by copying them from English and prefixing with 'TODO: translate:'.
+ """
+ for filename, diff in summary.items():
+ missing_keys = diff.missing_keys.get(language, [])
+ if not missing_keys:
+ continue
+ en_path = LOCALES_DIR / "en" / filename
+ lang_path = LOCALES_DIR / language / filename
+ try:
+ en_data = load_json(en_path)
+ except Exception as e:
+ console.print(f"[red]Failed to load English file {en_path}: {e}[/red]")
+ continue
+ try:
+ lang_data = load_json(lang_path)
+ except Exception as e:
+ console.print(f"[red]Failed to load {language} file {language}: {e}[/red]")
+ sys.exit(1)
+
+ # Helper to recursively add missing keys
+ def add_keys(src, dst, prefix=""):
+ for k, v in src.items():
+ full_key = f"{prefix}.{k}" if prefix else k
+ if full_key in missing_keys:
+ if isinstance(v, dict):
+ dst[k] = {}
+ add_keys(v, dst[k], full_key)
+ else:
+ dst[k] = f"TODO: translate: {v}"
+ else:
+ if isinstance(v, dict):
+ if k not in dst or not isinstance(dst[k], dict):
+ dst[k] = {}
+ add_keys(v, dst[k], full_key)
+
+ add_keys(en_data, lang_data)
+ # Write back to file, preserving order
+ lang_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(lang_path, "w", encoding="utf-8") as f:
+ json.dump(lang_data, f, ensure_ascii=False, indent=2, sort_keys=True)
+ f.write("\n") # Ensure newline at the end of the file
+ console.print(f"[green]Added missing translations to {lang_path}[/green]")
+
+
+if __name__ == "__main__":
+ cli()
diff --git a/contributing-docs/08_static_code_checks.rst b/contributing-docs/08_static_code_checks.rst
index 3be329db79f..7c4bfb3591b 100644
--- a/contributing-docs/08_static_code_checks.rst
+++ b/contributing-docs/08_static_code_checks.rst
@@ -181,6 +181,8 @@ require Breeze Docker image to be built locally.
+-----------------------------------------------------------+--------------------------------------------------------+---------+
| check-hooks-apply | Check if all hooks apply to the repository | |
+-----------------------------------------------------------+--------------------------------------------------------+---------+
+| check-i18n-json | Check i18n files validity | * |
++-----------------------------------------------------------+--------------------------------------------------------+---------+
| check-imports-in-providers | Check imports in providers | |
+-----------------------------------------------------------+--------------------------------------------------------+---------+
| check-incorrect-use-of-LoggingMixin | Make sure LoggingMixin is not used alone | |
diff --git a/dev/breeze/doc/images/output_static-checks.svg b/dev/breeze/doc/images/output_static-checks.svg
index 39e1fe79bde..10e225dc64f 100644
--- a/dev/breeze/doc/images/output_static-checks.svg
+++ b/dev/breeze/doc/images/output_static-checks.svg
@@ -341,7 +341,7 @@
</text><text class="breeze-static-checks-r5" x="0" y="434.8" textLength="12.2" clip-path="url(#breeze-static-checks-line-17)">│</text><text class="breeze-static-checks-r7" x="451.4" y="434.8" textLength="988.2" clip-path="url(#breeze-static-checks-line-17)">check-docstring-param-types | check-example-dags-urls |                          </text>< [...]
</text><text class="breeze-static-checks-r5" x="0" y="459.2" textLength="12.2" clip-path="url(#breeze-static-checks-line-18)">│</text><text class="breeze-static-checks-r7" x="451.4" y="459.2" textLength="988.2" clip-path="url(#breeze-static-checks-line-18)">check-executables-have-shebangs | check-extra-packages-references |              </text><text class="breeze-static-checks-r5" x="1451.8" y="459.2" te [...]
</text><text class="breeze-static-checks-r5" x="0" y="483.6" textLength="12.2" clip-path="url(#breeze-static-checks-line-19)">│</text><text class="breeze-static-checks-r7" x="451.4" y="483.6" textLength="988.2" clip-path="url(#breeze-static-checks-line-19)">check-extras-order | check-fab-migrations | check-for-inclusive-language |       </text><text class="breeze-static-checks-r5" x="1451.8" y="483.6" textLength="12.2" clip-path [...]
-</text><text class="breeze-static-checks-r5" x="0" y="508" textLength="12.2" clip-path="url(#breeze-static-checks-line-20)">│</text><text class="breeze-static-checks-r7" x="451.4" y="508" textLength="988.2" clip-path="url(#breeze-static-checks-line-20)">check-get-lineage-collector-providers | check-hooks-apply |                      </text><text class="breeze-stati [...]
+</text><text class="breeze-static-checks-r5" x="0" y="508" textLength="12.2" clip-path="url(#breeze-static-checks-line-20)">│</text><text class="breeze-static-checks-r7" x="451.4" y="508" textLength="988.2" clip-path="url(#breeze-static-checks-line-20)">check-get-lineage-collector-providers | check-hooks-apply | check-i18n-json |    </text><text class="breeze-static-checks-r5" x="1451.8" y="508" textLength="12.2" clip-path="url(#breeze-static- [...]
</text><text class="breeze-static-checks-r5" x="0" y="532.4" textLength="12.2" clip-path="url(#breeze-static-checks-line-21)">│</text><text class="breeze-static-checks-r7" x="451.4" y="532.4" textLength="988.2" clip-path="url(#breeze-static-checks-line-21)">check-imports-in-providers | check-incorrect-use-of-LoggingMixin |               </text><text class="breeze-static-checks-r5" x="1451.8" y="532. [...]
</text><text class="breeze-static-checks-r5" x="0" y="556.8" textLength="12.2" clip-path="url(#breeze-static-checks-line-22)">│</text><text class="breeze-static-checks-r7" x="451.4" y="556.8" textLength="988.2" clip-path="url(#breeze-static-checks-line-22)">check-init-decorator-arguments | check-integrations-list-consistent |            </text><text class="breeze-static-checks-r5" x="1451.8" y="556.8" textLength=" [...]
</text><text class="breeze-static-checks-r5" x="0" y="581.2" textLength="12.2" clip-path="url(#breeze-static-checks-line-23)">│</text><text class="breeze-static-checks-r7" x="451.4" y="581.2" textLength="988.2" clip-path="url(#breeze-static-checks-line-23)">check-lazy-logging | check-links-to-example-dags-do-not-use-hardcoded-versions | </text><text class="breeze-static-checks-r5" x="1451.8" y="581.2" textLength="12.2" clip-path="url(#breeze-static-checks-line-23)">│< [...]
diff --git a/dev/breeze/doc/images/output_static-checks.txt b/dev/breeze/doc/images/output_static-checks.txt
index 555ae8e5508..580ee2a0d33 100644
--- a/dev/breeze/doc/images/output_static-checks.txt
+++ b/dev/breeze/doc/images/output_static-checks.txt
@@ -1 +1 @@
-06e827be83fd345223ec466cb843ccc0
+7172ee858fff68b9d3c8c0e124c4c725
diff --git a/dev/breeze/src/airflow_breeze/pre_commit_ids.py b/dev/breeze/src/airflow_breeze/pre_commit_ids.py
index fbef1499867..7247d15ef5e 100644
--- a/dev/breeze/src/airflow_breeze/pre_commit_ids.py
+++ b/dev/breeze/src/airflow_breeze/pre_commit_ids.py
@@ -54,6 +54,7 @@ PRE_COMMIT_LIST = [
"check-for-inclusive-language",
"check-get-lineage-collector-providers",
"check-hooks-apply",
+ "check-i18n-json",
"check-imports-in-providers",
"check-incorrect-use-of-LoggingMixin",
"check-init-decorator-arguments",
diff --git a/devel-common/src/docs/utils/conf_constants.py b/devel-common/src/docs/utils/conf_constants.py
index 3a92e385a8b..99af2e2ee06 100644
--- a/devel-common/src/docs/utils/conf_constants.py
+++ b/devel-common/src/docs/utils/conf_constants.py
@@ -338,6 +338,7 @@ BASIC_AUTOAPI_IGNORE_PATTERNS = [
"*/tests/system/__init__.py",
"*/tests/system/example_empty.py",
"*/test_aws_auth_manager.py",
+ "*/check_translations_completeness.py",
]
IGNORE_PATTERNS_RECOGNITION = re.compile(r"\[AutoAPI\] .* Ignoring \s (?P<path>/[\w/.]*)", re.VERBOSE)
diff --git a/scripts/ci/pre_commit/check_i18n_json.py b/scripts/ci/pre_commit/check_i18n_json.py
new file mode 100755
index 00000000000..9c4f1af4a77
--- /dev/null
+++ b/scripts/ci/pre_commit/check_i18n_json.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Pre-commit script to check that all .json files in airflow-core/src/airflow/ui/src/i18n/locales/
+are valid JSON and do not contain any 'TODO:' entries.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+COMMON_PRECOMMIT_PATH = Path(__file__).parent.resolve()
+
+sys.path.insert(0, COMMON_PRECOMMIT_PATH.as_posix()) # make sure common_precommit_utils is imported
+from common_precommit_utils import AIRFLOW_ROOT_PATH, console
+
+LOCALES_DIR = AIRFLOW_ROOT_PATH / "airflow-core" / "src" / "airflow" / "ui" / "src" / "i18n" / "locales"
+
+
+def main():
+ failed = False
+ for json_file in LOCALES_DIR.rglob("*.json"):
+ console.print(f"[bright_blue]Checking {json_file.relative_to(LOCALES_DIR)}[/]")
+ rel_path = json_file.relative_to(Path.cwd()) if Path.cwd() in json_file.parents else json_file
+ try:
+ content = json_file.read_text(encoding="utf-8")
+ if "TODO:" in content:
+ console.print(f"[bold red][FAIL][/bold red] 'TODO:' found in [yellow]{rel_path}[/yellow]")
+ failed = True
+ # Check if valid JSON
+ try:
+ json.loads(content)
+ except Exception as e:
+ console.print(
+ f"[bold red][FAIL][/bold red] Invalid JSON in [yellow]{rel_path}[/yellow]: [red]{e}[/red]"
+ )
+ failed = True
+ except Exception as e:
+ console.print(
+ f"[bold red][FAIL][/bold red] Could not read [yellow]{rel_path}[/yellow]: [red]{e}[/red]"
+ )
+ failed = True
+ if failed:
+ console.print(
+ "\n[bold red][ERROR][/bold red] Some JSON files are invalid or contain 'TODO:'. Commit aborted."
+ )
+ sys.exit(1)
+ console.print("[bold green][OK][/bold green] All JSON files are valid and do not contain 'TODO:'.")
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()