From c0b01133e36063fdd69f5057500416dc9ed4d19b Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Fri, 2 Oct 2026 06:49:13 -0400 Subject: [PATCH 1/2] generate: choices combine into one form --- dbuild/choices.py | 78 ++++++- dbuild/docs.py | 334 +++++++++++++++++++++--------- dbuild/templates/README.j2 | 87 ++++++-- dbuild/templates/README.mkdocs.j2 | 102 +++++---- tests/test_docs.py | 225 +++++++++++++++++++- 5 files changed, 654 insertions(+), 172 deletions(-) diff --git a/dbuild/choices.py b/dbuild/choices.py index b040331..a6b7165 100644 --- a/dbuild/choices.py +++ b/dbuild/choices.py @@ -110,6 +110,9 @@ class Option: profile: str = "" env: dict[str, str] = field(default_factory=dict) ask: list[Ask] = field(default_factory=list) + # Services this option leaves out (a part the default runs and this + # option does without: immich without machine learning). + drop: list[str] = field(default_factory=list) # A service dbuild adds for this option (database kind), as compose YAML # text under services:, and the .env lines it needs beyond env/ask. service_yaml: str = "" @@ -242,6 +245,7 @@ def parse(meta: dict[str, Any], compose_data: dict[str, Any] | None = None) -> l profile=str(o.get("profile", "")), env={str(k): str(v) for k, v in (o.get("env") or {}).items()}, ask=asks, + drop=[str(x) for x in (o.get("drop") or [])], )) default = str(c.get("default", "")) or (opts[0].id if opts else "") out.append(Choice(id=str(cid), label=label, kind=kind, doc=doc, default=default, @@ -287,6 +291,11 @@ def validate(choices: list[Choice], compose_data: dict[str, Any]) -> list[str]: for o in c.options: if o.profile and o.profile not in profiled: errors.append(f"{where}.options.{o.id}: no service carries profiles: [{o.profile}]") + for d in o.drop: + if d not in services: + errors.append(f"{where}.options.{o.id}: drops '{d}', which is not a service") + if c.default_option is o and o.drop: + errors.append(f"{where}: the default option '{o.id}' drops a service; the default is the compose as it stands") for a in o.ask: if a.type not in ("string", "secret"): errors.append(f"{where}.options.{o.id}: ask {a.name}: type must be string or secret") @@ -298,19 +307,31 @@ def validate(choices: list[Choice], compose_data: dict[str, Any]) -> list[str]: _SERVICE_RE = re.compile(r"^ ([A-Za-z0-9_.-]+):\s*(#.*)?$") -def flatten_compose(text: str, compose_data: dict[str, Any], option: Option | None) -> str: - """The compose for one option: its profile's services in, every other - profiled service out, ``profiles:`` keys gone, and the service dbuild - supplies (a database) appended under ``services:``. +def _as_list(option) -> list[Option]: + if option is None: + return [] + return list(option) if isinstance(option, (list, tuple)) else [option] + + +def flatten_compose(text: str, compose_data: dict[str, Any], option) -> str: + """The compose for one option, or one option per choice taken together: + their profiles' services in, every other profiled service out, dropped + services gone, ``profiles:`` keys gone, and the service dbuild supplies + (a database) appended under ``services:``. Line-based so the comments survive -- they are the explanation a reader needs, and a YAML round trip drops them. Checked afterwards by parsing: - the result has to hold exactly the services the option gets. + the result has to hold exactly the services the options get. """ - profile = option.profile if option else "" + options = _as_list(option) + profiles = {o.profile for o in options if o.profile} + drop = {d for o in options for d in o.drop} + option = next((o for o in options if o.service_yaml), None) services = compose_data.get("services") or {} keep = {n for n, s in services.items() - if not (s or {}).get("profiles") or profile in [str(p) for p in (s or {}).get("profiles")]} + if n not in drop + and (not (s or {}).get("profiles") or profiles & {str(p) for p in (s or {}).get("profiles")})} + gone = set(services) - keep # The app waits for the service dbuild adds: compose starts a # depends_on first, so the app does not spend its first seconds # retrying a database that is still coming up. @@ -320,6 +341,7 @@ def flatten_compose(text: str, compose_data: dict[str, Any], option: Option | No in_services = False dropping = False skipping_profiles = False + in_depends = False inserted = False for line in text.splitlines(): top = bool(line) and not line[0].isspace() and not line.startswith("#") @@ -351,6 +373,31 @@ def flatten_compose(text: str, compose_data: dict[str, Any], option: Option | No if stripped.startswith("- "): continue skipping_profiles = False + # A service that is gone must leave the others' depends_on too, or + # compose refuses the file. Flow form ([a, b]) and block form. + if gone: + fm = re.match(r"^(\s*depends_on:\s*)\[(.*)\]\s*$", line) + if fm: + left = [x.strip() for x in fm.group(2).split(",") if x.strip() and x.strip() not in gone] + if not left: + continue + out.append(f"{fm.group(1)}[{', '.join(left)}]") + continue + if re.match(r"^\s*depends_on:\s*$", line): + in_depends = True + depends_header = line + depends_kept = 0 + continue + if in_depends: + dm = re.match(r"^\s*-\s*([A-Za-z0-9_.-]+)\s*$", line) + if dm: + if dm.group(1) in gone: + continue + if depends_kept == 0: + out.append(depends_header) + depends_kept += 1 + else: + in_depends = False out.append(line) if in_services and option and option.service_yaml and not inserted: out.append(option.service_yaml.rstrip("\n")) @@ -362,11 +409,26 @@ def flatten_compose(text: str, compose_data: dict[str, Any], option: Option | No want.add(_SERVICE_RE.match(option.service_yaml.splitlines()[0]).group(1)) got = set(((yaml.safe_load(result) or {}).get("services") or {}).keys()) if got != want: - raise ValueError(f"flattening for option '{option.id if option else 'default'}' kept {sorted(got)}, " + label = ", ".join(o.id for o in options) or "default" + raise ValueError(f"flattening for '{label}' kept {sorted(got)}, " f"wanted {sorted(want)}; the compose's services need two-space indents") return result +def env_with_options(example_env: str, pairs: list[tuple[Choice, Option]]) -> str: + """example.env with every (choice, option) pair applied in turn.""" + out = example_env + for c, o in pairs: + out = env_with_option(out, c, o) + return out + + +def variant_suffix(pairs: list[tuple[Choice, Option]]) -> str: + """``-