Skip to content

Commit c82e003

Browse files
committed
docs: translate pages in parallel and every language in one run
translations.py translate keeps --jobs pages (default 8) in flight at once on a thread pool; each page is still its own conversation, so the model sees exactly what it did before and only the wall-clock time changes. --lang becomes optional and repeatable like the other commands, so one invocation refreshes every language under a single concurrency cap and usage total. Work is ordered page by page across languages so each language's cached system prompt is written by its first request and read by the rest. The run prints its plan (pages, languages, concurrency, model) before the first request, progress lines carry the language code, and Ctrl-C leaves at once instead of waiting for pages in flight. The API client retries rate limits and overloads a few more times since many pages now share one limit, a connection dropping mid-reply fails that page rather than the run, and the shared Markdown renderer used for heading ids is serialised behind a lock.
1 parent 7bb486a commit c82e003

3 files changed

Lines changed: 305 additions & 71 deletions

File tree

i18n/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ The English pages under `docs/` are the source. This directory holds what steers
1111

1212
```text
1313
uv run --frozen python scripts/docs/translations.py status [--lang CODE]
14-
uv run --frozen --group translate python scripts/docs/translations.py translate --lang CODE [--pages PATH ...]
14+
uv run --frozen --group translate python scripts/docs/translations.py translate [--lang CODE ...] [--pages PATH ...] [--jobs N]
1515
uv run --frozen python scripts/docs/translations.py stage [--lang CODE]
1616
```
1717

18-
`status` is offline: per language it lists missing, outdated (with the sections that changed), current and removable pages (translations whose English page is gone — `git rm` them). `translate` calls the Claude API (`ANTHROPIC_API_KEY` in the environment; the registry's model, or `DOCS_TRANSLATE_MODEL` to trial another) for the missing and outdated pages, retranslating only the English sections that changed and keeping the rest byte for byte; `--pages` instead re-translates exactly the named pages from scratch, which is also how a glossary or instructions change reaches existing pages (each generated page records the English section hashes it reflects, so editing those inputs invalidates nothing). `stage` assembles the tree each language site is built from (every language's, or one with `--lang`): each generated page exactly as it was generated, under an "outdated" notice linking the current English page when the English has changed since, and the English page where nothing was generated yet; `scripts/docs/build.sh` runs it before building them. Commit the generated pages in an ordinary pull request.
18+
`status` is offline: per language it lists missing, outdated (with the sections that changed), current and removable pages (translations whose English page is gone — `git rm` them). `translate` calls the Claude API (`ANTHROPIC_API_KEY` in the environment; the registry's model, or `DOCS_TRANSLATE_MODEL` to trial another) for the missing and outdated pages of every language (or just the `--lang` ones), several pages at a time (`--jobs`, default 8; each page is its own request, so this changes how long the run takes, not what the model sees), retranslating only the English sections that changed and keeping the rest byte for byte; `--pages` instead re-translates exactly the named pages from scratch (in every language unless `--lang` narrows it), which is also how a glossary or instructions change reaches existing pages (each generated page records the English section hashes it reflects, so editing those inputs invalidates nothing). `stage` assembles the tree each language site is built from (every language's, or one with `--lang`): each generated page exactly as it was generated, under an "outdated" notice linking the current English page when the English has changed since, and the English page where nothing was generated yet; `scripts/docs/build.sh` runs it before building them. Commit the generated pages in an ordinary pull request.
1919

2020
To add a language, add an entry to `languages.yml`, write `<code>/instructions.md` (the sections the `pt` file has) and `<code>/glossary.json`, then run `translate --lang <code>`.

scripts/docs/translations.py

