--- /dev/null
+#
+# Copyright © 2025 Keith Packard <keithp@keithp.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA.
+#
+
+"""
+AltusMetrum collection of classes
+"""
+
+import csv;
+import tempfile;
+import subprocess;
+import os
+import sys
+import functools
+from pathlib import Path;
+import re;
+
+# These attributes form the 'key' used to uniquely identify the part
+key_attrs = ('device', 'value', 'footprint')
+
+# This is the preferred order when writing a CSV file
+pref_order = ('device', 'value', 'footprint', 'loadstatus', 'provided', 'mfg',
+ 'mfg_part_number', 'vendor', 'vendor_part_number', 'quantity', 'refdes')
+
+value_pattern=r'([0-9]+)(\.[0-9]*)?([kmMupng]?)(F|H|Hz)?'
+
+def numeric_value(a):
+ m = re.fullmatch(value_pattern, a, flags=re.IGNORECASE)
+ if m:
+ n = m.group(1)
+ if m.group(2):
+ n += m.group(2)
+ number = float(n)
+ scale = m.group(3)
+ if scale == 'G':
+ number *= 1000000000
+ elif scale == 'M':
+ number *= 1000000
+ elif scale == 'k':
+ number *= 1000
+ elif scale == 'm':
+ number /= 1000
+ elif scale == 'u' or scale == 'µ':
+ number /= 1000000
+ elif scale == 'p':
+ number /= 1000000000
+ return number
+ return None
+
+def value_cmp(a, b):
+ na = numeric_value(a)
+ nb = numeric_value(b)
+ if na and nb:
+ if na < nb:
+ return -1
+ if na > nb:
+ return 1
+ return 0
+ if na:
+ return -1
+ if nb:
+ return 1
+ if a < b:
+ return -1
+ if b > a:
+ return 1
+ return 0
+
+def str_cmp(a,b):
+ if a < b:
+ return -1
+ if a > b:
+ return 1
+ return 0
+
+def key_cmp(a, b):
+ c = str_cmp(a[0], b[0])
+ if c:
+ return c
+ c = value_cmp(a[1], b[1])
+ if c:
+ return c
+ return str_cmp(a[2], b[2])
+
+class Part():
+ """
+ A single part containing a dictionary with all of the attributes
+ """
+
+ # Initialize the object, optionally incorporating a value from a list
+ def __init__(self, attrs=None, values=None, keys=None):
+ if attrs is not None:
+ self.attrs = attrs
+ else:
+ self.attrs = {}
+
+ if values is not None:
+ for i in range(len(values)):
+ if values[i]:
+ self.attrs[keys[i]] = values[i]
+
+ # Get an attribute value, returning None for
+ # missing attributes
+ def get(self, attr):
+ if attr in self.attrs:
+ return self.attrs[attr]
+ return None
+
+ # Get an attribute value, returning 'unknown' for
+ # missing attributes
+ def get_unknown(self, attr):
+ v = self.get(attr)
+ if v is None:
+ v = 'unknown'
+ return v
+
+ # Check for a missing or unknown attribute
+ def missing(self, attr):
+ v = self.get(attr)
+ return v is None or v == 'unknown'
+
+ # Set an attribute value
+ def set(self, attr, value):
+ if value is None:
+ if attr in self.attrs:
+ del self.attrs[attr]
+ else:
+ self.attrs[attr] = value
+
+ # Compute the key tuple (device, value, footprint)
+ def key(self):
+ return tuple(map(self.get, key_attrs))
+
+ # Return a set of all attributes in the part
+ def attrs_set(self):
+ return set(self.attrs)
+
+ # Fill in missing values from the preferred part
+ def fill_values(self, pref_part):
+ for key in pref_part.attrs:
+ if self.missing(key):
+ self.set(key, pref_part.get(key))
+
+ def __repr__(self):
+ return str(self.attrs)
+
+class Parts():
+
+ """
+ A parts list, indexed by the part key (dice, value, footprint)
+ """
+
+ csv_dialect = csv.register_dialect('excel-nl', 'excel', lineterminator='\n')
+
+ def __init__(self, parts=None, ods=None, csv=None, csv_file=None, tab=None, tab_file=None):
+ if parts is not None:
+ self.parts = parts
+ else:
+ self.parts = {}
+
+ if csv is not None:
+ self.import_csv(csv)
+
+ if csv_file is not None:
+ self.import_csv_file(csv_file)
+
+ if ods is not None:
+ if ods == 'default':
+ ods = Path(sys.path[0]) / '..' / 'preferred-parts.ods'
+ self.import_ods(ods)
+
+ if tab is not None:
+ self.import_tab(tab)
+
+ if tab_file is not None:
+ self.import_tab_file(tab_file)
+
+ # Lookup a part by key
+ def get(self, key):
+ key = tuple(key)
+ if key in self.parts:
+ return self.parts[key]
+ return None
+
+ # Add/replace a part, computing the key
+ def set(self, part):
+ key = part.key()
+ self.parts[key] = part
+
+ # Import from a CSV file object
+ def import_csv_file(self, infile):
+ csvreader = csv.reader(infile)
+ csvkeys = None
+ for csvline in csvreader:
+ if csvkeys is None:
+ csvkeys = csvline
+ else:
+ self.set(Part(values=csvline, keys=csvkeys))
+
+ # Import from a CSV file
+ def import_csv(self, inname):
+ with open(inname) as infile:
+ self.import_csv_file(infile)
+
+ # Import from an ODS (Libreoffice calc) file
+ def import_ods(self, inname):
+ with tempfile.TemporaryDirectory() as dir:
+ outname = Path(dir) / Path(inname).with_suffix('.csv').name
+
+ ret = subprocess.run(('libreoffice', '--headless', '--convert-to', 'csv', inname, '--outdir', dir), stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
+ try:
+ if ret.returncode != 0:
+ raise ValueEror('cannot convert "%s": ' % (inname, ret.stderr))
+ return self.import_csv(outname)
+ finally:
+ os.remove(outname)
+
+ # Import from a tab-deliminted file object
+ def import_tab_file(self, infile):
+ keys=None
+ for line in infile:
+ values = tuple(map(str.strip, line.strip().split('\t')))
+ if keys is None:
+ keys = values
+ else:
+ self.set(Part(values=values, keys=keys))
+
+ # Import from a tab-delimited file
+ def import_tab(self, inname):
+ with open(inname) as infile:
+ self.import_tab_file(infile)
+
+ # Fill missing values from the preferred parts list
+ def fill_values(self, preferred_parts):
+ for key in self.parts:
+ part = self.parts[key]
+ pref = preferred_parts.get(key)
+ if pref is not None:
+ part.fill_values(pref)
+
+ # Compute the set of all attributes in all of the parts
+ def attrs_set(self):
+ ret = set(key_attrs)
+ for key in self.parts:
+ ret = ret | self.get(key).attrs_set()
+ return ret
+
+ # Generate a tuple of all attributes in the preferred CSV order
+ def attrs_tuple(self):
+ s = self.attrs_set()
+ t = key_attrs
+ for p in pref_order:
+ if p in s and p not in t:
+ t = t + (p,)
+ for i in s:
+ if i not in t:
+ t = t + (i,)
+ return t
+
+ # Export to a CSV file
+ def export_csv_file(self, outfile):
+ csvwriter = csv.writer(outfile, dialect='excel-nl')
+ attrs = self.attrs_tuple()
+ csvwriter.writerow(attrs)
+ keys = sorted(list(self.parts), key=functools.cmp_to_key(key_cmp))
+ for key in keys:
+ part = self.get(key)
+ csvwriter.writerow(tuple(map(part.get_unknown, attrs)))
+
+ # Export to a tab-delimited file
+ def export_tab_file(self, outfile):
+ attrs = self.attrs_tuple()
+ print("\t".join(attrs), file=outfile)
+ keys = sorted(list(self.parts), key=functools.cmp_to_key(key_cmp))
+ for key in keys:
+ part = self.get(key)
+ print("\t".join(tuple(map(part.get_unknown, attrs))), file=outfile)