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
						
					
					
				#!/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()    
 | 
						|
 |