Files
computer-playbook/cli/build/docs/readme/schema.py
T
kevinveenbirkenbachandClaude Opus 5 a9653698ce feat(docs): give every invokable role an Architecture sub-page
The Cosmos section drew one diagram inside the README and truncated its
dependents at twelve. It is replaced by an `ARCHITECTURE.md` sub-page per
invokable role with four sections, and the README keeps only a link table.

Compose lists the containers with image and published ports. Swarm shows the
replica count and the effective placement constraint per service. Volumes
draws the decision chain `swarm_nfs_backed` walks instead of its outcome,
because whether a volume is NFS-backed depends on the mode, the storage
backend, the entry's own `nfs:` flag and the role's placement at once, and a
diagram stating only the result hides the coupling that makes a placement
change move data silently. Dependencies is the cosmos graph with the cap
removed, so `_DEPENDENTS_CAP`, `shown`, `overflow` and the `dpt_more` node are
gone.

Which containers a role runs cannot be read off `meta/services.yml`: it mixes
the role's own services with the ones it merely consumes, and the entries that
do describe a container carry `name`, or only `image`, or neither when the
image is built, so no field discriminates. A heuristic over that file dropped
`pds` from web-app-bluesky, five of six services from web-app-funkwhale and
the only service of web-svc-cdn. The new `services` module reads the
assignments that actually render a container, `{% set service_name = ... %}`,
resolving literals directly and constants through `vars/main.yml`; every
template in the tree that includes the container base carries one.

The page is written by its own unconditional loop in `__main__.py`, not gated
behind `--update-architecture`, so the documentation build cannot emit the
link table without the page it points at. `--check` compares instead of
writing. A committed copy is refused by
`test_no_role_commits_the_architecture_sub_page`, the rule the managed README
sections already follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-16 23:37:17 +02:00

88 lines
2.9 KiB
Python

"""Derive the role-README section schema from the Jinja template.
``templates/roles/README.md.j2.tmpl`` is the single source of truth. The
required sections are the H2s the template emits with no optional context;
the optional sections are the extra H2s that appear once every
``{% if ... %}``-guarded variable is supplied. Rendering the template both
ways and diffing the H2 sets keeps the lint and the generator in lock-step
with the template.
"""
from __future__ import annotations
import functools
import jinja2
from cli.build.docs.readme.sections import h2_titles
from utils.cache.files import PROJECT_ROOT
from utils.software import SOFTWARE_REPOSITORY
TEMPLATE_DIR = PROJECT_ROOT / "templates" / "roles"
TEMPLATE_NAME = "README.md.j2.tmpl"
_REQUIRED_CTX: dict = {
"application_id": "web-app-example",
"application_name": "Example",
"application_url": "https://example.com/",
"application_description": "is an example application.",
"application_overview": "This role deploys the example application.",
"application_features": [{"name": "Example", "description": "An example feature."}],
"application_author": "Kevin Veen-Birkenbach",
"software_repository": SOFTWARE_REPOSITORY,
"application_deploy_image": "ghcr.io/example/core/debian:latest",
}
_OPTIONAL_CTX: dict = {
"application_invokable": True,
"application_author_url": "https://social.infinito.nexus/profile/kevinveenbirkenbach/profile",
"architecture_intro": "Example architecture intro.",
"application_use_cases": "Example use case.",
"application_developer_notes": [
{"file": "Administration.md", "description": "notes."}
],
"application_further_resources": [
{"label": "Example", "url": "https://example.com/"}
],
}
def _render(ctx: dict) -> str:
env = jinja2.Environment(
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
keep_trailing_newline=True,
autoescape=jinja2.select_autoescape(),
)
return env.get_template(TEMPLATE_NAME).render(**ctx)
def render_required() -> str:
return _render(dict(_REQUIRED_CTX))
def render_full() -> str:
return _render({**_REQUIRED_CTX, **_OPTIONAL_CTX})
@functools.lru_cache(maxsize=1)
def required_sections() -> tuple[str, ...]:
"""H2 titles the template emits without any optional context (mandatory)."""
return tuple(h2_titles(render_required()))
@functools.lru_cache(maxsize=1)
def canonical_order() -> tuple[str, ...]:
"""Full H2 order (required + optional) as the template lays them out."""
return tuple(h2_titles(render_full()))
@functools.lru_cache(maxsize=1)
def optional_sections() -> tuple[str, ...]:
required = set(required_sections())
return tuple(title for title in canonical_order() if title not in required)
def credits_heading() -> str:
"""The last required section (Credits by convention)."""
return required_sections()[-1]