update/navibeat
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()