main
  1#!/usr/bin/env python3
  2"""Generate a package table and inject it into README.md."""
  3
  4import json
  5import os
  6import subprocess
  7import sys
  8from pathlib import Path
  9from tempfile import NamedTemporaryFile
 10
 11REPO_DIR = Path(__file__).resolve().parent.parent
 12README = REPO_DIR / "README.md"
 13SYSTEM = os.environ.get("NIX_SYSTEM", "x86_64-linux")
 14CONFIG_PATH = Path(__file__).resolve().parent / "gen-readme-config.json"
 15
 16
 17def load_config():
 18    default = {"expand_attrs": {}}
 19    if CONFIG_PATH.exists():
 20        with open(CONFIG_PATH) as f:
 21            cfg = json.load(f)
 22        raw = cfg.get("expand_attrs", {})
 23        if isinstance(raw, list):
 24            default["expand_attrs"] = {k: {"heading": True} for k in raw}
 25        elif isinstance(raw, dict):
 26            default["expand_attrs"] = {
 27                k: (v if isinstance(v, dict) else {"heading": v})
 28                for k, v in raw.items()
 29            }
 30    return default
 31
 32
 33NIX_EXPR = """
 34let
 35  flake = builtins.getFlake (toString {repo_dir});
 36  pkgs = flake.legacyPackages."{system}";
 37
 38  reserved = [
 39    "lib" "nixosModules" "overlays"
 40    "homeModules" "darwinModules" "flakeModules"
 41    "callPackage" "newScope" "overrideScope" "packages"
 42    "override" "overrideDerivation"
 43  ];
 44
 45  resolveLic = lic:
 46    let
 47      go = l:
 48        if l == null then {{ shortName = null; spdxId = null; url = null; }}
 49        else if builtins.isList l then go (builtins.head l)
 50        else if builtins.isString l then {{ shortName = l; spdxId = null; url = null; }}
 51        else {{
 52          shortName = l.shortName or null;
 53          spdxId = l.spdxId or null;
 54          url = l.url or null;
 55        }};
 56    in go lic;
 57
 58  getLic = meta:
 59    resolveLic (meta.license or meta.licence or null);
 60
 61  isDer = x: builtins.isAttrs x && x ? type && x.type == "derivation";
 62  isScope = x: builtins.isAttrs x && x ? callPackage && x ? newScope && !isDer x;
 63
 64  reservedSet = builtins.listToAttrs (builtins.map (n: {{ name = n; value = true; }}) reserved);
 65
 66  collect = prefix: attrs:
 67    let
 68      names = builtins.filter (n: !(reservedSet.${{n}} or false)) (builtins.attrNames attrs);
 69    in
 70      builtins.concatMap (name:
 71        let
 72          v = attrs.${{name}};
 73          evaluated = builtins.tryEval v;
 74          fullPrefix = if prefix == "" then name else "${{prefix}}.${{name}}";
 75          pkgInfo = {{
 76            path = fullPrefix;
 77            pname = v.pname or v.name or "";
 78            version = v.version or "";
 79            homepage = v.meta.homepage or "";
 80            description = v.meta.description or "";
 81            license = getLic v.meta;
 82          }};
 83        in
 84          if !evaluated.success then []
 85          else if isDer evaluated.value then
 86            if evaluated.value.meta.nurRenamed or evaluated.value.meta.nurDeprecated or false then [] else [ pkgInfo ]
 87          else if isScope evaluated.value then collect fullPrefix evaluated.value
 88          else []
 89      ) names;
 90in
 91  collect "" pkgs
 92"""
 93
 94
 95def eval_packages():
 96    expr = NIX_EXPR.format(repo_dir=json.dumps(str(REPO_DIR)), system=SYSTEM)
 97    result = subprocess.run(
 98        ["nix", "eval", "--impure", "--expr", expr, "--json"],
 99        capture_output=True,
100        text=True,
101        cwd=REPO_DIR,
102    )
103    if result.returncode != 0:
104        print(f"nix eval failed: {result.stderr}", file=sys.stderr)
105        sys.exit(1)
106    return json.loads(result.stdout)
107
108
109def fmt_license(lic):
110    spdx = lic.get("spdxId")
111    url = lic.get("url")
112    short = lic.get("shortName")
113    if spdx:
114        if url and len(url) > 0:
115            return f"[{spdx}]({url})"
116        return spdx
117    if short == "unfree":
118        return "**Unfree**"
119    if short:
120        if url and len(url) > 0:
121            return f"[{short}]({url})"
122        return short
123    return "Not specified"
124
125
126def fmt_name(pname, homepage):
127    if pname and homepage:
128        return f"[{pname}]({homepage})"
129    return pname
130
131
132def fmt_version(version):
133    if version:
134        return f"`{version}`"
135    return "-"
136
137
138def fmt_row(item):
139    path = item["path"]
140    pname = item.get("pname", "")
141    homepage = item.get("homepage", "")
142    version = item.get("version", "")
143    license_info = item.get("license", {})
144    description = item.get("description", "")
145    return f"| `{path}` | {fmt_name(pname, homepage)} | {fmt_version(version)} | {fmt_license(license_info)} | {description} |"
146
147
148HEADER = "| Path | Name | Version | License | Description |"
149SEPARATOR = "| --- | --- | --- | --- | --- |"
150
151
152def mk_table(items):
153    lines = [HEADER, SEPARATOR]
154    lines.extend(fmt_row(item) for item in items)
155    lines.append("")
156    return "\n".join(lines)
157
158
159def group_packages(pkgs):
160    groups = {}
161    for item in pkgs:
162        first_seg = item["path"].split(".")[0]
163        groups.setdefault(first_seg, []).append(item)
164    return groups
165
166
167def generate_markdown(pkgs, config):
168    expand_attrs = config["expand_attrs"]  # {attr_name: {heading: bool}}
169
170    groups = group_packages(pkgs)
171    common_items = []
172    named_sections = []  # List of (section_name, items)
173
174    for group_key, items in groups.items():
175        count = len(items)
176        desc_set = {item.get("description", "") for item in items}
177        desc_count = len(desc_set)
178
179        if group_key in expand_attrs:
180            if expand_attrs[group_key].get("heading", True):
181                named_sections.append((group_key, items))
182            else:
183                common_items.extend(items)
184        elif count == 1:
185            common_items.append(items[0])
186        elif desc_count <= 1:
187            common_items.append(
188                {
189                    "path": group_key,
190                    "pname": group_key,
191                    "version": "",
192                    "homepage": items[0].get("homepage", ""),
193                    "description": items[0].get("description", ""),
194                    "license": items[0].get("license", {}),
195                }
196            )
197        else:
198            named_sections.append((group_key, items))
199
200    output_parts = []
201    if common_items:
202        output_parts.append("### Common\n")
203        output_parts.append(mk_table(common_items))
204
205    for section_name, items in named_sections:
206        output_parts.append(f"### {section_name}\n")
207        output_parts.append(mk_table(items))
208
209    return "\n".join(output_parts)
210
211
212BEGIN_MARKER = "<!-- BEGIN_PACKAGE_TABLE -->"
213END_MARKER = "<!-- END_PACKAGE_TABLE -->"
214
215
216def inject_into_readme(table_md):
217    if README.exists():
218        content = README.read_text()
219    else:
220        content = ""
221
222    if BEGIN_MARKER in content and END_MARKER in content:
223        before = content.split(BEGIN_MARKER)[0]
224        after = content.split(END_MARKER, 1)[1]
225        # Preserve trailing content after END_MARKER marker properly
226        if after.startswith("\n"):
227            after = after[1:]
228        new_content = f"{before}{BEGIN_MARKER}\n{table_md}\n{END_MARKER}\n{after}"
229    else:
230        new_content = f"{content}\n\n{BEGIN_MARKER}\n{table_md}\n{END_MARKER}\n"
231
232    README.write_text(new_content)
233
234
235def main():
236    print("==> Evaluating package metadata via nix...")
237    pkgs = eval_packages()
238    print(f"==> Found {len(pkgs)} derivations")
239
240    config = load_config()
241
242    print("==> Generating markdown tables...")
243    table_md = generate_markdown(pkgs, config)
244
245    print("==> Injecting table into README...")
246    inject_into_readme(table_md)
247
248    print(f"==> Done. README updated at {README}")
249
250
251if __name__ == "__main__":
252    main()