Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion backend/chainlit/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
29 changes: 19 additions & 10 deletions backend/chainlit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
7 changes: 7 additions & 0 deletions backend/chainlit/translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
80 changes: 80 additions & 0 deletions backend/tests/test_translations.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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