mirror of
https://github.com/apache/superset.git
synced 2026-07-10 16:55:30 +00:00
Compare commits
3 Commits
master
...
feat/i18n-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc475a19e3 | ||
|
|
4da89b9f34 | ||
|
|
504c3df43f |
@@ -17,6 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { LanguagePack } from '@apache-superset/core/translation';
|
||||
|
||||
const mockConfigure = jest.fn();
|
||||
const mockInitFeatureFlags = jest.fn();
|
||||
const mockMakeApi = jest.fn(() => jest.fn());
|
||||
@@ -111,3 +113,83 @@ test('falls back to en when passing locale to setupFormatters', async () => {
|
||||
|
||||
expect(mockSetupFormatters).toHaveBeenCalledWith({}, {}, 'en');
|
||||
});
|
||||
|
||||
// --- language pack loading semantics (issues #35330, PR #41780) -----------
|
||||
// English: nothing is loaded at all. Non-English: the pack is expected to
|
||||
// already be on window (set by the versioned classic <script> tag spa.html
|
||||
// emits before the entry bundle), so configuration is synchronous and no
|
||||
// English flash can occur. The async fetch is a fallback only, and it only
|
||||
// ever requests the selected locale.
|
||||
|
||||
const FAKE_PACK: LanguagePack = {
|
||||
domain: 'superset',
|
||||
locale_data: {
|
||||
superset: {
|
||||
'': {
|
||||
domain: 'superset',
|
||||
lang: 'fr',
|
||||
plural_forms: 'nplurals=2; plural=(n > 1);',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__SUPERSET_LANGUAGE_PACK__;
|
||||
});
|
||||
|
||||
test('configures synchronously from the window pack without fetching', async () => {
|
||||
mockGetBootstrapData.mockReturnValue(bootstrapData('fr'));
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = FAKE_PACK;
|
||||
const fetchSpy = jest.spyOn(global, 'fetch');
|
||||
|
||||
await runPreamble();
|
||||
|
||||
expect(mockConfigure).toHaveBeenCalledWith({ languagePack: FAKE_PACK });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockDayjsLocale).toHaveBeenCalledWith('fr');
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('prefers an operator-supplied bootstrap pack over the window pack', async () => {
|
||||
const overridePack = { ...FAKE_PACK, domain: 'override' };
|
||||
const data = bootstrapData('fr');
|
||||
(data.common as Record<string, unknown>).language_pack = overridePack;
|
||||
mockGetBootstrapData.mockReturnValue(data);
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = FAKE_PACK;
|
||||
|
||||
await runPreamble();
|
||||
|
||||
expect(mockConfigure).toHaveBeenCalledWith({ languagePack: overridePack });
|
||||
});
|
||||
|
||||
test('loads no language pack at all for English', async () => {
|
||||
mockGetBootstrapData.mockReturnValue(bootstrapData('en'));
|
||||
const fetchSpy = jest.spyOn(global, 'fetch');
|
||||
|
||||
await runPreamble();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
// The translator is configured bare (identity translations), never with
|
||||
// a pack: English pays zero payload.
|
||||
expect(mockConfigure).toHaveBeenCalledWith();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('fallback fetch requests only the selected locale and blocks until configured', async () => {
|
||||
mockGetBootstrapData.mockReturnValue(bootstrapData('fr'));
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(FAKE_PACK),
|
||||
} as unknown as Response);
|
||||
|
||||
await runPreamble();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockMakeUrl).toHaveBeenCalledWith('/language_pack/fr/');
|
||||
// configure() resolves before initPreamble() does, so nothing renders
|
||||
// untranslated even on the fallback path.
|
||||
expect(mockConfigure).toHaveBeenCalledWith({ languagePack: FAKE_PACK });
|
||||
expect(window.__SUPERSET_LANGUAGE_PACK__).toEqual(FAKE_PACK);
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -65,12 +65,14 @@ export default function initPreamble(): Promise<void> {
|
||||
setupClient({ appRoot: applicationRoot() });
|
||||
|
||||
// Load language pack before rendering.
|
||||
// Prefer the bootstrap-injected pack (stashed on window by the inline
|
||||
// script in spa.html, sourced from common.language_pack) so
|
||||
// module-level `const X = t('...')` calls in code-split chunks all
|
||||
// see a configured translator. Fall back to the async fetch only
|
||||
// when the bootstrap payload didn't carry the pack (e.g. embedded
|
||||
// or a legacy entry that doesn't extend spa.html). See issue #35330.
|
||||
// Prefer the pack already on hand: either an operator-supplied
|
||||
// common.language_pack (COMMON_BOOTSTRAP_OVERRIDES_FUNC) or the
|
||||
// window global set by the versioned script tag spa.html loads
|
||||
// before this bundle. Either way, module-level `const X = t('...')`
|
||||
// calls in code-split chunks all see a configured translator. Fall
|
||||
// back to the async fetch only when neither is present (e.g. the
|
||||
// script tag failed to load, or a legacy entry that doesn't extend
|
||||
// spa.html). See issue #35330.
|
||||
if (lang !== 'en') {
|
||||
const bootstrapPack =
|
||||
(bootstrapData.common as { language_pack?: LanguagePack })
|
||||
|
||||
@@ -25,7 +25,11 @@ from superset import event_logger, is_feature_enabled
|
||||
from superset.daos.dashboard import EmbeddedDashboardDAO
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.utils import json
|
||||
from superset.views.base import BaseSupersetView, common_bootstrap_payload
|
||||
from superset.views.base import (
|
||||
BaseSupersetView,
|
||||
common_bootstrap_payload,
|
||||
get_language_pack_template_context,
|
||||
)
|
||||
|
||||
|
||||
class EmbeddedView(BaseSupersetView):
|
||||
@@ -110,4 +114,5 @@ class EmbeddedView(BaseSupersetView):
|
||||
bootstrap_data=json.dumps(
|
||||
bootstrap_data, default=json.pessimistic_json_iso_dttm_ser
|
||||
),
|
||||
**get_language_pack_template_context(bootstrap_data["common"]),
|
||||
)
|
||||
|
||||
@@ -150,35 +150,41 @@
|
||||
|
||||
{% block tail_js %}
|
||||
{#
|
||||
Expose the language pack on window BEFORE the entry bundle loads,
|
||||
so module-level `const X = t('...')` calls across webpack-split
|
||||
chunks all find a configured translator on first access. The
|
||||
bootstrap div is the single source of truth; we parse it once and
|
||||
stash the pack on a global that the translator reads lazily.
|
||||
See upstream issue #35330.
|
||||
|
||||
Trade-off: this ships the full Jed pack inline in every full-page
|
||||
HTML response (only for non-English locales; English injects null)
|
||||
rather than via the separately-cacheable `/language_pack/` fetch.
|
||||
That synchronous availability is the whole point — the async fetch
|
||||
can't guarantee the pack is configured before a code-split chunk
|
||||
evaluates. Acceptable for an SPA where hard reloads are rare.
|
||||
Load the language pack BEFORE the entry bundle so module-level
|
||||
`const X = t('...')` calls across webpack-split chunks all find a
|
||||
configured translator on first access (upstream issue #35330). The
|
||||
script sets window.__SUPERSET_LANGUAGE_PACK__ and is content-addressed
|
||||
(/language_pack/<lang>/<version>/script.js), so browsers cache it as
|
||||
immutable across page loads and cache-bust automatically whenever
|
||||
translations change. English emits no tag at all. If the script fails
|
||||
to load, preamble.ts falls back to the async language pack fetch.
|
||||
#}
|
||||
<script nonce="{{ macros.get_nonce() }}">
|
||||
(function () {
|
||||
try {
|
||||
var el = document.getElementById('app');
|
||||
if (!el) return;
|
||||
var data = JSON.parse(el.getAttribute('data-bootstrap') || '{}');
|
||||
var pack = data && data.common && data.common.language_pack;
|
||||
if (pack) {
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = pack;
|
||||
{% if language_pack_src %}
|
||||
<script src="{{ language_pack_src }}" nonce="{{ macros.get_nonce() }}"></script>
|
||||
{% endif %}
|
||||
{#
|
||||
Operators can still supply a pack directly in the bootstrap payload
|
||||
via COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical #35330
|
||||
workaround). When one is present, stash it on window here instead of
|
||||
emitting the versioned script tag, so early chunks see the override.
|
||||
#}
|
||||
{% if language_pack_inline %}
|
||||
<script nonce="{{ macros.get_nonce() }}">
|
||||
(function () {
|
||||
try {
|
||||
var el = document.getElementById('app');
|
||||
if (!el) return;
|
||||
var data = JSON.parse(el.getAttribute('data-bootstrap') || '{}');
|
||||
var pack = data && data.common && data.common.language_pack;
|
||||
if (pack) {
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = pack;
|
||||
}
|
||||
} catch (e) {
|
||||
/* swallow: fall back to async fetch in preamble.ts */
|
||||
}
|
||||
} catch (e) {
|
||||
/* swallow: fall back to async fetch in preamble.ts */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if entry %}
|
||||
{{ js_bundle(assets_prefix, entry) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -24,9 +25,41 @@ logger = logging.getLogger(__name__)
|
||||
# Global caching for JSON language packs
|
||||
ALL_LANGUAGE_PACKS: dict[str, dict[str, Any]] = {"en": {}}
|
||||
|
||||
# Global caching for language pack content hashes, used to build
|
||||
# content-addressed (cache-busting) asset URLs
|
||||
ALL_LANGUAGE_PACK_VERSIONS: dict[str, str] = {}
|
||||
|
||||
DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_language_pack_filename(locale: str) -> str:
|
||||
"""Resolve the on-disk JSON pack for a locale (empty pack for English)"""
|
||||
if not locale or locale == "en":
|
||||
# Forcing a dummy, quasi-empty language pack for English since the file
|
||||
# in the en directory contains data with empty mappings
|
||||
return DIR + "/empty_language_pack.json"
|
||||
return DIR + f"/{locale}/LC_MESSAGES/messages.json"
|
||||
|
||||
|
||||
def get_language_pack_version(locale: str) -> Optional[str]:
|
||||
"""Get/cache a short content hash of the language pack file
|
||||
|
||||
The hash is embedded in the pack's asset URL so browsers can cache it
|
||||
as immutable and pick up a fresh copy whenever translations change.
|
||||
Returns None when the pack file cannot be read.
|
||||
"""
|
||||
version = ALL_LANGUAGE_PACK_VERSIONS.get(locale)
|
||||
if not version:
|
||||
try:
|
||||
with open(get_language_pack_filename(locale), "rb") as f:
|
||||
version = hashlib.sha256(f.read()).hexdigest()[:12]
|
||||
ALL_LANGUAGE_PACK_VERSIONS[locale] = version
|
||||
except OSError:
|
||||
logger.warning("No language pack file to version for locale %s", locale)
|
||||
return None
|
||||
return version
|
||||
|
||||
|
||||
def get_language_pack(locale: str) -> Optional[dict[str, Any]]:
|
||||
"""Get/cache a language pack
|
||||
|
||||
@@ -37,11 +70,7 @@ def get_language_pack(locale: str) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
pack = ALL_LANGUAGE_PACKS.get(locale)
|
||||
if not pack:
|
||||
filename = DIR + f"/{locale}/LC_MESSAGES/messages.json"
|
||||
if not locale or locale == "en":
|
||||
# Forcing a dummy, quasy-empty language pack for English since the file
|
||||
# in the en directory is contains data with empty mappings
|
||||
filename = DIR + "/empty_language_pack.json"
|
||||
filename = get_language_pack_filename(locale)
|
||||
try:
|
||||
with open(filename, encoding="utf8") as f:
|
||||
pack = json.load(f)
|
||||
|
||||
@@ -65,7 +65,7 @@ from superset.themes.types import Theme, ThemeMode
|
||||
from superset.themes.utils import (
|
||||
is_valid_theme,
|
||||
)
|
||||
from superset.translations.utils import get_language_pack
|
||||
from superset.translations.utils import get_language_pack_version
|
||||
from superset.utils import core as utils, json
|
||||
from superset.utils.filters import get_dataset_access_filters
|
||||
from superset.utils.version import get_version_metadata, visible_version_metadata
|
||||
@@ -578,25 +578,50 @@ def common_bootstrap_payload() -> dict[str, Any]:
|
||||
# Convert locale to string for proper cache key hashing
|
||||
locale_str = str(locale) if locale else None
|
||||
payload = dict(cached_common_bootstrap_data(utils.get_user_id(), locale_str))
|
||||
# Inject the Jed language pack outside the per-user memoize so the cached
|
||||
# payload stays small and the pack is shared across users for the same
|
||||
# locale. The frontend uses it to configure the translator synchronously,
|
||||
# before any code-split chunk evaluates a module-level `const X = t('...')`
|
||||
# (upstream issue #35330).
|
||||
language = payload.get("locale")
|
||||
if language and language != "en":
|
||||
# Respect a pack already provided via COMMON_BOOTSTRAP_OVERRIDES_FUNC
|
||||
# (the workaround in #35330 does exactly that), otherwise load the
|
||||
# shared one. `get_language_pack` returns the empty English pack on a
|
||||
# miss, which is the right result (English) when no translation file
|
||||
# exists.
|
||||
pack = payload.get("language_pack") or get_language_pack(language)
|
||||
else:
|
||||
pack = None
|
||||
payload["language_pack"] = pack
|
||||
# The language pack itself is NOT embedded in the payload: spa.html loads
|
||||
# it through the content-addressed /language_pack/<lang>/<version>/script.js
|
||||
# tag before the entry bundle, keeping HTML small while still configuring
|
||||
# the translator synchronously (upstream issue #35330). A pack provided via
|
||||
# COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical workaround) is respected
|
||||
# and takes precedence over the script tag.
|
||||
payload.setdefault("language_pack", None)
|
||||
return payload
|
||||
|
||||
|
||||
def get_language_pack_template_context(common: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Template vars controlling how spa.html delivers the language pack.
|
||||
|
||||
``common`` is the already-built common bootstrap payload for the request
|
||||
(passed in rather than re-derived, so callers that assembled or mocked
|
||||
their own payload stay consistent with what the template sees).
|
||||
|
||||
Three mutually exclusive outcomes:
|
||||
- English (or no locale): no script tag, nothing to stash.
|
||||
- Operator supplied a pack via COMMON_BOOTSTRAP_OVERRIDES_FUNC: no script
|
||||
tag; spa.html stashes the bootstrap pack on window instead.
|
||||
- Otherwise: emit the content-addressed script URL so the browser can
|
||||
cache the pack as immutable and cache-bust on translation changes.
|
||||
"""
|
||||
language = common.get("locale")
|
||||
if not language or language == "en":
|
||||
return {"language_pack_src": None, "language_pack_inline": False}
|
||||
if common.get("language_pack"):
|
||||
return {"language_pack_src": None, "language_pack_inline": True}
|
||||
version = get_language_pack_version(language)
|
||||
return {
|
||||
"language_pack_src": (
|
||||
url_for(
|
||||
"Superset.language_pack_script",
|
||||
lang=language,
|
||||
version=version,
|
||||
)
|
||||
if version
|
||||
else None
|
||||
),
|
||||
"language_pack_inline": False,
|
||||
}
|
||||
|
||||
|
||||
def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Generate standardized payload for spa.html template rendering.
|
||||
|
||||
@@ -695,6 +720,7 @@ def get_spa_template_context(
|
||||
"dark_theme_bg": dark_theme_bg,
|
||||
"spinner_svg": spinner_svg,
|
||||
"default_title": default_title,
|
||||
**get_language_pack_template_context(payload.get("common") or {}),
|
||||
**template_kwargs,
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ from superset.superset_typing import (
|
||||
FlaskResponse,
|
||||
)
|
||||
from superset.tasks.utils import get_current_user
|
||||
from superset.translations.utils import get_language_pack, get_language_pack_version
|
||||
from superset.utils import core as utils, json
|
||||
from superset.utils.cache import etag_cache
|
||||
from superset.utils.core import (
|
||||
@@ -917,6 +918,48 @@ class Superset(BaseSupersetView):
|
||||
"Language pack doesn't exist on the server", status=404
|
||||
)
|
||||
|
||||
@expose("/language_pack/<lang>/<version>/script.js")
|
||||
def language_pack_script(self, lang: str, version: str) -> FlaskResponse:
|
||||
"""Serve the language pack as a content-addressed classic script.
|
||||
|
||||
spa.html loads this BEFORE the entry bundle so translations are
|
||||
configured synchronously (no race with code-split chunks), while the
|
||||
versioned URL lets browsers cache the pack as immutable and pick up a
|
||||
fresh copy whenever translations change.
|
||||
|
||||
Deliberately unauthenticated: translation catalogs are static, public
|
||||
content shipped in the Superset repo, contain no user or tenant data,
|
||||
and must load for anonymous principals (login page, embedded).
|
||||
"""
|
||||
# Only allow expected language formats like "en", "pt_BR", etc.
|
||||
if not re.match(r"^[a-z]{2,3}(_[A-Z]{2})?$", lang):
|
||||
abort(400, "Invalid language code")
|
||||
if not re.match(r"^[0-9a-f]{12}$", version):
|
||||
abort(400, "Invalid language pack version")
|
||||
|
||||
current_version = get_language_pack_version(lang)
|
||||
pack = get_language_pack(lang)
|
||||
if current_version is None or pack is None:
|
||||
return json_error_response(
|
||||
"Language pack doesn't exist on the server", status=404
|
||||
)
|
||||
|
||||
response = Response(
|
||||
f"window.__SUPERSET_LANGUAGE_PACK__ = {json.dumps(pack)};",
|
||||
mimetype="application/javascript; charset=utf-8",
|
||||
)
|
||||
if version == current_version:
|
||||
# Content-addressed URL: safe to cache forever.
|
||||
response.cache_control.public = True
|
||||
response.cache_control.max_age = 31536000
|
||||
response.cache_control.immutable = True
|
||||
else:
|
||||
# Stale or unknown version (e.g. HTML rendered before an upgrade):
|
||||
# serve the current pack but keep caches from pinning it under the
|
||||
# wrong address.
|
||||
response.cache_control.no_cache = True
|
||||
return response
|
||||
|
||||
@event_logger.log_this
|
||||
@expose("/welcome/")
|
||||
def welcome(self) -> FlaskResponse:
|
||||
|
||||
@@ -1668,6 +1668,10 @@ class TestRolePermission(SupersetTestCase):
|
||||
# Serves the PWA web app manifest unauthenticated (PWA install
|
||||
# fetches have no session); mirrors the RedirectView precedent.
|
||||
["PwaManifestView", "manifest"],
|
||||
# Serves translation catalogs (static public repo content, no
|
||||
# user/tenant data) as content-addressed scripts; must load for
|
||||
# anonymous principals (login page, embedded dashboards).
|
||||
["Superset", "language_pack_script"],
|
||||
]
|
||||
unsecured_views = []
|
||||
for view_class in appbuilder.baseviews:
|
||||
|
||||
16
tests/unit_tests/translations/__init__.py
Normal file
16
tests/unit_tests/translations/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
71
tests/unit_tests/translations/utils_test.py
Normal file
71
tests/unit_tests/translations/utils_test.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import hashlib
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.translations import utils as translations_utils
|
||||
from superset.translations.utils import (
|
||||
get_language_pack_filename,
|
||||
get_language_pack_version,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_version_cache() -> Iterator[None]:
|
||||
translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear()
|
||||
yield
|
||||
translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear()
|
||||
|
||||
|
||||
def test_language_pack_filename_resolution() -> None:
|
||||
assert get_language_pack_filename("fr").endswith("/fr/LC_MESSAGES/messages.json")
|
||||
assert get_language_pack_filename("en").endswith("/empty_language_pack.json")
|
||||
assert get_language_pack_filename("").endswith("/empty_language_pack.json")
|
||||
|
||||
|
||||
def test_version_is_short_content_hash(tmp_path, monkeypatch) -> None:
|
||||
pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json"
|
||||
pack_file.parent.mkdir(parents=True)
|
||||
pack_file.write_bytes(b'{"domain": "superset"}')
|
||||
monkeypatch.setattr(translations_utils, "DIR", str(tmp_path))
|
||||
|
||||
version = get_language_pack_version("fr")
|
||||
|
||||
expected = hashlib.sha256(b'{"domain": "superset"}').hexdigest()[:12]
|
||||
assert version == expected
|
||||
|
||||
|
||||
def test_version_is_cached_and_changes_with_content(tmp_path, monkeypatch) -> None:
|
||||
pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json"
|
||||
pack_file.parent.mkdir(parents=True)
|
||||
pack_file.write_bytes(b"{}")
|
||||
monkeypatch.setattr(translations_utils, "DIR", str(tmp_path))
|
||||
|
||||
first = get_language_pack_version("fr")
|
||||
pack_file.write_bytes(b'{"changed": true}')
|
||||
# Cached within the process lifetime: same version until cache cleared.
|
||||
assert get_language_pack_version("fr") == first
|
||||
|
||||
translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear()
|
||||
assert get_language_pack_version("fr") != first
|
||||
|
||||
|
||||
def test_version_none_when_pack_missing(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(translations_utils, "DIR", str(tmp_path))
|
||||
assert get_language_pack_version("xx") is None
|
||||
@@ -135,71 +135,104 @@ def test_recaptcha_shown_for_non_federated_auth(
|
||||
assert payload["conf"]["RECAPTCHA_PUBLIC_KEY"] == "test-key"
|
||||
|
||||
|
||||
# --- language_pack injection --------------------------------------------
|
||||
# --- language pack delivery ----------------------------------------------
|
||||
#
|
||||
# The Jed pack is injected by `common_bootstrap_payload` (outside the
|
||||
# memoized `cached_common_bootstrap_data`) using the shared
|
||||
# `superset.translations.utils.get_language_pack`. Tests here cover the
|
||||
# wrapper to confirm the pack lands on the payload for non-English
|
||||
# locales and is None for English.
|
||||
# The pack is NOT embedded in the bootstrap payload; spa.html loads it via a
|
||||
# content-addressed script tag whose URL comes from
|
||||
# `get_language_pack_template_context`. A pack supplied through
|
||||
# COMMON_BOOTSTRAP_OVERRIDES_FUNC still rides the payload and suppresses the
|
||||
# script tag.
|
||||
|
||||
|
||||
def test_common_bootstrap_payload_includes_language_pack_for_non_english(
|
||||
def test_common_bootstrap_payload_does_not_embed_language_pack(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""common.language_pack carries the shared utility's pack for non-en."""
|
||||
fake_pack = {"domain": "superset", "locale_data": {"superset": {}}}
|
||||
"""The payload stays small: no full pack even for non-English locales."""
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.cached_common_bootstrap_data",
|
||||
return_value={"locale": "fr"},
|
||||
),
|
||||
patch("superset.views.base.utils.get_user_id", return_value=1),
|
||||
patch("superset.views.base.get_locale", return_value="fr"),
|
||||
):
|
||||
payload = common_bootstrap_payload()
|
||||
|
||||
assert payload["language_pack"] is None
|
||||
|
||||
|
||||
def test_common_bootstrap_payload_preserves_override_pack(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""A pack from COMMON_BOOTSTRAP_OVERRIDES_FUNC is passed through."""
|
||||
fake_pack = {"domain": "superset", "locale_data": {"superset": {}}}
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.get_language_pack",
|
||||
return_value=fake_pack,
|
||||
) as mock_get,
|
||||
"superset.views.base.cached_common_bootstrap_data",
|
||||
return_value={"locale": "fr", "language_pack": fake_pack},
|
||||
),
|
||||
patch("superset.views.base.utils.get_user_id", return_value=1),
|
||||
patch("superset.views.base.get_locale", return_value="fr"),
|
||||
):
|
||||
payload = common_bootstrap_payload()
|
||||
|
||||
assert payload["language_pack"] == fake_pack
|
||||
mock_get.assert_called_once_with("fr")
|
||||
|
||||
|
||||
def test_common_bootstrap_payload_skips_pack_for_english(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""English short-circuits: pack is None and the utility is not called."""
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.cached_common_bootstrap_data",
|
||||
return_value={"locale": "en"},
|
||||
),
|
||||
patch("superset.views.base.get_language_pack") as mock_get,
|
||||
patch("superset.views.base.utils.get_user_id", return_value=1),
|
||||
patch("superset.views.base.get_locale", return_value="en"),
|
||||
):
|
||||
payload = common_bootstrap_payload()
|
||||
|
||||
assert payload["language_pack"] is None
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
def test_common_bootstrap_payload_does_not_mutate_memoized_dict(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""Injecting language_pack must not write back into the memoize cache."""
|
||||
"""Defaulting language_pack must not write back into the memoize cache."""
|
||||
cached: dict[str, Any] = {"locale": "fr"}
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.cached_common_bootstrap_data",
|
||||
return_value=cached,
|
||||
),
|
||||
patch("superset.views.base.get_language_pack", return_value={"x": 1}),
|
||||
patch("superset.views.base.utils.get_user_id", return_value=1),
|
||||
patch("superset.views.base.get_locale", return_value="fr"),
|
||||
):
|
||||
common_bootstrap_payload()
|
||||
|
||||
assert "language_pack" not in cached
|
||||
|
||||
|
||||
def _language_pack_context(locale: str, payload_extra: dict[str, Any]) -> Any:
|
||||
from superset.views.base import get_language_pack_template_context
|
||||
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.get_language_pack_version",
|
||||
return_value="abc123def456",
|
||||
),
|
||||
patch(
|
||||
"superset.views.base.url_for",
|
||||
return_value="/language_pack/fr/abc123def456/script.js",
|
||||
),
|
||||
):
|
||||
return get_language_pack_template_context({"locale": locale, **payload_extra})
|
||||
|
||||
|
||||
def test_language_pack_template_context_versioned_src_for_non_english(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
context = _language_pack_context("fr", {})
|
||||
assert context == {
|
||||
"language_pack_src": "/language_pack/fr/abc123def456/script.js",
|
||||
"language_pack_inline": False,
|
||||
}
|
||||
|
||||
|
||||
def test_language_pack_template_context_none_for_english(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
context = _language_pack_context("en", {})
|
||||
assert context == {"language_pack_src": None, "language_pack_inline": False}
|
||||
|
||||
|
||||
def test_language_pack_template_context_inline_for_override_pack(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""An operator-supplied pack suppresses the script tag; spa.html inlines."""
|
||||
context = _language_pack_context("fr", {"language_pack": {"domain": "superset"}})
|
||||
assert context == {"language_pack_src": None, "language_pack_inline": True}
|
||||
|
||||
115
tests/unit_tests/views/test_language_pack_script.py
Normal file
115
tests/unit_tests/views/test_language_pack_script.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
FAKE_PACK = {"domain": "superset", "locale_data": {"superset": {"": {}}}}
|
||||
FAKE_VERSION = "abc123def456"
|
||||
|
||||
|
||||
def test_script_served_immutable_when_version_matches(client: Any) -> None:
|
||||
with (
|
||||
patch(
|
||||
"superset.views.core.get_language_pack_version",
|
||||
return_value=FAKE_VERSION,
|
||||
),
|
||||
patch("superset.views.core.get_language_pack", return_value=FAKE_PACK),
|
||||
):
|
||||
response = client.get(f"/language_pack/fr/{FAKE_VERSION}/script.js")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.mimetype == "application/javascript"
|
||||
body = response.get_data(as_text=True)
|
||||
assert body.startswith("window.__SUPERSET_LANGUAGE_PACK__ = ")
|
||||
assert '"domain": "superset"' in body
|
||||
cache_control = response.headers["Cache-Control"]
|
||||
assert "immutable" in cache_control
|
||||
assert "max-age=31536000" in cache_control
|
||||
assert "public" in cache_control
|
||||
|
||||
|
||||
def test_script_not_cacheable_when_version_stale(client: Any) -> None:
|
||||
"""A pre-upgrade HTML page may reference an old version: serve fresh
|
||||
content, but do not let caches pin it under the stale address."""
|
||||
with (
|
||||
patch(
|
||||
"superset.views.core.get_language_pack_version",
|
||||
return_value=FAKE_VERSION,
|
||||
),
|
||||
patch("superset.views.core.get_language_pack", return_value=FAKE_PACK),
|
||||
):
|
||||
response = client.get("/language_pack/fr/000000000000/script.js")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "no-cache" in response.headers["Cache-Control"]
|
||||
assert "immutable" not in response.headers["Cache-Control"]
|
||||
|
||||
|
||||
def test_script_404_when_pack_missing(client: Any) -> None:
|
||||
with patch(
|
||||
"superset.views.core.get_language_pack_version",
|
||||
return_value=None,
|
||||
):
|
||||
response = client.get(f"/language_pack/xx/{FAKE_VERSION}/script.js")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_script_rejects_malformed_lang_and_version(client: Any) -> None:
|
||||
assert client.get(f"/language_pack/../{FAKE_VERSION}/script.js").status_code in (
|
||||
400,
|
||||
404,
|
||||
)
|
||||
assert client.get("/language_pack/fr/not-a-hash!/script.js").status_code == 400
|
||||
|
||||
|
||||
def test_script_serves_only_the_requested_locale(client: Any) -> None:
|
||||
"""The endpoint resolves exactly one pack: the locale in the URL."""
|
||||
with (
|
||||
patch(
|
||||
"superset.views.core.get_language_pack_version",
|
||||
return_value=FAKE_VERSION,
|
||||
) as mock_version,
|
||||
patch(
|
||||
"superset.views.core.get_language_pack", return_value=FAKE_PACK
|
||||
) as mock_pack,
|
||||
):
|
||||
client.get(f"/language_pack/pt_BR/{FAKE_VERSION}/script.js")
|
||||
|
||||
mock_version.assert_called_once_with("pt_BR")
|
||||
mock_pack.assert_called_once_with("pt_BR")
|
||||
|
||||
|
||||
def test_spa_template_loads_pack_before_entry_bundle() -> None:
|
||||
"""Static guard on spa.html: the language pack script tag must precede
|
||||
the entry bundle and stay a classic script (no async/defer). A deferred
|
||||
or reordered tag would let code-split chunks evaluate module-level
|
||||
`t('...')` calls before the translator is configured (issue #35330)."""
|
||||
import superset
|
||||
|
||||
template = (
|
||||
Path(superset.__file__).parent / "templates" / "superset" / "spa.html"
|
||||
).read_text()
|
||||
|
||||
tag_start = template.index('<script src="{{ language_pack_src }}"')
|
||||
entry_start = template.index("js_bundle(assets_prefix, entry)")
|
||||
assert tag_start < entry_start
|
||||
|
||||
script_tag = template[tag_start : template.index(">", tag_start)]
|
||||
assert "async" not in script_tag
|
||||
assert "defer" not in script_tag
|
||||
Reference in New Issue
Block a user