mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
feat(web-app-docs): build a translated site on request instead of all of them inline
build() ran sphinx once per translated language before returning, so a deploy blocked for roughly 30 minutes per language. With 29 translated catalogs German sat eighth and its site appeared after about three and a half hours, which is why the language spec polled /deployed/de/ into a 404 for its whole run. The server already answered an unbuilt version with 202 and a build request; an unbuilt language got a bare 404. Give languages the same treatment. request() takes a code and writes a version:code marker, build_language() builds exactly one from its own checkout via the extracted _checkout(), and the server answers a translated but unbuilt language with 202 while an untranslated one keeps its 404. No language is dropped: build() now queues every translated one into queue/background/, which next_queued() drains only after the lane a visitor is waiting on, so the requested language overtakes the backlog. languages.json grows a translated list beside known, and _language_index() reads a pre-split file as known-only so an existing volume keeps naming its languages instead of rendering an empty switcher. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0ff296faf2
commit
f5eff500c1
4 files changed
+300
-76
No files matched your search
@@ -22,6 +22,8 @@ LATEST = "latest"
|
||||
DEPLOYED = "deployed"
|
||||
LOG_TAIL = 40
|
||||
POLL_SECONDS = 2
|
||||
QUEUE_SEPARATOR = ":"
|
||||
BACKGROUND_LANE = "background"
|
||||
REFS_TTL_SECONDS = 10
|
||||
TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
||||
|
||||
@@ -198,25 +200,54 @@ class Library:
|
||||
)
|
||||
return report
|
||||
|
||||
def request(self, version):
|
||||
"""Queue a build of ``version`` unless its site is current.
|
||||
def request(self, version, code=None, background=False):
|
||||
"""Queue a build of ``version``, or of one of its translated sites.
|
||||
|
||||
Args:
|
||||
version: ``latest`` or a release tag.
|
||||
code: ISO 639-1 code to build instead of the version's own site.
|
||||
background: queue behind every language a visitor asked for.
|
||||
"""
|
||||
head, _ = self.refs()
|
||||
if (not head and version != DEPLOYED) or self._current(version, head):
|
||||
if not head and version != DEPLOYED:
|
||||
return
|
||||
self.queue.mkdir(parents=True, exist_ok=True)
|
||||
(self.queue / version).touch(exist_ok=True)
|
||||
if code is None:
|
||||
if self._current(version, head):
|
||||
return
|
||||
marker = version
|
||||
elif self.translation_servable(version, code) or not self.translates(
|
||||
version, code
|
||||
):
|
||||
return
|
||||
else:
|
||||
marker = f"{version}{QUEUE_SEPARATOR}{code}"
|
||||
lane = self.queue / BACKGROUND_LANE if background else self.queue
|
||||
lane.mkdir(parents=True, exist_ok=True)
|
||||
if background and (self.queue / marker).exists():
|
||||
return
|
||||
(lane / marker).touch(exist_ok=True)
|
||||
|
||||
def _dequeue(self, marker):
|
||||
"""Drop ``marker`` from both lanes.
|
||||
|
||||
Args:
|
||||
marker: queue file name, ``version`` or ``version:code``.
|
||||
"""
|
||||
(self.queue / marker).unlink(missing_ok=True)
|
||||
(self.queue / BACKGROUND_LANE / marker).unlink(missing_ok=True)
|
||||
|
||||
def next_queued(self):
|
||||
if not self.queue.is_dir():
|
||||
return None
|
||||
waiting = sorted(
|
||||
self.queue.iterdir(), key=lambda marker: marker.stat().st_mtime
|
||||
)
|
||||
return waiting[0].name if waiting else None
|
||||
"""Return the oldest marker a visitor is waiting on, else the oldest background one."""
|
||||
for lane in (self.queue, self.queue / BACKGROUND_LANE):
|
||||
if not lane.is_dir():
|
||||
continue
|
||||
waiting = sorted(
|
||||
(marker for marker in lane.iterdir() if marker.is_file()),
|
||||
key=lambda marker: marker.stat().st_mtime,
|
||||
)
|
||||
if waiting:
|
||||
return waiting[0].name
|
||||
return None
|
||||
|
||||
def acquire_builder(self):
|
||||
"""Try to become the builder of all replicas without blocking.
|
||||
@@ -268,9 +299,13 @@ class Library:
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
print(f"fetch of {self.repository} failed: {exc}", file=sys.stderr)
|
||||
next_fetch = time.monotonic() + interval
|
||||
version = self.next_queued()
|
||||
if version is None:
|
||||
marker = self.next_queued()
|
||||
if marker is None:
|
||||
time.sleep(POLL_SECONDS)
|
||||
continue
|
||||
version, separator, code = marker.partition(QUEUE_SEPARATOR)
|
||||
if separator:
|
||||
self.build_language(version, code)
|
||||
else:
|
||||
self.build(version)
|
||||
|
||||
@@ -287,6 +322,19 @@ class Library:
|
||||
"""
|
||||
return self._resolve(self.sites / version / "html", rest)
|
||||
|
||||
def _language_index(self, version):
|
||||
try:
|
||||
payload = json.loads(
|
||||
(self.translations / version / "languages.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
if isinstance(payload.get("known"), dict):
|
||||
return payload
|
||||
return {"known": payload, "translated": []}
|
||||
|
||||
def languages(self, version):
|
||||
"""Return the languages of ``version`` and the ones with a built site.
|
||||
|
||||
@@ -297,19 +345,21 @@ class Library:
|
||||
``(known, built)``: every language code of the version mapped to
|
||||
its native name, and the codes whose translated site is servable.
|
||||
"""
|
||||
try:
|
||||
known = json.loads(
|
||||
(self.translations / version / "languages.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return {}, []
|
||||
known = self._language_index(version).get("known") or {}
|
||||
built = [
|
||||
code for code in sorted(known) if self.translation_servable(version, code)
|
||||
]
|
||||
return known, built
|
||||
|
||||
def translates(self, version, code):
|
||||
"""Return whether ``version`` carries translations for ``code``.
|
||||
|
||||
Args:
|
||||
version: ``latest`` or a release tag.
|
||||
code: ISO 639-1 code.
|
||||
"""
|
||||
return code in (self._language_index(version).get("translated") or [])
|
||||
|
||||
def translation_servable(self, version, code):
|
||||
return (self.translations / version / code / "html" / "index.html").is_file()
|
||||
|
||||
@@ -352,10 +402,10 @@ class Library:
|
||||
self._forget_refs()
|
||||
head, _ = self.refs()
|
||||
if version not in self.versions():
|
||||
(self.queue / version).unlink(missing_ok=True)
|
||||
self._dequeue(version)
|
||||
return
|
||||
if self._current(version, head):
|
||||
(self.queue / version).unlink(missing_ok=True)
|
||||
self._dequeue(version)
|
||||
return
|
||||
ref = {LATEST: head, DEPLOYED: self.snapshot_ref()}.get(version, version)
|
||||
work = self.scratch / version
|
||||
@@ -364,21 +414,7 @@ class Library:
|
||||
state = {"state": "building", "phase": "checkout", "progress": 0, "log": []}
|
||||
try:
|
||||
self._save_state(version, **state)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
if version == DEPLOYED:
|
||||
shutil.copytree(self.snapshot, src)
|
||||
else:
|
||||
src.mkdir(parents=True)
|
||||
archive = work / "src.tar"
|
||||
self._git("archive", "--format=tar", "-o", str(archive), ref)
|
||||
with tarfile.open(archive) as tar:
|
||||
tar.extractall(src, filter="data")
|
||||
|
||||
shutil.copytree(self.package_dir, conf)
|
||||
if (src / "assets" / "img").is_dir():
|
||||
shutil.copytree(
|
||||
src / "assets" / "img", conf / "assets" / "img", dirs_exist_ok=True
|
||||
)
|
||||
self._checkout(version, ref, work)
|
||||
|
||||
generators = generate_commands(src)
|
||||
env = {**os.environ, "PYTHONPATH": tooling}
|
||||
@@ -389,12 +425,7 @@ class Library:
|
||||
self._save_state(version, **state)
|
||||
|
||||
state["phase"] = "sphinx"
|
||||
sphinx_env = {
|
||||
**os.environ,
|
||||
"PYTHONPATH": os.pathsep.join([str(src), tooling]),
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"DOCS_VERSION": version,
|
||||
}
|
||||
sphinx_env = self._sphinx_env(version, src)
|
||||
self._run(
|
||||
version,
|
||||
state,
|
||||
@@ -415,42 +446,108 @@ class Library:
|
||||
self._publish(version, out / "html", ref)
|
||||
known, translated = translated_languages(src)
|
||||
(self.translations / version).mkdir(parents=True, exist_ok=True)
|
||||
_write_json(self.translations / version / "languages.json", known)
|
||||
_write_json(
|
||||
self.translations / version / "languages.json",
|
||||
{"known": known, "translated": translated},
|
||||
)
|
||||
self._save_state(version, state="ready", phase="", progress=100, log=[])
|
||||
for code in translated:
|
||||
state["phase"] = f"translate {code}"
|
||||
self._save_state(version, **state)
|
||||
target = work / f"out-{code}"
|
||||
self._run(
|
||||
version,
|
||||
state,
|
||||
[
|
||||
"sphinx-build",
|
||||
"-M",
|
||||
"html",
|
||||
str(src),
|
||||
str(target),
|
||||
"-c",
|
||||
str(conf),
|
||||
"-j",
|
||||
str(self.jobs),
|
||||
"-D",
|
||||
f"language={code}",
|
||||
"-D",
|
||||
"html_copy_source=0",
|
||||
],
|
||||
sphinx_env,
|
||||
work,
|
||||
)
|
||||
self._publish_site(
|
||||
self.translations / version, code, target / "html", ref
|
||||
)
|
||||
self.request(version, code, background=True)
|
||||
except (OSError, subprocess.CalledProcessError, tarfile.TarError) as exc:
|
||||
state["log"] = [*state["log"][-(LOG_TAIL - 1) :], str(exc)]
|
||||
self._save_state(version, **{**state, "state": "failed"})
|
||||
finally:
|
||||
self._dequeue(version)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
def _checkout(self, version, ref, work):
|
||||
"""Lay out ``src`` and ``conf`` of ``ref`` under a fresh ``work``.
|
||||
|
||||
Args:
|
||||
version: ``latest`` or a release tag.
|
||||
ref: the commit or digest the version resolves to.
|
||||
work: scratch directory to replace.
|
||||
"""
|
||||
src, conf = work / "src", work / "conf"
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
if version == DEPLOYED:
|
||||
shutil.copytree(self.snapshot, src)
|
||||
else:
|
||||
src.mkdir(parents=True)
|
||||
archive = work / "src.tar"
|
||||
self._git("archive", "--format=tar", "-o", str(archive), ref)
|
||||
with tarfile.open(archive) as tar:
|
||||
tar.extractall(src, filter="data")
|
||||
shutil.copytree(self.package_dir, conf)
|
||||
if (src / "assets" / "img").is_dir():
|
||||
shutil.copytree(
|
||||
src / "assets" / "img", conf / "assets" / "img", dirs_exist_ok=True
|
||||
)
|
||||
|
||||
def _sphinx_env(self, version, src):
|
||||
return {
|
||||
**os.environ,
|
||||
"PYTHONPATH": os.pathsep.join([str(src), str(self.package_dir.parent)]),
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"DOCS_VERSION": version,
|
||||
}
|
||||
|
||||
def build_language(self, version, code):
|
||||
"""Build one translated site of ``version`` into its own scratch.
|
||||
|
||||
Args:
|
||||
version: ``latest`` or a release tag.
|
||||
code: ISO 639-1 code of a language the version's catalogs translate.
|
||||
"""
|
||||
marker = f"{version}{QUEUE_SEPARATOR}{code}"
|
||||
self._forget_refs()
|
||||
head, _ = self.refs()
|
||||
if not self._current(version, head) or not self.translates(version, code):
|
||||
self._dequeue(marker)
|
||||
self.request(version)
|
||||
return
|
||||
ref = {LATEST: head, DEPLOYED: self.snapshot_ref()}.get(version, version)
|
||||
work = self.scratch / f"{version}{QUEUE_SEPARATOR}{code}"
|
||||
src, conf = work / "src", work / "conf"
|
||||
target = work / "out"
|
||||
state = {
|
||||
"state": "building",
|
||||
"phase": f"translate {code}",
|
||||
"progress": 0,
|
||||
"log": [],
|
||||
}
|
||||
try:
|
||||
self._save_state(version, **state)
|
||||
self._checkout(version, ref, work)
|
||||
self._run(
|
||||
version,
|
||||
state,
|
||||
[
|
||||
"sphinx-build",
|
||||
"-M",
|
||||
"html",
|
||||
str(src),
|
||||
str(target),
|
||||
"-c",
|
||||
str(conf),
|
||||
"-j",
|
||||
str(self.jobs),
|
||||
"-D",
|
||||
f"language={code}",
|
||||
"-D",
|
||||
"html_copy_source=0",
|
||||
],
|
||||
self._sphinx_env(version, src),
|
||||
work,
|
||||
)
|
||||
self._publish_site(self.translations / version, code, target / "html", ref)
|
||||
self._save_state(version, state="ready", phase="", progress=100, log=[])
|
||||
except (OSError, subprocess.CalledProcessError, tarfile.TarError) as exc:
|
||||
state["log"] = [*state["log"][-(LOG_TAIL - 1) :], str(exc)]
|
||||
self._save_state(version, **{**state, "state": "failed"})
|
||||
finally:
|
||||
(self.queue / version).unlink(missing_ok=True)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
self._dequeue(marker)
|
||||
|
||||
def _publish(self, version, html, ref):
|
||||
self._publish_site(self.sites, version, html, ref)
|
||||
|
||||
@@ -140,7 +140,12 @@ class DocsHandler(SimpleHTTPRequestHandler):
|
||||
def _translation(self, version, code, path, head):
|
||||
rest = path.lstrip("/").partition("/")[2].partition("/")[2]
|
||||
if not self.library.translation_servable(version, code):
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
if not self.library.translates(version, code):
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
self.library.request(version, code)
|
||||
body = BUILDING.format(version=html.escape(f"{version}/{code}"))
|
||||
self._page(HTTPStatus.ACCEPTED, f"Building {version}/{code}", body, head)
|
||||
elif (not rest and not path.endswith("/")) or (
|
||||
target := self.library.resolve_translation(version, code, rest)
|
||||
) is None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -161,7 +162,86 @@ class TestLibrary(unittest.TestCase):
|
||||
shelf.request("deployed")
|
||||
self.assertFalse((shelf.queue / "deployed").exists())
|
||||
|
||||
def test_every_translated_language_gets_its_own_site(self) -> None:
|
||||
def _drain(self, limit=20):
|
||||
"""Run the builder's dispatch until the queue empties.
|
||||
|
||||
Args:
|
||||
limit: hard stop so a marker that never clears fails loudly.
|
||||
"""
|
||||
drained = []
|
||||
for _ in range(limit):
|
||||
marker = self.library.next_queued()
|
||||
if marker is None:
|
||||
return drained
|
||||
drained.append(marker)
|
||||
version, separator, code = marker.partition(":")
|
||||
if separator:
|
||||
self.library.build_language(version, code)
|
||||
else:
|
||||
self.library.build(version)
|
||||
self.fail(f"queue did not empty in {limit} steps, drained {drained}")
|
||||
|
||||
def test_the_builder_drains_every_queued_language(self) -> None:
|
||||
(self.repo / "meta").mkdir()
|
||||
(self.repo / "meta" / "languages.yml").write_text(
|
||||
"en:\n native: English\nde:\n native: Deutsch\n"
|
||||
"fr:\n native: Français\nit:\n native: Italiano\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
for code in ("de", "fr", "it"):
|
||||
catalog = self.repo / "locale" / code / "LC_MESSAGES" / "docs.po"
|
||||
catalog.parent.mkdir(parents=True)
|
||||
catalog.write_text(
|
||||
f'msgid ""\nmsgstr ""\n"Language: {code}\\n"\n\nmsgid "Hello"\nmsgstr "Hallo {code}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
_commit(self.repo, "three translations")
|
||||
self.library.fetch()
|
||||
self.library.build("latest")
|
||||
|
||||
self.library.request("latest", "it")
|
||||
drained = self._drain()
|
||||
|
||||
self.assertEqual(
|
||||
drained[0], "latest:it", "the requested language must run first"
|
||||
)
|
||||
self.assertEqual(sorted(drained), ["latest:de", "latest:fr", "latest:it"])
|
||||
self.assertEqual(self.library.languages("latest")[1], ["de", "fr", "it"])
|
||||
self.assertIsNone(self.library.next_queued())
|
||||
|
||||
def test_a_language_index_from_before_the_split_still_names_its_languages(
|
||||
self,
|
||||
) -> None:
|
||||
index = self.library.translations / "latest" / "languages.json"
|
||||
index.parent.mkdir(parents=True, exist_ok=True)
|
||||
index.write_text(json.dumps({"de": "Deutsch", "fr": "Français"}), "utf-8")
|
||||
site = self.library.translations / "latest" / "de" / "html"
|
||||
site.mkdir(parents=True, exist_ok=True)
|
||||
(site / "index.html").write_text("de", encoding="utf-8")
|
||||
|
||||
known, built = self.library.languages("latest")
|
||||
|
||||
self.assertEqual(sorted(known), ["de", "fr"])
|
||||
self.assertEqual(built, ["de"])
|
||||
self.assertFalse(self.library.translates("latest", "de"))
|
||||
|
||||
def test_a_requested_language_outruns_an_older_background_one(self) -> None:
|
||||
background = self.library.queue / "background"
|
||||
background.mkdir(parents=True, exist_ok=True)
|
||||
for stale in (*self.library.queue.iterdir(), *background.iterdir()):
|
||||
if stale.is_file():
|
||||
stale.unlink()
|
||||
(background / "latest:ar").touch()
|
||||
(self.library.queue / "latest:de").touch()
|
||||
|
||||
self.assertEqual(self.library.next_queued(), "latest:de")
|
||||
|
||||
self.library._dequeue("latest:de")
|
||||
self.assertEqual(self.library.next_queued(), "latest:ar")
|
||||
self.library._dequeue("latest:ar")
|
||||
self.assertIsNone(self.library.next_queued())
|
||||
|
||||
def test_a_translated_language_builds_its_site_on_request(self) -> None:
|
||||
(self.repo / "meta").mkdir()
|
||||
(self.repo / "meta" / "languages.yml").write_text(
|
||||
"en:\n native: English\nde:\n native: Deutsch\nfr:\n native: Français\n",
|
||||
@@ -181,7 +261,27 @@ class TestLibrary(unittest.TestCase):
|
||||
|
||||
known, built = self.library.languages("latest")
|
||||
self.assertEqual(sorted(known), ["de", "en", "fr"])
|
||||
self.assertEqual(built, ["de"])
|
||||
self.assertEqual(built, [], "a version build must not prebuild any language")
|
||||
self.assertTrue(self.library.translates("latest", "de"))
|
||||
self.assertFalse(self.library.translates("latest", "fr"))
|
||||
self.assertTrue(
|
||||
(self.library.queue / "background" / "latest:de").exists(),
|
||||
"every translated language must stay queued for a background build",
|
||||
)
|
||||
self.assertFalse((self.library.queue / "background" / "latest:fr").exists())
|
||||
self.assertEqual(self.library.next_queued(), "latest:de")
|
||||
|
||||
self.library.request("latest", "fr")
|
||||
self.assertFalse(
|
||||
(self.library.queue / "latest:fr").exists(),
|
||||
"an untranslated language must not enter the queue",
|
||||
)
|
||||
self.library.request("latest", "de")
|
||||
self.assertTrue((self.library.queue / "latest:de").exists())
|
||||
self.library.build_language("latest", "de")
|
||||
|
||||
self.assertEqual(self.library.languages("latest")[1], ["de"])
|
||||
self.assertFalse((self.library.queue / "latest:de").exists())
|
||||
self.assertTrue(self.library.translation_servable("latest", "de"))
|
||||
page = self.library.resolve_translation("latest", "de", "docs/")
|
||||
self.assertEqual(read_text(str(page)), "docs")
|
||||
|
||||
@@ -99,6 +99,28 @@ class TestServer(unittest.TestCase):
|
||||
def test_unknown_version_is_not_found(self) -> None:
|
||||
self.assertEqual(self._request("/v9.9.9/")[0], 404)
|
||||
|
||||
def test_a_translated_language_starts_its_build_and_an_untranslated_one_is_not_found(
|
||||
self,
|
||||
) -> None:
|
||||
index = self.library.translations / "latest" / "languages.json"
|
||||
index.parent.mkdir(parents=True, exist_ok=True)
|
||||
index.write_text(
|
||||
json.dumps(
|
||||
{"known": {"de": "Deutsch", "aa": "Afar"}, "translated": ["de"]}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
status, _, body = self._request("/latest/de/")
|
||||
self.assertEqual(status, 202)
|
||||
self.assertIn("latest/de", body)
|
||||
self.assertTrue((self.library.queue / "latest:de").exists())
|
||||
self.assertEqual(self._request("/latest/aa/")[0], 404)
|
||||
self.assertFalse((self.library.queue / "latest:aa").exists())
|
||||
finally:
|
||||
(self.library.queue / "latest:de").unlink(missing_ok=True)
|
||||
index.unlink(missing_ok=True)
|
||||
|
||||
def test_unbuilt_version_starts_its_build_and_shows_progress(self) -> None:
|
||||
status, _, body = self._request("/v1.0.0/page.html")
|
||||
|
||||
|
||||
Reference in new issue
Block a user