Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions dbuild/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,18 +323,48 @@ def _site_placeholders(text: str, env_file: bool = False) -> str:
return text


# Each combination is a full set of files on the page, so they multiply:
# crafting-apps' ten on/off choices made 2048, a 42 MB page that crashed
# browsers. Past this, the docs show the defaults only.
MAX_COMBOS = 16


def _combination_count(cfg: Config) -> int:
import math

return math.prod(len(c.options) for c in cfg.metadata.choices) if cfg.metadata.choices else 0


def _combinations(cfg: Config) -> list[list[tuple[Any, Any]]]:
"""Every way of answering the choices: one option per choice, every
combination, the all-default one first."""
combination, the all-default one first. Only the default past MAX_COMBOS."""
import itertools

cs = cfg.metadata.choices
if not cs:
return []
ordered = [sorted(c.options, key=lambda o: o.id != c.default) for c in cs]
if _combination_count(cfg) > MAX_COMBOS:
return [[(c, o[0]) for c, o in zip(cs, ordered, strict=True)]]
return [list(zip(cs, picks, strict=True)) for picks in itertools.product(*ordered)]


def _capped_note(cfg: Config, runtime: str) -> str:
"""What the docs say in place of the choices once they are past
MAX_COMBOS, or "" when every combination is listed."""
count = _combination_count(cfg)
if count <= MAX_COMBOS:
return ""
cs = cfg.metadata.choices
where = "its jail from `appjail-director.yml`" if runtime == "appjail" else "its service from `compose.yaml`"
# Every option only keeps or drops services (the default drops none).
if all(not (o.profile or o.env or o.ask or o.service_yaml or o.env_lines or o.director_dep or o.env_appjail)
for c in cs for o in c.options):
return f"These files run every part. To leave one out, delete {where}."
picks = ", ".join(f"{c.label}: {o.label}" for c in cs for o in c.options if o.id == c.default)
return f"These files use the defaults ({picks}). The {count} other ways are too many to list here; change the files by hand."


def _choice_variants(cfg: Config) -> list[tuple[list[tuple[Any, Any]], str, str]]:
"""(pairs, compose text, env text) for every combination that is not the
default. The files people copy, one per combination."""
Expand Down Expand Up @@ -430,7 +460,7 @@ def _choice_context(cfg: Config, context_env: list[dict[str, Any]]) -> dict[str,
README lists ``combos`` flat. ``choices`` is the declaration itself.
"""
if not cfg.metadata.choices or not cfg.compose_text:
return {"choices": [], "combos": [], "tree": None}
return {"choices": [], "combos": [], "tree": None, "capped": {"podman": "", "appjail": ""}}
import re as _re

import yaml # lazy, as everywhere in this module: the wheel build imports it without yaml
Expand Down Expand Up @@ -565,7 +595,8 @@ def tree(level: int, picked: tuple) -> dict | None:
declared = [{"id": c.id, "label": c.label, "doc": c.doc, "default": c.default,
"options": [{"id": o.id, "label": o.label, "doc": o.doc, "default": o.id == c.default} for o in c.options]}
for c in cfg.metadata.choices]
return {"choices": declared, "combos": combos, "tree": tree(0, ())}
return {"choices": declared, "combos": combos, "tree": tree(0, ()),
"capped": {r: _capped_note(cfg, r) for r in ("podman", "appjail")}}


def _enrich_metadata(cfg: Config, community_override: str | None = None) -> dict[str, Any]:
Expand Down Expand Up @@ -825,6 +856,7 @@ def _enrich_metadata(cfg: Config, community_override: str | None = None) -> dict

