Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .github/workflows/i18n-download.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ jobs:
- name: Install gettext
run: sudo apt-get update && sudo apt-get install -y gettext
- name: Install Python dependencies
run: uv sync
# --all-packages installs the python_packages/* members; without them the
# Makefile's importability guard skips their message-file generation and
# their *-messages.json are never regenerated from the download.
run: uv sync --all-packages
- name: Install JavaScript dependencies
run: pnpm install --frozen-lockfile
- name: Download source fonts
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/i18n-upload.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ jobs:
- name: Install gettext
run: sudo apt-get update && sudo apt-get install -y gettext
- name: Install Python dependencies
run: uv sync
# --all-packages installs the python_packages/* members; without them the
# Makefile's importability guard skips their message extraction and their
# CSVs never reach Crowdin.
run: uv sync --all-packages

@rtibblesbot rtibblesbot Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

blocking: --all-packages cannot make that guard pass, so the comment asserts a behaviour that doesn't occur. The guard runs a bare interpreter:

# Makefile:260 (i18n-extract-frontend) and Makefile:316 (i18n-download-translations)
if ! python -c "import $$module" >/dev/null 2>&1; then \

Bare python, not uv run python — which every other Python recipe in this Makefile uses (205, 244, 248, 275, 285, 306). uv sync installs into .venv/ and nothing here puts it on PATH: setup-uv@v8.2.0 is invoked without activate-environment, and no step writes VIRTUAL_ENV/$GITHUB_PATH. So the guard's interpreter is the runner's system Python, which never sees the workspace members, and the plugin CSVs still don't reach Crowdin.

The fix is in the Makefile: change both guards to uv run python -c "import $$module" (keeping --all-packages here). Setting activate-environment: true on the setup-uv step would also work, but the Makefile change makes the recipes correct when run locally too.

@rtibblesbot rtibblesbot Jul 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

blocking: the diagnosis in the comment is right, but --all-packages can't fix it, so the comment asserts a behaviour that doesn't occur. The guards at Makefile:260 and Makefile:316 run a bare interpreter:

if ! python -c "import $$module" >/dev/null 2>&1; then \

not uv run python — unlike every other Python recipe in this Makefile (205, 244, 248, 275, 285, 306). uv sync installs into .venv/ and nothing here puts it on PATH: setup-uv@v8.2.0 is invoked without activate-environment, and no step writes VIRTUAL_ENV/$GITHUB_PATH. The guard's interpreter is the runner's system Python, which never sees the workspace members, so the plugin CSVs still don't reach Crowdin.

Fix in the Makefile: uv run python -c "import $$module" in both guards (keeping --all-packages here). That also makes the recipes correct when run locally.

- name: Install JavaScript dependencies
run: pnpm install --frozen-lockfile
- name: Extract and upload strings to Crowdin
Expand Down
10 changes: 6 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,12 @@ i18n-extract-frontend:
# buildConfig.js via the *installed* package (webpack_json.py files(module)), so
# skip any plugin whose package is not importable here — e.g. `make dist`, which
# installs only the root project, not the python_packages/* members. Where the
# members are installed (uv sync --all-packages) they extract normally.
# members are installed (uv sync --all-packages) they extract normally. The guard
# must run under uv: a bare `python` is the system interpreter, which never sees
# the members, so every plugin would be skipped however the project was synced.
@for dir in $(I18N_PLUGIN_FRONTEND_DIRS); do \
module=$$(basename $$dir); \
if ! python -c "import $$module" >/dev/null 2>&1; then \
if ! uv run python -c "import $$module" >/dev/null 2>&1; then \
echo "Skipping frontend messages for $$module (package not installed)"; \
continue; \
fi; \
Expand Down Expand Up @@ -303,7 +305,7 @@ i18n-download-translations: i18n-extract-frontend
fi
@echo "✅ Translation files downloaded successfully"
rm -f kolibri/locale/.crowdin-download-marker
uv run python build_tools/i18n/cleanup_unsupported_languages.py
uv run python -m build_tools.i18n.cleanup_unsupported_languages
pnpm exec kolibri-i18n code-gen --output-dir ./packages/kolibri/utils/internal
$(MAKE) i18n-django-compilemessages
pnpm exec kolibri-i18n create-message-files --pluginFile ./build_tools/build_plugins.txt --ignore '**/node_modules/!(kolibri-common)/**','**/static/**'
Expand All @@ -313,7 +315,7 @@ i18n-download-translations: i18n-extract-frontend
# package is not importable here (same reason as i18n-extract-frontend).
@for dir in $(I18N_PLUGIN_FRONTEND_DIRS); do \
module=$$(basename $$dir); \
if ! python -c "import $$module" >/dev/null 2>&1; then \
if ! uv run python -c "import $$module" >/dev/null 2>&1; then \
echo "Skipping message files for $$module (package not installed)"; \
continue; \
fi; \
Expand Down
148 changes: 128 additions & 20 deletions build_tools/i18n/cleanup_unsupported_languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,149 @@

