Add --json output to breeze ui check-translation-completeness (#75831)

The checker computed its per-file statistics inside the rich table
rendering loop, so the report existed only as terminal output. Extract
the aggregation into build_completeness_report() and add a --json flag
that prints the same data as a single JSON document on stdout —
following the breeze ci verify --json pattern (#73241): narration is
suppressed (or sent to stderr with --verbose) so the output can be
piped straight to jq.

The default output stays byte-identical, exit-code semantics are
unchanged (#55327), and the existing --language/--add-missing/
--remove-unused flags compose with --json.

closes: #75772

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01A18rBgPJ6X4oXAzsecJjnz
This commit is contained in:
조현준andClaude Fable 5 authored and GitHub committed 2026-11-01 23:27:55 +00:00
1 parent 7bfc8d32db
commit eb47cc9f15
8 files changed
+532 -117

No files matched your search

@@ -146,6 +146,13 @@ and 100% coverage:
breeze ui check-translation-completeness --language <locale>
```
For machine-readable validation, add `--json` and check the language's totals
instead — stdout carries only the JSON document:
```bash
breeze ui check-translation-completeness --language <locale> --json
```
Run pre-commit hooks to fix formatting, licenses, and linting issues:
```bash
@@ -202,6 +202,12 @@ Remove unused entries for all locales:
breeze ui check-translation-completeness --remove-unused
```
Print the report as machine-readable JSON, e.g. for scripts or agents (stdout carries only the JSON document, so it can be piped straight to `jq`):
```bash
breeze ui check-translation-completeness --language <language_code> --json
```
## Locale-specific guidance
Before translating, read the locale-specific guide if one exists. Those files contain glossary and style choices
+3
View File
@@ -86,6 +86,9 @@ Example usage:
# Fix translations for a specific language
breeze ui check-translation-completeness --language de --add-missing --remove-unused
# Print the report as machine-readable JSON (stdout carries only the JSON document)
breeze ui check-translation-completeness --language de --json
-----
@@ -1,4 +1,4 @@
<svg class="rich-terminal" viewBox="0 0 1482 416.0" xmlns="http://www.w3.org/2000/svg">
<svg class="rich-terminal" viewBox="0 0 1482 440.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@@ -43,7 +43,7 @@
<defs>
<clipPath id="breeze-ui-check-translation-completeness-clip-terminal">
<rect x="0" y="0" width="1463.0" height="365.0" />
<rect x="0" y="0" width="1463.0" height="389.4" />
</clipPath>
<clipPath id="breeze-ui-check-translation-completeness-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -87,9 +87,12 @@
<clipPath id="breeze-ui-check-translation-completeness-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="breeze-ui-check-translation-completeness-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="414" rx="8"/><text class="breeze-ui-check-translation-completeness-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">Command:&#160;ui&#160;check-translation-completeness</text>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="438.4" rx="8"/><text class="breeze-ui-check-translation-completeness-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">Command:&#160;ui&#160;check-translation-completeness</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -108,12 +111,13 @@
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="166.4" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-6)">│</text><text class="breeze-ui-check-translation-completeness-r4" x="24.4" y="166.4" textLength="183" clip-path="url(#breeze-ui-check-translation-completeness-line-6)">--language&#160;&#160;&#160;&#160;&#160;</text><text class="breeze-ui-check-translation-completeness-r6" x="231.8" y="166.4" textLength="24.4" clip-path="url(#breeze-ui-check-translation-completeness-line-6)">-l</text><text class="breeze-ui-check-translation-completeness-r1" x="280.6" y="166.4" textLength="719.8" clip-path="url(#breeze-ui-check-translation-completeness-line-6)">Show&#160;summary&#160;for&#160;a&#160;single&#160;language&#160;(e.g.&#160;en,&#160;de,&#160;pl,&#160;etc.)&#160;</text><text class="breeze-ui-check-translation-completeness-r7" x="1000.4" y="166.4" textLength="73.2" clip-path="url(#breeze-ui-check-translation-completeness-line-6)">(TEXT)</text><text class="breeze-ui-check-translation-completeness-r5" x="1451.8" y="166.4" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-6)">│</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-6)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="190.8" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-7)">│</text><text class="breeze-ui-check-translation-completeness-r4" x="24.4" y="190.8" textLength="183" clip-path="url(#breeze-ui-check-translation-completeness-line-7)">--add-missing&#160;&#160;</text><text class="breeze-ui-check-translation-completeness-r1" x="280.6" y="190.8" textLength="1122.4" clip-path="url(#breeze-ui-check-translation-completeness-line-7)">Add&#160;missing&#160;translations&#160;for&#160;all&#160;languages&#160;except&#160;English,&#160;prefixed&#160;with&#160;&#x27;TODO:&#160;translate:&#x27;.</text><text class="breeze-ui-check-translation-completeness-r5" x="1451.8" y="190.8" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-7)">│</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-7)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="215.2" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-8)">│</text><text class="breeze-ui-check-translation-completeness-r4" x="24.4" y="215.2" textLength="183" clip-path="url(#breeze-ui-check-translation-completeness-line-8)">--remove-unused</text><text class="breeze-ui-check-translation-completeness-r1" x="280.6" y="215.2" textLength="915" clip-path="url(#breeze-ui-check-translation-completeness-line-8)">Remove&#160;unused&#160;translations&#160;(keys&#160;present&#160;in&#160;the&#160;language&#160;but&#160;not&#160;required).</text><text class="breeze-ui-check-translation-completeness-r5" x="1451.8" y="215.2" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-8)">│</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-8)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="239.6" textLength="1464" clip-path="url(#breeze-ui-check-translation-completeness-line-9)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-9)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="264" textLength="24.4" clip-path="url(#breeze-ui-check-translation-completeness-line-10)">╭─</text><text class="breeze-ui-check-translation-completeness-r5" x="24.4" y="264" textLength="195.2" clip-path="url(#breeze-ui-check-translation-completeness-line-10)">&#160;Common&#160;options&#160;</text><text class="breeze-ui-check-translation-completeness-r5" x="219.6" y="264" textLength="1220" clip-path="url(#breeze-ui-check-translation-completeness-line-10)">────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="breeze-ui-check-translation-completeness-r5" x="1439.6" y="264" textLength="24.4" clip-path="url(#breeze-ui-check-translation-completeness-line-10)">─╮</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="264" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-10)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="288.4" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-11)">│</text><text class="breeze-ui-check-translation-completeness-r4" x="24.4" y="288.4" textLength="109.8" clip-path="url(#breeze-ui-check-translation-completeness-line-11)">--verbose</text><text class="breeze-ui-check-translation-completeness-r6" x="158.6" y="288.4" textLength="24.4" clip-path="url(#breeze-ui-check-translation-completeness-line-11)">-v</text><text class="breeze-ui-check-translation-completeness-r1" x="207.4" y="288.4" textLength="585.6" clip-path="url(#breeze-ui-check-translation-completeness-line-11)">Print&#160;verbose&#160;information&#160;about&#160;performed&#160;steps.</text><text class="breeze-ui-check-translation-completeness-r5" x="1451.8" y="288.4" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-11)">│</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-11)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="312.8" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">│</text><text class="breeze-ui-check-translation-completeness-r4" x="24.4" y="312.8" textLength="109.8" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">--dry-run</text><text class="breeze-ui-check-translation-completeness-r6" x="158.6" y="312.8" textLength="24.4" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">-D</text><text class="breeze-ui-check-translation-completeness-r1" x="207.4" y="312.8" textLength="719.8" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">If&#160;dry-run&#160;is&#160;set,&#160;commands&#160;are&#160;only&#160;printed,&#160;not&#160;executed.</text><text class="breeze-ui-check-translation-completeness-r5" x="1451.8" y="312.8" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">│</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="337.2" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">│</text><text class="breeze-ui-check-translation-completeness-r4" x="24.4" y="337.2" textLength="109.8" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">--help&#160;&#160;&#160;</text><text class="breeze-ui-check-translation-completeness-r6" x="158.6" y="337.2" textLength="24.4" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">-h</text><text class="breeze-ui-check-translation-completeness-r1" x="207.4" y="337.2" textLength="329.4" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">Show&#160;this&#160;message&#160;and&#160;exit.</text><text class="breeze-ui-check-translation-completeness-r5" x="1451.8" y="337.2" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">│</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="361.6" textLength="1464" clip-path="url(#breeze-ui-check-translation-completeness-line-14)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-14)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="239.6" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-9)">│</text><text class="breeze-ui-check-translation-completeness-r4" x="24.4" y="239.6" textLength="183" clip-path="url(#breeze-ui-check-translation-completeness-line-9)">--json&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="breeze-ui-check-translation-completeness-r1" x="280.6" y="239.6" textLength="951.6" clip-path="url(#breeze-ui-check-translation-completeness-line-9)">Print&#160;the&#160;completeness&#160;report&#160;as&#160;machine-readable&#160;JSON&#160;instead&#160;of&#160;rich&#160;tables.</text><text class="breeze-ui-check-translation-completeness-r5" x="1451.8" y="239.6" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-9)">│</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-9)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="264" textLength="1464" clip-path="url(#breeze-ui-check-translation-completeness-line-10)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="264" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-10)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="288.4" textLength="24.4" clip-path="url(#breeze-ui-check-translation-completeness-line-11)">╭─</text><text class="breeze-ui-check-translation-completeness-r5" x="24.4" y="288.4" textLength="195.2" clip-path="url(#breeze-ui-check-translation-completeness-line-11)">&#160;Common&#160;options&#160;</text><text class="breeze-ui-check-translation-completeness-r5" x="219.6" y="288.4" textLength="1220" clip-path="url(#breeze-ui-check-translation-completeness-line-11)">────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="breeze-ui-check-translation-completeness-r5" x="1439.6" y="288.4" textLength="24.4" clip-path="url(#breeze-ui-check-translation-completeness-line-11)">─╮</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-11)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="312.8" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">│</text><text class="breeze-ui-check-translation-completeness-r4" x="24.4" y="312.8" textLength="109.8" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">--verbose</text><text class="breeze-ui-check-translation-completeness-r6" x="158.6" y="312.8" textLength="24.4" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">-v</text><text class="breeze-ui-check-translation-completeness-r1" x="207.4" y="312.8" textLength="585.6" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">Print&#160;verbose&#160;information&#160;about&#160;performed&#160;steps.</text><text class="breeze-ui-check-translation-completeness-r5" x="1451.8" y="312.8" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">│</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-12)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="337.2" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">│</text><text class="breeze-ui-check-translation-completeness-r4" x="24.4" y="337.2" textLength="109.8" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">--dry-run</text><text class="breeze-ui-check-translation-completeness-r6" x="158.6" y="337.2" textLength="24.4" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">-D</text><text class="breeze-ui-check-translation-completeness-r1" x="207.4" y="337.2" textLength="719.8" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">If&#160;dry-run&#160;is&#160;set,&#160;commands&#160;are&#160;only&#160;printed,&#160;not&#160;executed.</text><text class="breeze-ui-check-translation-completeness-r5" x="1451.8" y="337.2" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">│</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-13)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="361.6" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-14)">│</text><text class="breeze-ui-check-translation-completeness-r4" x="24.4" y="361.6" textLength="109.8" clip-path="url(#breeze-ui-check-translation-completeness-line-14)">--help&#160;&#160;&#160;</text><text class="breeze-ui-check-translation-completeness-r6" x="158.6" y="361.6" textLength="24.4" clip-path="url(#breeze-ui-check-translation-completeness-line-14)">-h</text><text class="breeze-ui-check-translation-completeness-r1" x="207.4" y="361.6" textLength="329.4" clip-path="url(#breeze-ui-check-translation-completeness-line-14)">Show&#160;this&#160;message&#160;and&#160;exit.</text><text class="breeze-ui-check-translation-completeness-r5" x="1451.8" y="361.6" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-14)">│</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-14)">
</text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="386" textLength="1464" clip-path="url(#breeze-ui-check-translation-completeness-line-15)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="breeze-ui-check-translation-completeness-r1" x="1464" y="386" textLength="12.2" clip-path="url(#breeze-ui-check-translation-completeness-line-15)">
</text>
</g>
</g>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

@@ -1 +1 @@
4d8c7e8b2fe193a4b8e8cf25a429ff7e
297713f7f93bba54244a44180842df41
@@ -16,10 +16,11 @@
# under the License.
from __future__ import annotations
import contextlib
import io
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any, NamedTuple
@@ -27,10 +28,12 @@ import click
from airflow_breeze.commands.common_options import option_dry_run, option_verbose
from airflow_breeze.utils.click_utils import BreezeGroup
from airflow_breeze.utils.console import console_print, get_console
from airflow_breeze.utils.console import console_print, get_console, get_stderr_console
from airflow_breeze.utils.docker_command_utils import perform_environment_checks
from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
from airflow_breeze.utils.reproduce_ci import SKIP_LOCAL_REPRODUCTION
from airflow_breeze.utils.run_utils import assert_prek_installed, run_compile_ui_assets
from airflow_breeze.utils.shared_options import get_verbose
LOCALES_DIR = AIRFLOW_ROOT_PATH / "airflow-core" / "src" / "airflow" / "ui" / "public" / "i18n" / "locales"
@@ -313,6 +316,15 @@ def print_locale_file_table(locale_files: list[LocaleFiles], language: str | Non
console.print(table)
def locale_file_sets_differ(locale_files: list[LocaleFiles], language: str | None = None) -> bool:
"""Return True when the compared locales do not all have the same set of translation files."""
filtered = (
locale_files if language is None else [lf for lf in locale_files if lf.locale in (language, "en")]
)
file_sets = [set(lf.files) for lf in filtered]
return len(file_sets) > 1 and any(file_sets[0] != fs for fs in file_sets[1:])
def print_file_set_differences(locale_files: list[LocaleFiles], language: str | None = None) -> bool:
if language and language == "en":
return False
@@ -321,12 +333,10 @@ def print_file_set_differences(locale_files: list[LocaleFiles], language: str |
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
en_files = set(all_file_sets["en"])
if len(file_sets) > 1 and any(file_sets[0] != fs for fs in file_sets[1:]):
found_difference = True
found_difference = locale_file_sets_differ(locale_files, language)
if found_difference:
console.print("[bold red]Error: Locales have different set of translation files![/bold red]")
en_files = set(all_file_sets["en"])
all_locales_sorted = sorted(all_file_sets.keys())
for locale in all_locales_sorted:
files_missing = sorted(en_files - all_file_sets[locale])
@@ -392,40 +402,34 @@ def count_todos(obj) -> int:
return 0
def print_translation_progress(locale_files, summary):
console = get_console()
from rich.table import Table
def build_completeness_report(
locale_files: list[LocaleFiles], summary: dict[str, LocaleSummary]
) -> dict[str, dict[str, Any]]:
"""
Aggregate per-language, per-file translation statistics.
tables = defaultdict(lambda: Table(show_lines=True))
all_files = set()
coverage_per_language = {}
Returns a mapping: language -> {"files": {filename: stats}, "totals": stats}. File stats
carry the counts shown by the progress tables plus the sorted lists of missing and unused
keys. Coverage values are raw floats; renderers round them for display.
"""
all_files: set[str] = set()
for lf in locale_files:
all_files.update(lf.files)
has_todos = False
report: dict[str, dict[str, Any]] = {}
for lf in sorted(locale_files):
lang = lf.locale
if lang == "en":
continue
table = tables[lang]
table.title = f"Translation Progress: {lang}"
table.add_column("File", style="bold cyan")
table.add_column("Required (base EN)", style="dim")
table.add_column("Required (plural)", style="dim")
table.add_column("Total required", style="bold")
table.add_column("Translated", style="green")
table.add_column("Missing", style="red")
table.add_column("Coverage", style="bold")
table.add_column("TODOs", style="magenta")
table.add_column("Unused", style="yellow")
total_required_base = 0
total_required_plural = 0
total_required = 0
total_translated = 0
total_missing = 0
total_todos = 0
total_unused = 0
files: dict[str, dict[str, Any]] = {}
totals: dict[str, Any] = {
"required": 0,
"required_base_en": 0,
"required_plural": 0,
"translated": 0,
"missing": 0,
"todos": 0,
"unused": 0,
}
for filename in sorted(all_files):
en_path = Path(LOCALES_DIR / "en" / filename)
file_path = Path(LOCALES_DIR / lang / filename)
@@ -440,8 +444,8 @@ def print_translation_progress(locale_files, summary):
required_base_en = len(en_keys)
required_plural = len(required_keys) - required_base_en
total_req = len(required_keys)
file_missing = len(diff.missing_keys.get(lang, []))
file_unused = len(diff.unused_keys.get(lang, []))
missing_keys = sorted(diff.missing_keys.get(lang, []))
unused_keys = sorted(diff.unused_keys.get(lang, []))
if file_path.exists():
with open(file_path) as f:
lang_data = json.load(f)
@@ -454,54 +458,90 @@ def print_translation_progress(locale_files, summary):
file_todos = sum(
1 for k in required_keys if k in lang_key_to_value and is_todo_value(lang_key_to_value[k])
)
if file_todos > 0:
has_todos = True
else:
file_translated = 0
file_todos = 0
file_coverage = 100 * file_translated / total_req if total_req else 100.0
if file_missing > 0:
files[filename] = {
"required": total_req,
"required_base_en": required_base_en,
"required_plural": required_plural,
"translated": file_translated,
"missing": len(missing_keys),
"todos": file_todos,
"unused": len(unused_keys),
"coverage": file_coverage,
"missing_keys": missing_keys,
"unused_keys": unused_keys,
}
totals["required"] += total_req
totals["required_base_en"] += required_base_en
totals["required_plural"] += required_plural
totals["translated"] += file_translated
totals["missing"] += len(missing_keys)
totals["todos"] += file_todos
totals["unused"] += len(unused_keys)
totals["coverage"] = 100 * totals["translated"] / totals["required"] if totals["required"] else 100.0
report[lang] = {"files": files, "totals": totals}
return report
def print_translation_progress(locale_files, summary):
console = get_console()
from rich.table import Table
report = build_completeness_report(locale_files, summary)
has_todos = any(
file_stats["todos"] > 0 for lang_data in report.values() for file_stats in lang_data["files"].values()
)
coverage_per_language = {lang: lang_data["totals"]["coverage"] for lang, lang_data in report.items()}
for lang, lang_data in report.items():
table = Table(show_lines=True)
table.title = f"Translation Progress: {lang}"
table.add_column("File", style="bold cyan")
table.add_column("Required (base EN)", style="dim")
table.add_column("Required (plural)", style="dim")
table.add_column("Total required", style="bold")
table.add_column("Translated", style="green")
table.add_column("Missing", style="red")
table.add_column("Coverage", style="bold")
table.add_column("TODOs", style="magenta")
table.add_column("Unused", style="yellow")
for filename, file_stats in lang_data["files"].items():
if file_stats["missing"] > 0:
style = "red"
elif file_todos > 0 or file_unused > 0:
elif file_stats["todos"] > 0 or file_stats["unused"] > 0:
style = "yellow"
else:
style = "bold green"
table.add_row(
f"[bold reverse]{filename}[/bold reverse]",
str(required_base_en),
str(required_plural),
str(total_req),
str(file_translated),
str(file_missing),
f"{file_coverage:.1f}%",
str(file_todos),
str(file_unused),
str(file_stats["required_base_en"]),
str(file_stats["required_plural"]),
str(file_stats["required"]),
str(file_stats["translated"]),
str(file_stats["missing"]),
f"{file_stats['coverage']:.1f}%",
str(file_stats["todos"]),
str(file_stats["unused"]),
style=style,
)
total_required_base += required_base_en
total_required_plural += required_plural
total_required += total_req
total_translated += file_translated
total_missing += file_missing
total_todos += file_todos
total_unused += file_unused
total_coverage = 100 * total_translated / total_required if total_required else 100.0
coverage_per_language[lang] = total_coverage
totals = lang_data["totals"]
table.add_row(
"All files",
str(total_required_base),
str(total_required_plural),
str(total_required),
str(total_translated),
str(total_missing),
f"{total_coverage:.1f}%",
str(total_todos),
str(total_unused),
style="red" if total_missing > 0 or total_todos > 0 or total_unused > 0 else "bold green",
str(totals["required_base_en"]),
str(totals["required_plural"]),
str(totals["required"]),
str(totals["translated"]),
str(totals["missing"]),
f"{totals['coverage']:.1f}%",
str(totals["todos"]),
str(totals["unused"]),
style="red"
if totals["missing"] > 0 or totals["todos"] > 0 or totals["unused"] > 0
else "bold green",
)
for _lang, table in tables.items():
console.print(table)
return has_todos, coverage_per_language
@@ -618,44 +658,15 @@ def remove_unused_translations(language: str, summary: dict[str, LocaleSummary])
console.print(f"[green]Removed {len(unused_keys)} unused translations from {lang_path}[/green]")
@click.group(cls=BreezeGroup, name="ui", help="Tools for UI development and maintenance")
def ui_group():
pass
@ui_group.command(
name="check-translation-completeness",
help="Check completeness of UI translations.",
)
@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 all languages except English, prefixed with 'TODO: translate:'.",
)
@click.option(
"--remove-unused",
is_flag=True,
default=False,
help="Remove unused translations (keys present in the language but not required).",
)
@option_verbose
@option_dry_run
def check_translation_completeness(
language: str | None = None, add_missing: bool = False, remove_unused: bool = False
):
locale_files = get_locale_files()
console = get_console()
print_locale_file_table(locale_files, language)
found_difference = print_file_set_differences(locale_files, language)
summary, missing_counts = compare_keys(locale_files)
console.print("\n[bold underline]Summary of differences by language:[/bold underline]", style="cyan")
def apply_translation_modifications(
locale_files: list[LocaleFiles],
summary: dict[str, LocaleSummary],
missing_counts: dict[str, dict[str, int]],
language: str | None,
add_missing: bool,
remove_unused: bool,
) -> tuple[dict[str, LocaleSummary], dict[str, dict[str, int]]]:
"""Run --add-missing / --remove-unused and recompute the summary after each pass."""
if add_missing and language != "en":
# Loop through all languages except 'en' and add missing translations
if language:
@@ -688,6 +699,120 @@ def check_translation_completeness(
remove_unused_translations(lf.locale, filtered_summary)
# After removing, re-run the summary for all languages
summary, missing_counts = compare_keys(get_locale_files())
return summary, missing_counts
def print_completeness_json(language: str | None, add_missing: bool, remove_unused: bool) -> None:
"""Print the completeness report as a single JSON document on stdout.
Mirrors the data the progress tables show. Console narration (load errors and
--add-missing / --remove-unused progress) is kept off stdout so the output can be piped
straight to tools like jq: it goes to stderr with --verbose and is suppressed otherwise —
the same stdout contract as ``breeze ci verify --json``. If the run aborts, the suppressed
narration is replayed to stderr so the error is not silent.
"""
locale_files = get_locale_files()
if language:
locales = [lf.locale for lf in locale_files]
if language not in locales:
get_stderr_console().print(
f"[red]Language '{language}' not found among locales: {', '.join(locales)}[/red]"
)
sys.exit(2)
if language == "en":
get_stderr_console().print("[bold red]Cannot check completeness for English language![/bold red]")
sys.exit(2)
buffer = None if get_verbose() else io.StringIO()
completed = False
try:
with contextlib.redirect_stdout(buffer if buffer is not None else sys.stderr):
summary, missing_counts = compare_keys(locale_files)
summary, _ = apply_translation_modifications(
locale_files, summary, missing_counts, language, add_missing, remove_unused
)
reported_locale_files = [lf for lf in locale_files if language is None or lf.locale == language]
report = build_completeness_report(reported_locale_files, summary)
completed = True
finally:
# Replay the suppressed narration so a fatal error inside the computation is not
# silent, whether it aborted via sys.exit() or an unexpected exception.
if not completed and buffer is not None:
sys.stderr.write(buffer.getvalue())
languages_payload: dict[str, Any] = {}
for lang, lang_data in report.items():
languages_payload[lang] = {
"files": {
filename: {**file_stats, "coverage": round(file_stats["coverage"], 1)}
for filename, file_stats in lang_data["files"].items()
},
"totals": {**lang_data["totals"], "coverage": round(lang_data["totals"]["coverage"], 1)},
}
has_missing = any(lang_data["totals"]["missing"] > 0 for lang_data in report.values())
has_todos = any(lang_data["totals"]["todos"] > 0 for lang_data in report.values())
payload = {
"languages": languages_payload,
"complete": not locale_file_sets_differ(locale_files, language) and not has_missing and not has_todos,
}
print(json.dumps(payload, indent=2))
@click.group(cls=BreezeGroup, name="ui", help="Tools for UI development and maintenance")
def ui_group():
pass
@ui_group.command(
name="check-translation-completeness",
help="Check completeness of UI translations.",
)
@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 all languages except English, prefixed with 'TODO: translate:'.",
)
@click.option(
"--remove-unused",
is_flag=True,
default=False,
help="Remove unused translations (keys present in the language but not required).",
)
@click.option(
"--json",
"as_json",
is_flag=True,
default=False,
help="Print the completeness report as machine-readable JSON instead of rich tables.",
)
@option_verbose
@option_dry_run
@click.pass_context
def check_translation_completeness(
ctx: click.Context,
language: str | None = None,
add_missing: bool = False,
remove_unused: bool = False,
as_json: bool = False,
):
if as_json:
ctx.meta[SKIP_LOCAL_REPRODUCTION] = True
print_completeness_json(language, add_missing, remove_unused)
return
locale_files = get_locale_files()
console = get_console()
print_locale_file_table(locale_files, language)
found_difference = print_file_set_differences(locale_files, language)
summary, missing_counts = compare_keys(locale_files)
console.print("\n[bold underline]Summary of differences by language:[/bold underline]", style="cyan")
summary, missing_counts = apply_translation_modifications(
locale_files, summary, missing_counts, language, add_missing, remove_unused
)
if language:
locales = [lf.locale for lf in locale_files]
if language not in locales:
@@ -32,6 +32,7 @@ UI_PARAMETERS: dict[str, list[dict[str, str | list[str]]]] = {
"--language",
"--add-missing",
"--remove-unused",
"--json",
],
},
],
+269
View File
@@ -17,13 +17,17 @@
from __future__ import annotations
import json
import re
import pytest
from click.testing import CliRunner
from airflow_breeze.commands.ui_commands import (
LocaleFiles,
LocaleKeySet,
LocaleSummary,
build_completeness_report,
check_translation_completeness,
compare_keys,
expand_plural_keys,
flatten_keys,
@@ -489,3 +493,268 @@ class TestNaturalSorting:
)
def test_natural_sort_key_matches_eslint(self, keys, expected):
assert sorted(keys, key=natural_sort_key) == expected
def _write_locale(tmp_path, locale: str, filename: str, data: dict) -> None:
locale_dir = tmp_path / locale
locale_dir.mkdir(exist_ok=True)
(locale_dir / filename).write_text(json.dumps(data))
class TestBuildCompletenessReport:
def test_report_counts_and_key_lists(self, tmp_path):
# en: 3 plain keys + a plural pair with {{count}}; de: one TODO, one missing, one unused
_write_locale(
tmp_path,
"en",
"test.json",
{"a": "A", "b": "B", "c": "C", "n_one": "1 thing", "n_other": "{{count}} things"},
)
_write_locale(
tmp_path,
"de",
"test.json",
{"a": "A!", "b": "TODO: translate: B", "n_one": "Ding", "n_other": "Dinge", "zz": "extra"},
)
import airflow_breeze.commands.ui_commands as ui_commands
original_locales_dir = ui_commands.LOCALES_DIR
ui_commands.LOCALES_DIR = tmp_path
try:
locale_files = [
LocaleFiles(locale="en", files=["test.json"]),
LocaleFiles(locale="de", files=["test.json"]),
]
summary, _ = compare_keys(locale_files)
report = build_completeness_report(locale_files, summary)
assert "en" not in report
stats = report["de"]["files"]["test.json"]
assert stats["required"] == 5
assert stats["required_base_en"] == 5
assert stats["required_plural"] == 0
assert stats["translated"] == 3 # a, n_one, n_other; b is a TODO and c is missing
assert stats["missing"] == 1
assert stats["missing_keys"] == ["c"]
assert stats["todos"] == 1
assert stats["unused"] == 1
assert stats["unused_keys"] == ["zz"]
assert stats["coverage"] == pytest.approx(60.0)
totals = report["de"]["totals"]
assert totals["required"] == 5
assert totals["translated"] == 3
assert totals["missing"] == 1
assert totals["todos"] == 1
assert totals["unused"] == 1
assert totals["coverage"] == pytest.approx(60.0)
finally:
ui_commands.LOCALES_DIR = original_locales_dir
def test_report_expands_locale_specific_plural_forms(self, tmp_path):
# pl requires _one/_few/_many/_other for a base whose EN value interpolates count
_write_locale(tmp_path, "en", "test.json", {"n_one": "1 thing", "n_other": "{{count}} things"})
_write_locale(tmp_path, "pl", "test.json", {"n_one": "rzecz", "n_other": "rzeczy"})
import airflow_breeze.commands.ui_commands as ui_commands
original_locales_dir = ui_commands.LOCALES_DIR
ui_commands.LOCALES_DIR = tmp_path
try:
locale_files = [
LocaleFiles(locale="en", files=["test.json"]),
LocaleFiles(locale="pl", files=["test.json"]),
]
summary, _ = compare_keys(locale_files)
report = build_completeness_report(locale_files, summary)
stats = report["pl"]["files"]["test.json"]
assert stats["required_base_en"] == 2
assert stats["required_plural"] == 2 # _few and _many on top of the EN base keys
assert stats["required"] == 4
assert stats["missing_keys"] == ["n_few", "n_many"]
finally:
ui_commands.LOCALES_DIR = original_locales_dir
def test_report_skips_files_without_english_source(self, tmp_path):
_write_locale(tmp_path, "en", "test.json", {"a": "A"})
_write_locale(tmp_path, "de", "test.json", {"a": "A!"})
_write_locale(tmp_path, "de", "orphan.json", {"b": "B"})
import airflow_breeze.commands.ui_commands as ui_commands
original_locales_dir = ui_commands.LOCALES_DIR
ui_commands.LOCALES_DIR = tmp_path
try:
locale_files = [
LocaleFiles(locale="en", files=["test.json"]),
LocaleFiles(locale="de", files=["test.json", "orphan.json"]),
]
summary, _ = compare_keys(locale_files)
report = build_completeness_report(locale_files, summary)
assert list(report["de"]["files"]) == ["test.json"]
finally:
ui_commands.LOCALES_DIR = original_locales_dir
class TestCheckTranslationCompletenessJson:
@pytest.fixture
def runner(self):
return CliRunner()
def test_json_prints_a_single_pure_json_document(self, runner, tmp_path):
_write_locale(tmp_path, "en", "test.json", {"a": "A", "b": "B"})
_write_locale(tmp_path, "de", "test.json", {"a": "A!", "zz": "extra"})
import airflow_breeze.commands.ui_commands as ui_commands
original_locales_dir = ui_commands.LOCALES_DIR
ui_commands.LOCALES_DIR = tmp_path
try:
result = runner.invoke(check_translation_completeness, ["--json"], catch_exceptions=False)
assert result.exit_code == 0
# The whole stdout must parse as JSON — nothing else may be printed around it.
payload = json.loads(result.stdout)
assert payload["complete"] is False
stats = payload["languages"]["de"]["files"]["test.json"]
assert stats["missing"] == 1
assert stats["missing_keys"] == ["b"]
assert stats["unused_keys"] == ["zz"]
assert stats["coverage"] == 50.0
assert payload["languages"]["de"]["totals"]["missing"] == 1
finally:
ui_commands.LOCALES_DIR = original_locales_dir
def test_json_reports_complete_when_nothing_is_missing(self, runner, tmp_path):
data = {"a": "A", "b": "B"}
_write_locale(tmp_path, "en", "test.json", data)
_write_locale(tmp_path, "de", "test.json", {"a": "A!", "b": "B!"})
import airflow_breeze.commands.ui_commands as ui_commands
original_locales_dir = ui_commands.LOCALES_DIR
ui_commands.LOCALES_DIR = tmp_path
try:
result = runner.invoke(check_translation_completeness, ["--json"], catch_exceptions=False)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["complete"] is True
assert payload["languages"]["de"]["totals"]["coverage"] == 100.0
finally:
ui_commands.LOCALES_DIR = original_locales_dir
def test_json_complete_stays_false_while_todos_remain(self, runner, tmp_path):
# The post---add-missing state: nothing missing, nothing unused, only TODO stubs.
# The table verdict treats TODOs as incomplete, so the JSON flag must as well.
_write_locale(tmp_path, "en", "test.json", {"a": "A", "b": "B"})
_write_locale(tmp_path, "de", "test.json", {"a": "TODO: translate: A", "b": "TODO: translate: B"})
import airflow_breeze.commands.ui_commands as ui_commands
original_locales_dir = ui_commands.LOCALES_DIR
ui_commands.LOCALES_DIR = tmp_path
try:
result = runner.invoke(check_translation_completeness, ["--json"], catch_exceptions=False)
assert result.exit_code == 0
payload = json.loads(result.stdout)
totals = payload["languages"]["de"]["totals"]
assert totals["missing"] == 0
assert totals["unused"] == 0
assert totals["todos"] == 2
assert payload["complete"] is False
finally:
ui_commands.LOCALES_DIR = original_locales_dir
def test_json_with_language_filter_reports_only_that_language(self, runner, tmp_path):
_write_locale(tmp_path, "en", "test.json", {"a": "A"})
_write_locale(tmp_path, "de", "test.json", {"a": "A!"})
_write_locale(tmp_path, "pl", "test.json", {"a": "A?"})
import airflow_breeze.commands.ui_commands as ui_commands
original_locales_dir = ui_commands.LOCALES_DIR
ui_commands.LOCALES_DIR = tmp_path
try:
result = runner.invoke(
check_translation_completeness, ["--json", "--language", "de"], catch_exceptions=False
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert list(payload["languages"]) == ["de"]
finally:
ui_commands.LOCALES_DIR = original_locales_dir
@pytest.mark.parametrize("language", ["xx", "en"])
def test_json_exits_2_for_invalid_language_without_printing_json(self, runner, tmp_path, language):
_write_locale(tmp_path, "en", "test.json", {"a": "A"})
_write_locale(tmp_path, "de", "test.json", {"a": "A!"})
import airflow_breeze.commands.ui_commands as ui_commands
original_locales_dir = ui_commands.LOCALES_DIR
ui_commands.LOCALES_DIR = tmp_path
try:
result = runner.invoke(check_translation_completeness, ["--json", "--language", language])
assert result.exit_code == 2
assert "{" not in result.stdout
finally:
ui_commands.LOCALES_DIR = original_locales_dir
def test_json_replays_fatal_error_output_to_stderr(self, runner, tmp_path):
# A locale without a PLURAL_SUFFIXES entry aborts the checker; in --json mode the
# suppressed narration must be replayed to stderr instead of vanishing with the exit.
_write_locale(tmp_path, "en", "test.json", {"a": "A"})
_write_locale(tmp_path, "xy", "test.json", {"a": "A!"})
import airflow_breeze.commands.ui_commands as ui_commands
original_locales_dir = ui_commands.LOCALES_DIR
ui_commands.LOCALES_DIR = tmp_path
try:
result = runner.invoke(check_translation_completeness, ["--json"])
assert result.exit_code == 1
stderr_text = re.sub(r"\x1b\[[0-9;]*m", "", result.stderr)
assert "No plural suffixes defined for language 'xy'" in stderr_text
assert "{" not in result.stdout
finally:
ui_commands.LOCALES_DIR = original_locales_dir
def test_json_replays_narration_when_computation_raises(self, runner, tmp_path):
# A malformed locale file makes the computation raise (not sys.exit) after
# compare_keys() has narrated "Error loading ..."; the narration must surface
# on stderr instead of vanishing with the suppression buffer.
_write_locale(tmp_path, "en", "test.json", {"a": "A"})
locale_dir = tmp_path / "de"
locale_dir.mkdir(exist_ok=True)
(locale_dir / "test.json").write_text("{not valid json")
import airflow_breeze.commands.ui_commands as ui_commands
original_locales_dir = ui_commands.LOCALES_DIR
ui_commands.LOCALES_DIR = tmp_path
try:
result = runner.invoke(check_translation_completeness, ["--json"])
assert result.exit_code == 1
assert "{" not in result.stdout
stderr_text = re.sub(r"\x1b\[[0-9;]*m", "", result.stderr)
assert "Error loading" in stderr_text
finally:
ui_commands.LOCALES_DIR = original_locales_dir
def test_default_table_mode_still_prints_tables(self, runner, tmp_path):
_write_locale(tmp_path, "en", "test.json", {"a": "A", "b": "B"})
_write_locale(tmp_path, "de", "test.json", {"a": "A!"})
import airflow_breeze.commands.ui_commands as ui_commands
original_locales_dir = ui_commands.LOCALES_DIR
ui_commands.LOCALES_DIR = tmp_path
try:
result = runner.invoke(check_translation_completeness, [], catch_exceptions=False)
assert result.exit_code == 0
assert "Translation Progress: de" in result.output
assert "Some translations are not complete!" in result.output
finally:
ui_commands.LOCALES_DIR = original_locales_dir