diff --git a/dbuild/docs.py b/dbuild/docs.py index d6016c4..6457bfd 100644 --- a/dbuild/docs.py +++ b/dbuild/docs.py @@ -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.""" @@ -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 @@ -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]: @@ -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) diff --git a/dbuild/templates/README.j2 b/dbuild/templates/README.j2 index e311715..e52a615 100644 --- a/dbuild/templates/README.j2 +++ b/dbuild/templates/README.j2 @@ -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 }} @@ -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) }} diff --git a/dbuild/templates/README.mkdocs.j2 b/dbuild/templates/README.mkdocs.j2 index 246663d..3c3cc2b 100644 --- a/dbuild/templates/README.mkdocs.j2 +++ b/dbuild/templates/README.mkdocs.j2 @@ -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 -%}
{% for c in choices %} @@ -28,6 +33,7 @@
{% endfor %} +{%- endif %} {%- endmacro %} {%- macro combo_files(cb, runtime) -%} diff --git a/tests/test_docs.py b/tests/test_docs.py index 761352c..9ca8eb3 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -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)