ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes

OCI manager Apps
- App tab: containers installed from an OCI image are identified from their
  installation record; the application and image versions are shown and an
  update is detected by image digest; repository link; Refresh data.
- Updates tab for OCI containers: Update and Recreate run the same flow as the
  OCI menu in the Monitor terminal; the pre-update backup can be kept in a
  backup storage; scheduled image updates with an optional minimum age.
- Logs tab: console output of the application, kept on the host
  (lxc.console.logfile + logrotate) and followed live.
- The Proxmox console opens a shell (cmode: shell) when the image has one.
- A damaged image download is fetched again before failing.
- Multi-container applications open at their LAN address; volume mount
  points on block storage report their usage.

Monitor
- Proxmox notifications are delivered to a loopback-only HTTP listener when
  HTTPS is enabled, so they no longer fail certificate verification.
- Log persistence counts recurring patterns only; an ended burst is not
  reported as persistent and its warning clears on its own (#386).
- Proxmox notification config backups are deduplicated and capped at three.
- The update icon on the Apps page opens the container on its Updates tab.
- Version 1.2.6.2-beta and its release notes in every Monitor language.

Docs
- OCI manager Apps and Audit & Report rebuilt as per-page message files,
  with a new page for OCI containers in the Monitor.
- Seven pages fixed where rich-text tags were missing from t.rich.

Translations
- Spanish fixes across the OCI engine, the Monitor and the TUI menus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-25 21:51:12 +02:00
co-authored by Claude Opus 5.5
parent 386d33df6e
commit 4437a671d2
524 changed files with 14459 additions and 3841 deletions
+33 -8
View File
@@ -116,7 +116,9 @@ def protect_placeholders(text: str) -> tuple[str, list[str]]:
def _swap(match: re.Match) -> str:
placeholders.append(match.group(0))
return f"__PMX_PH_{len(placeholders) - 1}__"
# No underscores: a local model splits on them and returns
# "PMX PH 0", so the placeholder never comes back.
return f"PMXPH{len(placeholders) - 1:03d}"
return PLACEHOLDER_RE.sub(_swap, text), placeholders
@@ -127,7 +129,7 @@ def restore_placeholders(text: str, placeholders: list[str]) -> str:
fallback assignment (target = existing or EN) upstream catches
the worst case."""
for i, original in enumerate(placeholders):
text = text.replace(f"__PMX_PH_{i}__", original)
text = text.replace(f"PMXPH{i:03d}", original)
return text
@@ -241,9 +243,16 @@ def build_arg_parser() -> argparse.ArgumentParser:
"--refresh",
action="store_true",
help=(
"Re-translate EVERY key, ignoring existing translations. "
"Dangerous: this DOES overwrite human-curated strings. "
"Use only when you know what you are doing."
"Retranslate the keys named by --refresh-keys. On its own it has "
"no effect: an existing translation is never replaced."
),
)
parser.add_argument(
"--refresh-keys",
type=Path,
help=(
"File with one dotted message key per line. Only these may be "
"retranslated over an existing value, and only with --refresh."
),
)
parser.add_argument(
@@ -283,6 +292,21 @@ def main() -> int:
print(f"Provider: {args.provider}", flush=True)
print(f"Sleep between calls: {args.sleep}s", flush=True)
refresh_keys: set[str] = set()
if args.refresh_keys is not None:
if not args.refresh:
print("--refresh-keys needs --refresh.", file=sys.stderr)
return 1
refresh_keys = {
line.strip()
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
if line.strip()
}
print(f"Refreshing {len(refresh_keys)} named keys.", flush=True)
elif args.refresh:
print("--refresh without --refresh-keys: existing translations are kept.",
flush=True)
total_failures: list[tuple[str, CatalogPath, str]] = []
# Preflight every locale before any provider call or write. Serialized
@@ -331,8 +355,9 @@ def main() -> int:
# 3. Auto-fills from prior runs whose output happened to
# match EN — leaving them alone is the intended
# steady-state, not a bug.
# When someone genuinely wants to redo everything, --refresh
# is still available (and is destructive by design).
# An existing translation is never replaced. --refresh only reaches
# the keys named in --refresh-keys, so an overwrite is always one
# somebody asked for by name rather than a side effect of a run.
# Seed structural nodes and non-text leaves before any checkpoint.
# Only strings are sent to a translation provider.
original_flat = target_flat.copy()
@@ -348,7 +373,7 @@ def main() -> int:
if not isinstance(en_value, str) or not en_value:
continue
existing = target_flat.get(key, "")
if args.refresh or not existing:
if not existing or ".".join(str(part) for part in key) in refresh_keys:
missing.append(key)
if args.limit > 0:
+77 -9
View File
@@ -104,6 +104,15 @@ PROTECTED_TECHNICAL_TERMS = (
"OCI manager Apps",
"OCI",
"beta",
# Debian release names. "Bookworm" is a bird and a reader of books, and
# came back as "el libro de Debian"; the others are equally ordinary
# words in the languages that borrowed them.
"Bookworm",
"Trixie",
"Bullseye",
"Buster",
"Noble",
"Jammy",
)
TECHNICAL_TERM_RE = re.compile(
"|".join(
@@ -127,9 +136,37 @@ STRUCTURAL_LITERAL_RE = re.compile(
r"""
/(?:dev|etc|usr|var|opt|srv|proc|sys|run|boot|tmp)/[\w./+-]*[\w/+-]
| (?<![\w-])--[A-Za-z][\w-]*
# Angle-bracket placeholders a reader substitutes: <CTID> came back as
# "■CTID confiar", with the closing bracket translated as a verb.
| <[A-Za-z][\w-]*>
# Shell operators. "&&" became the HTML entity "&apos;" in Spanish, and
# the command it joined stopped being one command; a lone pipe came back
# as the word "Silencio".
| &&|\|\||(?<!\|)\|(?!\|)
# Environment variable names. THELOUNGE_HOME lost its underscore and
# became two words, so the command no longer set anything.
| \b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b
| \b[\w.-]+\.(?:sh|py|json|yml|yaml|conf|service|timer|func|cfg|list|log)\b
| \b(?:pct|qm|pvesm|pveam|pveum|pvecm|apt-get|dpkg|systemctl|journalctl
|zpool|smartctl|modprobe|blkid|lsblk|mkfs)\s+[a-z][\w-]*
# Reverse-DNS identifiers: org.opencontainers.image.version came back as
# "org.open containers.image.version" — a label name with a space in it.
| \b[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*){2,}\b
# A command plus a real subcommand. An allowlist, not "any word": with
# a wildcard, "pvesm command not found" and "apt-get exited" were read as
# invocations, and the strings a contributor had already translated by
# hand were discarded as damaged.
| \b(?:pct|qm)\s+(?:config|start|stop|shutdown|reboot|destroy|create|clone
|enter|exec|push|pull|set|list|status|snapshot|rollback|resize|migrate
|template|unlock|mount|unmount|df|fsck|restore|suspend|resume|monitor)\b
| \bpvesm\s+(?:path|status|list|alloc|free|add|remove|set|scan|export|import)\b
| \b(?:pveam|pveum|pvecm)\s+(?:available|download|list|update|remove|add|create
|delete|status|nodes|keygen|expected|modify|set|useradd|userdel|passwd)\b
| \bapt-get\s+(?:install|update|upgrade|remove|purge|autoremove|dist-upgrade|clean)\b
| \bdpkg\s+(?:-l|-i|-r|-P|--configure|--get-selections)\b
| \b(?:systemctl|journalctl)\s+(?:start|stop|restart|reload|enable|disable|status
|daemon-reload|mask|unmask|is-active|is-enabled|list-units|-u|-f|-b)\b
| \bzpool\s+(?:status|list|import|export|scrub|trim|get|set|history)\b
| \bsmartctl\s+(?:-a|-A|-H|-i|-t|-l|-x|--all|--health|--info)\b
| \b(?:modprobe|blkid|lsblk|mkfs)\s+(?:-a|-r|-n|-o|-f|-t|\.[a-z0-9]+)\b
""",
re.VERBOSE,
)
@@ -551,14 +588,17 @@ def load_language_cache(path: Path) -> dict[str, str]:
def is_fully_protected(source: str) -> bool:
"""Whether the string is glossary terms and punctuation, nothing else.
"""Whether the string is protected material and punctuation, nothing else.
"Docker Volume Backup" and "NVIDIA (NVDEC/CUDA)" are product and API
names from end to end. Coming back unchanged is the right answer for
them, so the guard below must not read it as a silent failure and throw
the result away.
names from end to end, and "apt-get exited" is a command with a word
the command itself supplies. Coming back unchanged is the right answer
for all of them, so the guard below must not read it as a silent
failure and throw the result away. Both kinds of protection count:
the glossary catches the names, the structural pattern the rest.
"""
return not re.search(r"[A-Za-z]{2,}", TECHNICAL_TERM_RE.sub(" ", source))
remainder = STRUCTURAL_LITERAL_RE.sub(" ", source)
return not re.search(r"[A-Za-z]{2,}", TECHNICAL_TERM_RE.sub(" ", remainder))
def looks_untranslated(source: str, result: str) -> bool:
@@ -667,7 +707,14 @@ def build_arg_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--refresh",
action="store_true",
help="Translate all entries again instead of reusing existing cache values.",
help="Retranslate the strings named by --refresh-keys. On its own it has "
"no effect: an existing translation is never replaced.",
)
parser.add_argument(
"--refresh-keys",
type=Path,
help="File with one source string per line. Only these may be "
"retranslated over an existing value, and only with --refresh.",
)
parser.add_argument(
"--extract-only",
@@ -730,6 +777,21 @@ def main() -> int:
print(f"Output directory: {output_dir}", flush=True)
print(f"Languages: {', '.join(languages)}", flush=True)
refresh_keys: set[str] = set()
if args.refresh_keys is not None:
if not args.refresh:
print("--refresh-keys needs --refresh.", file=sys.stderr)
return 1
refresh_keys = {
line.rstrip("\n")
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
if line.strip()
}
print(f"Refreshing {len(refresh_keys)} named strings.", flush=True)
elif args.refresh:
print("--refresh without --refresh-keys: existing translations are kept.",
flush=True)
failures: list[tuple[str, str, str]] = []
total = len(texts) * len(languages)
done = 0
@@ -740,7 +802,13 @@ def main() -> int:
for index, text in enumerate(texts, start=1):
done += 1
if not args.refresh and existing.get(text):
# A translation that is already there is never replaced. Most of
# this catalogue is hand-written by the people who speak the
# language, and a machine pass over it trades their wording for
# something worse without anyone seeing the swap. Only a string
# named in --refresh-keys may be overwritten, so every overwrite
# is one somebody asked for by name.
if existing.get(text) and text not in refresh_keys:
next_by_lang[lang][text] = existing[text]
continue
if args.extract_only:
+69 -23
View File
@@ -18,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent))
from build_translation_cache import ( # noqa: E402
clean_translation,
translate_appimage,
translate_argos,
translate_google_web,
translate_googletrans,
)
@@ -86,6 +87,7 @@ PROTECTED_PATTERNS = (
re.compile(r"(?<![\w-])--[A-Za-z0-9][A-Za-z0-9_-]*"),
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
)
LITERAL_TAG_RE = re.compile(
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
@@ -100,6 +102,11 @@ TERM_RE = re.compile(
re.IGNORECASE,
)
TAG_RE = re.compile(r"</?([A-Za-z][A-Za-z0-9]*)>")
# Every tag as written, attributes and self-closing slash included.
ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
# Set from --provider: a local model rewrites bracketed sentinels, a remote
# one drops bare ones beside inline markup. See protect_text.
BARE_SENTINELS = False
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
NON_TRANSLATABLE_KEYS = {
"command",
@@ -206,13 +213,13 @@ def needs_translation(
source: str,
target: Any,
path: tuple[str | int, ...],
refresh: bool,
forced_tokens: set[str] | None = None,
) -> bool:
if should_copy(source, path):
return False
if refresh:
return True
# An existing translation is never replaced. Only a leaf whose token was
# named on the command line may be retranslated over a real value, so an
# overwrite is always one somebody asked for rather than a side effect.
if forced_tokens and leaf_token(path) in forced_tokens:
return True
return not isinstance(target, str) or not target.strip() or target == source
@@ -279,6 +286,17 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
for match in TERM_RE.finditer(text)
if not overlaps_literal(match.start(), match.end())
)
if BARE_SENTINELS:
# A remote provider carries inline tags through untouched, so they are
# only validated afterwards. A local model rewrites them — dropping a
# </code>, merging two <em> — and the whole file is then discarded for
# a broken contract. Protecting them costs the translator the tag as
# context and saves the paragraph.
candidates.extend((match.start(), match.end()) for match in ANY_TAG_RE.finditer(text))
# A local model also rewrites what a literal block holds: it drops the
# underscores of an identifier, translates a subcommand and turns a
# pipe into punctuation. The whole block is one opaque token instead.
candidates.extend(literal_ranges)
candidates.sort(key=lambda item: (item[0], -(item[1] - item[0])))
selected: list[tuple[int, int]] = []
@@ -296,7 +314,12 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
# sit directly beside inline markup (for example an opening <em>
# token followed by translated prose). Triple brackets remain opaque
# in that position and preserve the complete rich-text contract.
token = f"[[[PMXDOC{index:04d}]]]"
#
# A local model treats the brackets as punctuation instead, and
# rewrites them: argos returns [PMXDOC0000] in German, [[PMXDOC0000]]
# in Slovak and [[[[PMXDOC0000]]]]] in Italian. The bare identifier
# survives every provider tried, so it is what a local run uses.
token = f"PMXDOC{index:04d}" if BARE_SENTINELS else f"[[[PMXDOC{index:04d}]]]"
chunks.append(text[cursor:start])
chunks.append(token)
mapping[token] = text[start:end]
@@ -317,13 +340,20 @@ def restore_text(text: str, mapping: dict[str, str]) -> str:
def validate_contract(source: str, target: str) -> None:
if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)):
raise ValueError("rich-text tag contract changed")
if sorted(ANY_TAG_RE.findall(source)) != sorted(ANY_TAG_RE.findall(target)):
raise ValueError("markup changed")
literal = lambda text: sorted(match.group(0) for match in LITERAL_TAG_RE.finditer(text))
if literal(source) != literal(target):
raise ValueError("literal code changed")
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
raise ValueError("placeholder contract changed")
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
def translate(text: str, language: str) -> str:
if args.provider == "googletrans":
if args.provider == "argos":
raw = translate_argos(text, language)
elif args.provider == "googletrans":
raw = translate_googletrans(text, language, args.context)
elif args.provider == "google-web":
raw = translate_google_web(text, language, args.context, args.timeout)
@@ -446,7 +476,6 @@ def collect_memory(source_root: Path, messages_root: Path, language: str) -> dic
def pending_leaves(
source: Any,
target: Any,
refresh: bool,
forced_tokens: set[str] | None = None,
) -> list[Leaf]:
return [
@@ -456,7 +485,6 @@ def pending_leaves(
leaf.source,
get_at_path(target, leaf.path),
leaf.path,
refresh,
forced_tokens,
)
]
@@ -533,13 +561,14 @@ def translate_file(
memory: dict[str, str],
translate: Callable[[str, str], str],
args: argparse.Namespace,
) -> tuple[Any | None, list[str], int]:
) -> tuple[Any | None, list[str], int, set[str]]:
resolved: dict[tuple[str | int, ...], str] = {}
failures: list[str] = []
failed_tokens: set[str] = set()
jobs: dict[str, list[tuple[str | int, ...]]] = {}
for leaf in leaves:
if not args.refresh and leaf.source in memory:
if leaf.source in memory:
resolved[leaf.path] = memory[leaf.source]
continue
jobs.setdefault(leaf.source, []).append(leaf.path)
@@ -566,12 +595,17 @@ def translate_file(
resolved[path] = translated
except Exception as exc:
failures.append(f"{text[:90]}: {exc}")
failed_tokens.update(leaf_token(path) for path in jobs[text])
if args.sleep:
time.sleep(args.sleep)
if failures:
return None, failures, len(jobs)
return merge_tree(source, target, resolved), [], len(jobs)
# What did translate is kept. merge_tree leaves a string it has no
# translation for as whatever the locale already held, or as the English
# source, so a file is written with the strings that worked rather than
# discarded whole: a dense paragraph that a provider cannot carry its
# protected tokens through used to take the other hundred and seventy
# strings of its file with it.
return merge_tree(source, target, resolved), failures, len(jobs), failed_tokens
def build_parser() -> argparse.ArgumentParser:
@@ -586,8 +620,12 @@ def build_parser() -> argparse.ArgumentParser:
)
parser.add_argument(
"--provider",
choices=("google-web", "googletrans", "appimage"),
choices=("argos", "google-web", "googletrans", "appimage"),
default="googletrans",
help=(
"Translation provider. The default is what CI uses; `argos` runs "
"locally with no quota, which is what a corpus this size needs."
),
)
parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage"))
parser.add_argument(
@@ -623,7 +661,9 @@ def build_parser() -> argparse.ArgumentParser:
def main() -> int:
global BARE_SENTINELS
args = build_parser().parse_args()
BARE_SENTINELS = args.provider == "argos"
if args.workers < 1 or args.retries < 0 or args.max_files < 0:
print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr)
return 2
@@ -646,7 +686,8 @@ def main() -> int:
print("No target languages selected.", file=sys.stderr)
return 2
if args.refresh:
print("WARNING: --refresh overwrites existing translations in the selected scope.")
print("--refresh no longer overwrites existing translations; "
"name the leaves with --tokens instead.")
translate = provider_function(args)
state = load_source_state(source_state_path)
@@ -716,7 +757,7 @@ def main() -> int:
pending_tokens.update(changed_tokens)
pending_tokens.update(
leaf_token(leaf.path)
for leaf in pending_leaves(source, target, args.refresh)
for leaf in pending_leaves(source, target)
)
set_state_pending_tokens(state, language, relative, pending_tokens)
@@ -742,6 +783,11 @@ def main() -> int:
total_failures = 0
total_written = 0
# The provider decides the sentinel format, so a run that cannot be
# attributed to one cannot be diagnosed afterwards: a night of failures
# reading "translation provider changed protected token" left no way to
# tell whether the provider was the one the tokens were shaped for.
print(f"Provider: {args.provider} | bare sentinels: {BARE_SENTINELS}")
print(f"English files: {len(files)} | locales: {', '.join(languages)}")
for language in languages:
@@ -773,7 +819,7 @@ def main() -> int:
source = file_info[relative_key][1]
target = read_json(target_path)
queued_tokens = state_pending_tokens(state, language, relative_key)
leaves = pending_leaves(source, target, args.refresh, queued_tokens)
leaves = pending_leaves(source, target, queued_tokens)
total_strings += len(iter_leaves(source))
missing_strings += len(leaves)
schema_changed = not schema_matches(source, target)
@@ -816,7 +862,7 @@ def main() -> int:
f"({len(leaves)} strings{suffix})",
flush=True,
)
built, failures, calls = translate_file(
built, failures, calls, failed_tokens = translate_file(
source,
target,
leaves,
@@ -827,18 +873,18 @@ def main() -> int:
)
if failures:
total_failures += len(failures)
set_state_pending_tokens(state, language, relative_key, queued_tokens)
write_json(source_state_path, state)
print(
f" skipped atomically after {len(failures)} failures "
f"({calls} calls)",
f" {len(failures)} strings left untranslated ({calls} calls)",
file=sys.stderr,
)
for failure in failures[:5]:
print(f" - {failure}", file=sys.stderr)
continue
write_json(target_path, built)
set_state_pending_tokens(state, language, relative_key, set())
# The strings that failed stay pending, so the next run comes back
# for them and leaves the ones that translated alone.
set_state_pending_tokens(state, language, relative_key, failed_tokens)
if failures:
write_json(source_state_path, state)
write_json(source_state_path, state)
total_written += 1
print(f" wrote {target_path} ({calls} provider calls)", flush=True)
@@ -59,7 +59,7 @@ def fetch_directory_json(api_url: str) -> list[dict]:
r.raise_for_status()
data = r.json()
if not isinstance(data, list):
raise RuntimeError("GitHub API no devolvió una lista.")
raise RuntimeError("The GitHub API did not return a list.")
return data
@@ -67,7 +67,7 @@ def main() -> int:
try:
directory = fetch_directory_json(API_URL)
except Exception as e:
print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr)
print(f"ERROR: Could not read the JSON index: {e}", file=sys.stderr)
return 1
cache: list[dict] = []
@@ -35,8 +35,7 @@ def consumers():
def fixture(directory, backend='pbs', consumer='attached', rows='', second=None,
locale='en', catalog=None, real_dialog=None):
directory = Path(directory)
if catalog is not None:
(directory / f'{locale}.json').write_text(json.dumps(catalog), encoding='utf-8')
(directory / 'xx.json').write_text(json.dumps(catalog or {}))
(directory / 'rows').write_text(rows)
(directory / 'second').write_text(rows if second is None else second)
bindir = directory / 'bin'
@@ -52,7 +51,7 @@ FIXTURE={shlex.quote(str(directory))}
BACKEND={backend}
HB_UI_MENU_H=20 HB_UI_MENU_W=84 HB_UI_MENU_LIST=10
command_not_found_handle() {{ printf 'FORBIDDEN:%s\\n' "$*" >> "$FIXTURE/errors"; return 99; }}
jq() {{ [[ "$#" == 6 && "$1" == -r && "$2" == --arg && "$3" == text && "$5" == '.[$text] // empty' && "$6" == "$FIXTURE/$LANGUAGE.json" ]] || {{ printf 'bad jq' >> "$FIXTURE/errors"; return 99; }}; command jq "$@"; }}
jq() {{ [[ "$#" == 6 && "$1" == -r && "$2" == --arg && "$3" == text && "$5" == '.[$text] // empty' && "$6" == "$FIXTURE/xx.json" ]] || {{ printf 'bad jq' >> "$FIXTURE/errors"; return 99; }}; command jq "$@"; }}
head() {{ [[ "$*" == '-1' ]] || return 99; local first; IFS= read -r first; printf '%s\\n' "$first"; while IFS= read -r first; do :; done; }}
hb_pve_list_vzdump_jobs() {{
local file="$FIXTURE/rows" line
@@ -108,40 +107,20 @@ class SchedulerMessages(unittest.TestCase):
self.assertEqual(result['status'], '1')
def test_lookup_cache_modes_at_both_consumers(self):
# Unconditional synthetic coverage, independent of cache completeness.
shipped = json.loads((ROOT / 'lang/it.json').read_text())
for backend, message in MESSAGES.items():
for consumer in ('attached', 'new'):
for mode, locale, catalog, expected, hint in (
('english', 'en', {message: 'ignored', HINT: 'ignored'}, message, HINT),
('missing-file', 'missing', None, message, HINT),
('missing-key', 'xx', {'unrelated': 'unrelated'}, message, HINT),
('translated', 'xx', {message: 'SYNTHETIC reordered backend: ' + backend,
HINT: 'SYNTHETIC hint'},
'SYNTHETIC reordered backend: ' + backend, 'SYNTHETIC hint'),
for locale, catalog, expected in (
('en', {message: 'ignored'}, message),
('missing', {}, message),
('xx', {'unrelated': 'unrelated'}, message),
('xx', shipped, shipped.get(message, message)),
('xx', {message: 'SYNTHETIC reordered backend: ' + backend},
'SYNTHETIC reordered backend: ' + backend),
):
with self.subTest(backend=backend, consumer=consumer, mode=mode):
with self.subTest(backend=backend, consumer=consumer, locale=locale, expected=expected):
result = run_case(backend=backend, consumer=consumer, locale=locale, catalog=catalog)
suffix = '\n\n' + hint if consumer == 'new' else ''
self.assertEqual(result['message'], expected + suffix)
self.assertEqual(result['status'], '1')
def test_all_shipped_caches_at_both_consumers(self):
catalogs = sorted((ROOT / 'lang').glob('*.json'))
self.assertTrue(catalogs, 'No shipped locale catalogs discovered')
for path in catalogs:
catalog = json.loads(path.read_text(encoding='utf-8'))
self.assertIsInstance(catalog, dict, path.name)
for backend, message in MESSAGES.items():
for consumer in ('attached', 'new'):
with self.subTest(locale=path.stem, backend=backend, consumer=consumer):
# Exercise the real translate function with the actual locale
# filename, not just a comparison of JSON values. Missing or
# empty translations are valid: runtime falls back to English.
result = run_case(backend=backend, consumer=consumer,
locale=path.stem, catalog=catalog)
expected = message if path.stem == 'en' else catalog.get(message) or message
hint = HINT if path.stem == 'en' else catalog.get(HINT) or HINT
suffix = '\n\n' + hint if consumer == 'new' else ''
suffix = '\n\n' + catalog.get(HINT, HINT) if consumer == 'new' else ''
self.assertEqual(result['message'], expected + suffix)
self.assertEqual(result['status'], '1')