cc = _choice_context(cfg, context["env"])
context["choices"], context["combos"], context["choice_tree"] = cc["choices"], cc["combos"], cc["tree"]
context["choices_capped"] = cc["capped"]
# A stack whose author wrote the whole director: the README shows that,
# not the one-service template, with the jail templates it ships.
authored = _authored_director(cfg)
Expand Down
12 changes: 12 additions & 0 deletions dbuild/templates/README.j2
Original file line number Diff line number Diff line change
Expand Up @@ -426,9 +426,15 @@ Before deploying, ensure your host environment is ready. See the [Quick Start Gu

**{{ c.label }}.** {{ c.doc }}
{%- endfor %}
{%- if choices_capped.podman %}

{{ choices_capped.podman }}
{%- endif %}
{%- for cb in combos %}
{%- if not choices_capped.podman %}

#### {{ cb.label }}{% if cb.default %} (default){% endif %}
{%- endif %}
{%- if cb.doc %}

{{ cb.doc }}
Expand Down Expand Up @@ -492,9 +498,15 @@ Save as `compose.yaml`, then run `podman-compose up -d`.
{%- if appjail_enabled and choices %}

### AppJail Director
{%- if choices_capped.appjail %}

{{ choices_capped.appjail }}
{%- endif %}
{%- for cb in combos %}
{%- if not choices_capped.appjail %}

#### {{ cb.label }}{% if cb.default %} (default){% endif %}
{%- endif %}

{{ appjail_director_snippet(option=cb) }}

Expand Down
6 changes: 6 additions & 0 deletions dbuild/templates/README.mkdocs.j2
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
keeps Podman and AppJail on the same answers. Markdown inside the HTML
(md_in_html) so the fences keep their zip attributes. #}
{%- macro choice_form(runtime) -%}
{%- if choices_capped[runtime] %}
{{ choices_capped[runtime] }}

{{ combo_files(combos[0], runtime) }}
{% else -%}
<div class="deploy-choices" data-app="{{ name }}" data-runtime="{{ runtime }}" markdown>
{% for c in choices %}

Expand All @@ -28,6 +33,7 @@

</div>
{% endfor %}
{%- endif %}
{%- endmacro %}

{%- macro combo_files(cb, runtime) -%}
Expand Down
66 changes: 66 additions & 0 deletions tests/test_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1000,3 +1000,69 @@ def test_a_command_line_is_not_wrapped(self):
outputs = self._render(" cli: [thelounge, 'php occ']\n")
self.assertIn("RUN daemonless-cli --install thelounge\n", outputs["Containerfile"],
"'php occ' would wrap php itself; lint reports it")


def _toggles_compose(n: int) -> str:
"""A stack of n apps, each a choice that keeps it or drops it."""
choices = "".join(f"""\
app{i}:
label: App{i}
doc: App number {i}.
default: "on"
options:
"on": {{ label: "Included" }}
"off": {{ label: "Left out", drop: [app{i}] }}
""" for i in range(n))
services = "".join(f"""\
app{i}:
image: ghcr.io/daemonless/app{i}:latest
ports:
- "{8080 + i}:80"
""" for i in range(n))
return f"""\
name: suite
x-daemonless:
title: "Suite"
description: "Some apps."
category: "Utilities"
icon: ":test:"
upstream_url: "https://example.com"
user: "bsd"
choices:
{choices}services:
{services}"""


class TestChoiceCap(unittest.TestCase):
"""Each combination is a full set of files on the page: past MAX_COMBOS
the docs show the defaults, not 2^n copies."""

def _ctx(self, n: int):
with tempfile.TemporaryDirectory() as d:
tmp = Path(d)
(tmp / "compose.yaml").write_text(_toggles_compose(n))
(tmp / "example.env").write_text("TZ=UTC\n")
(tmp / "Containerfile.j2").write_text(CONTAINERFILE_J2)
with _chdir(tmp):
cfg = dbuild_config.load(tmp)
ctx = docs._enrich_metadata(cfg, None)
outputs, errors = docs.render_generated(cfg, argparse.Namespace(community=None), tmp)
return ctx, outputs, errors

def test_under_the_cap_every_combination_is_listed(self):
ctx, _, errors = self._ctx(4)
self.assertEqual(errors, [])
self.assertEqual(len(ctx["combos"]), 16)
self.assertEqual(ctx["choices_capped"], {"podman": "", "appjail": ""})

def test_past_the_cap_only_the_defaults(self):
ctx, outputs, errors = self._ctx(10)
self.assertEqual(errors, [])
self.assertEqual(len(ctx["combos"]), 1)
self.assertTrue(ctx["combos"][0]["default"])
self.assertIn("app9:", ctx["combos"][0]["compose_text"], "the default runs every app")
readme = outputs["README.md"]
self.assertIn("To leave one out, delete its service from `compose.yaml`.", readme)
self.assertNotIn("Left out", readme.split("### Podman Compose", 1)[1].split("###", 1)[0],
"no heading per combination")
self.assertLess(len(readme), 50_000)
Loading