Compare commits
7 Commits
fix/issue-
...
feat/cross
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6d0ffd8ca | ||
|
|
18a12f668b | ||
|
|
b3cf68469c | ||
|
|
c91d3ad0d2 | ||
|
|
0510b2682e | ||
|
|
61753fd54f | ||
|
|
d4caf1952d |
@@ -97,6 +97,10 @@ RUN --mount=type=cache,target=/root/.npm \
|
||||
# Copy translation files
|
||||
COPY superset/translations /app/superset/translations
|
||||
|
||||
# compile_po.py (invoked by `npm run build-translation` below) lives outside
|
||||
# superset-frontend/, which is the only source tree this stage has copied so far.
|
||||
COPY scripts/translations /app/scripts/translations
|
||||
|
||||
# Build translations if enabled, then cleanup localization files
|
||||
RUN if [ "${BUILD_TRANSLATIONS}" = "true" ]; then \
|
||||
npm run build-translation; \
|
||||
|
||||
@@ -56,6 +56,9 @@ PATTERNS = {
|
||||
"frontend": [
|
||||
r"^\.github/workflows/.*(bashlib|frontend|e2e)",
|
||||
r"^superset-frontend/",
|
||||
# `npm run build-translation` shells out to this script; a change
|
||||
# limited to it should still exercise that frontend build step.
|
||||
r"^scripts/translations/compile_po\.py$",
|
||||
],
|
||||
"docker": [
|
||||
r"^Dockerfile$",
|
||||
|
||||
207
scripts/translations/compile_po.py
Normal file
207
scripts/translations/compile_po.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
"""Cross-platform Python port of ``po2json.sh``.
|
||||
|
||||
Compiles ``superset/translations/**/*.po`` into sibling ``.json`` files for
|
||||
the frontend (via the in-repo ``scripts/po2json-cli.js``), then formats the
|
||||
generated JSON with ``oxfmt`` -- the same two steps ``po2json.sh`` performed,
|
||||
in the same order.
|
||||
|
||||
``po2json-cli.js`` is a fixed path in this repo, not an installed npm
|
||||
package (master dropped the ``po2json`` npm dependency entirely in favor of
|
||||
owning the ~40-line PO-to-Jed transform directly, see #44742), so it's
|
||||
invoked at that path rather than resolved. ``oxfmt`` is still a real
|
||||
devDependency, so it's still resolved from its own ``package.json`` "bin"
|
||||
field rather than through the platform-specific ``node_modules/.bin``
|
||||
wrapper (a symlink on POSIX, a ``.cmd``/``.ps1`` shim on Windows). Either
|
||||
way, ``node`` is a real executable on every platform, so this never goes
|
||||
through ``cmd.exe`` the way invoking a ``.cmd`` wrapper does (even with
|
||||
``subprocess``'s ``shell=False``) -- so there is no shell-metacharacter or
|
||||
``%VAR%``-expansion surface to defend against here.
|
||||
|
||||
Usage:
|
||||
python scripts/translations/compile_po.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json # noqa: TID251 - standalone script, not the Flask app
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT_DIR = os.path.abspath(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
||||
)
|
||||
FRONTEND_DIR = os.path.join(ROOT_DIR, "superset-frontend")
|
||||
TRANSLATIONS_DIR = os.path.join(ROOT_DIR, "superset", "translations")
|
||||
PO2JSON_CLI = os.path.join(FRONTEND_DIR, "scripts", "po2json-cli.js")
|
||||
|
||||
|
||||
def resolve_node_entry(package: str, bin_name: str | None = None) -> str | None:
|
||||
"""Resolve an installed npm package's CLI entry point.
|
||||
|
||||
Reads the entry path out of the package's own ``package.json`` "bin"
|
||||
field instead of guessing at the ``node_modules/.bin`` wrapper's shape,
|
||||
so the result can always be run via ``node <entry>`` directly.
|
||||
|
||||
``package`` is the npm package name -- e.g. ``@some-scope/some-tool`` --
|
||||
which may be scoped and so span two ``node_modules`` path segments.
|
||||
``bin_name`` is the key under the package's "bin" field (the installed
|
||||
binary name, which for a scoped package is just its unscoped last
|
||||
segment, e.g. ``some-tool``); it defaults to ``package`` itself for
|
||||
unscoped packages whose bin name matches the package name.
|
||||
"""
|
||||
pkg_dir = os.path.join(FRONTEND_DIR, "node_modules", *package.split("/"))
|
||||
manifest_path = os.path.join(pkg_dir, "package.json")
|
||||
if not os.path.isfile(manifest_path):
|
||||
return None
|
||||
with open(manifest_path, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
bin_field = manifest.get("bin")
|
||||
key = bin_name or package
|
||||
rel_entry = bin_field.get(key) if isinstance(bin_field, dict) else bin_field
|
||||
if not rel_entry:
|
||||
return None
|
||||
entry_path = os.path.join(pkg_dir, rel_entry)
|
||||
return entry_path if os.path.isfile(entry_path) else None
|
||||
|
||||
|
||||
def run(command: list[str]) -> int:
|
||||
"""Run a command directly, with no shell involved, and return its exit
|
||||
code.
|
||||
|
||||
``NODE_NO_WARNINGS=1`` keeps node's own warnings (experimental-feature
|
||||
notices and the like) out of the build log, as po2json.sh did.
|
||||
"""
|
||||
env = {**os.environ, "NODE_NO_WARNINGS": "1"}
|
||||
return subprocess.run(command, check=False, env=env).returncode # noqa: S603
|
||||
|
||||
|
||||
def convert_po_to_json(node_bin: str, po2json_cli: str, po_file: str) -> str | None:
|
||||
"""Convert one ``.po`` file to its sibling ``.json`` via ``po2json-cli.js``.
|
||||
|
||||
Returns the generated ``.json`` path on success, ``None`` on failure.
|
||||
"""
|
||||
json_dest = f"{os.path.splitext(po_file)[0]}.json"
|
||||
rc = run(
|
||||
[
|
||||
node_bin,
|
||||
po2json_cli,
|
||||
"--domain",
|
||||
"superset",
|
||||
po_file,
|
||||
json_dest,
|
||||
]
|
||||
)
|
||||
return json_dest if rc == 0 else None
|
||||
|
||||
|
||||
def resolve_prerequisites() -> tuple[str, str] | None:
|
||||
"""Resolve ``node`` and ``oxfmt``, and confirm ``po2json-cli.js`` and the
|
||||
translations directory are present. Prints an error and returns ``None``
|
||||
for the first thing missing; returns ``(node_bin, oxfmt_entry)`` once
|
||||
everything needed is confirmed present.
|
||||
"""
|
||||
node_bin = shutil.which("node")
|
||||
if not node_bin:
|
||||
print("ERROR: node not found in PATH.", file=sys.stderr)
|
||||
return None
|
||||
|
||||
if not os.path.isfile(PO2JSON_CLI):
|
||||
print(f"ERROR: {PO2JSON_CLI} not found.", file=sys.stderr)
|
||||
return None
|
||||
|
||||
oxfmt_entry = resolve_node_entry("oxfmt")
|
||||
if not oxfmt_entry:
|
||||
print(
|
||||
f"ERROR: oxfmt not found under {FRONTEND_DIR}/node_modules. "
|
||||
"Run `npm install` in superset-frontend first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
if not os.path.isdir(TRANSLATIONS_DIR):
|
||||
print(
|
||||
f"ERROR: translations directory not found: {TRANSLATIONS_DIR}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
return node_bin, oxfmt_entry
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Convert every ``.po`` file under ``superset/translations`` to
|
||||
``.json``, then format the generated JSON with ``oxfmt``."""
|
||||
prerequisites = resolve_prerequisites()
|
||||
if prerequisites is None:
|
||||
return 1
|
||||
node_bin, oxfmt_entry = prerequisites
|
||||
|
||||
po_files = sorted(
|
||||
glob.glob(os.path.join(TRANSLATIONS_DIR, "**", "*.po"), recursive=True)
|
||||
)
|
||||
print(f"Converting {len(po_files)} .po file(s) to .json...")
|
||||
json_files: list[str] = []
|
||||
failed: list[str] = []
|
||||
for po_file in po_files:
|
||||
json_dest = convert_po_to_json(node_bin, PO2JSON_CLI, po_file)
|
||||
if json_dest:
|
||||
json_files.append(json_dest)
|
||||
else:
|
||||
failed.append(po_file)
|
||||
if failed:
|
||||
print(f"ERROR: {len(failed)} file(s) failed conversion:", file=sys.stderr)
|
||||
for po_file in failed:
|
||||
print(f" - {po_file}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if json_files:
|
||||
# Format only the files po2json just generated -- not every tracked
|
||||
# ``.json`` under TRANSLATIONS_DIR (e.g. the committed
|
||||
# empty_language_pack.json, which po2json.sh never touched either).
|
||||
# Every generated file here is named messages.json, which
|
||||
# "superset/translations/**/messages.json" gitignores; oxfmt >=0.62
|
||||
# respects .gitignore even for explicitly-passed paths, so passing
|
||||
# only these would otherwise exit non-zero with "All matched files
|
||||
# may have been excluded by ignore rules." --no-error-on-unmatched-
|
||||
# pattern tolerates that instead of failing (carried over from
|
||||
# po2json.sh's identical per-file workaround).
|
||||
rc = run(
|
||||
[
|
||||
node_bin,
|
||||
oxfmt_entry,
|
||||
"--write",
|
||||
"--no-error-on-unmatched-pattern",
|
||||
*json_files,
|
||||
]
|
||||
)
|
||||
if rc != 0:
|
||||
print("ERROR: oxfmt step failed.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("Done.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -43,7 +43,7 @@
|
||||
"build-dev": "cross-env NODE_OPTIONS=--max_old_space_size=8192 NODE_ENV=development webpack --mode=development --color",
|
||||
"build-instrumented": "cross-env NODE_ENV=production BABEL_ENV=instrumented webpack --mode=production --color",
|
||||
"build-storybook": "storybook build",
|
||||
"build-translation": "scripts/po2json.sh",
|
||||
"build-translation": "python3 ../scripts/translations/compile_po.py",
|
||||
"translations:build-index": "python3 ../scripts/translations/build_translation_index.py",
|
||||
"translations:backfill": "python3 ../scripts/translations/backfill_po.py",
|
||||
"bundle-stats": "cross-env BUNDLE_ANALYZER=true npm run build && npx open-cli ../superset/static/stats/statistics.html",
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 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.
|
||||
|
||||
|
||||
# This script generates .json files from .po translation files
|
||||
# these json files are used by the frontend to load translations
|
||||
|
||||
set -e
|
||||
|
||||
export NODE_NO_WARNINGS=1
|
||||
|
||||
for file in $( find ../superset/translations/** -name '*.po' );
|
||||
do
|
||||
extension=${file##*.}
|
||||
filename="${file%.*}"
|
||||
if [ $extension == "po" ]
|
||||
then
|
||||
echo "po2json-cli.js --domain superset $file $filename.json"
|
||||
node scripts/po2json-cli.js --domain superset "$file" "$filename.json"
|
||||
# messages.json is gitignored (generated output); oxfmt >=0.62 respects
|
||||
# .gitignore even for explicitly-passed paths, so it would otherwise
|
||||
# exit non-zero here with "All matched files may have been excluded by
|
||||
# ignore rules." Skip the format step for this file instead of failing.
|
||||
oxfmt --write --no-error-on-unmatched-pattern "$(realpath "$filename.json")"
|
||||
fi
|
||||
done
|
||||
@@ -186,3 +186,14 @@ def test_grype_config_changes_trigger_docker_build() -> None:
|
||||
[".grype.yaml"],
|
||||
change_detector.PATTERNS["docker"],
|
||||
)
|
||||
|
||||
|
||||
def test_compile_po_change_triggers_frontend_translation_build() -> None:
|
||||
"""A change limited to ``compile_po.py`` must still be classified as
|
||||
"frontend", or the Translations workflow's `npm run build-translation`
|
||||
step -- which is the only thing that actually exercises this script
|
||||
against real po2json/oxfmt -- gets skipped entirely."""
|
||||
assert change_detector.detect_changes(
|
||||
["scripts/translations/compile_po.py"],
|
||||
change_detector.PATTERNS["frontend"],
|
||||
)
|
||||
|
||||
352
tests/unit_tests/scripts/translations/compile_po_test.py
Normal file
352
tests/unit_tests/scripts/translations/compile_po_test.py
Normal file
@@ -0,0 +1,352 @@
|
||||
# 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.
|
||||
"""Tests for ``scripts/translations/compile_po.py``.
|
||||
|
||||
The script is not installed as a package, so it is loaded via importlib from
|
||||
its filesystem path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json # noqa: TID251 - testing a standalone script that uses stdlib json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
_SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parents[4] / "scripts" / "translations" / "compile_po.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("compile_po", _SCRIPT_PATH)
|
||||
assert _spec is not None, f"Could not load {_SCRIPT_PATH}"
|
||||
assert _spec.loader is not None, f"No loader on spec for {_SCRIPT_PATH}"
|
||||
compile_po = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(compile_po)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_node_entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_node_entry_dict_bin(tmp_path: Path) -> None:
|
||||
"""A dict "bin" field is resolved by package name, not the first key."""
|
||||
pkg_dir = tmp_path / "node_modules" / "po2json"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
(pkg_dir / "package.json").write_text(
|
||||
json.dumps({"bin": {"po2json": "bin/po2json"}})
|
||||
)
|
||||
(pkg_dir / "bin").mkdir()
|
||||
entry = pkg_dir / "bin" / "po2json"
|
||||
entry.touch()
|
||||
|
||||
with patch.object(compile_po, "FRONTEND_DIR", str(tmp_path)):
|
||||
resolved = compile_po.resolve_node_entry("po2json")
|
||||
assert resolved == str(entry)
|
||||
|
||||
|
||||
def test_resolve_node_entry_scoped_package(tmp_path: Path) -> None:
|
||||
"""A scoped package (e.g. ``@some-scope/some-tool``) is looked up under
|
||||
its own two-segment ``node_modules`` path, with the bin key coming from
|
||||
``bin_name`` (the unscoped installed binary name) rather than the full
|
||||
scoped package name."""
|
||||
pkg_dir = tmp_path / "node_modules" / "@some-scope" / "some-tool"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
(pkg_dir / "package.json").write_text(
|
||||
json.dumps({"bin": {"some-tool": "bin/some-tool"}})
|
||||
)
|
||||
(pkg_dir / "bin").mkdir()
|
||||
entry = pkg_dir / "bin" / "some-tool"
|
||||
entry.touch()
|
||||
|
||||
with patch.object(compile_po, "FRONTEND_DIR", str(tmp_path)):
|
||||
resolved = compile_po.resolve_node_entry("@some-scope/some-tool", "some-tool")
|
||||
assert resolved == str(entry)
|
||||
|
||||
|
||||
def test_resolve_node_entry_string_bin(tmp_path: Path) -> None:
|
||||
"""A plain string "bin" field (single-command package shorthand) resolves too."""
|
||||
pkg_dir = tmp_path / "node_modules" / "oxfmt"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
(pkg_dir / "package.json").write_text(json.dumps({"bin": "bin/oxfmt"}))
|
||||
(pkg_dir / "bin").mkdir()
|
||||
entry = pkg_dir / "bin" / "oxfmt"
|
||||
entry.touch()
|
||||
|
||||
with patch.object(compile_po, "FRONTEND_DIR", str(tmp_path)):
|
||||
resolved = compile_po.resolve_node_entry("oxfmt")
|
||||
assert resolved == str(entry)
|
||||
|
||||
|
||||
def test_resolve_node_entry_missing_package(tmp_path: Path) -> None:
|
||||
"""Returns None when the package isn't installed at all."""
|
||||
with patch.object(compile_po, "FRONTEND_DIR", str(tmp_path)):
|
||||
assert compile_po.resolve_node_entry("po2json") is None
|
||||
|
||||
|
||||
def test_resolve_node_entry_missing_bin_field(tmp_path: Path) -> None:
|
||||
"""Returns None when package.json has no "bin" field."""
|
||||
pkg_dir = tmp_path / "node_modules" / "po2json"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
(pkg_dir / "package.json").write_text(json.dumps({"name": "po2json"}))
|
||||
|
||||
with patch.object(compile_po, "FRONTEND_DIR", str(tmp_path)):
|
||||
assert compile_po.resolve_node_entry("po2json") is None
|
||||
|
||||
|
||||
def test_resolve_node_entry_entry_file_missing(tmp_path: Path) -> None:
|
||||
"""Returns None when package.json declares a bin entry that doesn't exist
|
||||
on disk (a partially-installed / corrupted node_modules)."""
|
||||
pkg_dir = tmp_path / "node_modules" / "po2json"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
(pkg_dir / "package.json").write_text(
|
||||
json.dumps({"bin": {"po2json": "bin/po2json"}})
|
||||
)
|
||||
|
||||
with patch.object(compile_po, "FRONTEND_DIR", str(tmp_path)):
|
||||
assert compile_po.resolve_node_entry("po2json") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_returns_process_returncode() -> None:
|
||||
"""run() returns the child process's exit code and never shells out."""
|
||||
with patch.object(compile_po.subprocess, "run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=3)
|
||||
rc = compile_po.run(["node", "script.js"])
|
||||
assert rc == 3
|
||||
args, kwargs = mock_run.call_args
|
||||
assert args[0] == ["node", "script.js"]
|
||||
assert "shell" not in kwargs
|
||||
assert kwargs["env"]["NODE_NO_WARNINGS"] == "1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# convert_po_to_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_convert_po_to_json_success(tmp_path: Path) -> None:
|
||||
"""Builds the po2json-cli.js argv and writes to the .po file's .json
|
||||
sibling."""
|
||||
po_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.po"
|
||||
po_file.parent.mkdir(parents=True)
|
||||
po_file.write_text('msgid ""\nmsgstr ""\n')
|
||||
|
||||
with patch.object(compile_po, "run", return_value=0) as mock_run:
|
||||
json_dest = compile_po.convert_po_to_json(
|
||||
"/usr/bin/node", "/repo/scripts/po2json-cli.js", str(po_file)
|
||||
)
|
||||
|
||||
assert json_dest == str(po_file.with_suffix(".json"))
|
||||
mock_run.assert_called_once_with(
|
||||
[
|
||||
"/usr/bin/node",
|
||||
"/repo/scripts/po2json-cli.js",
|
||||
"--domain",
|
||||
"superset",
|
||||
str(po_file),
|
||||
str(po_file.with_suffix(".json")),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_convert_po_to_json_failure() -> None:
|
||||
"""Reports failure when po2json-cli.js returns non-zero."""
|
||||
with patch.object(compile_po, "run", return_value=1):
|
||||
json_dest = compile_po.convert_po_to_json(
|
||||
"/usr/bin/node", "/repo/scripts/po2json-cli.js", "x.po"
|
||||
)
|
||||
assert json_dest is None
|
||||
|
||||
|
||||
def test_convert_po_to_json_preserves_locale_in_path(tmp_path: Path) -> None:
|
||||
"""Regression: two locales' messages.po must not collide on one .json
|
||||
output -- the destination is derived from the full glob path (which
|
||||
includes the locale and LC_MESSAGES components), not a locale-stripped
|
||||
relative path."""
|
||||
fr_po = tmp_path / "fr" / "LC_MESSAGES" / "messages.po"
|
||||
de_po = tmp_path / "de" / "LC_MESSAGES" / "messages.po"
|
||||
for f in (fr_po, de_po):
|
||||
f.parent.mkdir(parents=True)
|
||||
f.touch()
|
||||
|
||||
destinations = []
|
||||
with patch.object(compile_po, "run", return_value=0) as mock_run:
|
||||
for po_file in (fr_po, de_po):
|
||||
compile_po.convert_po_to_json(
|
||||
"/usr/bin/node", "/repo/scripts/po2json-cli.js", str(po_file)
|
||||
)
|
||||
destinations.append(mock_run.call_args.args[0][-1])
|
||||
|
||||
assert destinations[0] != destinations[1]
|
||||
assert destinations[0] == str(fr_po.with_suffix(".json"))
|
||||
assert destinations[1] == str(de_po.with_suffix(".json"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_main_missing_node() -> None:
|
||||
"""Returns 1 when node isn't on PATH."""
|
||||
with patch.object(compile_po.shutil, "which", return_value=None):
|
||||
assert compile_po.main() == 1
|
||||
|
||||
|
||||
def test_main_missing_po2json_cli(tmp_path: Path) -> None:
|
||||
"""Returns 1 when po2json-cli.js isn't present at its fixed repo path
|
||||
(an incomplete checkout, not a missing npm install)."""
|
||||
with (
|
||||
patch.object(compile_po.shutil, "which", return_value="/usr/bin/node"),
|
||||
patch.object(compile_po, "PO2JSON_CLI", str(tmp_path / "nope.js")),
|
||||
):
|
||||
assert compile_po.main() == 1
|
||||
|
||||
|
||||
def test_main_missing_oxfmt() -> None:
|
||||
"""Returns 1 when oxfmt isn't installed under node_modules."""
|
||||
with (
|
||||
patch.object(compile_po.shutil, "which", return_value="/usr/bin/node"),
|
||||
patch.object(compile_po, "resolve_node_entry", return_value=None),
|
||||
):
|
||||
assert compile_po.main() == 1
|
||||
|
||||
|
||||
def test_main_missing_translations_dir(tmp_path: Path) -> None:
|
||||
"""Returns 1 when the translations directory doesn't exist."""
|
||||
with (
|
||||
patch.object(compile_po.shutil, "which", return_value="/usr/bin/node"),
|
||||
patch.object(compile_po, "resolve_node_entry", return_value="/pkg/bin/x"),
|
||||
patch.object(compile_po, "TRANSLATIONS_DIR", str(tmp_path / "nope")),
|
||||
):
|
||||
assert compile_po.main() == 1
|
||||
|
||||
|
||||
def test_main_reports_conversion_failures(tmp_path: Path) -> None:
|
||||
"""Returns 1 and does not run oxfmt when any .po conversion fails."""
|
||||
po_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.po"
|
||||
po_file.parent.mkdir(parents=True)
|
||||
po_file.touch()
|
||||
|
||||
with (
|
||||
patch.object(compile_po.shutil, "which", return_value="/usr/bin/node"),
|
||||
patch.object(compile_po, "resolve_node_entry", return_value="/pkg/bin/x"),
|
||||
patch.object(compile_po, "TRANSLATIONS_DIR", str(tmp_path)),
|
||||
patch.object(compile_po, "convert_po_to_json", return_value=None),
|
||||
patch.object(compile_po, "run") as mock_run,
|
||||
):
|
||||
assert compile_po.main() == 1
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_main_runs_oxfmt_on_generated_json(tmp_path: Path) -> None:
|
||||
"""On success, oxfmt is invoked on the generated JSON with the gitignore
|
||||
workaround flag, and main() returns 0."""
|
||||
po_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.po"
|
||||
po_file.parent.mkdir(parents=True)
|
||||
po_file.touch()
|
||||
json_file = po_file.with_suffix(".json")
|
||||
json_file.touch()
|
||||
|
||||
with (
|
||||
patch.object(compile_po.shutil, "which", return_value="/usr/bin/node"),
|
||||
patch.object(compile_po, "resolve_node_entry", return_value="/pkg/bin/x"),
|
||||
patch.object(compile_po, "TRANSLATIONS_DIR", str(tmp_path)),
|
||||
patch.object(compile_po, "convert_po_to_json", return_value=str(json_file)),
|
||||
patch.object(compile_po, "run", return_value=0) as mock_run,
|
||||
):
|
||||
rc = compile_po.main()
|
||||
|
||||
assert rc == 0
|
||||
mock_run.assert_called_once()
|
||||
oxfmt_args = mock_run.call_args.args[0]
|
||||
assert "--write" in oxfmt_args
|
||||
assert "--no-error-on-unmatched-pattern" in oxfmt_args
|
||||
assert str(json_file) in oxfmt_args
|
||||
|
||||
|
||||
def test_main_does_not_format_unrelated_tracked_json(tmp_path: Path) -> None:
|
||||
"""Regression: oxfmt only runs on the .json this pass generated, not
|
||||
every tracked .json under TRANSLATIONS_DIR -- a blanket glob would also
|
||||
catch the checked-in empty_language_pack.json, which po2json.sh never
|
||||
touched, and reformat it as a side effect."""
|
||||
po_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.po"
|
||||
po_file.parent.mkdir(parents=True)
|
||||
po_file.touch()
|
||||
json_file = po_file.with_suffix(".json")
|
||||
json_file.touch()
|
||||
unrelated_json = tmp_path / "empty_language_pack.json"
|
||||
unrelated_json.touch()
|
||||
|
||||
with (
|
||||
patch.object(compile_po.shutil, "which", return_value="/usr/bin/node"),
|
||||
patch.object(compile_po, "resolve_node_entry", return_value="/pkg/bin/x"),
|
||||
patch.object(compile_po, "TRANSLATIONS_DIR", str(tmp_path)),
|
||||
patch.object(compile_po, "convert_po_to_json", return_value=str(json_file)),
|
||||
patch.object(compile_po, "run", return_value=0) as mock_run,
|
||||
):
|
||||
rc = compile_po.main()
|
||||
|
||||
assert rc == 0
|
||||
oxfmt_args = mock_run.call_args.args[0]
|
||||
assert str(unrelated_json) not in oxfmt_args
|
||||
|
||||
|
||||
def test_main_reports_oxfmt_failure(tmp_path: Path) -> None:
|
||||
"""Returns 1 when the oxfmt formatting step fails."""
|
||||
po_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.po"
|
||||
po_file.parent.mkdir(parents=True)
|
||||
po_file.touch()
|
||||
json_file = po_file.with_suffix(".json")
|
||||
json_file.touch()
|
||||
|
||||
with (
|
||||
patch.object(compile_po.shutil, "which", return_value="/usr/bin/node"),
|
||||
patch.object(compile_po, "resolve_node_entry", return_value="/pkg/bin/x"),
|
||||
patch.object(compile_po, "TRANSLATIONS_DIR", str(tmp_path)),
|
||||
patch.object(compile_po, "convert_po_to_json", return_value=str(json_file)),
|
||||
patch.object(compile_po, "run", return_value=1),
|
||||
):
|
||||
assert compile_po.main() == 1
|
||||
|
||||
|
||||
def test_main_never_touches_a_shell(tmp_path: Path) -> None:
|
||||
"""The whole pipeline runs with no shell involved -- the thing the
|
||||
previous shell-metacharacter/`%VAR%`-expansion bugs required. Nothing
|
||||
here branches on the platform, so one run covers them all.
|
||||
"""
|
||||
po_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.po"
|
||||
po_file.parent.mkdir(parents=True)
|
||||
po_file.touch()
|
||||
po_file.with_suffix(".json").touch()
|
||||
|
||||
with (
|
||||
patch.object(compile_po.shutil, "which", return_value="/usr/bin/node"),
|
||||
patch.object(compile_po, "resolve_node_entry", return_value="/pkg/bin/x"),
|
||||
patch.object(compile_po, "TRANSLATIONS_DIR", str(tmp_path)),
|
||||
patch.object(compile_po.subprocess, "run") as mock_subprocess_run,
|
||||
):
|
||||
mock_subprocess_run.return_value = MagicMock(returncode=0)
|
||||
assert compile_po.main() == 0
|
||||
|
||||
for call in mock_subprocess_run.call_args_list:
|
||||
assert call.args[0][0] == "/usr/bin/node"
|
||||
assert "shell" not in call.kwargs
|
||||
Reference in New Issue
Block a user