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()