parent
56673d14dc
commit
f1cd9d0069
@ -0,0 +1,263 @@
|
||||
#!/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()
|
||||
|
Loading…
Reference in new issue