Files
XC_VM/tools/docs/translate.py
T
Divarion_D bdbcde97c7 docs: commit generated ru, translate locally before release (not in CI)
CI translation was slow, so move it out of GitHub Actions: docs/ru is now a
committed, generated tree refreshed LOCALLY before a release; CI only builds it.

- pages.yml: drop the setup-python/cache/translate steps — the workflow now just
  installs the build toolchain and runs `mkdocs build --strict` on the committed
  en+ru trees.
- .gitignore: stop ignoring docs/ru (now committed); keep site/ + .docs-cache/.
- Split deps: docs/requirements.txt = build only (mkdocs-material, static-i18n,
  used by CI); tools/docs/requirements.txt = translators (local-only).
- Makefile: docs-venv installs both; docs-build/docs-serve no longer translate
  (translation is the deliberate `make docs-translate` release step).
- updates_checklist.md: new "Regenerate translated documentation" step
  (make docs-translate + docs-build, commit docs/ru with the release commit).
- Commit the generated docs/ru (37 files, translators/yandex).

Rule: edit ONLY docs/en; docs/ru is generated — never hand-edit it.
2026-08-20 22:37:44 +03:00

346 lines
13 KiB
Python

#!/usr/bin/env python3
"""Generate translated docs from the canonical English source.
English (``docs/en``) is the single source of truth; every other language tree
(e.g. ``docs/ru``) is produced from it by this script. The generated tree IS
committed and refreshed by running this script LOCALLY before a release (not in
CI); ``mkdocs build`` then consumes it via the mkdocs-static-i18n plugin.
Design goals
------------
* **Engine-agnostic.** The concrete translator is chosen at runtime via the
``DOCS_TRANSLATE_PROVIDER`` env var (``noop`` | ``anthropic`` | ``deepl``);
the interface is a single ``translate(text, target_lang, glossary) -> text``.
* **Incremental.** Each file's translation is cached by the sha256 of its source
content (plus language + provider + glossary), so only changed English files
hit the translation backend on a subsequent run — locally and in CI.
* **Markdown-safe.** Providers are instructed to preserve fenced/inline code,
URLs, HTML and Markdown structure, and to leave glossary terms untouched.
Usage
-----
DOCS_TRANSLATE_PROVIDER=noop \
python3 tools/docs/translate.py --lang ru
# provider-specific:
DOCS_TRANSLATE_PROVIDER=anthropic ANTHROPIC_API_KEY=... \
python3 tools/docs/translate.py --lang ru
"""
from __future__ import annotations
import argparse
import hashlib
import os
import re
import sys
import time
from pathlib import Path
# Bumped when the translation prompt/rules change, to invalidate the cache.
PROMPT_VERSION = "1"
LANG_NAMES = {
"ru": "Russian",
"es": "Spanish",
"de": "German",
"fr": "French",
"pt": "Portuguese",
"bg": "Bulgarian",
}
def load_glossary(path: Path) -> list[str]:
"""Do-not-translate terms, one per line (``#`` comments / blanks ignored)."""
if not path.is_file():
return []
terms = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
terms.append(line)
return terms
def cache_key(text: str, lang: str, provider: str, glossary: list[str]) -> str:
h = hashlib.sha256()
h.update(PROMPT_VERSION.encode())
h.update(b"\0")
h.update(provider.encode())
h.update(b"\0")
h.update(lang.encode())
h.update(b"\0")
h.update("\n".join(glossary).encode())
h.update(b"\0")
h.update(text.encode("utf-8"))
return h.hexdigest()
# ── Providers ────────────────────────────────────────────────────────────────
# Each provider is ``translate(text, lang, glossary) -> text``. Add a new engine
# by writing one function and registering it in PROVIDERS below.
def _system_prompt(lang_name: str, glossary: list[str]) -> str:
rules = [
f"You are a professional technical translator. Translate the given "
f"Markdown document from English into {lang_name}.",
"Rules:",
"- Output ONLY the translated Markdown, nothing else. No preamble.",
"- Preserve the Markdown structure EXACTLY: headings, lists, tables, "
"blockquotes, admonitions, front matter keys, link/image syntax.",
"- NEVER translate content inside fenced code blocks (```...```) or "
"inline code (`...`); copy it verbatim.",
"- NEVER translate URLs, file paths, HTML tags/attributes, or link "
"targets — translate only human-visible link text.",
"- Keep heading text natural; anchors are derived from it automatically.",
"- Preserve trailing/leading whitespace and blank-line layout.",
]
if glossary:
rules.append(
"- Do NOT translate these terms (keep them verbatim): "
+ ", ".join(glossary)
)
return "\n".join(rules)
def provider_noop(text: str, lang: str, glossary: list[str]) -> str:
"""Passthrough: copy English verbatim. For structural/CI dry-runs."""
return text
def provider_anthropic(text: str, lang: str, glossary: list[str]) -> str:
"""Translate via the Anthropic API (requires the `anthropic` SDK + key)."""
import anthropic # lazy: only needed when this provider is selected
lang_name = LANG_NAMES.get(lang, lang)
model = os.environ.get("DOCS_TRANSLATE_MODEL", "claude-sonnet-5")
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
msg = client.messages.create(
model=model,
max_tokens=16000,
system=_system_prompt(lang_name, glossary),
messages=[{"role": "user", "content": text}],
)
return "".join(
block.text for block in msg.content if getattr(block, "type", "") == "text"
)
def provider_deepl(text: str, lang: str, glossary: list[str]) -> str:
"""Translate via the DeepL API (requires `deepl` SDK + DEEPL_AUTH_KEY).
DeepL is text-oriented; ``tag_handling='html'`` is a poor fit for Markdown,
so we send Markdown as plain text and rely on ignore-tag protection for code.
Prefer the anthropic provider for structure-heavy docs.
"""
import deepl # lazy
translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"])
result = translator.translate_text(
text,
target_lang=lang.upper(),
preserve_formatting=True,
)
return result.text
# ── Markdown-safe web translation (free, no API key) ─────────────────────────
# The `translators` library (UlionTse/translators) drives free web engines
# (yandex/google/bing/...). They translate PLAIN TEXT and will happily mangle
# Markdown, so we translate line-by-line and mask everything that must survive
# verbatim (code, URLs, HTML tags, glossary terms) behind @@N@@ sentinels — which
# the engines pass through unchanged — then restore them afterwards.
_TS_SENTINEL = re.compile(r"@@(\d+)@@")
_HR = re.compile(r"^\s*([-*_])( *\1){2,}\s*$") # thematic break ---
_TABLE_SEP = re.compile(r"^\s*\|?[\s:|-]+\|?\s*$") # |---|:--:| row
_HEADING = re.compile(r"^(\s{0,3}#{1,6}\s+)(.*)$")
_LIST = re.compile(r"^(\s*(?:[-*+]|\d+[.)])\s+)(.*)$")
_QUOTE = re.compile(r"^(\s*>+\s*)(.*)$")
def _protect_inline(text: str, glossary: list[str]) -> tuple[str, list[str]]:
store: list[str] = []
def keep(m: "re.Match[str]") -> str:
store.append(m.group(0))
return f"@@{len(store) - 1}@@"
text = re.sub(r"`[^`]*`", keep, text) # inline code
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", keep, text) # images (whole)
text = re.sub(r"(?<=\])\([^)]*\)", keep, text) # link target (url)
text = re.sub(r"https?://[^\s)]+", keep, text) # bare URLs
text = re.sub(r"</?[A-Za-z][^>]*>", keep, text) # HTML tags
for term in glossary:
text = re.sub(rf"(?<![\w-]){re.escape(term)}(?![\w-])", keep, text)
return text, store
def _restore(text: str, store: list[str]) -> str:
return _TS_SENTINEL.sub(lambda m: store[int(m.group(1))], text)
def _ts_span(text, tr, glossary, memo):
"""Translate one run of prose, masking inline non-translatables."""
if not text.strip():
return text
if text in memo:
return memo[text]
protected, store = _protect_inline(text, glossary)
if not _TS_SENTINEL.sub("", protected).strip():
out = text # nothing left to translate (all masked)
else:
out = _restore(tr(protected), store)
memo[text] = out
return out
def _ts_line(line, tr, glossary, memo):
"""Translate one Markdown line, preserving its structural prefix/markup."""
if not line.strip() or _HR.match(line):
return line
if line.lstrip().startswith("|"): # table row
if _TABLE_SEP.match(line):
return line
return "|".join(
_ts_span(cell, tr, glossary, memo) if cell.strip() else cell
for cell in line.split("|")
)
m = _HEADING.match(line) or _LIST.match(line) or _QUOTE.match(line)
if m:
prefix, rest = m.group(1), m.group(2)
if rest.strip() and (_LIST.match(rest) or _QUOTE.match(rest)):
return prefix + _ts_line(rest, tr, glossary, memo) # nested marker
return prefix + _ts_span(rest, tr, glossary, memo)
return _ts_span(line, tr, glossary, memo)
def _ts_document(md, tr, glossary):
memo: dict[str, str] = {}
out, in_code, fence = [], False, "```"
for line in md.split("\n"):
stripped = line.lstrip()
if not in_code and (stripped.startswith("```") or stripped.startswith("~~~")):
in_code, fence = True, stripped[:3]
out.append(line)
continue
if in_code:
out.append(line)
if stripped.startswith(fence):
in_code = False
continue
out.append(_ts_line(line, tr, glossary, memo))
return "\n".join(out)
def provider_translators(text: str, lang: str, glossary: list[str]) -> str:
"""Free, key-less translation via the `translators` web engines.
Engine order is set by DOCS_TRANSLATE_TS_ENGINES (first that answers wins);
yandex is the default lead as it is the most reliable en->ru here.
"""
import translators as ts # lazy
engines = [
e.strip() for e in
os.environ.get("DOCS_TRANSLATE_TS_ENGINES", "yandex,google,bing,alibaba").split(",")
if e.strip()
]
def tr(s: str) -> str:
last_err = None
for engine in engines:
for _ in range(3):
try:
return ts.translate_text(
s, translator=engine, from_language="en", to_language=lang
)
except Exception as exc: # noqa: BLE001 — try the next engine
last_err = exc
time.sleep(1.0)
raise RuntimeError(f"all translators engines failed: {last_err}")
return _ts_document(text, tr, glossary)
PROVIDERS = {
"noop": provider_noop,
"translators": provider_translators,
"anthropic": provider_anthropic,
"deepl": provider_deepl,
}
# ── Driver ───────────────────────────────────────────────────────────────────
def main() -> int:
repo_root = Path(__file__).resolve().parents[2]
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--lang", required=True, help="target language code, e.g. ru")
ap.add_argument("--src", default=str(repo_root / "docs" / "en"),
help="source (English) docs dir")
ap.add_argument("--dst", default=None,
help="destination dir (default: docs/<lang>)")
ap.add_argument("--cache", default=str(repo_root / ".docs-cache"),
help="per-file translation cache dir")
ap.add_argument("--glossary", default=str(repo_root / "tools" / "docs" / "glossary.txt"),
help="do-not-translate term list")
args = ap.parse_args()
provider_name = os.environ.get("DOCS_TRANSLATE_PROVIDER", "noop")
translate = PROVIDERS.get(provider_name)
if translate is None:
print(f"error: unknown DOCS_TRANSLATE_PROVIDER={provider_name!r} "
f"(known: {', '.join(PROVIDERS)})", file=sys.stderr)
return 2
src = Path(args.src)
dst = Path(args.dst) if args.dst else repo_root / "docs" / args.lang
cache = Path(args.cache)
cache.mkdir(parents=True, exist_ok=True)
glossary = load_glossary(Path(args.glossary))
md_files = sorted(src.rglob("*.md"))
if not md_files:
print(f"error: no .md files under {src}", file=sys.stderr)
return 1
translated = cached = failed = 0
for path in md_files:
rel = path.relative_to(src)
text = path.read_text(encoding="utf-8")
key = cache_key(text, args.lang, provider_name, glossary)
cache_file = cache / f"{key}.md"
if cache_file.is_file():
out = cache_file.read_text(encoding="utf-8")
cached += 1
else:
try:
out = translate(text, args.lang, glossary)
except Exception as exc: # noqa: BLE001
# Graceful degradation: a flaky/rate-limited web engine must never
# break the docs build — fall back to the English source for this
# file (and do NOT cache it, so it is retried next run).
print(f" WARN {rel}: translation failed ({exc}); keeping English",
file=sys.stderr)
failed += 1
out = text
else:
cache_file.write_text(out, encoding="utf-8")
translated += 1
print(f" translated {rel}")
out_path = dst / rel
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(out, encoding="utf-8")
print(f"[{provider_name}] {args.lang}: {len(md_files)} files "
f"({translated} translated, {cached} from cache, {failed} fell back "
f"to English) -> {dst}")
return 0
if __name__ == "__main__":
raise SystemExit(main())