Compare commits

...

7 Commits

Author SHA1 Message Date
Evan Rusackas
c6d0ffd8ca Merge master into feat/cross-platform-po2json
# Conflicts:
#	superset-frontend/scripts/po2json.sh
2026-09-28 15:59:02 -07:00
Evan Rusackas
18a12f668b fix(scripts): resolve scoped po2json package under node_modules
resolve_node_entry() looked up node_modules/po2json, but the installed
package is the scoped @hainenber/po2json fork, so the frontend
translations build always failed with "po2json/oxfmt not found".

Co-Authored-By: Evan Rusackas <evan@preset.io>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 09:32:33 -07:00
Evan Rusackas
b3cf68469c fix(scripts): use --format jed for the modernized po2json fork
Master swapped the po2json devDependency to @hainenber/po2json, which
changed the required --format value from jed1.x to jed. Carry that fix
into the Python port picked up while rebasing this PR.

Co-Authored-By: Evan Rusackas <evan@preset.io>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 02:52:32 -07:00
Evan Rusackas
c91d3ad0d2 fix(scripts): restore NODE_NO_WARNINGS=1 in compile_po.py, drop inert os.name test parametrization
po2json.sh exported NODE_NO_WARNINGS=1 so node's own warnings stayed
out of the build log; the port dropped it. Set it on the subprocess env
in run() and assert it in the run() test.

test_main_never_touches_a_shell parametrized over os.name and patched
it, but nothing in compile_po reads os.name, so both runs were
identical. Drop the dead parametrization and the now-unused pytest
import.

Co-Authored-By: Evan Rusackas <evan@preset.io>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 02:49:56 -07:00
Evan Rusackas
0510b2682e fix(scripts): copy compile_po.py into the Docker build stage, keep change-detector marking it frontend
Docker's superset-node stage only copied superset-frontend/, so `npm run
build-translation` couldn't find compile_po.py when BUILD_TRANSLATIONS=true.
Also mark a change limited to compile_po.py as "frontend" in the CI change
detector so the Translations workflow still exercises it.

Co-Authored-By: Evan Rusackas <evan@preset.io>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 02:49:55 -07:00
Evan Rusackas
61753fd54f fix(scripts): scope compile_po.py's oxfmt pass to generated JSON only
The blanket **/*.json glob after po2json also swept up tracked JSON
under superset/translations (e.g. empty_language_pack.json) that
po2json.sh never touched, formatting it as a side effect. Track and
format only the per-.po JSON this run actually generated instead.

Co-Authored-By: Evan Rusackas <evan@preset.io>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 02:49:53 -07:00
Evan Rusackas
d4caf1952d feat(scripts): cross-platform Python port of po2json.sh, retire the shell script
po2json.sh only runs under a Unix-like shell, so Windows contributors need
WSL or Git Bash to regenerate frontend translation JSON. #39724 attempted a
cross-platform Python port, but spent 5 months patching a chronic bug class:
invoking node_modules/.bin's .cmd/.bat wrappers on Windows always routes
through cmd.exe regardless of subprocess's shell=False, and cmd.exe's own
command-line parsing (metacharacter operators, %VAR% expansion) kept
reopening new edge cases each time one was closed.

This sidesteps the whole bug class instead of chasing it: each npm
package's own package.json "bin" field is read directly, and its resolved
entry point is invoked as `node <entry.js>` rather than through the
platform-specific node_modules/.bin wrapper. node is a real executable on
every platform, so this never touches cmd.exe (or any shell) at all --
there is no shell-metacharacter or environment-variable-expansion surface
to defend against, on any OS.

Also drops the auto-install-missing-deps and ThreadPoolExecutor pieces of
that PR's version: the former is a footgun if this ever runs somewhere
unexpected (flagged in review), and the latter parallelizes a ~45s job
that doesn't need it, both trading complexity for no real benefit.

superset-frontend/package.json's build-translation now points at the new
script; po2json.sh is removed rather than keeping two pipelines to
maintain.

Closes #39724

Co-Authored-By: Evan Rusackas <evan@preset.io>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 02:49:52 -07:00
7 changed files with 578 additions and 42 deletions

View File

@@ -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; \

View File

@@ -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$",

View 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())

View File

@@ -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",

View File

@@ -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

View File

@@ -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"],
)

View 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