mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
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:
co-authored by
Claude Opus 5.5
parent
386d33df6e
commit
4437a671d2
@@ -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:
|
||||
|
||||
@@ -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 "'" 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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user