KiBot/kibot/out_kiri.py

327 lines
14 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2022-2023 Salvador E. Tropea
# Copyright (c) 2022-2023 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
"""
Dependencies:
- name: KiCad PCB/SCH Diff
version: 2.5.0
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 pwd
import os
from shutil import copy2, rmtree, copytree
from subprocess import CalledProcessError
from tempfile import mkdtemp, NamedTemporaryFile
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 .out_base import VariantOptions
from .macros import macros, document, output_class # noqa: F401
from . import log
logger = log.get_logger()
STASH_MSG = 'KiBot_Changes_Entry'
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(VariantOptions):
def __init__(self):
with document:
self.output = GS.def_global_output
""" *Filename for the output (%i=diff_pcb/diff_sch, %x=pdf) """
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.keep_generated = False
""" *Avoid PCB and SCH images regeneration. Useful for incremental usage """
super().__init__()
self._expand_id = 'diff'
self._expand_ext = 'pdf'
def get_targets(self, out_dir):
# TODO: Implement
return [self._parent.expand_filename(out_dir, self.output)]
def add_to_cache(self, name, hash):
cmd = [self.command, '--no_reader', '--only_cache', '--old_file_hash', hash[:7], '--cache_dir', self.cache_dir,
'--kiri_mode', '--all_pages']
if self.incl_file:
cmd.extend(['--layers', self.incl_file])
if GS.debug_enabled:
cmd.insert(1, '-'+'v'*GS.debug_level)
cmd.extend([name, name])
self.name_used_for_cache = name
run_command(cmd)
def run_git(self, cmd, cwd=None, just_raise=False):
if cwd is None:
cwd = self.repo_dir
return run_command([self.git_command]+cmd, change_to=cwd, just_raise=just_raise)
def git_dirty(self, file):
return self.run_git(['status', '--porcelain', '-uno', file])
def remove_git_worktree(self, name):
logger.debug('Removing temporal checkout at '+name)
self.run_git(['worktree', 'remove', '--force', name])
def create_layers_incl(self, layers):
self.incl_file = None
if not isinstance(layers, type):
layers = Layer.solve(layers)
# TODO no list (ALL)
self._solved_layers = layers
logger.debug('Including layers:')
with NamedTemporaryFile(mode='w', suffix='.lst', delete=False) as f:
self.incl_file = f.name
for la in layers:
logger.debug('- {} ({})'.format(la.layer, la.id))
f.write(str(la.id)+'\n')
def do_cache(self, name, tmp_wd, hash):
name_copy = self.run_git(['ls-files', '--full-name', name])
name_copy = os.path.join(tmp_wd, name_copy)
logger.debug('- Using temporal copy: '+name_copy)
self.add_to_cache(name_copy, hash)
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:
for la in self._solved_layers:
if os.path.isfile(subdir_layers+('%02d' % la.id)+'.svg'):
f.write(str(la.id)+'|'+la.layer+'\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))
# 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', '--', pcb_file])
self.commits_with_changed_pcb = set(res.split())
res = self.run_git(['log', '--pretty=format:%H', '--'] + 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 create_kiri_files(self):
src_dir = GS.get_resource_path('kiri')
copy2(os.path.join(src_dir, 'redirect.html'), os.path.join(self.cache_dir, 'index.html'))
copy2(os.path.join(src_dir, 'kiri-server'), os.path.join(self.cache_dir, 'kiri-server'))
web_dir = os.path.join(self.cache_dir, 'web')
os.makedirs(web_dir, exist_ok=True)
copy2(os.path.join(src_dir, 'favicon.ico'), os.path.join(web_dir, 'favicon.ico'))
copy2(os.path.join(src_dir, 'kiri.css'), os.path.join(web_dir, 'kiri.css'))
copy2(os.path.join(src_dir, 'kiri.js'), os.path.join(web_dir, 'kiri.js'))
copy2(os.path.join(src_dir, 'index.html'), os.path.join(web_dir, 'index.html'))
copytree(os.path.join(src_dir, 'images'), os.path.join(web_dir, 'images'), dirs_exist_ok=True)
copytree(os.path.join(src_dir, 'utils'), os.path.join(web_dir, 'utils'), dirs_exist_ok=True)
# Colors for the layers
with open(os.path.join(web_dir, 'layer_colors.css'), 'wt') as f:
f.write(LAYER_COLORS_HEAD)
for id, color in self._color_theme.layer_id2color.items():
f.write(f'.layer_color_{id} {{ color: {color[:7]}; }}\n')
def run(self, name):
self.cache_dir = self._parent.output_dir
self.command = self.ensure_tool('KiDiff')
self.git_command = self.ensure_tool('Git')
# Get a list of files for the project
GS.check_sch()
sch_files = GS.sch.get_files()
self.repo_dir = GS.sch_dir
GS.check_pcb()
# Get a list of hashes where we have changes
# TODO implement a limit -n X
res = self.run_git(['log', "--date=format:%Y-%m-%d %H:%M:%S", '--pretty=format:%H | %ad | %an | %s', '--',
GS.pcb_file] + sch_files)
hashes = [r.split(' | ') for r in res.split('\n')]
self.create_layers_incl(self.layers)
self.solve_layer_colors()
# Get more information about what is changed
self.get_modified_status(GS.pcb_file, sch_files)
# TODO ensure we have at least 2
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', 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(GS.sch_file, git_tmp_wd, hash)
# Generate SVGs for the PCB
self.do_cache(GS.pcb_file, 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?
sch_dirty = self.git_dirty(GS.sch_file)
pcb_dirty = self.git_dirty(GS.pcb_file)
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 a PDF with the differences between two PCBs or schematics.
Recursive git submodules aren't supported (submodules inside submodules) """
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]
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')
# TODO: Implement
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)):
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]
outs.append(gb)
return outs
def run(self, name):
self.options.layers = self.layers
super().run(name)