Files
computer-playbook/roles/web-app-docs/files/python/infinito_docs/sites.py
T
kevinveenbirkenbachandClaude Opus 5 c39f3ec9ee feat(web-app-docs): replay the published instructions in its CLI test
The replay of a role's "### Production" block moves out of the CI guide
scripts and into the documentation role itself. The block is read off the
rendered page over HTTP, so what runs is what a reader copies, and the role
that publishes the page is also the role that proves it.

scripts/github/guide/{one,compose_deploy,host_deploy}.sh and
cli/build/docs/readme/production.py are gone with the workflow step that
called them. The instructions marker now only adds web-app-docs to the row's
apps; test-e2e-cli discovers the role through its new templates/test.env.j2
and runs files/test/test.sh.

The block runs in a throwaway machine, never on the stack host. compose.yml
starts one service built from files/test/Dockerfile, whose base is
INFINITO_PARENT_IMAGE out of the mounted checkout's .env, so the machine is
the stack host's own distribution and the replay exercises the installation
instead of a prebuilt image. files/test/init.sh adds an init when the base
carries none and keeps the full -Syu that ef0f03ea98 established for arch.
scripts/tests/workspace/base/01_install.sh then prepares the machine before
the block runs.

Nothing is skipped any more. The previous host-install branch exited 0 and
passed silently over 65 of the 178 invokable roles; a block without a
"docker run" is now replayed against systemd in that same machine, with the
git clone dropped in favour of the mounted tree under test.

The target role comes from the new guide_role lookup, which reads
list_invokable_app_ids() rather than matching role-name prefixes, and falls
back to the asking role when the round deploys nothing else. deployment.py
grows running_apps() so both lookups share one definition of what the round
deploys. required_by_coverage.role_is_invokable drops its own walk over
categories.yml and delegates to the same SPOT; measured equal across all 280
roles.

Carried in the same index, because the refactor pass ran over this change
set: library.py splits into sites.py and build_queue.py mixins, builder.py
hands translated_languages to catalogs.py and folds three repeated log-tail
and state writes, and placeholders.py becomes a facade over spans.py,
repairs.py and damage.py. No test changed for those splits, and the two
ref-resolution dicts builder.py carried are replaced by _wanted_ref, which
also stops hashing the deployed tree on every tag build.

Verified: 7132 unit and lint tests pass over the whole tree, the guide_role
lookup renders through a real Templar, the base image resolves from .env,
and compose config validates the service. The replay itself has never been
executed; that needs a deploy of web-app-docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 04:39:48 +02:00

115 lines
3.9 KiB
Python

"""The built site trees on disk: what exists, what it was built from, and
which file a request maps to.
``Sites`` is a mixin of :class:`infinito_docs.library.Library` and reads only
``self.sites`` and ``self.translations``.
"""
from __future__ import annotations
import json
class Sites:
def built_ref(self, version):
stamp = self.sites / version / "ref"
return stamp.read_text(encoding="utf-8").strip() if stamp.is_file() else ""
def servable(self, version):
return (self.sites / version / "html" / "index.html").is_file()
def translation_ref(self, version, code):
"""Return the ref the translated site of ``code`` was built from.
Args:
version: ``latest`` or a release tag.
code: ISO 639-1 code.
"""
stamp = self.translations / version / code / "ref"
return stamp.read_text(encoding="utf-8").strip() if stamp.is_file() else ""
def translation_servable(self, version, code):
return (self.translations / version / code / "html" / "index.html").is_file()
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 _language_index_is_current(self, version) -> bool:
"""Whether the version's language index was written by this code.
An index from before the split lists only the known languages, so the
translated ones are unknown and no language can be requested. Calling
that version current would leave it in that state forever, because only
a build rewrites the index.
Args:
version: ``latest`` or a release tag.
"""
path = self.translations / version / "languages.json"
if not path.is_file():
return True
try:
return "translated" in json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return False
def languages(self, version):
"""Return the languages of ``version`` and the ones with a built site.
Args:
version: ``latest`` or a release tag.
Returns:
``(known, built)``: every language code of the version mapped to
its native name, and the codes whose translated site is servable.
"""
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 resolve(self, version, rest):
"""Return the file a request for ``rest`` in ``version`` maps to.
Args:
version: a built version.
rest: the request path below the version, already unquoted.
Returns:
The file inside the version's site, or ``None`` when the site has
no such file or the path escapes it.
"""
return self._resolve(self.sites / version / "html", rest)
def resolve_translation(self, version, code, rest):
return self._resolve(self.translations / version / code / "html", rest)
def _resolve(self, site, rest):
root = site.resolve()
target = (root / rest).resolve()
if target != root and root not in target.parents:
return None
if target.is_dir():
target /= "index.html"
return target if target.is_file() else None