KiBot/kibot/out_kibom.py

430 lines
18 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2020-2021 Salvador E. Tropea
# Copyright (c) 2020-2021 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
import os
from re import search
from tempfile import NamedTemporaryFile
from subprocess import (check_output, STDOUT, CalledProcessError)
from .misc import (CMD_KIBOM, URL_KIBOM, BOM_ERROR, W_EXTNAME)
from .kiplot import (check_script)
from .gs import (GS)
from .optionable import Optionable, BaseOptions
from .error import KiPlotConfigurationError
from .bom.columnlist import ColumnList
from .macros import macros, document, output_class # noqa: F401
from . import log
logger = log.get_logger()
CONFIG_FILENAME = 'config.kibom.ini'
class KiBoMRegex(Optionable):
""" Implements the pair column/regex """
def __init__(self):
super().__init__()
self._unkown_is_error = True
with document:
self.column = ''
""" Name of the column to apply the regular expression """
self.regex = ''
""" Regular expression to match """
self.field = None
""" {column} """
self.regexp = None
""" {regex} """
def __str__(self):
return self.column+'\t'+self.regex
class KiBoMColumns(Optionable):
""" Information for the BoM columns """
def __init__(self):
super().__init__()
self._unkown_is_error = True
with document:
self.field = ''
""" Name of the field to use for this column """
self.name = ''
""" Name to display in the header. The field is used when empty """
self.join = Optionable
""" [list(string)|string=''] List of fields to join to this column """
self._field_example = 'Row'
self._name_example = 'Line'
def config(self, parent):
super().config(parent)
if not self.field:
raise KiPlotConfigurationError("Missing or empty `field` in columns list ({})".format(str(self._tree)))
if isinstance(self.join, type):
self.join = None
elif isinstance(self.join, list):
self.join = '\t'.join(self.join)
class ComponentAliases(Optionable):
_default = [['r', 'r_small', 'res', 'resistor'],
['l', 'l_small', 'inductor'],
['c', 'c_small', 'cap', 'capacitor'],
['sw', 'switch'],
['zener', 'zenersmall'],
['d', 'diode', 'd_small'],
]
def __init__(self):
super().__init__()
class GroupFields(Optionable):
_default = ['Part', 'Part Lib', 'Value', 'Footprint', 'Footprint Lib']
def __init__(self):
super().__init__()
class KiBoMConfig(Optionable):
""" Implements the .ini options """
def __init__(self):
super().__init__()
with document:
self.ignore_dnf = True
""" Exclude DNF (Do Not Fit) components """
self.html_generate_dnf = True
""" Generate a separated section for DNF (Do Not Fit) components (HTML only) """
self.use_alt = False
""" Print grouped references in the alternate compressed style eg: R1-R7,R18 """
self.number_rows = True
""" First column is the row number """
self.group_connectors = True
""" Connectors with the same footprints will be grouped together, independent of the name of the connector """
self.test_regex = True
""" Each component group will be tested against a number of regular-expressions (see ``). """
self.merge_blank_fields = True
""" Component groups with blank fields will be merged into the most compatible group, where possible """
self.fit_field = 'Config'
""" Field name used to determine if a particular part is to be fitted (also DNC and variants) """
self.datasheet_as_link = ''
""" Column with links to the datasheet (HTML only) """
self.hide_headers = False
""" Hide column headers """
self.hide_pcb_info = False
""" Hide project information """
self.ref_separator = ' '
""" Separator used for the list of references """
self.digikey_link = Optionable
""" [string|list(string)=''] Column/s containing Digi-Key part numbers, will be linked to web page (HTML only) """
self.group_fields = GroupFields
""" [list(string)] List of fields used for sorting individual components into groups.
Components which match (comparing *all* fields) will be grouped together.
Field names are case-insensitive.
If empty: ['Part', 'Part Lib', 'Value', 'Footprint', 'Footprint Lib'] is used """
self.component_aliases = ComponentAliases
""" [list(list(string))] A series of values which are considered to be equivalent for the part name.
Each entry is a list of equivalen names. Example: ['c', 'c_small', 'cap' ]
will ensure the equivalent capacitor symbols can be grouped together.
If empty the following aliases are used:
- ['r', 'r_small', 'res', 'resistor']
- ['l', 'l_small', 'inductor']
- ['c', 'c_small', 'cap', 'capacitor']
- ['sw', 'switch']
- ['zener', 'zenersmall']
- ['d', 'diode', 'd_small'] """
self.include_only = KiBoMRegex
""" [list(dict)] A series of regular expressions used to select included parts.
If there are any regex defined here, only components that match against ANY of them will be included.
Column names are case-insensitive.
If empty all the components are included """
self.exclude_any = KiBoMRegex
""" [list(dict)] A series of regular expressions used to exclude parts.
If a component matches ANY of these, it will be excluded.
Column names are case-insensitive.
If empty the following list is used:
- column: References
..regex: '^TP[0-9]*'
- column: References
..regex: '^FID'
- column: Part
..regex: 'mount.*hole'
- column: Part
..regex: 'solder.*bridge'
- column: Part
..regex: 'test.*point'
- column: Footprint
..regex 'test.*point'
- column: Footprint
..regex: 'mount.*hole'
- column: Footprint
..regex: 'fiducial' """
self.columns = KiBoMColumns
""" [list(dict)|list(string)] List of columns to display.
Can be just the name of the field """
@staticmethod
def _create_minimal_ini():
""" KiBoM config to get only the headers """
with NamedTemporaryFile(mode='w', delete=False) as f:
f.write('[BOM_OPTIONS]\n')
f.write('output_file_name = %O\n')
f.write('hide_pcb_info = 1\n')
f.write('\n[IGNORE_COLUMNS]\n')
f.write('\n[REGEX_EXCLUDE]\n')
f.write('Part\t.*\n')
f.close()
return f.name
@staticmethod
def _get_columns():
""" Create a list of valid columns """
if not GS.sch:
return ColumnList.COLUMNS_DEFAULT
check_script(CMD_KIBOM, URL_KIBOM, '1.8.0')
config = None
csv = None
columns = None
try:
xml = GS.sch_no_ext+'.xml'
config = os.path.abspath(KiBoMConfig._create_minimal_ini())
with NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
csv = f.name
cmd = [CMD_KIBOM, '--cfg', config, '-d', os.path.dirname(csv), '-s', ',', xml, csv]
logger.debug('Running: '+str(cmd))
cmd_output = check_output(cmd, stderr=STDOUT)
with open(csv, 'rt') as f:
columns = f.readline().rstrip().split(',')
except CalledProcessError as e:
logger.error('Failed to get the column names for `{}`, error {}'.format(xml, e.returncode))
if e.output:
logger.debug('Output from command: '+e.output.decode())
exit(BOM_ERROR)
finally:
if config:
os.remove(config)
if csv:
os.remove(csv)
logger.debug('Output from command:\n'+cmd_output.decode())
return columns
def config(self, parent):
super().config(parent)
# digikey_link
if isinstance(self.digikey_link, type):
self.digikey_link = None
elif isinstance(self.digikey_link, list):
self.digikey_link = '\t'.join(self.digikey_link)
# group_fields
if isinstance(self.group_fields, type):
self.group_fields = None
# component_aliases
if isinstance(self.component_aliases, type):
self.component_aliases = None
else:
self.component_aliases = ['\t'.join(a) for a in self.component_aliases]
# include_only
if isinstance(self.include_only, type):
self.include_only = None
else:
self.include_only = [str(r) for r in self.include_only]
# exclude_any
if isinstance(self.exclude_any, type):
self.exclude_any = None
else:
self.exclude_any = [str(r) for r in self.exclude_any]
# columns
if isinstance(self.columns, type):
self.columns = None
self.col_rename = None
self.join = None
self.ignore = None
else:
# This is tricky
# Lower case available columns
valid_columns = self._get_columns()
valid_columns_l = {c.lower(): c for c in valid_columns}
logger.debug("Valid columns: "+str(valid_columns))
# Create the different lists
columns = []
columns_l = {}
self.col_rename = []
self.join = []
for col in self.columns:
if isinstance(col, str):
# Just a string, add to the list of used
new_col = col
else:
# A complete entry
new_col = col.field
# A column rename
if col.name:
self.col_rename.append(col.field+'\t'+col.name)
# Attach other columns
if col.join:
self.join.append(col.field+'\t'+col.join)
# Check this is a valid column
if new_col.lower() not in valid_columns_l:
raise KiPlotConfigurationError('Invalid column name `{}`'.format(new_col))
columns.append(new_col)
columns_l[new_col.lower()] = new_col
# Create a list of the columns we don't want
self.ignore = [c for c in valid_columns_l.keys() if c not in columns_l]
# And this is the ordered list with the case style defined by the user
self.columns = columns
def write_bool(self, attr):
""" Write a .INI bool option """
self.f.write('{} = {}\n'.format(attr, '1' if getattr(self, attr) else '0'))
def write_str(self, attr):
""" Write a .INI string option """
val = getattr(self, attr)
if val:
self.f.write('{} = {}\n'.format(attr, val))
def write_vector(self, vector, section):
""" Write a .INI section filled with a vector of strings """
if vector:
self.f.write('\n[{}]\n'.format(section))
for v in vector:
self.f.write(v+'\n')
def save(self, filename):
""" Create an INI file for KiBoM """
logger.debug("Saving KiBoM config to `{}`".format(filename))
with open(filename, 'wt') as f:
self.f = f
f.write('[BOM_OPTIONS]\n')
self.write_bool('ignore_dnf')
self.write_bool('html_generate_dnf')
self.write_bool('use_alt')
self.write_bool('number_rows')
self.write_bool('group_connectors')
self.write_bool('test_regex')
self.write_bool('merge_blank_fields')
self.write_str('fit_field')
self.write_str('datasheet_as_link')
self.write_bool('hide_headers')
self.write_bool('hide_pcb_info')
self.write_str('ref_separator')
self.write_str('digikey_link')
# Ask to keep the output name
f.write('output_file_name = %O\n')
self.write_vector(self.group_fields, 'GROUP_FIELDS')
self.write_vector(self.include_only, 'REGEX_INCLUDE')
self.write_vector(self.exclude_any, 'REGEX_EXCLUDE')
self.write_vector(self.columns, 'COLUMN_ORDER')
self.write_vector(self.ignore, 'IGNORE_COLUMNS')
self.write_vector(self.col_rename, 'COLUMN_RENAME')
self.write_vector(self.join, 'JOIN')
self.write_vector(self.component_aliases, 'COMPONENT_ALIASES')
class KiBoMOptions(BaseOptions):
def __init__(self):
with document:
self.number = 1
""" Number of boards to build (components multiplier) """
self.variant = ''
""" Board variant(s), used to determine which components
are output to the BoM. To specify multiple variants,
with a BOM file exported for each variant, separate
variants with the ';' (semicolon) character.
This isn't related to the KiBot concept of variants """
self.conf = KiBoMConfig
""" [string|dict] BoM configuration file, relative to PCB.
You can also define the configuration here, will be stored in `config.kibom.ini` """
self.separator = ','
""" CSV Separator """
self.output = GS.def_global_output
""" filename for the output (%i=bom)"""
self.format = 'HTML'
""" [HTML,CSV,XML,XLSX] format for the BoM """
super().__init__()
self._expand_id = 'bom'
def config(self, parent):
super().config(parent)
if isinstance(self.conf, type):
self.conf = 'bom.ini'
elif isinstance(self.conf, str):
if not self.conf:
self.conf = 'bom.ini'
else:
# A configuration
conf = os.path.abspath(os.path.join(self.expand_filename_sch(GS.out_dir), CONFIG_FILENAME))
self.conf.save(conf)
self.conf = conf
self._expand_ext = self.format.lower()
def get_targets(self, out_dir):
if self.output:
return [self.expand_filename(out_dir, self.output, 'bom', self.format.lower())]
logger.warning(W_EXTNAME+'{} uses a name generated by the external tool.'.format(self._parent))
logger.warning(W_EXTNAME+'Please use a name generated by KiBot or specify the name explicitly.')
return []
def run(self, name):
check_script(CMD_KIBOM, URL_KIBOM, '1.8.0')
format = self.format.lower()
prj = GS.sch_no_ext
config = os.path.join(GS.sch_dir, self.conf)
if self.output:
force_output = True
output = name
output_dir = os.path.dirname(name)
else:
force_output = False
output = os.path.basename(prj)+'.'+format
output_dir = name
logger.debug('Doing BoM, format {} prj: {} config: {} output: {}'.format(format, prj, config, output))
cmd = [CMD_KIBOM,
'-n', str(self.number),
'--cfg', config,
'-s', self.separator,
'-d', output_dir]
if GS.debug_enabled:
cmd.append('-v')
if self.variant:
cmd.extend(['-r', self.variant])
cmd.extend([prj+'.xml', output])
logger.debug('Running: '+str(cmd))
try:
cmd_output = check_output(cmd, stderr=STDOUT)
except CalledProcessError as e:
logger.error('Failed to create BoM, error %d', e.returncode)
if e.output:
logger.debug('Output from command: '+e.output.decode())
exit(BOM_ERROR)
if force_output:
# When we create the .ini we can control the name.
# But when the user does it we can trust the settings.
m = search(r'Saving BOM File: (.*)', cmd_output.decode())
if m and m.group(1) != output:
cur = m.group(1)
logger.debug('Renaming output file: {} -> {}'.format(cur, output))
os.rename(cur, output)
logger.debug('Output from command:\n'+cmd_output.decode())
@output_class
class KiBoM(BaseOutput): # noqa: F821
""" KiBoM (KiCad Bill of Materials)
Used to generate the BoM in HTML or CSV format using the KiBoM plug-in.
For more information: https://github.com/INTI-CMNB/KiBoM
Note that this output is provided as a compatibility tool.
We recommend using the `bom` output instead.
This output is what you get from the 'Tools/Generate Bill of Materials' menu in eeschema. """
def __init__(self):
super().__init__()
with document:
self.options = KiBoMOptions
""" [dict] Options for the `kibom` output """
self._sch_related = True
def get_dependencies(self):
files = super().get_dependencies()
if isinstance(self.options.conf, str):
files.append(self.options.conf)
return files