WireViz/src/wireviz/wv_bom.py
Ryan Malloy 65af27e0da Add notebook-ready API: graph cache invalidation, structured BOM, fragment merging
Three additions to support interactive/notebook-style harness building:

- Graph cache invalidation: _invalidate_graph() called from all mutating
  methods so svg/png output reflects latest state after mutations
- bom_list_dicts(): JSON-serializable BOM export as list of dicts
- parse(harness=, populate_bom=): append YAML fragments to existing
  harness for cell-by-cell building with deferred BOM population

Templates persist on the Harness object across parse() calls so
component definitions in one fragment are available to connections
in later fragments.

Includes 24 new tests covering all three features plus full incremental
workflow simulation. All 122 tests pass.
2026-02-13 07:08:21 -07:00

163 lines
5.0 KiB
Python

# -*- coding: utf-8 -*-
from collections import namedtuple
from dataclasses import dataclass
from enum import Enum, IntEnum
from typing import Dict, List, Optional, Union
import tabulate as tabulate_module
from wireviz.wv_utils import html_line_breaks
BOM_HASH_FIELDS = "description qty_unit amount partnumbers url"
BomEntry = namedtuple("BomEntry", "category qty designators")
BomHash = namedtuple("BomHash", BOM_HASH_FIELDS)
BomHashList = namedtuple("BomHashList", BOM_HASH_FIELDS)
PartNumberInfo = namedtuple("PartNumberInfo", "pn manufacturer mpn supplier spn")
# TODO: different BOM modes
# BomMode
# "normal" # no bubbles, full PN info in GV node
# "bubbles" # = "full" -> maximum info in GV node
# "hide PN info"
# "PN crossref" = "PN bubbles" + "hide PN info"
# "additionally: BOM table in GV graph label (#227)"
# "title block in GV graph label"
BomCategory = IntEnum( # to enforce ordering in BOM
"BomEntry", "CONNECTOR CABLE WIRE ADDITIONAL_INSIDE ADDITIONAL_OUTSIDE"
)
QtyMultiplierConnector = Enum(
"QtyMultiplierConnector", "PINCOUNT POPULATED UNPOPULATED CONNECTIONS"
)
QtyMultiplierCable = Enum(
"QtyMultiplierCable", "WIRECOUNT TERMINATION LENGTH TOTAL_LENGTH"
)
PART_NUMBER_HEADERS = PartNumberInfo(
pn="P/N", manufacturer=None, mpn="MPN", supplier=None, spn="SPN"
)
def partnumbers2list(
partnumbers: PartNumberInfo, parent_partnumbers: PartNumberInfo = None
) -> List[str]:
if parent_partnumbers is None:
_is_toplevel = True
parent_partnumbers = partnumbers
else:
_is_toplevel = False
# Note: != operator used as XOR in the following section (https://stackoverflow.com/a/433161)
if _is_toplevel != isinstance(parent_partnumbers.pn, List):
# top level and not a list, or wire level and list
cell_pn = pn_info_string(PART_NUMBER_HEADERS.pn, None, partnumbers.pn)
else:
# top level and list -> do per wire later
# wire level and not list -> already done at top level
cell_pn = None
if _is_toplevel != isinstance(parent_partnumbers.mpn, List):
# TODO: edge case: different manufacturers, but same MPN?
cell_mpn = pn_info_string(
PART_NUMBER_HEADERS.mpn, partnumbers.manufacturer, partnumbers.mpn
)
else:
cell_mpn = None
if _is_toplevel != isinstance(parent_partnumbers.spn, List):
# TODO: edge case: different suppliers, but same SPN?
cell_spn = pn_info_string(
PART_NUMBER_HEADERS.spn, partnumbers.supplier, partnumbers.spn
)
else:
cell_spn = None
cell_contents = [cell_pn, cell_mpn, cell_spn]
if any(cell_contents):
return [html_line_breaks(cell) for cell in cell_contents]
else:
return None
def pn_info_string(
header: str, name: Optional[str], number: Optional[str]
) -> Optional[str]:
"""Return the company name and/or the part number in one single string or None otherwise."""
number = str(number).strip() if number is not None else ""
if name or number:
return f'{name if name else header}{": " + number if number else ""}'
else:
return None
def bom_list(bom):
headers = (
"# Qty Unit Description Amount Unit Designators "
"P/N Manufacturer MPN Supplier SPN URL Category".split(" ")
)
rows = []
rows.append(headers)
# fill rows
for hash, entry in bom.items():
cells = [
entry["id"],
entry["qty"],
hash.qty_unit,
hash.description,
hash.amount.number if hash.amount else None,
hash.amount.unit if hash.amount else None,
", ".join(sorted(entry["designators"])),
]
if hash.partnumbers:
cells.extend(
[
hash.partnumbers.pn,
hash.partnumbers.manufacturer,
hash.partnumbers.mpn,
hash.partnumbers.supplier,
hash.partnumbers.spn,
]
)
else:
cells.extend([None, None, None, None, None])
cells.append(hash.url)
# cells.extend([f"{entry['category']} ({entry['category'].name})"]) # for debugging
rows.append(cells)
# remove empty columns
transposed = list(map(list, zip(*rows)))
transposed = [
column
for column in transposed
if any([cell is not None for cell in column[1:]])
# ^ ignore header cell in check
]
rows = list(map(list, zip(*transposed)))
return rows
def bom_list_dicts(bom) -> List[Dict]:
"""Return BOM as a list of dicts (JSON-serializable).
Each dict maps column header to cell value, making it suitable
for JSON APIs and notebook contexts.
"""
rows = bom_list(bom)
if not rows:
return []
headers = rows[0]
return [
{h: cell for h, cell in zip(headers, row)}
for row in rows[1:]
]
def print_bom_table(bom):
print()
print(tabulate_module.tabulate(bom_list(bom), headers="firstrow"))
print()