diff --git a/KiBOM/component.py b/KiBOM/component.py index 5e4c1712..285d0100 100644 --- a/KiBOM/component.py +++ b/KiBOM/component.py @@ -35,14 +35,6 @@ class Component(): #no match, return False return False - #compare footprint with another component - def compareFootprint(self, other): - return self.getFootprint().lower() == other.getFootprint().lower() - - #compare the component library of this part to another part - def compareLibName(self, other): - return self.getLibName().lower() == other.getLibName().lower() - #determine if two parts have the same name def comparePartName(self, other): pn1 = self.getPartName().lower() @@ -57,24 +49,43 @@ class Component(): return True return False - + + def compareField(self, other, field): + + this_field = self.getField(field).lower() + other_field = other.getField(field).lower() + + if this_field == other_field: return True + + #if blank comparisons are allowed + if self.prefs.mergeBlankFields: + if this_field == "" or other_field == "": + return True + + return False + #Equivalency operator is used to determine if two parts are 'equal' def __eq__(self, other): """Equlivalency operator, remember this can be easily overloaded""" - valueResult = self.compareValue(other) - - #if connector comparison is overridden, set valueResult to True - if self.prefs.groupConnectors: - if "conn" in self.getDescription().lower(): - valueResult = True - - if self.prefs.compareFootprints: - fpResult = self.compareFootprint(other) - else: - fpResult = True + results = [] + + #'fitted' value must be the same for both parts + results.append(self.isFitted() == other.isFitted()) + + for c in self.prefs.groups: + #perform special matches + if c.lower() == ColumnList.COL_VALUE.lower(): + results.append(self.compareValue(other)) + #match part name + elif c.lower() == ColumnList.COL_PART.lower(): + results.append(self.comparePartName(other)) - return valueResult and fpResult and self.compareLibName(other) and self.comparePartName(other) and self.isFitted() == other.isFitted() + #generic match + else: + results.append(self.compareField(other, c)) + + return all(results) def setLibPart(self, part): self.libpart = part @@ -107,7 +118,7 @@ class Component(): def getValue(self): return self.element.get("value") - def getField(self, name, libraryToo=True): + def getField(self, name, ignoreCase=True, libraryToo=True): """Return the value of a field named name. The component is first checked for the field, and then the components library part is checked for the field. If the field doesn't exist in either, an empty string is @@ -118,10 +129,40 @@ class Component(): libraryToo -- look in the libpart's fields for the same name if not found in component itself """ + + + #special fields + + if name.lower() == ColumnList.COL_REFERENCE.lower(): + return self.getRef() + + if name.lower() == ColumnList.COL_DESCRIPTION.lower(): + return self.getDescription() + + if name.lower() == ColumnList.COL_DATASHEET.lower(): + return self.getDatasheet() + + if name.lower() == ColumnList.COL_FP.lower(): + return self.getFootprint().split(":")[0] + + if name.lower() == ColumnList.COL_FP.lower(): + return self.getFootprint().split(":")[1] + + if name.lower() == ColumnList.COL_VALUE.lower(): + return self.getValue() + if name.lower() == ColumnList.COL_PART.lower(): + return self.getPartName() + + if name.lower() == ColumnList.COL_PART_LIB.lower(): + return self.getLibName() + + field = self.element.get("field", "name", name) + if field == "" and libraryToo: field = self.libpart.getField(name) + return field def getFieldNames(self): @@ -142,13 +183,31 @@ class Component(): #determine if a component is FITTED or not def isFitted(self): - - check = [self.getValue().lower(), self.getField("Notes").lower()] - - for item in check: - if any([dnf in item for dnf in DNF]): return False - - return True + + check = self.getField(self.prefs.configField).lower() + + #check the value field first + if self.getValue().lower() in DNF or check.lower() in DNF: + return False + + if check == "": + return True #empty is fitted + + opts = check.split(",") + + result = True + + for opt in opts: + #options that start with '-' are explicitly removed from certain configurations + if opt.startswith('-') and opt[1:].lower() == self.prefs.pcbConfig.lower(): + result = False + break + if opt.startswith("+"): + if opt.lower() == self.prefs.pcbConfig.lower(): + result = True + + #by default, part is fitted + return result def getFootprint(self, libraryToo=True): ret = self.element.get("footprint") @@ -180,6 +239,7 @@ class ComponentGroup(): self.prefs = prefs def getField(self, field): + if not field in self.fields.keys(): return "" if not self.fields[field]: return "" return u''.join((self.fields[field])) @@ -191,6 +251,8 @@ class ComponentGroup(): def matchComponent(self, c): if len(self.components) == 0: return True if c == self.components[0]: return True + + return False #test if a given component is already contained in this grop def containsComponent(self, c): @@ -260,6 +322,7 @@ class ComponentGroup(): self.fields[ColumnList.COL_GRP_QUANTITY] = "{n}{dnf}".format( n=q, dnf = " (DNF)" if not self.isFitted() else "") + self.fields[ColumnList.COL_GRP_BUILD_QUANTITY] = str(q * self.prefs.boards) if self.isFitted() else "0" self.fields[ColumnList.COL_VALUE] = self.components[0].getValue() self.fields[ColumnList.COL_PART] = self.components[0].getPartName() @@ -278,7 +341,11 @@ class ComponentGroup(): #return True if none match (i.e. this group is OK) #retunr False if any match def testRegex(self): + + return True + #run the excusion + """ for key in self.prefs.regex.keys(): reg = self.prefs.regex[key] if not type(reg) in [str, list]: continue #regex must be a string, or a list of strings @@ -303,6 +370,7 @@ class ComponentGroup(): return False return True + """ #return a dict of the KiCAD data based on the supplied columns diff --git a/KiBOM/preferences.py b/KiBOM/preferences.py index c79a6dc6..990c7dba 100644 --- a/KiBOM/preferences.py +++ b/KiBOM/preferences.py @@ -12,46 +12,57 @@ class BomPref: SECTION_IGNORE = "IGNORE_COLUMNS" SECTION_GENERAL = "BOM_OPTIONS" - SECTION_EXCLUDE_VALUES = "EXCLUDE_COMPONENT_VALUES" - SECTION_EXCLUDE_REFS = "EXCLUDE_COMPONENT_REFS" - SECTION_EXCLUDE_FP = "EXCLUDE_COMPONENT_FP" - SECTION_EXCLUDE_PART = "EXCLUDE_COMPONENT_PART" - SECTION_EXCLUDE_DESC = "EXCLUDE_COMPONENT_DESC" SECTION_ALIASES = "COMPONENT_ALIASES" - SECTION_CONFIGURATIONS = "PCB_CONFIGURATIONS" + SECTION_GROUPING_FIELDS = "GROUP_FIELDS" + SECTION_REGEXCLUDES = "REGEX_EXCLUDE" + SECTION_REGINCLUDES = "REGEX_INCLUDE" - OPT_IGNORE_DNF = "ignore_dnf" + OPT_PCB_CONFIG = "pcb_configuration" OPT_NUMBER_ROWS = "number_rows" OPT_GROUP_CONN = "group_connectors" OPT_USE_REGEX = "test_regex" - OPT_COMP_FP = "compare_footprints" - OPT_INC_PRICE = "calculate_price" - - #list of columns which we can use regex on - COL_REG_EX = [ - ColumnList.COL_REFERENCE, - ColumnList.COL_DESCRIPTION, - ColumnList.COL_VALUE, - ColumnList.COL_FP, - ColumnList.COL_FP_LIB, - ColumnList.COL_PART, - ColumnList.COL_PART_LIB - ] + OPT_MERGE_BLANK = "merge_blank_fields" + OPT_IGNORE_DNF = "ignore_dnf" + OPT_CONFIG_FIELD = "configuration_field" + def __init__(self): self.ignore = [ ColumnList.COL_PART_LIB, ColumnList.COL_FP_LIB, ] #list of headings to ignore in BoM generation - self.ignoreDNF = False #ignore rows for do-not-fit parts + self.ignoreDNF = True #ignore rows for do-not-fit parts self.numberRows = True #add row-numbers to BoM output self.groupConnectors = True #group connectors and ignore component value self.useRegex = True #Test various columns with regex - self.compareFootprints = True #test footprints when comparing components self.boards = 1 + self.mergeBlankFields = True #blanks fields will be merged when possible self.hideHeaders = False self.verbose = False #by default, is not verbose - self.configurations = [] #list of various configurations + self.configField = "Config" #default field used for part fitting config + self.pcbConfig = "default" + + #default fields used to group components + self.groups = [ + ColumnList.COL_PART, + ColumnList.COL_PART_LIB, + ColumnList.COL_VALUE, + ColumnList.COL_FP, + ColumnList.COL_FP_LIB, + #user can add custom grouping columns in bom.ini + ] + + self.regIncludes = [] #none by default + + self.regExcludes = [ + [ColumnList.COL_REFERENCE,'TP[0-9]'], + [ColumnList.COL_PART,'mount[\s-_]*hole'], + [ColumnList.COL_PART,'solder[\s-_]*bridge'], + [ColumnList.COL_PART,'test[\s-_]*point'], + [ColumnList.COL_FP,'test[\s-_]*point'], + [ColumnList.COL_FP,'mount[\s-_]*hole'], + [ColumnList.COL_FP,'fiducial'], + ] #default component groupings self.aliases = [ @@ -62,29 +73,6 @@ class BomPref: ["zener","zenersmall"], ["d","diode","d_small"] ] - - #dictionary of possible regex expressions for ignoring component row(s) - self.regex = dict.fromkeys(self.COL_REG_EX) - - #default regex values - self.regex[ColumnList.COL_REFERENCE] = [ - 'TP[0-9]+', - ] - - self.regex[ColumnList.COL_PART] = [ - 'mounthole', - 'scopetest', - 'mount_hole', - 'solder_bridge', - 'test_point', - ] - - self.regex[ColumnList.COL_FP] = [ - 'mounthole' - ] - - def columnToGroup(self, col): - return "REGEXCLUDE_" + col.upper().replace(" ","_") #check an option within the SECTION_GENERAL group def checkOption(self, parser, opt, default=False): @@ -102,6 +90,7 @@ class BomPref: with open(file, 'rb') as configfile: cf = ConfigParser.RawConfigParser(allow_no_value = True) + cf.optionxform=str cf.read(file) @@ -111,12 +100,18 @@ class BomPref: self.numberRows = self.checkOption(cf, self.OPT_NUMBER_ROWS, default=True) self.groupConnectors = self.checkOption(cf, self.OPT_GROUP_CONN, default=True) self.useRegex = self.checkOption(cf, self.OPT_USE_REGEX, default=True) - self.compareFootprints = self.checkOption(cf, self.OPT_COMP_FP, default=True) - - #read out configurations - if self.SECTION_CONFIGURATIONS in cf.sections(): - self.configurations = [i for i in cf.options(self.SECTION_CONFIGURATIONS)] - + self.mergeBlankFields = self.checkOption(cf, self.OPT_MERGE_BLANK, default = True) + + if cf.has_option(self.SECTION_GENERAL, self.OPT_PCB_CONFIG): + self.pcbConfig = cf.get(self.SECTION_GENERAL, self.OPT_PCB_CONFIG) + + if cf.has_option(self.SECTION_GENERAL, self.OPT_CONFIG_FIELD): + self.configField = cf.get(self.SECTION_GENERAL, self.OPT_CONFIG_FIELD) + + #read out grouping colums + if self.SECTION_GROUPING_FIELDS in cf.sections(): + self.groups = [i for i in cf.options(self.SECTION_GROUPING_FIELDS)] + #read out ignored-rows if self.SECTION_IGNORE in cf.sections(): self.ignore = [i for i in cf.options(self.SECTION_IGNORE)] @@ -125,12 +120,11 @@ class BomPref: if self.SECTION_ALIASES in cf.sections(): self.aliases = [a.split(" ") for a in cf.options(self.SECTION_ALIASES)] - #read out the regex - for key in self.regex.keys(): - section = self.columnToGroup(key) - if section in cf.sections(): - self.regex[key] = [r for r in cf.options(section)] - + if self.SECTION_REGEXCLUDES in cf.sections(): + pass + + if self.SECTION_REGINCLUDES in cf.sections(): + pass #add an option to the SECTION_GENRAL group def addOption(self, parser, opt, value, comment=None): @@ -145,6 +139,7 @@ class BomPref: file = os.path.abspath(file) cf = ConfigParser.RawConfigParser(allow_no_value = True) + cf.optionxform=str cf.add_section(self.SECTION_GENERAL) cf.set(self.SECTION_GENERAL, "; General BoM options here") @@ -152,7 +147,14 @@ class BomPref: self.addOption(cf, self.OPT_NUMBER_ROWS, self.numberRows, comment="If '{opt}' option is set to 1, each row in the BoM will be prepended with an incrementing row number".format(opt=self.OPT_NUMBER_ROWS)) self.addOption(cf, self.OPT_GROUP_CONN, self.groupConnectors, comment="If '{opt}' option is set to 1, connectors with the same footprints will be grouped together, independent of the name of the connector".format(opt=self.OPT_GROUP_CONN)) self.addOption(cf, self.OPT_USE_REGEX, self.useRegex, comment="If '{opt}' option is set to 1, each component group will be tested against a number of regular-expressions (specified, per column, below). If any matches are found, the row is ignored in the output file".format(opt=self.OPT_USE_REGEX)) - self.addOption(cf, self.OPT_COMP_FP, self.compareFootprints, comment="If '{opt}' option is set to 1, two components must have the same footprint to be grouped together. If '{opt}' is not set, then footprint comparison is ignored.".format(opt=self.OPT_COMP_FP)) + self.addOption(cf, self.OPT_MERGE_BLANK, self.mergeBlankFields, comment="If '{opt}' option is set to 1, component groups with blank fields will be merged into the most compatible group, where possible".format(opt=self.OPT_MERGE_BLANK)) + + cf.set(self.SECTION_GENERAL, '; Field name used to determine if a particular part is to be fitted') + cf.set(self.SECTION_GENERAL, self.OPT_CONFIG_FIELD, self.configField) + + cf.set(self.SECTION_GENERAL, "; Configuration string used to determine which components are loaded on a particular board") + cf.set(self.SECTION_GENERAL, '; Configuration string is case-insensitive') + cf.set(self.SECTION_GENERAL, self.OPT_PCB_CONFIG, self.pcbConfig) cf.add_section(self.SECTION_IGNORE) cf.set(self.SECTION_IGNORE, "; Any column heading that appears here will be excluded from the Generated BoM") @@ -161,36 +163,40 @@ class BomPref: for i in self.ignore: cf.set(self.SECTION_IGNORE, i) - cf.add_section(self.SECTION_CONFIGURATIONS) - cf.set(self.SECTION_CONFIGURATIONS, '; List of PCB configuration parameters') - for i in self.configurations: - cf.set(self.SECTION_CONFIGURATIONS, i) - + #write the component grouping fields + cf.add_section(self.SECTION_GROUPING_FIELDS) + cf.set(self.SECTION_GROUPING_FIELDS, '; List of fields used for sorting individual components into groups') + cf.set(self.SECTION_GROUPING_FIELDS, '; Components which match (comparing *all* fields) will be grouped together') + cf.set(self.SECTION_GROUPING_FIELDS, '; Field names are CASE-SENSITIVE!') + + for i in self.groups: + cf.set(self.SECTION_GROUPING_FIELDS, i) + cf.add_section(self.SECTION_ALIASES) cf.set(self.SECTION_ALIASES, "; A series of values which are considered to be equivalent for the part name") cf.set(self.SECTION_ALIASES, "; Each line represents a space-separated list of equivalent component name values") cf.set(self.SECTION_ALIASES, "; e.g. 'c c_small cap' will ensure the equivalent capacitor symbols can be grouped together") + cf.set(self.SECTION_ALIASES, '; Aliases are case-insensitive') + for a in self.aliases: cf.set(self.SECTION_ALIASES, " ".join(a)) + + cf.add_section(self.SECTION_REGINCLUDES) + cf.set(self.SECTION_REGINCLUDES, '; A series of regular expressions used to include parts in the BoM') + cf.set(self.SECTION_REGINCLUDES, '; Column names are case-insensitive') + for i in self.regIncludes: + if not len(i) == 2: continue - for col in self.regex.keys(): + cf.set(self.SECTION_REGINCLUDE, i[0], i[1]) - reg = self.regex[col] - - section = self.columnToGroup(col) - cf.add_section(section) - #comments - cf.set(section, "; A list of regex to compare against the '{col}' column".format(col=col)) - cf.set(section, "; If the value in the '{col}' column matches any of these expressions, the row will be excluded from the BoM".format(col=col)) - - if type(reg) == str: - cf.set(section, reg) - - elif type(reg) == list: - for r in reg: - cf.set(section, r) + cf.add_section(self.SECTION_REGEXCLUDES) + cf.set(self.SECTION_REGEXCLUDES, '; A series of regular expressions used to exclude parts from the BoM') + cf.set(self.SECTION_REGINCLUDES, '; Column names are case-insensitive') + + for i in self.regExcludes: + if not len(i) == 2: continue + cf.set(self.SECTION_REGEXCLUDES, i[0], i[1]) - with open(file, 'wb') as configfile: cf.write(configfile) \ No newline at end of file diff --git a/KiBOM/version.py b/KiBOM/version.py new file mode 100644 index 00000000..b421fb04 --- /dev/null +++ b/KiBOM/version.py @@ -0,0 +1 @@ +KIBOM_VERSION = 1.2 \ No newline at end of file diff --git a/KiBOM_CLI.py b/KiBOM_CLI.py index e4a3169e..96f96269 100644 --- a/KiBOM_CLI.py +++ b/KiBOM_CLI.py @@ -32,9 +32,9 @@ parser = argparse.ArgumentParser(description="KiBOM Bill of Materials generator parser.add_argument("netlist", help='xml netlist file. Use "%%I" when running from within KiCad') parser.add_argument("output", default="", help='BoM output file name.\nUse "%%O" when running from within KiCad to use the default output name (csv file).\nFor e.g. HTML output, use "%%O.html"') -parser.add_argument("-b", "--boards", help="Number of boards to build (default = 1)", type=int, default=1) +parser.add_argument("-n", "--number", help="Number of boards to build (default = 1)", type=int, default=1) parser.add_argument("-v", "--verbose", help="Enable verbose output", action='count') -parser.add_argument("-n", "--noheader", help="Do not generate file headers; data only.", action='count') +parser.add_argument("-r", "--revision", help="Board variant, used to determine which components are output to the BoM", type=str, default=None) parser.add_argument("--cfg", help="BoM config file (script will try to use 'bom.ini' if not specified here)") args = parser.parse_args() @@ -62,20 +62,22 @@ if args.cfg: #read preferences from file. If file does not exists, default preferences will be used pref = BomPref() -#pass various command-line options through -pref.verbose = verbose -pref.boards = args.boards -if args.noheader: - pref.hideHeaders = True - if os.path.exists(config_file): pref.Read(config_file) say("Config:",config_file) +#pass various command-line options through +pref.verbose = verbose +pref.boards = args.number + +if args.revision is not None: + pref.pcbConfig = args.revision + print("PCB Revision:",args.revision) + + #write preference file back out (first run will generate a file with default preferences) -if not os.path.exists(ini): - pref.Write(ini) - say("Writing preferences file bom.ini") +pref.Write(ini) +say("Writing preferences file bom.ini") #individual components components = []