bsc

Comprehensive codebase and coursework for the 4-year B.Sc. Computer Science Honours degree (WBSU)

git clone https://github.com/notamitgamer/bsc.git
· rebuilt hourlyOpen on GitHub
root / build_tools.py

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