# -*- coding: utf-8 -*- # Copyright (c) 2022-2024 Salvador E. Tropea # Copyright (c) 2022-2024 Instituto Nacional de TecnologĂ­a Industrial # License: GPL-3.0 # Project: KiBot (formerly KiPlot) """ Dependencies: - name: KiCad PCB/SCH Diff version: 2.5.1 role: mandatory github: INTI-CMNB/KiDiff command: kicad-diff.py pypi: kidiff downloader: pytool id: KiDiff - from: Git role: Compare with files in the repo - from: KiAuto role: Compare schematics version: 2.2.0 """ import datetime import glob import pwd import os from shutil import copy2, rmtree from subprocess import CalledProcessError from tempfile import mkdtemp from .error import KiPlotConfigurationError from .gs import GS from .kicad.color_theme import load_color_theme from .kiplot import load_any_sch, run_command from .layer import Layer from .misc import W_NOTHCMP from .out_any_diff import AnyDiffOptions from .macros import macros, document, output_class # noqa: F401 from . import log logger = log.get_logger() HASH_LOCAL = '_local_' UNDEF_COLOR = '#DBDBDB' LAYER_COLORS_HEAD = """/* ============================== Layer colors ** ============================*/ """ def get_cur_user(): try: name = pwd.getpwuid(os.geteuid())[4] return name.split(',')[0] except Exception: return 'Local user' class KiRiOptions(AnyDiffOptions): def __init__(self): with document: self.color_theme = '_builtin_classic' """ *Selects the color theme. Only applies to KiCad 6. To use the KiCad 6 default colors select `_builtin_default`. Usually user colors are stored as `user`, but you can give it another name """ self.background_color = "#FFFFFF" """ Color used for the background of the diff canvas """ self.max_commits = 0 """ Maximum number of commits to include. Use 0 for all available commits """ self.revision = 'HEAD' """ Starting point for the commits, can be a branch, a hash, etc. Note that this can be a revision-range, consult the gitrevisions manual for more information """ self.keep_generated = False """ *Avoid PCB and SCH images regeneration. Useful for incremental usage """ super().__init__() self.add_to_doc("zones", "Be careful with the *keep_generated* option when changing this setting") self._kiri_mode = True def config(self, parent): super().config(parent) self.validate_colors(['background_color']) if self.max_commits < 0: raise KiPlotConfigurationError(f"Wrong number of commits ({self.max_commits}) must be positive") def _get_targets(self, out_dir, only_index=False): self.init_tools(out_dir) hashes, sch_dirty, pcb_dirty, sch_files = self.collect_hashes() if len(hashes) + (1 if sch_dirty or pcb_dirty else 0) < 2: return [] if only_index: return [os.path.join(self.cache_dir, 'index.html')] files = [os.path.join(self.cache_dir, f) for f in ['blank.svg', 'commits', 'index.html', 'kiri-server', 'project']] for h in hashes: files.append(os.path.join(self.cache_dir, h[0][:7])) if sch_dirty or pcb_dirty: files.append(os.path.join(self.cache_dir, HASH_LOCAL)) return files def get_targets(self, out_dir): return self._get_targets(out_dir) def get_navigate_targets(self, out_dir): """ Targets for the navigate results, just the index """ return self._get_targets(out_dir, True) def create_layers_incl(self, layers): self.incl_file = None if isinstance(layers, type): self._solved_layers = None return False self.save_layers_incl(Layer.solve(layers)) return True def do_cache(self, name, tmp_wd, hash): name_copy = os.path.join(tmp_wd, name) if not os.path.isfile(name_copy): self.write_empty_file(name_copy) logger.debug('- Using temporal copy: '+name_copy) self.add_to_cache(name_copy, hash[:7]) return name_copy def save_pcb_layers(self, hash): subdir = os.path.join(hash[:7], '_KIRI_') subdir_layers = os.path.join(self.cache_dir, subdir, 'pcb', 'layer-') with open(os.path.join(self.cache_dir, subdir, 'pcb_layers'), 'wt') as f: if self._solved_layers: for la in self._solved_layers: if os.path.isfile(subdir_layers+('%02d' % la.id)+'.svg'): f.write(str(la.id)+'|'+la.layer+'\n') else: discard = len(subdir_layers) for la in sorted(glob.glob(subdir_layers+'??.svg')): id = int(la[discard:discard+2]) f.write(str(id)+'|'+GS.board.GetLayerName(id)+'\n') def solve_layer_colors(self): # Color theme self._color_theme = load_color_theme(self.color_theme) if self._color_theme is None: raise KiPlotConfigurationError("Unable to load `{}` color theme".format(self.color_theme)) if self._solved_layers is None: return # Assign a color if none was defined layer_id2color = self._color_theme.layer_id2color for la in self._solved_layers: if la._id in layer_id2color: la.color = layer_id2color[la._id] else: la.color = UNDEF_COLOR def save_sch_sheet(self, hash, name_sch): # Load the schematic. Really worth? sch = load_any_sch(name_sch, GS.sch_basename) with open(os.path.join(self.cache_dir, hash[:7], '_KIRI_', 'sch_sheets'), 'wt') as f: base_dir = os.path.dirname(name_sch) for s in sorted(sch.all_sheets, key=lambda x: x.sheet_path_h): fname = s.fname no_ext = os.path.splitext(os.path.basename(fname))[0] rel_name = os.path.relpath(fname, base_dir) if s.sheet_path_h == '/': instance_name = sheet_path = GS.sch_basename else: instance_name = os.path.basename(s.sheet_path_h) sheet_path = s.sheet_path_h.replace('/', '-') sheet_path = GS.sch_basename+'-'+sheet_path[1:] f.write(f'{no_ext}|{rel_name}||{instance_name}|{sheet_path}\n') def save_commits(self, commits): with open(os.path.join(self.cache_dir, 'commits'), 'wt') as f: for c in commits: hash = c[0][:7] dt = c[1].split()[0] author = c[2] desc = c[3] sch_changed = c[0] in self.commits_with_changed_sch pcb_changed = c[0] in self.commits_with_changed_pcb f.write(f'{hash}|{dt}|{author}|{desc}|{sch_changed}|{pcb_changed}\n') def save_project_data(self): today = datetime.datetime.today().strftime('%Y-%m-%d') with open(os.path.join(self.cache_dir, 'project'), 'wt') as f: f.write((GS.pro_basename or GS.sch_basename or GS.pcb_basename or 'unknown')+'\n') f.write((GS.sch_title or 'No title')+'|'+(GS.sch_rev or '')+'|'+(GS.sch_date or today)+'\n') f.write((GS.pcb_title or 'No title')+'|'+(GS.pcb_rev or '')+'|'+(GS.pcb_date or today)+'\n') def get_modified_status(self, pcb_file, sch_files): res = self.run_git(['log', '--pretty=format:%H', self.revision] + self._max_commits + ['--', pcb_file]) self.commits_with_changed_pcb = set(res.split()) res = self.run_git(['log', '--pretty=format:%H', self.revision] + self._max_commits + ['--'] + sch_files) self.commits_with_changed_sch = set(res.split()) if GS.debug_level > 1: logger.debug(f'Commits with changes in the PCB: {self.commits_with_changed_pcb}') logger.debug(f'Commits with changes in the Schematics: {self.commits_with_changed_sch}') def copy_index(self, src_dir, src_index, dst_index): with open(src_index, 'rt') as src: with open(dst_index, 'wt') as dst: for ln in src: ln_stripped = ln.strip() if ln_stripped.startswith('\n') elif ln_stripped.startswith('\n') dst.write(script) dst.write('\n\n') else: dst.write(ln) def create_kiri_files(self): src_dir = GS.get_resource_path('kiri') copy2(os.path.join(src_dir, 'kiri-server'), os.path.join(self.cache_dir, 'kiri-server')) web_dir = self.cache_dir os.makedirs(web_dir, exist_ok=True) copy2(os.path.join(src_dir, 'blank.svg'), os.path.join(web_dir, 'blank.svg')) self.copy_index(src_dir, os.path.join(src_dir, 'index.html'), os.path.join(web_dir, 'index.html')) def init_tools(self, out_dir): self.cache_dir = out_dir self.command = self.ensure_tool('KiDiff') self.git_command = self.ensure_tool('Git') # Only needed for schematic self.ensure_tool('KiAuto') def collect_hashes(self): # Get a list of files for the project GS.check_sch() sch_files = GS.sch.get_files() self.repo_dir = GS.sch_dir self.sch_rel_name = self.run_git(['ls-files', '--full-name', GS.sch_file]) if not self.sch_rel_name: raise KiPlotConfigurationError("The schematic must be committed") GS.check_pcb() self.pcb_rel_name = self.run_git(['ls-files', '--full-name', GS.pcb_file]) if not self.pcb_rel_name: raise KiPlotConfigurationError("The PCB must be committed") # Get a list of hashes where we have changes self._max_commits = ['-n', str(self.max_commits)] if self.max_commits else [] cmd = ['log', "--date=format:%Y-%m-%d %H:%M:%S", '--pretty=format:%H | %ad | %an | %s'] res = self.run_git(cmd + self._max_commits + [self.revision, '--', GS.pcb_file] + sch_files) hashes = [r.split(' | ') for r in res.split('\n')] sch_dirty = self.git_dirty(GS.sch_file) pcb_dirty = self.git_dirty(GS.pcb_file) return hashes, sch_dirty, pcb_dirty, sch_files def run(self, name): self.init_tools(self._parent.output_dir) hashes, sch_dirty, pcb_dirty, sch_files = self.collect_hashes() # Ensure we have at least 2 if len(hashes) + (1 if sch_dirty or pcb_dirty else 0) < 2: logger.warning(W_NOTHCMP+'Nothing to compare') return # Get more information about what is changed self.get_modified_status(GS.pcb_file, sch_files) self.create_layers_incl(self.layers) self.solve_layer_colors() try: git_tmp_wd = None try: for h in hashes: hash = h[0] dst_dir = os.path.join(self.cache_dir, hash[:7]) already_generated = os.path.isdir(dst_dir) if self.keep_generated and already_generated: logger.debug(f'- Images for {hash} already generated') continue if already_generated: rmtree(dst_dir) git_tmp_wd = mkdtemp() logger.debug('Checking out '+hash+' to '+git_tmp_wd) self.run_git(['worktree', 'add', '--detach', '--force', git_tmp_wd, hash]) self.run_git(['submodule', 'update', '--init', '--recursive'], cwd=git_tmp_wd) # Generate SVGs for the schematic name_sch = self.do_cache(self.sch_rel_name, git_tmp_wd, hash) # Generate SVGs for the PCB self.do_cache(self.pcb_rel_name, git_tmp_wd, hash) # List of layers self.save_pcb_layers(hash) # Schematic hierarchy self.save_sch_sheet(hash, name_sch) self.remove_git_worktree(git_tmp_wd) git_tmp_wd = None finally: if git_tmp_wd: self.remove_git_worktree(git_tmp_wd) # Do we have modifications? if sch_dirty or pcb_dirty: # Include the current files dst_dir = os.path.join(self.cache_dir, HASH_LOCAL) already_generated = os.path.isdir(dst_dir) if self.keep_generated and already_generated: logger.debug(f'- Images for {HASH_LOCAL} already generated') else: if already_generated: rmtree(dst_dir) name_sch = self.do_cache(GS.sch_file, GS.sch_dir, HASH_LOCAL) self.save_sch_sheet(HASH_LOCAL, name_sch) self.do_cache(GS.pcb_file, GS.pcb_dir, HASH_LOCAL) self.save_pcb_layers(HASH_LOCAL) hashes.insert(0, (HASH_LOCAL, datetime.datetime.today().strftime('%Y-%m-%d %H:%M:%S'), get_cur_user(), 'Local changes not committed')) if pcb_dirty: self.commits_with_changed_pcb.add(HASH_LOCAL) if sch_dirty: self.commits_with_changed_sch.add(HASH_LOCAL) finally: if self.incl_file: os.remove(self.incl_file) self.create_kiri_files() self.save_commits(hashes) self.save_project_data() @output_class class KiRi(BaseOutput): # noqa: F821 """ KiRi Generates an interactive web page to browse the schematic and/or PCB differences between git commits. Must be applied to a git repository. Recursive git submodules aren't supported (submodules inside submodules). Note that most browsers won't allow Java Script to read local files, needed to load the SCH/PCB. So you must use a web server or enable the access to local files. In the case of Google Chrome you can use the `--allow-file-access-from-files` command line option. This output generates a simple Python web server called `kiri-server` that you can use. For more information visit the [KiRi web](https://github.com/leoheck/kiri) """ def __init__(self): super().__init__() self._category = ['PCB/docs', 'Schematic/docs'] self._both_related = True with document: self.options = KiRiOptions """ *[dict] Options for the `diff` output """ self.layers = Layer """ *[list(dict)|list(string)|string] [all,selected,copper,technical,user,inners,outers] List of PCB layers to use. When empty all available layers are used. Note that if you want to support adding/removing layers you should specify a list here """ @staticmethod def layer2dict(la): return {'layer': la.layer, 'suffix': la.suffix, 'description': la.description} @staticmethod def has_repo(git_command, file): try: run_command([git_command, 'ls-files', '--error-unmatch', file], change_to=os.path.dirname(file), just_raise=True) except CalledProcessError: logger.debug("File `{}` not inside a repo".format(file)) return False return True @staticmethod def get_conf_examples(name, layers): outs = [] git_command = GS.check_tool(name, 'Git') if not git_command or not GS.check_tool(name, 'KiDiff'): return None if (GS.pcb_file and GS.sch_file and KiRi.has_repo(git_command, GS.pcb_file) and KiRi.has_repo(git_command, GS.sch_file)): ops = KiRiOptions() ops.git_command = git_command hashes, sch_dirty, pcb_dirty, _ = ops.collect_hashes() if sch_dirty or pcb_dirty: hashes.append(HASH_LOCAL) if len(hashes) < 2: return None if GS.debug_level > 1: logger.debug(f'KiRi get_conf_examples found: {hashes}') gb = {} gb['name'] = 'basic_{}'.format(name) gb['comment'] = 'Interactive diff between commits' gb['type'] = name gb['dir'] = 'diff' gb['layers'] = [KiRi.layer2dict(la) for la in layers] # Avoid taking too much time gb['options'] = {'max_commits': 4} outs.append(gb) return outs def get_navigate_targets(self, out_dir): return (self.options.get_navigate_targets(out_dir), [os.path.join(GS.get_resource_path('kiri'), 'images', 'icon.svg')]) def run(self, name): self.options.layers = self.layers super().run(name)