#!/usr/bin/env python3 """ apply_variant.py ================ Apply a variant to a KiCad schematic by setting DNP (Do Not Place) flags on components. Uses KiCad CLI to modify the schematic. """ import sys import json import subprocess from pathlib import Path from variant_manager import VariantManager def get_all_schematic_files(root_schematic: str) -> list: """ Get all schematic files in a hierarchical design. Args: root_schematic: Path to root .kicad_sch file Returns: List of all schematic file paths (including root) """ root_path = Path(root_schematic) if not root_path.exists(): return [root_schematic] schematic_files = [str(root_path)] schematic_dir = root_path.parent # Read root schematic to find sheet files try: with open(root_path, 'r', encoding='utf-8') as f: content = f.read() # Find all sheet file references for line in content.split('\n'): if '(property "Sheetfile"' in line: parts = line.split('"') if len(parts) >= 4: sheet_file = parts[3] sheet_path = schematic_dir / sheet_file if sheet_path.exists(): # Recursively get sheets from this sheet sub_sheets = get_all_schematic_files(str(sheet_path)) for sub in sub_sheets: if sub not in schematic_files: schematic_files.append(sub) except Exception as e: print(f"Warning: Error reading sheet files: {e}") return schematic_files def apply_variant_to_schematic(schematic_file: str, variant_name: str, kicad_cli: str = "kicad-cli") -> bool: """ Apply a variant to a schematic by setting DNP flags. Handles hierarchical schematics by processing all sheets. Args: schematic_file: Path to root .kicad_sch file variant_name: Name of variant to apply kicad_cli: Path to kicad-cli executable Returns: True if successful, False otherwise """ manager = VariantManager(schematic_file) if variant_name not in manager.get_variants(): print(f"Error: Variant '{variant_name}' not found") return False dnp_parts = manager.get_dnp_parts(variant_name) print(f"Applying variant '{variant_name}' to {Path(schematic_file).name}") print(f"DNP parts ({len(dnp_parts)}): {dnp_parts}") # Get all schematic files (root + hierarchical sheets) all_schematics = get_all_schematic_files(schematic_file) print(f"Processing {len(all_schematics)} schematic file(s)") overall_success = True # Process each schematic file for idx, sch_file in enumerate(all_schematics): is_root = (idx == 0) # First file is the root schematic if not process_single_schematic(sch_file, dnp_parts, variant_name, is_root): overall_success = False if overall_success: print(f"\nVariant '{variant_name}' applied successfully") print(f"Please reload the schematic in KiCad to see changes") return overall_success def process_single_schematic(schematic_file: str, dnp_uuids: list, variant_name: str = None, is_root: bool = False) -> bool: """ Process a single schematic file to apply DNP flags. Args: schematic_file: Path to .kicad_sch file dnp_uuids: List of UUIDs that should be DNP variant_name: Name of variant being applied (for title block) is_root: True if this is the root schematic (not a sub-sheet) Returns: True if successful, False otherwise """ sch_path = Path(schematic_file) if not sch_path.exists(): print(f"Error: Schematic file not found: {schematic_file}") return False print(f"\n Processing: {sch_path.name}") try: with open(sch_path, 'r', encoding='utf-8') as f: content = f.read() # Parse schematic and set DNP flags # KiCad 9 schematic format uses S-expressions # Component structure: # (symbol # (lib_id ...) # (at ...) # (uuid "...") <- UUID appears here # (dnp no) <- DNP flag appears here # (property "Reference" "U1" ...) # ... # ) lines = content.split('\n') modified = False # Update title block comment if this is the root schematic if is_root and variant_name: in_title_block = False for i, line in enumerate(lines): stripped = line.strip() if stripped.startswith('(title_block'): in_title_block = True elif in_title_block and stripped == ')': in_title_block = False elif in_title_block and '(comment 1' in stripped: # Update comment 1 with variant name indent = line[:len(line) - len(line.lstrip())] new_line = indent + f'(comment 1 "{variant_name}")' if lines[i] != new_line: lines[i] = new_line modified = True print(f" Updated title block: Variant = {variant_name}") break for i, line in enumerate(lines): stripped = line.strip() # Look for DNP lines if '(dnp' in stripped and (stripped.startswith('(dnp') or '\t(dnp' in line or ' (dnp' in line): # Find the UUID for this symbol by looking forward (UUID comes after DNP) current_uuid = None current_ref = None is_power_symbol = False # Look backward to check for power symbols for j in range(i - 1, max(0, i - 10), -1): if '(lib_id' in lines[j] and 'power:' in lines[j]: is_power_symbol = True break if lines[j].strip().startswith('(symbol'): break # Skip power symbols if is_power_symbol: continue # Look forward for UUID (it comes right after DNP in the structure) for j in range(i + 1, min(len(lines), i + 10)): if '(uuid' in lines[j]: # Extract UUID from line like: (uuid "681abb84-6eb2-4c95-9a2f-a9fc19a34beb") # Make sure it's at symbol level (minimal indentation) if '\t(uuid' in lines[j] or ' (uuid' in lines[j]: uuid_parts = lines[j].split('"') if len(uuid_parts) >= 2: current_uuid = uuid_parts[1] break # Stop if we hit properties or other structures if '(property "Reference"' in lines[j]: break # Look forward for reference (for logging purposes) for j in range(i + 1, min(len(lines), i + 20)): if '(property "Reference"' in lines[j]: ref_parts = lines[j].split('"') if len(ref_parts) >= 4: current_ref = ref_parts[3] # Also skip if reference starts with # if current_ref.startswith('#'): is_power_symbol = True break if lines[j].strip().startswith('(symbol') or (lines[j].strip() == ')' and len(lines[j]) - len(lines[j].lstrip()) <= len(line) - len(line.lstrip())): break if current_uuid and not is_power_symbol: # Get indentation indent = line[:len(line) - len(line.lstrip())] # Check if this part should be DNP should_be_dnp = current_uuid in dnp_uuids # Determine what the DNP line should say if should_be_dnp: target_dnp = '(dnp yes)' else: target_dnp = '(dnp no)' # Update DNP flag if it's different from target if stripped != target_dnp.strip(): lines[i] = indent + target_dnp modified = True if should_be_dnp: print(f" Set DNP: {current_ref if current_ref else current_uuid}") else: print(f" Cleared DNP: {current_ref if current_ref else current_uuid}") if modified: # Backup original file backup_path = sch_path.with_suffix('.kicad_sch.bak') # Remove old backup if it exists if backup_path.exists(): backup_path.unlink() # Create new backup import shutil shutil.copy2(sch_path, backup_path) print(f" Backup created: {backup_path.name}") # Write modified schematic with open(sch_path, 'w', encoding='utf-8') as f: f.write('\n'.join(lines)) print(f" Updated successfully") else: print(f" No changes needed") return True except Exception as e: print(f" Error: {e}") return False if __name__ == "__main__": if len(sys.argv) < 3: print("Usage: python apply_variant.py [kicad-cli]") sys.exit(1) schematic = sys.argv[1] variant = sys.argv[2] kicad_cli = sys.argv[3] if len(sys.argv) > 3 else "kicad-cli" success = apply_variant_to_schematic(schematic, variant, kicad_cli) sys.exit(0 if success else 1)