Lines changed: 107 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313
1414
Usage (from the repository root):
1515
python scripts/docs/translations.py status [--lang CODE]
16-
python scripts/docs/translations.py translate --lang CODE [--pages PATH ...]
16+
python scripts/docs/translations.py translate [--lang CODE ...] [--pages PATH ...] [--jobs N]
1717
python scripts/docs/translations.py stage [--lang CODE]
1818
1919
Only `translate` calls the model (credentials come from the environment, e.g.
20-
`ANTHROPIC_API_KEY`) and needs the `translate` dependency group;
21-
`DOCS_TRANSLATE_MODEL`, if set, replaces the registry's `model` for that run.
20+
`ANTHROPIC_API_KEY`) and needs the `translate` dependency group; it keeps
21+
`--jobs` pages in flight at once, and `DOCS_TRANSLATE_MODEL`, if set, replaces
22+
the registry's `model` for that run.
2223
Exit codes: 0 done, 1 some page failed, 2 configuration or credential error.
2324
"""
2425

@@ -31,12 +32,16 @@
3132
import re
3233
import shutil
3334
import sys
34-
from collections import Counter
35+
import threading
36+
from collections import Counter, deque
3537
from collections.abc import Callable, Iterator, Sequence
38+
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
3639
from dataclasses import dataclass, field
40+
from itertools import chain, zip_longest
3741
from pathlib import Path
3842
from typing import Any, Literal, Protocol, cast, get_args
3943

44+
import httpx
4045
import markdown
4146
import yaml
4247
import zensical.config
@@ -54,6 +59,12 @@
5459
OUTPUT_TOKEN_BUDGET = 64_000
5560
# Repair turns fed back to the model after the first reply before a page fails.
5661
MAX_REPAIRS = 2
62+
# Pages in flight at once unless `--jobs` says otherwise: each page is its own
63+
# conversation, so concurrency changes nothing the model sees, only wall-clock time.
64+
DEFAULT_JOBS = 8
65+
# Retries (with backoff) the API client makes on rate limits and overloads before
66+
# a request fails its page; generous, since many pages share one rate limit.
67+
API_RETRIES = 6
5768
NOTICES_PAGE = "i18n/notices.md"
5869
# The nav page the notices link to for how the translations are made.
5970
TRANSLATIONS_DOC = "translations.md"
@@ -385,6 +396,8 @@ class Repo:
385396
prose_pages: list[str]
386397
translatable: list[str]
387398
renderer: markdown.Markdown
399+
# python-markdown instances are not thread-safe; pages render one at a time.
400+
_render_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
388401

389402
def language(self, code: str) -> Language:
390403
for language in self.registry.languages:
@@ -413,10 +426,12 @@ def heading_ids(self, body: str) -> list[str]:
413426
Raises:
414427
PageError: The renderer sees headings the source scan does not (setext, indented, HTML).
415428
"""
416-
self.renderer.reset()
417-
self.renderer.convert(body)
418-
tokens = cast("list[dict[str, Any]]", getattr(self.renderer, "toc_tokens", []))
419-
ids, found = [str(token["id"]) for token in _flatten(tokens)], parse_headings(body)
429+
with self._render_lock:
430+
self.renderer.reset()
431+
self.renderer.convert(body)
432+
tokens = cast("list[dict[str, Any]]", getattr(self.renderer, "toc_tokens", []))
433+
ids = [str(token["id"]) for token in _flatten(tokens)]
434+
found = parse_headings(body)
420435
if len(ids) != len(found):
421436
raise PageError(f"the page renders {len(ids)} headings but {len(found)} are ATX headings at column 0")
422437
return ids
@@ -579,14 +594,23 @@ class Completion:
579594

580595

581596
class Translator(Protocol):
582-
"""Anything that answers a conversation (`ConfigError`: credentials rejected; `PageError`: request failed)."""
597+
"""Anything that answers a conversation (`ConfigError`: credentials rejected; `PageError`: request failed).
598+
599+
`complete` is called from several threads at once when pages run in parallel.
600+
"""
583601

584602
def complete(self, *, model: str, system: str, messages: Sequence[Message], max_tokens: int) -> Completion: ...
585603

586604

