#!/usr/bin/env python3 """ Parses all .kicad_sym files in symbols/ and updates the Component Index table in README.md with the actual contents of the library. Reads the following fields from each symbol: Value, MPN, Digikey_PN, Manufacturer, Footprint, ki_description (or Description) Symbol Library column = which .kicad_sym file the symbol lives in. Used to locate a symbol quickly in KiCad Symbol Editor. Handles derived symbols (Derive from symbol) by merging parent fields with child overrides so all fields resolve correctly. Run locally: python3 .github/scripts/update_component_index.py Run in CI: triggered automatically on push to symbols/ """ import re from pathlib import Path from datetime import date SYMBOLS_DIR = Path("symbols") README_PATH = Path("README.md") CATEGORY_ORDER = [ "0_ic_logic", "0_ic_driver", "0_ic_power", "0_ic_analog", "0_ic_mcu", "0_ic_rf", "0_ic_interface", "0_passive", "0_connector", "0_discrete", ] def extract_all_fields(block: str) -> dict: """Extract all property fields from a symbol block into a dict.""" fields = {} for m in re.finditer(r'\(property\s+"([^"]+)"\s+"([^"]*)"', block): fields[m.group(1)] = m.group(2).strip() return fields def shorten_footprint(footprint: str) -> str: """Trim long footprint names for table readability.""" if footprint and ":" in footprint: lib_part, fp_name = footprint.split(":", 1) if len(fp_name) > 30: fp_name = fp_name[:27] + "..." return f"{lib_part}:{fp_name}" return footprint def parse_symbols_from_file(filepath: Path) -> list: """ Parse all symbols from a .kicad_sym file. Handles both normal symbols and derived symbols (Derive from symbol). Derived symbols only store fields that differ from their parent — the parser merges parent fields first then overlays child overrides so all fields resolve correctly. """ content = filepath.read_text(encoding="utf-8") # --- Pass 1: collect ALL raw symbol blocks indexed by name --- symbol_pattern = re.compile(r'\(symbol\s+"([^"]+)"', re.MULTILINE) all_matches = list(symbol_pattern.finditer(content)) raw_blocks = {} # name -> raw text block extends_map = {} # child name -> parent name for i, match in enumerate(all_matches): name = match.group(1) # Skip KiCad internal sub-unit blocks e.g. SN74HC193DR_0_1 if re.search(r'_\d+_\d+$', name): continue start = match.start() end = all_matches[i + 1].start() if i + 1 < len(all_matches) else len(content) block = content[start:end] raw_blocks[name] = block extends_match = re.search(r'\(extends\s+"([^"]+)"\)', block) if extends_match: extends_map[name] = extends_match.group(1) # --- Pass 2: resolve fields, merging parent into child --- def resolve_fields(name, visited=None): if visited is None: visited = set() if name in visited: return {} visited.add(name) block = raw_blocks.get(name, "") own_fields = extract_all_fields(block) parent_name = extends_map.get(name) if parent_name: parent_fields = resolve_fields(parent_name, visited) return {**parent_fields, **own_fields} return own_fields # --- Pass 3: build symbol list --- symbols = [] for name in raw_blocks: is_derived = name in extends_map fields = resolve_fields(name) mpn = fields.get("MPN") or fields.get("Value") or name # Skip base/template symbols — no MPN field, just drawing sources if not is_derived and not fields.get("MPN"): continue description = (fields.get("ki_description") or fields.get("Description") or fields.get("description") or "—") footprint = shorten_footprint(fields.get("Footprint", "")) or "-" digikey_pn = fields.get("Digikey_PN") or fields.get("Digikey PN") or "-" manufacturer = fields.get("Manufacturer") or "-" symbols.append({ "name": name, "mpn": mpn, "description": description, "footprint": footprint, "digikey_pn": digikey_pn, "manufacturer": manufacturer, }) return symbols def build_component_table(all_symbols: dict) -> str: """Build the full markdown component index table.""" lines = [] lines.append("| MPN | Description | Manufacturer | Symbol Library | Footprint | Digikey PN |") lines.append("|---|---|---|---|---|---|") total = 0 for category in CATEGORY_ORDER: symbols = all_symbols.get(category, []) if not symbols: continue for s in sorted(symbols, key=lambda x: x["mpn"]): lines.append( f"| {s['mpn']} " f"| {s['description']} " f"| {s['manufacturer']} " f"| {category} " f"| {s['footprint']} " f"| {s['digikey_pn']} |" ) total += 1 lines.append("") lines.append(f"*{total} components — auto-generated {date.today().isoformat()}*") return "\n".join(lines) def update_readme(new_table: str) -> bool: """ Replace the Component Index section in README.md with the new table. Returns True if the file was changed. """ readme = README_PATH.read_text(encoding="utf-8") pattern = re.compile( r'(## Component Index\n+)(.*?)(\n## |\Z)', re.DOTALL ) match = pattern.search(readme) if not match: print("ERROR: Could not find '## Component Index' section in README.md") return False original_block = match.group(2) new_block = new_table + "\n\n" if original_block == new_block: return False updated_readme = ( readme[:match.start(2)] + new_block + readme[match.start(3):] ) README_PATH.write_text(updated_readme, encoding="utf-8") return True def main(): if not SYMBOLS_DIR.exists(): print("ERROR: symbols/ directory not found. Run from repo root.") return if not README_PATH.exists(): print("ERROR: README.md not found. Run from repo root.") return all_symbols = {} for sym_file in sorted(SYMBOLS_DIR.glob("*.kicad_sym")): lib_name = sym_file.stem symbols = parse_symbols_from_file(sym_file) if symbols: all_symbols[lib_name] = symbols print(f" {lib_name}: {len(symbols)} symbol(s)") else: print(f" {lib_name}: empty") if not any(all_symbols.values()): print("No symbols found — README not updated.") return total = sum(len(v) for v in all_symbols.values()) print(f"\nTotal: {total} symbols across {len(all_symbols)} libraries") table = build_component_table(all_symbols) changed = update_readme(table) if changed: print("\nREADME.md component index updated.") else: print("\nREADME.md already up to date.") if __name__ == "__main__": main()