Utensil's Zettelkasten-style forest of evergreen notes on math and tech. utensil.tngl.sh/forest/
0

Configure Feed

Select the types of activity you want to include in your feed.

forest / convert_xml_to_html.py
8.3 kB 210 lines
1#!/usr/bin/env -S uv run 2# /// script 3# requires-python = ">=3.11,<3.12" 4# dependencies = ["lxml>=5.2.0"] 5# /// 6""" 7XML to HTML Incremental Build Script 8=================================== 9 10This script incrementally converts XML files in the output/ directory to HTML using XSLT (output/uts-forest.xsl). 11It is designed to be idempotent, robust, and fast, supporting parallel execution and accurate dependency checking. 12 13Key Features: 14- Only rebuilds HTML files if the corresponding XML or the XSL file is newer, or if the HTML is missing. 15- Uses Python's concurrent.futures for parallelism, maximizing CPU utilization while avoiding overload. 16- Reports progress and the number of files actually updated. 17- Handles errors gracefully, printing clear error messages but continuing the build. 18- Designed for integration with build systems (e.g., mise, just) and for deterministic, reproducible builds. 19 20Usage: 21 uv run convert_xml_to_html.py 22 23Extension Points: 24- To support additional output formats or XSLT parameters, extend the convert_one() function. 25- To change the input/output directories, modify OUTPUT_DIR and XSL_PATH. 26- For CLI options, add argument parsing to main(). 27 28AGENT-NOTE: CRITICAL FEATURES TO MAINTAIN 291. IDEMPOTENT: Multiple runs must produce identical results 302. ERROR HANDLING: Graceful degradation for missing data 313. BUILD INTEGRATION: Validates syntax after processing 324. DETERMINISTIC: Sorted processing ensures consistent output 33""" 34 35import os 36import sys 37import pathlib 38import concurrent.futures 39import multiprocessing 40from lxml import etree 41from typing import List 42 43# AGENT-NOTE: perf-hot-path; parallel conversion, progress bar, and update count must be robust 44# 45# This script is called by mise-tasks/build/xml_to_html for incremental HTML builds. 46# It is safe to run repeatedly and will only update files as needed. 47 48OUTPUT_DIR = pathlib.Path("output") 49XSL_PATH = OUTPUT_DIR / "uts-forest.xsl" 50BAK_DIR = OUTPUT_DIR / ".bak" 51 52 53def get_max_workers() -> int: 54 """ 55 Determine the number of worker threads to use for parallel processing. 56 Leaves 2 CPUs free to avoid overloading the system. 57 Returns: 58 int: Number of worker threads to use (minimum 2). 59 """ 60 try: 61 cpu_count = multiprocessing.cpu_count() 62 return max(2, cpu_count - 2) 63 except Exception: 64 return 2 65 66 67def get_xml_files() -> List[pathlib.Path]: 68 """ 69 Find all XML files in the output directory. 70 Returns: 71 List[pathlib.Path]: Sorted list of XML file paths. 72 """ 73 return sorted(OUTPUT_DIR.glob("*.xml")) 74 75 76def needs_rebuild(xml_path: pathlib.Path, html_path: pathlib.Path, xsl_path: pathlib.Path) -> bool: 77 """ 78 Determine if the HTML file needs to be rebuilt. 79 Args: 80 xml_path (pathlib.Path): Path to the XML source file. 81 html_path (pathlib.Path): Path to the HTML output file. 82 xsl_path (pathlib.Path): Path to the XSLT stylesheet. 83 Returns: 84 bool: True if HTML should be rebuilt, False otherwise. 85 """ 86 if not html_path.exists(): 87 # print(f"[DEBUG] {html_path} does not exist; will rebuild.") 88 return True 89 if xml_path.stat().st_mtime > html_path.stat().st_mtime: 90 # print(f"[DEBUG] {xml_path} is newer than {html_path}; will rebuild.") 91 return True 92 if xsl_path.exists() and xsl_path.stat().st_mtime > html_path.stat().st_mtime: 93 # print(f"[DEBUG] {xsl_path} is newer than {html_path}; will rebuild.") 94 return True 95 return False 96 97 98def convert_one(xml_path: pathlib.Path, xsl_path: pathlib.Path, html_path: pathlib.Path, bak_dir: pathlib.Path) -> bool: 99 """ 100 Convert a single XML file to HTML using the provided XSLT stylesheet. 101 Only overwrite the HTML file if the output differs from the backup. 102 Args: 103 xml_path (pathlib.Path): Path to the XML source file. 104 xsl_path (pathlib.Path): Path to the XSLT stylesheet. 105 html_path (pathlib.Path): Path to the HTML output file. 106 bak_dir (pathlib.Path): Path to the backup directory. 107 Returns: 108 bool: True if HTML was updated (content changed), False otherwise. 109 """ 110 try: 111 # Parse XML and XSLT files 112 dom = etree.parse(str(xml_path)) 113 xslt = etree.parse(str(xsl_path)) 114 transform = etree.XSLT(xslt) 115 # Apply the XSLT transformation 116 newdom = transform(dom) 117 html_path.parent.mkdir(parents=True, exist_ok=True) 118 new_html = str(newdom) 119 bak_path = bak_dir / html_path.name 120 # Compare with backup if it exists 121 if bak_path.exists(): 122 old_html = bak_path.read_text(encoding="utf-8") 123 if old_html == new_html: 124 return False # No change 125 # Write the HTML output 126 html_path.write_text(new_html, encoding="utf-8") 127 return True 128 except Exception as e: 129 # Print error but do not stop the build 130 print(f"[ERROR] Failed to convert {xml_path} -> {html_path}: {e}", file=sys.stderr) 131 return False 132 133 134def main(): 135 """ 136 Main entry point for the incremental XML-to-HTML build process. 137 - Scans for XML files and checks for the XSLT stylesheet. 138 - Uses a thread pool to process files in parallel. 139 - Only rebuilds HTML files when necessary and content changes. 140 - Prints progress and a summary of updated files. 141 """ 142 xml_files = get_xml_files() 143 if not xml_files: 144 print("No XML files found in output/.") 145 sys.exit(0) 146 if not XSL_PATH.exists(): 147 print(f"XSL file not found: {XSL_PATH}", file=sys.stderr) 148 sys.exit(1) 149 150 max_workers = get_max_workers() 151 print(f"Max jobs: {max_workers}") 152 updated_count = 0 153 total_files = len(xml_files) 154 progress = 0 155 progress_step = max(1, total_files // 20) 156 print("Progress: ", end="", flush=True) 157 158 def process(xml_path: pathlib.Path) -> bool: 159 """ 160 Worker function for a single XML file. 161 Returns True if the file was rebuilt (HTML content changed), False otherwise. 162 """ 163 basename = xml_path.stem 164 html_path = OUTPUT_DIR / f"{basename}.html" 165 bak_path = BAK_DIR / html_path.name 166 try: 167 # If backup is missing, always rebuild (skip needs_rebuild) 168 if not bak_path.exists(): 169 dom = etree.parse(str(xml_path)) 170 xslt = etree.parse(str(XSL_PATH)) 171 transform = etree.XSLT(xslt) 172 new_html = str(transform(dom)) 173 html_path.parent.mkdir(parents=True, exist_ok=True) 174 html_path.write_text(new_html, encoding="utf-8") 175 return True 176 # If backup exists, only rebuild if needed and content differs 177 if not needs_rebuild(xml_path, html_path, XSL_PATH): 178 return False 179 dom = etree.parse(str(xml_path)) 180 xslt = etree.parse(str(XSL_PATH)) 181 transform = etree.XSLT(xslt) 182 new_html = str(transform(dom)) 183 old_html = bak_path.read_text(encoding="utf-8") 184 if old_html == new_html: 185 return False 186 html_path.parent.mkdir(parents=True, exist_ok=True) 187 html_path.write_text(new_html, encoding="utf-8") 188 return True 189 except Exception as e: 190 print(f"[ERROR] Failed to convert {xml_path} -> {html_path}: {e}", file=sys.stderr) 191 return False 192 193 # Use ThreadPoolExecutor for parallel processing of XML files 194 with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: 195 futures = {executor.submit(process, xml_path): i for i, xml_path in enumerate(xml_files)} 196 for i, future in enumerate(concurrent.futures.as_completed(futures)): 197 try: 198 if future.result(): 199 updated_count += 1 200 except Exception as e: 201 print(f"[ERROR] Exception in worker: {e}", file=sys.stderr) 202 # AGENT-NOTE: progress bar logic 203 if (i + 1) % progress_step == 0 or (i + 1) == total_files: 204 print("", end="", flush=True) 205 print() 206 print(f"📝 Updated {updated_count} HTML file(s) out of {total_files}") 207 208if __name__ == "__main__": 209 # Entry point for script execution 210 main()