587605
def anthropic_translator() -> Translator:
588606
"""The Claude Messages API client, streaming, with the system prompt as one cached block.
589607
608+
One client serves every thread. The system prompt is everything a language's
609+
pages share (rules, instructions, glossary), so it is the cacheable prefix:
610+
the first request of a language writes it and the rest read it. A page that
611+
starts before that first reply has begun streaming writes it again instead;
612+
`command_translate` orders the work so that is rare, and nothing waits on it.
613+
590614
`anthropic` lives in the non-default `translate` dependency group, so it is
591615
imported here, by name: offline commands and type checking never need it.
592616
@@ -602,7 +626,7 @@ def anthropic_translator() -> Translator:
602626
# The SDK resolves every credential source it knows at construction; fail
603627
# here, before any page work, rather than on the first request.
604628
try:
605-
client = sdk.Anthropic()
629+
client = sdk.Anthropic(max_retries=API_RETRIES)
606630
except sdk.AnthropicError as exc: # e.g. a credential profile it was pointed at is unreadable
607631
raise ConfigError(f"cannot set up the API client: {exc}") from exc
608632
if not (client.api_key or client.auth_token or client.credentials):
@@ -619,6 +643,8 @@ def complete(self, *, model: str, system: str, messages: Sequence[Message], max_
619643
raise ConfigError(f"the API rejected the credentials: {exc.message}") from exc
620644
except sdk.APIError as exc:
621645
raise PageError(f"API request failed: {exc.message}") from exc
646+
except httpx.HTTPError as exc: # the connection failing mid-reply is not wrapped by the SDK
647+
raise PageError(f"API connection failed: {exc!r}") from exc
622648
usage = Usage(
623649
reply.usage.input_tokens,
624650
reply.usage.output_tokens,
@@ -926,29 +952,71 @@ def translate_page(repo: Repo, inputs: Inputs, job: Job, translator: Translator,
926952

927953

928954
def command_translate(repo: Repo, args: argparse.Namespace, translator: Translator | None) -> int:
929-
language = repo.language(args.lang)
930-
inputs = repo.inputs(language)
931-
jobs = select_jobs([classify(page) for page in repo.pages(language)], args.pages)
932-
if not jobs:
933-
print(f"{language.code}: nothing to translate")
955+
codes = list(dict.fromkeys(args.lang)) # each language once, in the order given
956+
languages = [repo.language(code) for code in codes] if codes else repo.registry.languages
957+
per_language: list[list[tuple[Inputs, Job]]] = []
958+
for language in languages:
959+
inputs = repo.inputs(language)
960+
jobs = select_jobs([classify(page) for page in repo.pages(language)], args.pages)
961+
if not jobs:
962+
print(f"{language.code}: nothing to translate")
963+
per_language.append([(inputs, job) for job in jobs])
964+
# Page-major across languages (every language's first page, then every second page, ...),
965+
# so each language's first request is under way, and its cached prefix written, before
966+
# its next page goes out, however many pages are in flight.
967+
work = [item for item in chain.from_iterable(zip_longest(*per_language)) if item is not None]
968+
if not work:
934969
return 0
935970
model = os.environ.get("DOCS_TRANSLATE_MODEL") or repo.registry.model # never recorded in the generated files
936971
# Only a job with open sections calls the model; a run without one needs no client and no
937972
# credentials. Otherwise both are set up here, so bad credentials fail before any page work.
938-
if translator is None and any(job.open for job in jobs):
973+
if translator is None and any(job.open for _, job in work):
939974
translator = anthropic_translator()
940-
usage, failed = Usage(), False
941-
for job in jobs:
942-
page = job.state.page
975+
976+
def produce(inputs: Inputs, job: Job) -> tuple[str | PageError, Usage]:
977+
"""One page's body (or why it failed) and the tokens it cost; runs on a pool thread."""
978+
spent = Usage()
943979
try:
944-
body = translate_page(repo, inputs, job, translator, model, usage) if translator else reassemble(repo, job)
980+
body = translate_page(repo, inputs, job, translator, model, spent) if translator else reassemble(repo, job)
945981
except PageError as exc:
946-
failed = True
947-
print(f"error: {page.key}: {exc}", file=sys.stderr)
948-
continue
949-
page.target.parent.mkdir(parents=True, exist_ok=True)
950-
page.target.write_text(with_provenance(body, job.state.hashes), encoding="utf-8", newline="\n")
951-
print(f"translated: {page.key} ({len(job.open)} of {len(job.state.hashes)} sections)", flush=True)
982+
return exc, spent
983+
return body, spent
984+
985+
active = sorted({inputs.language.code for inputs, _ in work}, key=[lang.code for lang in languages].index)
986+
print(f"translating {len(work)} pages ({', '.join(active)}), {args.jobs} at a time, with {model}", flush=True)
987+
usage, failed = Usage(), False
988+
queue = deque(work)
989+
running: dict[Future[tuple[str | PageError, Usage]], tuple[Language, Job]] = {}
990+
pool = ThreadPoolExecutor(max_workers=args.jobs)
991+
try:
992+
# The window is refilled here rather than handing the pool every page up front, so
993+
# once a page raises (rejected credentials) no further page starts; the ones already
994+
# in flight run out unreported, and the process exits when they have.
995+
while queue or running:
996+
while queue and len(running) < args.jobs:
997+
inputs, job = queue.popleft()
998+
running[pool.submit(produce, inputs, job)] = (inputs.language, job)
999+
for future in wait(running, return_when=FIRST_COMPLETED).done:
1000+
language, job = running.pop(future)
1001+
result, spent = future.result()
1002+
usage.add(spent)
1003+
page = job.state.page
1004+
if isinstance(result, PageError):
1005+
failed = True
1006+
print(f"{language.code}: error: {page.key}: {result}", file=sys.stderr)
1007+
continue
1008+
page.target.parent.mkdir(parents=True, exist_ok=True)
1009+
page.target.write_text(with_provenance(result, job.state.hashes), encoding="utf-8", newline="\n")
1010+
done = f"{len(job.open)} of {len(job.state.hashes)} sections"
1011+
print(f"{language.code}: translated {page.key} ({done})", flush=True)
1012+
except KeyboardInterrupt:
1013+
# The pool's threads are not daemons: left to them, the process would sit until every
1014+
# page in flight finished (and was thrown away). Leave now instead.
1015+
print(f"interrupted: {len(running)} pages in flight abandoned, {len(queue)} not started", file=sys.stderr)
1016+
print(f"usage: {usage}", flush=True)
1017+
os._exit(130)
1018+
finally:
1019+
pool.shutdown(wait=False)
9521020
print(f"usage: {usage}")
9531021
return 1 if failed else 0
9541022

