build_tools.py (11395B)
1 """Unified CLI for the repo's build-time scripts. 2 3 Merges the old standalone list.py / main.py / changelog.py into one 4 entry point with subcommands, since they were always run back-to-back 5 in CI anyway. 6 7 Usage: 8 python build_tools.py list # was: python list.py 9 python build_tools.py generate # was: python main.py 10 python build_tools.py changelog # was: python changelog.py 11 """ 12 import argparse 13 import os 14 from datetime import datetime, timezone 15 16 17 # --------------------------------------------------------------------------- 18 # list: scan the repo for source files and write list.txt 19 # --------------------------------------------------------------------------- 20 21 LIST_EXTENSIONS = ('.c', '.r', '.cpp', '.py') 22 LIST_EXCLUDE = ('list.py', 'main.py', 'build_tools.py', 'utils', 'docs') 23 LIST_ALGO_FOLDER_NAME = 'algorithms' 24 25 26 def cmd_list(args): 27 start_path = os.getcwd() 28 output_file = args.output 29 print(f"Scanning for {', '.join(LIST_EXTENSIONS)} files and algorithm .md files in: {start_path}\n") 30 print("-" * 30) 31 32 try: 33 count = 0 34 with open(output_file, 'w', encoding='utf-8') as f: 35 for root, dirs, files in os.walk(start_path): 36 dirs[:] = [d for d in dirs if d not in LIST_EXCLUDE] 37 in_algo_folder = os.path.basename(root).lower() == LIST_ALGO_FOLDER_NAME 38 39 for file in files: 40 if file in LIST_EXCLUDE: 41 continue 42 if file.lower().endswith(LIST_EXTENSIONS): 43 f.write(os.path.abspath(os.path.join(root, file)) + '\n') 44 count += 1 45 elif in_algo_folder and file.lower().endswith('.md'): 46 f.write(os.path.abspath(os.path.join(root, file)) + '\n') 47 count += 1 48 49 print(f"\nSuccessfully found {count} file(s).") 50 print(f"Results have been saved to: {os.path.abspath(output_file)}") 51 except PermissionError: 52 print("Error: Permission denied in some directories.") 53 except Exception as e: 54 print(f"An unexpected error occurred: {e}") 55 56 print("-" * 30) 57 print("Scan complete.") 58 59 60 # --------------------------------------------------------------------------- 61 # generate: build VitePress docs from list.txt 62 # --------------------------------------------------------------------------- 63 64 def cmd_generate(args): 65 from utils.bsc_md.pipeline import run 66 run() 67 68 69 # --------------------------------------------------------------------------- 70 # changelog: regenerate docs/changelog.md and CHANGELOG.md from CI git logs 71 # --------------------------------------------------------------------------- 72 73 STATUS_LABELS = { 74 'A': 'Added', 75 'M': 'Modified', 76 'D': 'Removed', 77 'T': 'Type changed', 78 'U': 'Unmerged', 79 } 80 81 82 def status_label(code): 83 letter = code[0] 84 if letter == 'R': 85 return f'Renamed ({code[1:]}%)' if len(code) > 1 else 'Renamed' 86 if letter == 'C': 87 return f'Copied ({code[1:]}%)' if len(code) > 1 else 'Copied' 88 return STATUS_LABELS.get(letter, letter) 89 90 91 def cmd_changelog(args): 92 sha = os.environ.get('BUILD_SHA', 'unknown') 93 actor = os.environ.get('BUILD_ACTOR', 'unknown') 94 ref = os.environ.get('BUILD_REF', 'unknown') 95 timestamp = os.environ.get('BUILD_TIMESTAMP', '') 96 97 try: 98 dt = datetime.fromisoformat(timestamp).astimezone(timezone.utc) 99 build_time = dt.strftime('%B %d, %Y at %H:%M UTC') 100 except Exception: 101 build_time = timestamp 102 103 # ---- Pass 1: commit metadata + numstat (line/diff counts) ---- 104 commits = [] 105 commit_index = {} 106 107 try: 108 with open('git_log_numstat.txt', 'r', encoding='utf-8') as f: 109 current = None 110 for raw_line in f: 111 line = raw_line.rstrip('\n') 112 if line.startswith('COMMIT|'): 113 parts = line.split('|') 114 if len(parts) == 5: 115 _, c_sha, c_msg, c_author, c_time = parts 116 try: 117 c_dt = datetime.fromisoformat(c_time).astimezone(timezone.utc) 118 c_time_fmt = c_dt.strftime('%b %d, %Y %H:%M UTC') 119 except Exception: 120 c_time_fmt = c_time 121 current = { 122 'sha': c_sha[:7], 123 'full_sha': c_sha, 124 'msg': c_msg, 125 'author': c_author, 126 'time': c_time_fmt, 127 'files': {}, 128 'order': [], 129 } 130 commit_index[c_sha] = len(commits) 131 commits.append(current) 132 continue 133 if not line.strip() or current is None: 134 continue 135 cols = line.split('\t') 136 if len(cols) == 3: 137 add, dele, path = cols 138 add_n = int(add) if add.isdigit() else 0 139 del_n = int(dele) if dele.isdigit() else 0 140 current['files'][path] = {'add': add_n, 'del': del_n, 'status': 'M'} 141 current['order'].append(path) 142 except FileNotFoundError: 143 pass 144 145 # ---- Pass 2: name-status (A/M/D/R/C) merged into the same commits ---- 146 try: 147 with open('git_log_namestatus.txt', 'r', encoding='utf-8') as f: 148 current = None 149 for raw_line in f: 150 line = raw_line.rstrip('\n') 151 if line.startswith('COMMIT|'): 152 parts = line.split('|') 153 if len(parts) == 2: 154 c_sha = parts[1] 155 idx = commit_index.get(c_sha) 156 current = commits[idx] if idx is not None else None 157 continue 158 if not line.strip() or current is None: 159 continue 160 cols = line.split('\t') 161 if len(cols) == 2: 162 code, path = cols 163 if path in current['files']: 164 current['files'][path]['status'] = code 165 else: 166 current['files'][path] = {'add': 0, 'del': 0, 'status': code} 167 current['order'].append(path) 168 elif len(cols) == 3: 169 code, old_path, new_path = cols 170 target = new_path 171 stats = current['files'].pop(old_path, {'add': 0, 'del': 0, 'status': code}) 172 stats['status'] = code 173 current['files'][target] = stats 174 if old_path in current['order']: 175 current['order'][current['order'].index(old_path)] = target 176 else: 177 current['order'].append(target) 178 except FileNotFoundError: 179 pass 180 181 icon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline; margin-bottom:-2px; margin-right:6px;" class="lucide lucide-history"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>' 182 183 def build_commit_block(c): 184 commit_url = f"https://github.com/notamitgamer/bsc/commit/{c['full_sha']}" 185 total_add = sum(v['add'] for v in c['files'].values()) 186 total_del = sum(v['del'] for v in c['files'].values()) 187 file_count = len(c['files']) 188 189 header = ( 190 f"### [`{c['sha']}`]({commit_url}) {c['msg']}\n\n" 191 f"**{c['author']}** committed on {c['time']}" 192 ) 193 194 if file_count: 195 stat_bits = [] 196 if total_add or total_del: 197 stat_bits.append(f"+{total_add} / -{total_del} lines") 198 stat_bits.append(f"{file_count} file{'s' if file_count != 1 else ''} changed") 199 header += f" · {' · '.join(stat_bits)}" 200 201 if not file_count: 202 return header + "\n" 203 204 file_lines = [] 205 for path in c['order']: 206 info = c['files'][path] 207 label = status_label(info['status']) 208 diff_bits = [] 209 if info['add']: 210 diff_bits.append(f"+{info['add']}") 211 if info['del']: 212 diff_bits.append(f"-{info['del']}") 213 diff_str = ' '.join(diff_bits) if diff_bits else 'binary/no diff' 214 file_lines.append(f"| `{path}` | {label} | {diff_str} |") 215 216 files_table = ( 217 "| File | Change | Lines |\n" 218 "| --- | --- | --- |\n" 219 + '\n'.join(file_lines) 220 ) 221 222 details = ( 223 "<details>\n" 224 f"<summary>Show {file_count} changed file{'s' if file_count != 1 else ''}</summary>\n\n" 225 f"{files_table}\n" 226 "</details>\n" 227 ) 228 229 return f"{header}\n\n{details}" 230 231 commit_blocks = [build_commit_block(c) for c in commits] 232 commit_section = '\n\n---\n\n'.join(commit_blocks) if commit_blocks else 'No commit history available.' 233 234 docs_content = f"""--- 235 title: '{icon} Changelog' 236 description: 'Current build information and recent commit history.' 237 --- 238 239 # Build Info 240 241 ::: tip Important 242 Compare the Build ID (listed below) against the one in the [GitHub Changelog](https://github.com/notamitgamer/bsc/blob/main/CHANGELOG.md#latest-build) to verify that your browser is displaying the latest version. 243 ::: 244 245 - **Build ID** — <span style="word-break: break-all;">`{sha}`</span> 246 - **Triggered by** — [@{actor}](https://github.com/{actor}) 247 - **Branch** — `{ref}` 248 - **Build time** — {build_time} 249 250 ## Recent Commits 251 252 {commit_section} 253 """ 254 255 with open('docs/changelog.md', 'w', encoding='utf-8') as f: 256 f.write(docs_content) 257 258 root_content = f"""# Changelog 259 260 > Last build: {build_time} 261 262 ## Latest Build 263 264 - **Build ID** — `{sha}` 265 - **Triggered by** — [@{actor}](https://github.com/{actor}) 266 - **Branch** — `{ref}` 267 - **Build time** — {build_time} 268 269 ## Recent Commits 270 271 {commit_section} 272 273 --- 274 275 *This file is auto-generated on every deployment. For the live site, visit [code.amit.is-a.dev](https://code.amit.is-a.dev).* 276 *Check [/changelog](https://code.amit.is-a.dev/changelog) on the site to verify your browser is showing the latest build.* 277 """ 278 279 with open('CHANGELOG.md', 'w', encoding='utf-8') as f: 280 f.write(root_content) 281 282 print(f"Generated changelog with {len(commits)} commits.") 283 284 285 def main(): 286 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) 287 sub = parser.add_subparsers(dest='command', required=True) 288 289 p_list = sub.add_parser('list', help='Scan repo for source files -> list.txt (was list.py)') 290 p_list.add_argument('-o', '--output', default='list.txt') 291 p_list.set_defaults(func=cmd_list) 292 293 p_gen = sub.add_parser('generate', help='Generate VitePress docs from list.txt (was main.py)') 294 p_gen.set_defaults(func=cmd_generate) 295 296 p_chg = sub.add_parser('changelog', help='Regenerate changelog files from CI git logs (was changelog.py)') 297 p_chg.set_defaults(func=cmd_changelog) 298 299 args = parser.parse_args() 300 args.func(args) 301 302 303 if __name__ == '__main__': 304 main()