diff --git a/backend/chainlit/cli/__init__.py b/backend/chainlit/cli/__init__.py index 85daf7dea6..0701b1cee9 100644 --- a/backend/chainlit/cli/__init__.py +++ b/backend/chainlit/cli/__init__.py @@ -241,4 +241,10 @@ def chainlit_create_secret(args=None, **kwargs): @cli.command("lint-translations") @click.argument("args", nargs=-1) def chainlit_lint_translations(args=None, **kwargs): - lint_translations() + error_count = lint_translations() + if error_count: + print( + f"\nāŒ Found {error_count} translation " + f"{'difference' if error_count == 1 else 'differences'}." + ) + raise SystemExit(1) diff --git a/backend/chainlit/config.py b/backend/chainlit/config.py index a540752b33..fa1cf02dd1 100644 --- a/backend/chainlit/config.py +++ b/backend/chainlit/config.py @@ -689,22 +689,31 @@ def load_config(): return ChainlitConfig(**settings) -def lint_translations(): +def lint_translations() -> int: + """Lint the app's translation files against the packaged ``en-US.json``. + + Returns the total number of structural differences found, so callers can + fail instead of reporting success on a broken translation. + """ # Load the ground truth (en-US.json file from chainlit source code) src = os.path.join(TRANSLATIONS_DIR, "en-US.json") with open(src, encoding="utf-8") as f: truth = json.load(f) - # Find the local app translations - for file in os.listdir(config_translation_dir): - if file.endswith(".json"): - # Load the translation file - to_lint = os.path.join(config_translation_dir, file) - with open(to_lint, encoding="utf-8") as f2: - translation = json.load(f2) + error_count = 0 + + # Find the local app translations. Sorted so the report is deterministic. + for file in sorted(os.listdir(config_translation_dir)): + if file.endswith(".json"): + # Load the translation file + to_lint = os.path.join(config_translation_dir, file) + with open(to_lint, encoding="utf-8") as f2: + translation = json.load(f2) + + # Lint the translation file + error_count += len(lint_translation_json(file, truth, translation)) - # Lint the translation file - lint_translation_json(file, truth, translation) + return error_count config = load_config() diff --git a/backend/chainlit/translations.py b/backend/chainlit/translations.py index 41fe2eb495..a925c673f0 100644 --- a/backend/chainlit/translations.py +++ b/backend/chainlit/translations.py @@ -49,6 +49,11 @@ def compare_json_structures(truth, to_compare, path=""): def lint_translation_json(file, truth, to_compare): + """Print the structural differences between a translation and the truth. + + Returns the list of differences so callers can distinguish a clean + translation from a broken one instead of relying on the printed output. + """ print(f"\nLinting {file}...") errors = compare_json_structures(truth, to_compare) @@ -58,3 +63,5 @@ def lint_translation_json(file, truth, to_compare): print(f"{error}") else: print(f"āœ… No errors found in {file}") + + return errors diff --git a/backend/tests/test_translations.py b/backend/tests/test_translations.py index 6e517c4748..4ebc50e15b 100644 --- a/backend/tests/test_translations.py +++ b/backend/tests/test_translations.py @@ -1,8 +1,11 @@ +import json from io import StringIO +from pathlib import Path from unittest.mock import patch import pytest +from chainlit import config as config_module from chainlit.translations import compare_json_structures, lint_translation_json @@ -384,3 +387,80 @@ def test_compare_preserves_error_order(self): missing_errors = [e for e in errors if "Missing" in e] assert len(extra_errors) == 2 assert len(missing_errors) == 3 + + +class TestLintTranslationJsonReturnValue: + """``lint_translation_json`` must report differences to its caller. + + It previously only printed them and returned ``None``, so every caller - + including the ``lint-translations`` CLI command - had no way to tell a + clean translation from a broken one. + """ + + def test_returns_the_errors_it_printed(self): + """A broken translation returns its differences.""" + truth = {"key1": "value1", "key2": "value2"} + to_compare = {"key1": "value1"} + + with patch("sys.stdout", new=StringIO()): + errors = lint_translation_json("broken.json", truth, to_compare) + + assert errors == ["āŒ Missing key: 'key2'"] + + def test_returns_empty_list_for_a_clean_translation(self): + """A matching translation returns no differences.""" + truth = {"key1": "value1"} + + with patch("sys.stdout", new=StringIO()): + errors = lint_translation_json("clean.json", truth, {"key1": "value1"}) + + assert errors == [] + + +class TestLintTranslationsExitCode: + """``chainlit lint-translations`` must fail on a broken translation. + + ``lint_translations()`` returned ``None`` and the CLI command ignored it, + so the command exited 0 even when keys were missing and could not be used + as a CI gate. + """ + + def _seed_translation_dir(self, tmp_path, mutate=None): + """Copy the packaged ground truth into a temp dir, optionally broken.""" + truth_path = Path(config_module.TRANSLATIONS_DIR) / "en-US.json" + payload = json.loads(truth_path.read_text(encoding="utf-8")) + if mutate is not None: + mutate(payload) + (tmp_path / "en-US.json").write_text( + json.dumps(payload, ensure_ascii=False), encoding="utf-8" + ) + return tmp_path + + def test_returns_zero_when_translations_match(self, tmp_path): + """An untouched copy of the ground truth reports no differences.""" + self._seed_translation_dir(tmp_path) + + with patch("chainlit.config.config_translation_dir", str(tmp_path)): + with patch("sys.stdout", new=StringIO()): + assert config_module.lint_translations() == 0 + + def test_counts_differences_when_a_key_is_missing(self, tmp_path): + """Dropping a top-level key is reported as one difference.""" + + def drop_chat(payload): + payload.pop("chat", None) + + self._seed_translation_dir(tmp_path, drop_chat) + + with patch("chainlit.config.config_translation_dir", str(tmp_path)): + with patch("sys.stdout", new=StringIO()): + assert config_module.lint_translations() == 1 + + def test_ignores_non_json_files(self, tmp_path): + """Stray files in the translations directory are not linted.""" + self._seed_translation_dir(tmp_path) + (tmp_path / "notes.txt").write_text("not a translation", encoding="utf-8") + + with patch("chainlit.config.config_translation_dir", str(tmp_path)): + with patch("sys.stdout", new=StringIO()): + assert config_module.lint_translations() == 0