@@ -1072,6 +1140,12 @@ def command_status(repo: Repo, args: argparse.Namespace) -> int:
10721140
# ---- Command line ----
10731141

10741142

1143+
def _positive(text: str) -> int:
1144+
if (value := int(text)) < 1:
1145+
raise argparse.ArgumentTypeError("must be at least 1")
1146+
return value
1147+
1148+
10751149
def _parser() -> argparse.ArgumentParser:
10761150
parser = argparse.ArgumentParser(
10771151
prog="translations.py", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
@@ -1080,9 +1154,14 @@ def _parser() -> argparse.ArgumentParser:
10801154
status = commands.add_parser("status", help="what each language is missing")
10811155
status.add_argument("--lang", metavar="CODE")
10821156
translate = commands.add_parser("translate", help="translate missing and outdated pages (calls the model)")
1083-
translate.add_argument("--lang", metavar="CODE", required=True)
10841157
translate.add_argument(
1085-
"--pages", nargs="+", metavar="PATH", default=[], help="re-translate exactly these pages from scratch"
1158+
"--lang", nargs="+", action="extend", metavar="CODE", default=[], help="only these (default: every language)"
1159+
)
1160+
translate.add_argument(
1161+
"--pages", nargs="+", action="extend", metavar="PATH", default=[], help="re-translate exactly these, afresh"
1162+
)
1163+
translate.add_argument(
1164+
"--jobs", type=_positive, metavar="N", default=DEFAULT_JOBS, help=f"pages in flight (default {DEFAULT_JOBS})"
10861165
)
10871166
staged = commands.add_parser("stage", help="assemble .build/i18n/CODE/docs for the site build")
10881167
staged.add_argument("--lang", metavar="CODE", help="stage this language only (default: every language)")

0 commit comments

Comments
 (0)