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
			|   
											8 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()     | ||
|  | 
 |