You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
264 lines
12 KiB
264 lines
12 KiB
7 years ago
|
#!/usr/bin/python3
|
||
|
#
|
||
|
# KiCAD BOM to DigiKey BOM conversion
|
||
|
#
|
||
|
# John Nagle
|
||
|
# October, 2016
|
||
|
# License: GPL
|
||
|
#
|
||
|
# Output is a tab-delimited file.
|
||
|
#
|
||
|
#
|
||
|
#
|
||
|
import re
|
||
|
import sys
|
||
|
import argparse
|
||
|
import xml.etree.ElementTree
|
||
|
#
|
||
|
# Main program
|
||
|
#
|
||
|
def main() :
|
||
|
# Get command line arguments
|
||
|
parser = argparse.ArgumentParser(description="KiCAD XML bill of materials converter to .CSV format")
|
||
|
parser.add_argument("--verbose", default=False, action="store_true", help="set verbose mode")
|
||
|
parser.add_argument("--split", metavar="COLUMN", help="Split BOM into multiple output files based on this column")
|
||
|
parser.add_argument("--select", action="append", metavar="COLUMN=VALUE", help="COLUMN=VALUE to select")
|
||
|
parser.add_argument("files", action="append", help="XML file")
|
||
|
args = parser.parse_args() # parse command line
|
||
|
print(args)
|
||
|
verbose = args.verbose
|
||
|
selects = {} # dict of (k, set(v))
|
||
|
splitcol = args.split # split on this arg
|
||
|
if splitcol is not None :
|
||
|
splitcol = splitcol.upper() # upper case column name
|
||
|
print('Split on column "%s".' % (splitcol,)) # split on this column
|
||
|
# Accumulate select keys
|
||
|
if args.select is not None :
|
||
|
for select in args.select :
|
||
|
parts = select.split("=") # split at "="
|
||
|
if len(parts) != 2 : # must be COLUMN=VALUE
|
||
|
print('"--select COLUMN=VALUE" required.')
|
||
|
parser.print_help()
|
||
|
exit(1)
|
||
|
k = parts[0].strip().upper()
|
||
|
v = parts[1].strip().upper() # save selects as upper case
|
||
|
if k not in selects :
|
||
|
selects[k] = set() # need set for this key
|
||
|
selects[k].add(v) # add value to set for this key
|
||
|
print("Selection rules: ")
|
||
|
for (k, sset) in selects.items() :
|
||
|
print(' Select if %s is in %s.' % (k, list(sset)))
|
||
|
# Process all files on command line
|
||
|
for infname in args.files :
|
||
|
print(infname)
|
||
|
try :
|
||
|
(outfname, suffix) = infname.rsplit(".",1) # remove suffix
|
||
|
except ValueError :
|
||
|
print("Input must be a .xml file.")
|
||
|
parser.print_help()
|
||
|
exit(1)
|
||
|
if suffix.lower() != "xml" : # must be XML
|
||
|
print("Input must be a .xml file.")
|
||
|
parser.print_help()
|
||
|
exit(1)
|
||
|
cv = Converter(selects, splitcol, verbose)
|
||
|
cv.convert(infname, outfname)
|
||
|
|
||
|
#
|
||
|
# converter -- converts file
|
||
|
#
|
||
|
class Converter(object) :
|
||
|
FIXEDFIELDS = ["REF","FOOTPRINT","VALUE", "QUANTITY"] # fields we always want
|
||
|
NOTDIFFERENTPART = set(["REF"]) # still same part if this doesn't match
|
||
|
OUTPUTSUFFIX = ".csv" # file output suffix
|
||
|
|
||
|
def __init__(self, selects, splitcol, verbose = False) :
|
||
|
self.selects = selects # selection list
|
||
|
self.splitcol = splitcol # split output into multiple files based on this
|
||
|
self.verbose = verbose # debug info
|
||
|
self.tree = None # no tree yet
|
||
|
self.fieldset = set(self.FIXEDFIELDS) # set of all field names found
|
||
|
self.fieldlist = None # list of column headings
|
||
|
|
||
|
def cleanstr(self, s) :
|
||
|
"""
|
||
|
Clean up a string to avoid CSV format problems
|
||
|
"""
|
||
|
return(re.sub(r'\s+|,',' ', s).strip()) # remove tabs, newlines, and commas
|
||
|
|
||
|
def handlecomp1(self, comp) :
|
||
|
"""
|
||
|
Handle one component entry, pass 1 - find all field names
|
||
|
"""
|
||
|
for field in comp.iter("field") : # for all "field" items
|
||
|
name = field.get("name") # get all "name" names
|
||
|
name = name.upper()
|
||
|
self.fieldset.add(name)
|
||
|
|
||
|
def selectitem(self, fieldvals) :
|
||
|
"""
|
||
|
Given a set of field values, decide if we want to keep this one.
|
||
|
|
||
|
All SELECTs must be true.
|
||
|
"""
|
||
|
for k in self.selects : # for all select rules
|
||
|
if k not in fieldvals : # if not found, fail, unless missing allowed
|
||
|
if "" not in self.selects[k] : # if "--select FOO=" allow
|
||
|
return(False) # fails
|
||
|
if fieldvals[k].upper() not in self.selects[k] :# if no find
|
||
|
return(False)
|
||
|
return(True) # no select succeeded
|
||
|
|
||
|
def handlecomp2(self, comp) :
|
||
|
"""
|
||
|
Handle one component entry, pass 2 - Collect and output fields
|
||
|
"""
|
||
|
fieldvals = dict()
|
||
|
try :
|
||
|
ref = comp.attrib.get("ref")
|
||
|
footprint = getattr(comp.find("footprint"),"text","")
|
||
|
value = comp.find("value").text
|
||
|
except (ValueError, AttributeError) as message :
|
||
|
print("Required field missing from %s" % (comp.attrib))
|
||
|
exit(1)
|
||
|
fieldvals["REF"] = ref
|
||
|
fieldvals["FOOTPRINT"] = footprint
|
||
|
fieldvals["VALUE"] = value
|
||
|
fieldvals["QUANTITY"] = "1" # one item at this point
|
||
|
if self.verbose :
|
||
|
print("%s" % (fieldvals,))
|
||
|
# Get user-defined fields
|
||
|
for field in comp.iter("field") :
|
||
|
name = field.get("name")
|
||
|
name = name.upper()
|
||
|
fieldvals[name] = field.text
|
||
|
if self.verbose :
|
||
|
print("%s" % (fieldvals,))
|
||
|
if self.selectitem(fieldvals) : # if we want this item
|
||
|
return(self.assembleline(fieldvals)) # return list of fields
|
||
|
return(None)
|
||
|
|
||
|
def assembleline(self, fieldvals) :
|
||
|
"""
|
||
|
Assemble output fields into a list
|
||
|
"""
|
||
|
s = '' # empty line
|
||
|
outfields = [] # output fields
|
||
|
for fname in self.fieldlist : # for all fields
|
||
|
if fname in fieldvals :
|
||
|
val = fieldvals[fname] # get value
|
||
|
else :
|
||
|
val = "" # empty string otherwise
|
||
|
outfields.append(self.cleanstr(val)) # remove things not desirable in CSV files
|
||
|
return(outfields) # ordered list of fields
|
||
|
|
||
|
def issamepart(self, rowa, rowb) :
|
||
|
"""
|
||
|
True if both lists represent the same part
|
||
|
"""
|
||
|
if rowa is None or rowb is None :
|
||
|
return(False) # None doesn't match
|
||
|
for i in range(len(self.fieldlist)) : # across 3 lists in sync
|
||
|
if self.fieldlist[i] in self.NOTDIFFERENTPART : # some fields, such as REF, don't mean a new part
|
||
|
continue
|
||
|
if rowa[i] != rowb[i] :
|
||
|
return(False)
|
||
|
return(True) # all important fields matched
|
||
|
|
||
|
def additems(self, rows) :
|
||
|
"""
|
||
|
Combine multiple instances of same part, adding to quantity
|
||
|
"""
|
||
|
quanpos = self.fieldlist.index("QUANTITY") # get index of quantity column
|
||
|
refpos = self.fieldlist.index("REF") # get index of ref column
|
||
|
outrows = []
|
||
|
prevrow = None
|
||
|
quan = 0
|
||
|
refs = [] # concat REF fields
|
||
|
for row in rows : # for all rows
|
||
|
if not self.issamepart(prevrow, row) : # if control break
|
||
|
if prevrow is not None :
|
||
|
prevrow[quanpos] = str(quan) # set quantity
|
||
|
prevrow[refpos] = " ".join(refs) # set list of refs
|
||
|
outrows.append(prevrow) # output stored row
|
||
|
quan = 0
|
||
|
refs = []
|
||
|
prevrow = row # process new row
|
||
|
quan = quan + int(row[quanpos]) # add this quantity
|
||
|
refs.append(row[refpos]) # add to list of refs
|
||
|
|
||
|
if prevrow is not None : # end of file
|
||
|
prevrow[quanpos] = str(quan) # do last item
|
||
|
prevrow[refpos] = " ".join(refs) # set list of refs
|
||
|
outrows.append(prevrow) # output stored row
|
||
|
return(outrows) # return summed rows
|
||
|
|
||
|
def outputfile(self, rows, outfname) :
|
||
|
"""
|
||
|
Output one file containing given rows
|
||
|
"""
|
||
|
heading = ",".join(self.fieldlist) # heading line
|
||
|
outf = open(outfname,"w") # open output file
|
||
|
outf.write(heading + "\n") # heading line
|
||
|
for row in rows : # output all rows
|
||
|
s = ",".join(row)
|
||
|
outf.write(s + "\n") # print to file
|
||
|
outf.close() # done
|
||
|
print('Created file "%s".' % (outfname),) # report
|
||
|
|
||
|
def splitrows(self, splitcol, rows) :
|
||
|
"""
|
||
|
Split rows into multiple lists of rows based on values in selected column
|
||
|
|
||
|
Usually used to break up a BOM by vendor
|
||
|
"""
|
||
|
rowsets = {} # col value, [rows]
|
||
|
splitpos = self.fieldlist.index(splitcol) # get index of split column
|
||
|
if splitpos is None : # must find
|
||
|
print('Column "%s" from --split not found in BOM' % (splitcol,))
|
||
|
exit(1)
|
||
|
for row in rows :
|
||
|
k = row[splitpos] # get split column value
|
||
|
if k is None or k == "" :
|
||
|
k = "NONE" # use NONE if no value available
|
||
|
k = k.upper() # always in upper case
|
||
|
if k not in rowsets :
|
||
|
rowsets[k] = [] # add new output file
|
||
|
rowsets[k].append(row) # add this row to appropriate output file
|
||
|
return(rowsets) # returns col value, [rows]
|
||
|
|
||
|
def convert(self, infname, outfname) :
|
||
|
"""
|
||
|
Convert one file
|
||
|
"""
|
||
|
self.tree = xml.etree.ElementTree.parse(infname)
|
||
|
root = self.tree.getroot() # root element
|
||
|
# Pass 1 - inventory fields
|
||
|
for comp in root.iter("comp") :
|
||
|
self.handlecomp1(comp)
|
||
|
if self.verbose :
|
||
|
print("Field names found: %s" % (self.fieldset))
|
||
|
self.fieldlist = list(self.fieldset)
|
||
|
self.fieldlist.sort() # sort in place
|
||
|
# Pass 2 - accumulate rows
|
||
|
rows = []
|
||
|
for comp in root.iter("comp") :
|
||
|
row = self.handlecomp2(comp)
|
||
|
if row is not None :
|
||
|
rows.append(row)
|
||
|
# Pass 3 - combine rows of same items
|
||
|
rows.sort()
|
||
|
rows = self.additems(rows) # combine items
|
||
|
# Pass 4 - output rows
|
||
|
if self.splitcol is None : # all in one file
|
||
|
self.outputfile(rows, outfname + self.OUTPUTSUFFIX) # one file
|
||
|
else : # one file per column value
|
||
|
rowsets = self.splitrows(self.splitcol, rows)
|
||
|
for (colval, rows) in rowsets.items() : # output multiple files
|
||
|
self.outputfile(rows, outfname + "-" + colval + self.OUTPUTSUFFIX)
|
||
|
|
||
|
|
||
|
if __name__ == "__main__" :
|
||
|
main()
|
||
|
|