Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
66f672d2eThis file is pinned to the selected release. Download the original to open it in your design tool.
#!/usr/bin/env python3
import argparse
import csv
import re
import sys
from collections import OrderedDict
from pathlib import Path
TRUTHY_DNP = {"1", "true", "yes", "y", "x", "dnp", "do not populate", "fitted=no"}
FALSY_DNP = {"", "0", "false", "no", "n"}
def normalize_header(name: str) -> str:
return name.lstrip("\ufeff").strip().lower().replace("_", " ")
def get_field(row: dict, *names: str) -> str:
for wanted in names:
wanted_norm = normalize_header(wanted)
for key, value in row.items():
if normalize_header(key) == wanted_norm:
return (value or "").strip()
return ""
def is_dnp(value: str) -> bool:
normalized = value.strip().lower()
if normalized in FALSY_DNP:
return False
if normalized in TRUTHY_DNP:
return True
return bool(normalized)
def split_references(value: str) -> list[str]:
if not value.strip():
return []
return [part.strip() for part in value.split(",") if part.strip()]
def ref_sort_key(ref: str):
return ref.strip()
def normalize_footprint(footprint: str) -> str:
suffix = footprint.split(":")[-1].strip()
metric_match = re.match(r"^[A-Za-z]+_(\d{4})_\d+Metric(?:_.*)?$", suffix)
if metric_match:
return metric_match.group(1)
return suffix
def normalize_note(note: str) -> str:
return re.sub(r"\s+", " ", note or "").strip()
def parse_quantity(row: dict, references: list[str]) -> int:
qty = get_field(row, "Quantity", "Qty")
if qty:
try:
return int(qty)
except ValueError:
pass
return max(len(references), 1)
def sexpr_paren_delta(text: str) -> int:
delta = 0
in_string = False
escape = False
for ch in text:
if escape:
escape = False
continue
if in_string and ch == "\\":
escape = True
continue
if ch == '"':
in_string = not in_string
continue
if not in_string:
if ch == "(":
delta += 1
elif ch == ")":
delta -= 1
return delta
def load_schematic_properties(path: Path) -> dict[str, dict[str, str]]:
symbols: dict[str, dict[str, str]] = {}
depth = 0
in_symbol_block = False
symbol_depth = 0
block_lines: list[str] = []
with path.open("r", encoding="utf-8") as infile:
for line in infile:
stripped = line.lstrip()
if not in_symbol_block and depth == 1 and stripped.startswith("(symbol"):
in_symbol_block = True
symbol_depth = depth
block_lines = [line]
elif in_symbol_block:
block_lines.append(line)
depth += sexpr_paren_delta(line)
if in_symbol_block and depth == symbol_depth:
block = "".join(block_lines)
props = dict(re.findall(r'\(property "([^"]+)" "([^"]*)"', block))
ref = props.get("Reference", "").strip()
if ref:
symbols[ref] = props
in_symbol_block = False
block_lines = []
return symbols
def get_schematic_property_values(
schematic_props: dict[str, dict[str, str]], references: list[str], property_name: str
) -> list[str]:
values: list[str] = []
seen = set()
for ref in references:
value = schematic_props.get(ref, {}).get(property_name, "").strip()
if property_name == "AssemblyNote":
value = normalize_note(value)
if value and value not in seen:
values.append(value)
seen.add(value)
return values
def main() -> int:
parser = argparse.ArgumentParser(description="Convert a KiCad BOM into grouped assembly BOM format.")
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument(
"--allow-missing-lcsc",
action="store_true",
help="Allow populated BOM entries without an LCSC Part # and leave the field empty.",
)
parser.add_argument(
"--schematic",
type=Path,
help="Optional KiCad schematic used to backfill properties like LCSC and AssemblyNote by reference.",
)
args = parser.parse_args()
grouped = OrderedDict()
missing_lcsc = []
schematic_props = load_schematic_properties(args.schematic) if args.schematic else {}
with args.input.open("r", encoding="utf-8-sig", newline="") as infile:
reader = csv.DictReader(infile)
for row in reader:
references = split_references(get_field(row, "Reference", "Designator"))
value = get_field(row, "Value")
footprint = normalize_footprint(get_field(row, "Footprint"))
lcsc = get_field(row, "LCSC Part #", "LCSC")
note = normalize_note(get_field(row, "Assembly Note", "AssemblyNote"))
dnp = get_field(row, "DNP")
if is_dnp(dnp):
continue
if schematic_props:
if not lcsc:
lcsc_values = get_schematic_property_values(schematic_props, references, "LCSC")
if len(lcsc_values) == 1:
lcsc = lcsc_values[0]
if not note:
note = " | ".join(get_schematic_property_values(schematic_props, references, "AssemblyNote"))
if not lcsc:
missing_lcsc.append(
{
"refs": references,
"value": value,
"footprint": footprint,
}
)
key = (footprint, value, lcsc, note)
if key not in grouped:
grouped[key] = {
"refs": [],
"quantity": 0,
"notes": [],
}
grouped[key]["refs"].extend(references)
grouped[key]["quantity"] += parse_quantity(row, references)
if note and note not in grouped[key]["notes"]:
grouped[key]["notes"].append(note)
if missing_lcsc:
level = "warning" if args.allow_missing_lcsc else "error"
print(f"{level}: missing LCSC Part # for populated BOM entries:", file=sys.stderr)
for item in missing_lcsc:
refs = ", ".join(item["refs"]) or "<unknown ref>"
value = item["value"] or "<unknown value>"
footprint = item["footprint"] or "<unknown footprint>"
print(f" - {refs}: {value} [{footprint}]", file=sys.stderr)
if not args.allow_missing_lcsc:
return 1
rows = []
for (footprint, value, lcsc, note), data in grouped.items():
refs = sorted(dict.fromkeys(data["refs"]), key=ref_sort_key)
notes = data["notes"] or ([note] if note else [])
rows.append(
{
"Designator": ", ".join(refs),
"Footprint": footprint,
"Quantity": str(data["quantity"] or len(refs) or 1),
"Value": value,
"LCSC Part #": lcsc,
"Assembly Note": " | ".join(notes),
"_sort_refs": refs,
}
)
rows.sort(key=lambda row: ref_sort_key(row["_sort_refs"][0]) if row["_sort_refs"] else ("", 0, ""))
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("w", encoding="utf-8", newline="") as outfile:
fieldnames = ["Designator", "Footprint", "Quantity", "Value", "LCSC Part #", "Assembly Note"]
writer = csv.DictWriter(outfile, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
row.pop("_sort_refs", None)
writer.writerow(row)
return 0
if __name__ == "__main__":
raise SystemExit(main())