This script should be run after downloading translations from Crowdin to clean up
any languages that were downloaded but are not officially supported by Kolibri.
A `languages_mapping` in crowdin.yml only renames codes, it does not filter, so
Crowdin delivers every language enabled on the project into every tree we sync.
"""

import glob
import logging
import os
import re
import shutil

from utils import available_languages
from utils import LOCALE_DATA_FOLDER
from utils import to_locale
from build_tools.i18n.generate_mapping import get_android_language_mapping
from build_tools.i18n.generate_mapping import get_installer_language_mapping
from build_tools.i18n.generate_mapping import get_language_mapping

REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))

def cleanup_unsupported_languages():
# `values-` prefixes every Android resource qualifier, not just locales, so match the
# shape of a locale one — `fr`, `fr-rFR` or BCP-47 `b+es+419` — and leave the rest
# (values-night, values-v21, values-sw600dp) alone. `car` and `tv` are the UI-mode
# qualifiers short enough to also be shaped like a language code (both are real ISO 639

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: tv is not an ISO 639 code — there is no tv in 639-1/2/3 (Tuvaluan is tvl; the 639-1 t* set is ta/te/tg/th/ti/tk/tl/tn/to/tr/ts/tt/tw/ty). car is real (639-3, Galibi Carib).

The denial is still correct and still necessary — tv matches [a-z]{2,3}, which is the actual reason it needs excluding. Only the parenthetical rationale is wrong; "short enough to also be shaped like a language code" already carries the argument, so it can just be dropped.

# codes), so they are denied outright: in a res/ directory they are never a locale.
ANDROID_LOCALE_QUALIFIER = re.compile(
r"^values-(?!(?:car|tv)$)(b\+[a-z]{2,3}(?:\+[A-Za-z0-9]+)*|[a-z]{2,3}(?:-r[A-Z]{2})?)$"
)

# Non-hidden build output that recursive discovery would otherwise walk into: p4a
# unpacks a full CPython under android_root/, and an unpacked Kolibri tar or a
# PyInstaller/sdist staging tree holds catalogs we do not own. glob already skips
# dot-prefixed directories, so .venv/ needs no entry.
EXCLUDED_DIR_NAMES = frozenset(
{
"node_modules",
"site-packages",
"android_root",
"build",
"dist",
"tar",
}
)


def _supported_locale_names():
"""Every locale directory name any of our sync targets may legitimately use.

A directory's path does not reveal which convention its tree follows — the
desktop-app installer keys folders by hyphen-lowercase intl codes (es-es) while
everything gettext-based uses Django codes (es_ES) — so plain locale directories
are pruned against the union. Only unsupported *languages* are being removed
here; a code in the wrong convention for its tree is a different problem.
"""
Remove locale directories that are not in our supported languages list.
return set(get_language_mapping().values()) | set(
get_installer_language_mapping().values()
)


def prune_locale_dirs(directory, supported_names, pattern=None):
"""Remove immediate subdirectories of `directory` for unsupported languages.

`pattern` is a compiled regex whose first group extracts the locale name from a
subdirectory name; a name it does not match is not a locale directory and is left
alone. Without one, the subdirectory name is the locale name. Returns the sorted
names of what was removed.
"""
# Get the set of supported locale directory names
supported_locales = set()
for lang_obj in available_languages():
intl_code = lang_obj["intl_code"]
locale_dir = to_locale(intl_code)
supported_locales.add(locale_dir)

# Check all directories in the locale folder
removed_locales = []
for item in os.listdir(LOCALE_DATA_FOLDER):
item_path = os.path.join(LOCALE_DATA_FOLDER, item)
if not os.path.isdir(directory):
return []

removed = []
for item in sorted(os.listdir(directory)):
item_path = os.path.join(directory, item)

# Skip if not a directory or if it's a special file
if not os.path.isdir(item_path):
continue

# If this directory is not in our supported list, remove it
if item not in supported_locales:
if pattern is None:
locale_name = item
else:
match = pattern.match(item)
if match is None:
continue
locale_name = match.group(1)

if locale_name not in supported_names:
shutil.rmtree(item_path)
removed_locales.append(item)
removed.append(item)

return removed


def _is_excluded(path, repo_root):
# Relative to the root: a checkout under ~/build/kolibri must not exclude everything.
parts = os.path.relpath(path, repo_root).split(os.sep)
return any(
part in EXCLUDED_DIR_NAMES or part.endswith(".egg-info") for part in parts
)


def _locale_roots(repo_root):
"""Directories named locale/ or locales/, holding one subdirectory per language."""
yield os.path.join(repo_root, "kolibri", "locale")

for top in ("python_packages", "platforms"):
for name in ("locale", "locales"):
for path in glob.glob(
os.path.join(repo_root, top, "**", name), recursive=True
):
# Vendored dependencies and staged build output ship their own
# catalogs; not ours to prune.
if _is_excluded(path, repo_root):
continue
yield path


def _android_res_roots(repo_root):
"""Android res/ directories, identified by their untranslated values/ folder."""
for path in glob.glob(
os.path.join(repo_root, "platforms", "**", "res", "values"), recursive=True
):
if _is_excluded(path, repo_root):
continue
yield os.path.dirname(path)


def cleanup_targets(repo_root=REPO_ROOT):
"""Yield (directory, supported_names, pattern) for every tree Crowdin writes to."""
supported = _supported_locale_names()
for directory in _locale_roots(repo_root):
yield directory, supported, None

android_supported = set(get_android_language_mapping().values())
for directory in _android_res_roots(repo_root):
yield directory, android_supported, ANDROID_LOCALE_QUALIFIER


def cleanup_unsupported_languages(repo_root=REPO_ROOT):
"""
Remove locale directories that are not in our supported languages list.
"""
removed_locales = []
for directory, supported_names, pattern in cleanup_targets(repo_root):
for name in prune_locale_dirs(directory, supported_names, pattern):
removed_locales.append(
os.path.join(os.path.relpath(directory, repo_root), name)
)

if removed_locales:
logging.info(
Expand Down
Loading
Loading