mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-29 08:46:45 +00:00
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
* Translations - support for translation overlays/local translations of existing strings * Dont forget translations_overlay for pip
182 lines
8.1 KiB
Python
182 lines
8.1 KiB
Python
"""Guards for the translation overlay layer (changedetectionio/translations_overlay).
|
|
|
|
The overlay is a second gettext tree merged on top of ``changedetectionio/translations``, letting a
|
|
deployment reword individual strings without editing the ``_()`` call site. See that directory's
|
|
README.md. Three separate things can break it, so there is a test for each.
|
|
|
|
1. Overlay entries key on the exact upstream msgid. When a string is reworded upstream the override
|
|
stops matching and silently reverts to upstream wording - no error, no log entry.
|
|
``test_overlay_catalogs_are_valid`` makes that a build failure.
|
|
|
|
2. The layering relies on Flask-Babel merging catalogs with ``dict.update`` semantics (later
|
|
directory wins per-message). Were a Flask-Babel upgrade to change that to an ``add_fallback``
|
|
chain, overrides would stop applying while everything still looked fine.
|
|
``test_overlay_overrides_a_string_in_a_rendered_page`` pins it against a real rendered page.
|
|
|
|
3. The directory has to actually reach ``BABEL_TRANSLATION_DIRECTORIES``.
|
|
``test_overlay_dir_*`` cover the wiring in flask_app.py, including the env var.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
from babel.messages.catalog import Catalog
|
|
from babel.messages.mofile import write_mo
|
|
from flask import url_for
|
|
|
|
PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
REPO_ROOT = os.path.dirname(PKG_DIR)
|
|
OVERLAY_DIR = os.path.join(PKG_DIR, 'translations_overlay')
|
|
BASE_DIR = os.path.join(PKG_DIR, 'translations')
|
|
MANAGE = os.path.join(OVERLAY_DIR, 'manage.py')
|
|
|
|
# A msgid that renders as a settings-page tab label. The test asserts it is present *before*
|
|
# overriding it, so a rename upstream fails loudly rather than making the test silently vacuous.
|
|
OVERRIDDEN_MSGID = 'Global Filters'
|
|
SENTINEL = 'zzOverlaySentinelFiltersZZ'
|
|
|
|
|
|
def _write_mo(root, locale, entries):
|
|
"""Write a compiled catalog at <root>/<locale>/LC_MESSAGES/messages.mo."""
|
|
catalog = Catalog(locale=locale, domain='messages')
|
|
for msgid, msgstr in entries.items():
|
|
catalog.add(msgid, msgstr)
|
|
mo_dir = os.path.join(root, locale, 'LC_MESSAGES')
|
|
os.makedirs(mo_dir, exist_ok=True)
|
|
with open(os.path.join(mo_dir, 'messages.mo'), 'wb') as fp:
|
|
write_mo(fp, catalog)
|
|
|
|
|
|
def _import_app_with(env_overlay_dir):
|
|
"""Import flask_app in a clean subprocess and report its BABEL_TRANSLATION_DIRECTORIES.
|
|
|
|
Has to be a subprocess: the config is built at module import time, so it cannot be re-evaluated
|
|
under a different environment once flask_app is already in sys.modules.
|
|
"""
|
|
env = dict(os.environ, TRANSLATION_OVERLAY_DIR=env_overlay_dir)
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
'-c',
|
|
'from changedetectionio import flask_app;'
|
|
'print("DIRS=" + flask_app.app.config["BABEL_TRANSLATION_DIRECTORIES"])',
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
cwd=REPO_ROOT,
|
|
)
|
|
assert result.returncode == 0, f"importing flask_app failed:\n{result.stdout}\n{result.stderr}"
|
|
line = [l for l in result.stdout.splitlines() if l.startswith('DIRS=')]
|
|
assert line, f"no config line in output:\n{result.stdout}\n{result.stderr}"
|
|
return line[0][len('DIRS=') :].split(';')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. The overlay catalogs shipped in this repo are internally consistent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not os.path.isdir(OVERLAY_DIR), reason='no translation overlay in this deployment'
|
|
)
|
|
def test_overlay_catalogs_are_valid():
|
|
"""Every override must still match an upstream msgid, be non-empty, and be compiled.
|
|
|
|
A failure here usually means upstream edited a string the overlay overrides. Re-copy the new
|
|
msgid verbatim from translations/messages.pot into the overlay catalog, then recompile with
|
|
`python changedetectionio/translations_overlay/manage.py compile`.
|
|
"""
|
|
result = subprocess.run([sys.executable, MANAGE, 'check'], capture_output=True, text=True)
|
|
assert result.returncode == 0, (
|
|
f"translation overlay is invalid:\n{result.stdout}{result.stderr}"
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not os.path.isdir(OVERLAY_DIR), reason='no translation overlay in this deployment'
|
|
)
|
|
def test_overlay_locales_have_a_base_catalog():
|
|
"""An overlay for a locale the app does not ship never loads, so it is silently dead."""
|
|
for locale in sorted(os.listdir(OVERLAY_DIR)):
|
|
if not os.path.isfile(os.path.join(OVERLAY_DIR, locale, 'LC_MESSAGES', 'messages.po')):
|
|
continue
|
|
assert os.path.isdir(os.path.join(BASE_DIR, locale)), (
|
|
f"overlay locale {locale!r} has no base catalog in translations/"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. The merge actually happens, end to end, on a real page
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_overlay_overrides_a_string_in_a_rendered_page(client, live_server, tmp_path):
|
|
"""A real overlay catalog changes real rendered output, and only the string it names."""
|
|
app = client.application
|
|
|
|
# The rest of this test injects its own directory, which would still pass if flask_app.py had
|
|
# stopped configuring the real one. Tie the two together so that regression fails here too.
|
|
if os.path.isdir(OVERLAY_DIR):
|
|
configured = app.config['BABEL_TRANSLATION_DIRECTORIES'].split(';')
|
|
assert OVERLAY_DIR in configured, (
|
|
f"{OVERLAY_DIR} exists but is not in BABEL_TRANSLATION_DIRECTORIES ({configured})"
|
|
)
|
|
|
|
baseline = client.get(url_for('settings.settings_page'))
|
|
assert baseline.status_code == 200
|
|
assert OVERRIDDEN_MSGID.encode() in baseline.data, (
|
|
f"{OVERRIDDEN_MSGID!r} no longer renders on the settings page - this test needs a new msgid"
|
|
)
|
|
assert SENTINEL.encode() not in baseline.data
|
|
|
|
overlay = tmp_path / 'overlay'
|
|
# en_GB is BABEL_DEFAULT_LOCALE, and the test client sends no Accept-Language header
|
|
_write_mo(str(overlay), 'en_GB', {OVERRIDDEN_MSGID: SENTINEL})
|
|
|
|
# The default Domain delegates to the app-level directory list, and caches per (locale, domain),
|
|
# so both have to be touched for a new catalog to be picked up mid-process.
|
|
dirs = app.extensions['babel'].translation_directories
|
|
domain_cache = app.extensions['babel'].instance.domain_instance.cache
|
|
dirs.append(str(overlay))
|
|
domain_cache.clear()
|
|
try:
|
|
overridden = client.get(url_for('settings.settings_page'))
|
|
assert overridden.status_code == 200
|
|
assert SENTINEL.encode() in overridden.data, (
|
|
'overlay catalog did not override the base catalog'
|
|
)
|
|
assert OVERRIDDEN_MSGID.encode() not in overridden.data, 'base wording is still rendering'
|
|
# Neighbouring tab label, deliberately not in the overlay - merging must not drop it
|
|
assert b'UI Options' in overridden.data, (
|
|
'overlay replaced the catalog instead of merging into it'
|
|
)
|
|
finally:
|
|
dirs.remove(str(overlay))
|
|
domain_cache.clear()
|
|
|
|
restored = client.get(url_for('settings.settings_page'))
|
|
assert SENTINEL.encode() not in restored.data
|
|
assert OVERRIDDEN_MSGID.encode() in restored.data, 'base wording did not come back'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. flask_app.py wires the directory up, and stays a no-op when there isn't one
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_overlay_dir_from_env_var_is_used(tmp_path):
|
|
overlay = tmp_path / 'my-overlay'
|
|
overlay.mkdir()
|
|
dirs = _import_app_with(str(overlay))
|
|
assert dirs[-1] == str(overlay), f"TRANSLATION_OVERLAY_DIR not appended, got {dirs}"
|
|
assert dirs[0] == BASE_DIR, 'base catalog must stay first so the overlay wins on conflicts'
|
|
|
|
|
|
def test_missing_overlay_dir_is_a_noop(tmp_path):
|
|
"""No overlay directory means the config is exactly what it was before the feature existed."""
|
|
dirs = _import_app_with(str(tmp_path / 'does-not-exist'))
|
|
assert dirs == [BASE_DIR], f"expected only the base catalog, got {dirs}"
|