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          fullPrefix = if prefix == "" then name else "${{prefix}}.${{name}}";
 74          pkgInfo = {{
 75            path = fullPrefix;
 76            pname = v.pname or v.name or "";
 77            version = v.version or "";
 78            homepage = v.meta.homepage or "";
 79            description = v.meta.description or "";
 80            license = getLic v.meta;
 81          }};
 82        in
 83          if isDer v then
 84            if v.meta.nurRenamed or false then [] else [ pkgInfo ]
 85          else if isScope v then collect fullPrefix v
 86          else []
 87      ) names;
 88in
 89  collect "" pkgs
 90"""
 91
 92
 93def eval_packages():
 94    expr = NIX_EXPR.format(repo_dir=json.dumps(str(REPO_DIR)), system=SYSTEM)
 95    result = subprocess.run(
 96        ["nix", "eval", "--impure", "--expr", expr, "--json"],
 97        capture_output=True,
 98        text=True,
 99        cwd=REPO_DIR,
100    )
101    if result.returncode != 0:
102        print(f"nix eval failed: {result.stderr}", file=sys.stderr)
103        sys.exit(1)
104    return json.loads(result.stdout)
105
106
107def fmt_license(lic):
108    spdx = lic.get("spdxId")
109    url = lic.get("url")
110    short = lic.get("shortName")
111    if spdx:
112        if url and len(url) > 0:
113            return f"[{spdx}]({url})"
114        return spdx
115    if short == "unfree":
116        return "**Unfree**"
117    if short:
118        if url and len(url) > 0:
119            return f"[{short}]({url})"
120        return short
121    return "Not specified"
122
123
124def fmt_name(pname, homepage):
125    if pname and homepage:
126        return f"[{pname}]({homepage})"
127    return pname
128
129
130def fmt_version(version):
131    if version:
132        return f"`{version}`"
133    return "-"
134
135
136def fmt_row(item):
137    path = item["path"]
138    pname = item.get("pname", "")
139    homepage = item.get("homepage", "")
140    version = item.get("version", "")
141    license_info = item.get("license", {})
142    description = item.get("description", "")
143    return f"| `{path}` | {fmt_name(pname, homepage)} | {fmt_version(version)} | {fmt_license(license_info)} | {description} |"
144
145
146HEADER = "| Path | Name | Version | License | Description |"
147SEPARATOR = "| --- | --- | --- | --- | --- |"
148
149
150def mk_table(items):
151    lines = [HEADER, SEPARATOR]
152    lines.extend(fmt_row(item) for item in items)
153    lines.append("")
154    return "\n".join(lines)
155
156
157def group_packages(pkgs):
158    groups = {}
159    for item in pkgs:
160        first_seg = item["path"].split(".")[0]
161        groups.setdefault(first_seg, []).append(item)
162    return groups
163
164
165def generate_markdown(pkgs, config):
166    expand_attrs = config["expand_attrs"]  # {attr_name: {heading: bool}}
167
168    groups = group_packages(pkgs)
169    common_items = []
170    named_sections = []  # List of (section_name, items)
171
172    for group_key, items in groups.items():
173        count = len(items)
174        desc_set = {item.get("description", "") for item in items}
175        desc_count = len(desc_set)
176
177        if group_key in expand_attrs:
178            if expand_attrs[group_key].get("heading", True):
179                named_sections.append((group_key, items))
180            else:
181                common_items.extend(items)
182        elif count == 1:
183            common_items.append(items[0])
184        elif desc_count <= 1:
185            common_items.append(
186                {
187                    "path": group_key,
188                    "pname": group_key,
189                    "version": "",
190                    "homepage": items[0].get("homepage", ""),
191                    "description": items[0].get("description", ""),
192                    "license": items[0].get("license", {}),
193                }
194            )
195        else:
196            named_sections.append((group_key, items))
197
198    output_parts = []
199    if common_items:
200        output_parts.append("### Common\n")
201        output_parts.append(mk_table(common_items))
202
203    for section_name, items in named_sections:
204        output_parts.append(f"### {section_name}\n")
205        output_parts.append(mk_table(items))
206
207    return "\n".join(output_parts)
208
209
210BEGIN_MARKER = "<!-- BEGIN_PACKAGE_TABLE -->"
211END_MARKER = "<!-- END_PACKAGE_TABLE -->"
212
213
214def inject_into_readme(table_md):
215    if README.exists():
216        content = README.read_text()
217    else:
218        content = ""
219
220    if BEGIN_MARKER in content and END_MARKER in content:
221        before = content.split(BEGIN_MARKER)[0]
222        after = content.split(END_MARKER, 1)[1]
223        # Preserve trailing content after END_MARKER marker properly
224        if after.startswith("\n"):
225            after = after[1:]
226        new_content = f"{before}{BEGIN_MARKER}\n{table_md}\n{END_MARKER}\n{after}"
227    else:
228        new_content = f"{content}\n\n{BEGIN_MARKER}\n{table_md}\n{END_MARKER}\n"
229
230    README.write_text(new_content)
231
232
233def main():
234    print("==> Evaluating package metadata via nix...")
235    pkgs = eval_packages()
236    print(f"==> Found {len(pkgs)} derivations")
237
238    config = load_config()
239
240    print("==> Generating markdown tables...")
241    table_md = generate_markdown(pkgs, config)
242
243    print("==> Injecting table into README...")
244    inject_into_readme(table_md)
245
246    print(f"==> Done. README updated at {README}")
247
248
249if __name__ == "__main__":
250    main()