Add url/href support for clickable SVG nodes and BOM links (port of upstream PR #168)
Some checks failed
Create Examples / build (ubuntu-22.04, 3.7) (push) Has been cancelled
Create Examples / build (ubuntu-22.04, 3.8) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.10) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.11) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.12) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.9) (push) Has been cancelled

Add `url` field to Component, WireClass, and Cable with per-wire URL
support for bundles. URLs produce clickable href attributes on GraphViz
nodes/edges and appear as a linked column in HTML BOM output.
This commit is contained in:
Ryan Malloy 2026-02-13 05:06:51 -07:00
parent 3d639f6ec2
commit cfc25dfabf
5 changed files with 363 additions and 8 deletions

View File

@ -9,7 +9,7 @@ import tabulate as tabulate_module
from wireviz.wv_utils import html_line_breaks from wireviz.wv_utils import html_line_breaks
BOM_HASH_FIELDS = "description qty_unit amount partnumbers" BOM_HASH_FIELDS = "description qty_unit amount partnumbers url"
BomEntry = namedtuple("BomEntry", "category qty designators") BomEntry = namedtuple("BomEntry", "category qty designators")
@ -98,7 +98,7 @@ def pn_info_string(
def bom_list(bom): def bom_list(bom):
headers = ( headers = (
"# Qty Unit Description Amount Unit Designators " "# Qty Unit Description Amount Unit Designators "
"P/N Manufacturer MPN Supplier SPN Category".split(" ") "P/N Manufacturer MPN Supplier SPN URL Category".split(" ")
) )
rows = [] rows = []
rows.append(headers) rows.append(headers)
@ -125,6 +125,7 @@ def bom_list(bom):
) )
else: else:
cells.extend([None, None, None, None, None]) cells.extend([None, None, None, None, None])
cells.append(hash.url)
# cells.extend([f"{entry['category']} ({entry['category'].name})"]) # for debugging # cells.extend([f"{entry['category']} ({entry['category'].name})"]) # for debugging
rows.append(cells) rows.append(cells)
# remove empty columns # remove empty columns

View File

@ -191,6 +191,8 @@ class Component:
mpn: str = None mpn: str = None
supplier: str = None supplier: str = None
spn: str = None spn: str = None
# URL (makes nodes/edges clickable in SVG, appears in BOM)
url: Optional[Union[str, List[str]]] = None
# BOM info # BOM info
qty: Optional[Union[None, int, float]] = None qty: Optional[Union[None, int, float]] = None
amount: Optional[NumberAndUnit] = None amount: Optional[NumberAndUnit] = None
@ -212,12 +214,15 @@ class Component:
else: else:
_amount = self.amount _amount = self.amount
# For BOM hashing, use scalar url only (lists are per-wire, handled by WireClass)
_url = self.url if isinstance(self.url, str) else None
if self.sum_amounts_in_bom: if self.sum_amounts_in_bom:
_hash = BomHash( _hash = BomHash(
description=self.description, description=self.description,
qty_unit=_amount.unit if _amount else None, qty_unit=_amount.unit if _amount else None,
amount=None, amount=None,
partnumbers=self.partnumbers, partnumbers=self.partnumbers,
url=_url,
) )
else: else:
_hash = BomHash( _hash = BomHash(
@ -225,6 +230,7 @@ class Component:
qty_unit=None, qty_unit=None,
amount=_amount, amount=_amount,
partnumbers=self.partnumbers, partnumbers=self.partnumbers,
url=_url,
) )
return _hash return _hash
@ -630,6 +636,7 @@ class WireClass:
ignore_in_bom: Optional[bool] = False ignore_in_bom: Optional[bool] = False
sum_amounts_in_bom: bool = True sum_amounts_in_bom: bool = True
partnumbers: PartNumberInfo = None partnumbers: PartNumberInfo = None
url: Optional[str] = None
@property @property
def bom_hash(self) -> BomHash: def bom_hash(self) -> BomHash:
@ -639,6 +646,7 @@ class WireClass:
qty_unit=self.length.unit if self.length else None, qty_unit=self.length.unit if self.length else None,
amount=None, amount=None,
partnumbers=self.partnumbers, partnumbers=self.partnumbers,
url=self.url,
) )
else: else:
_hash = BomHash( _hash = BomHash(
@ -646,6 +654,7 @@ class WireClass:
qty_unit=None, qty_unit=None,
amount=self.length, amount=self.length,
partnumbers=self.partnumbers, partnumbers=self.partnumbers,
url=self.url,
) )
return _hash return _hash
@ -786,6 +795,14 @@ class Cable(TopLevelGraphicalComponent):
else: else:
return None # non-bundles do not support lists of part data return None # non-bundles do not support lists of part data
def _get_wire_url(self, idx) -> Optional[str]:
if self.category == "bundle" and isinstance(self.url, list):
return self.url[idx] if idx < len(self.url) else None
elif self.category == "bundle" and isinstance(self.url, str):
return self.url # scalar URL applies to all wires
else:
return None
def __post_init__(self) -> None: def __post_init__(self) -> None:
super().__post_init__() super().__post_init__()
@ -851,6 +868,14 @@ class Cable(TopLevelGraphicalComponent):
else: else:
raise Exception("lists of part data are only supported for bundles") raise Exception("lists of part data are only supported for bundles")
# validate URL lists
if isinstance(self.url, list):
if self.category == "bundle":
if len(self.url) != self.wirecount:
raise Exception("URL list length must match wirecount")
else:
raise Exception("URL lists are only supported for bundles")
# all checks have passed # all checks have passed
wire_tuples = zip_longest( wire_tuples = zip_longest(
# TODO: self.wire_ids # TODO: self.wire_ids
@ -874,6 +899,7 @@ class Cable(TopLevelGraphicalComponent):
sum_amounts_in_bom=self.sum_amounts_in_bom, sum_amounts_in_bom=self.sum_amounts_in_bom,
ignore_in_bom=self.ignore_in_bom, ignore_in_bom=self.ignore_in_bom,
partnumbers=self._get_wire_partnumber(wire_index), partnumbers=self._get_wire_partnumber(wire_index),
url=self._get_wire_url(wire_index),
) )
if self.shield: if self.shield:

View File

@ -309,17 +309,18 @@ class Harness:
label=f"<\n{gv_html}\n>", label=f"<\n{gv_html}\n>",
shape="box", shape="box",
style="filled", style="filled",
href=connector.url if isinstance(connector.url, str) else '',
) )
# generate edges for connector loops # generate edges for connector loops
if len(connector.loops) > 0: if len(connector.loops) > 0:
dot.attr("edge", color="#000000") dot.attr("edge", color="#000000", href='')
loops = gv_connector_loops(connector) loops = gv_connector_loops(connector)
for head, tail, color in loops: for head, tail, color in loops:
dot.edge(head, tail, color = color, label = " ", noLabel="noLabel") dot.edge(head, tail, color = color, label = " ", noLabel="noLabel")
# generate edges for connector shorts # generate edges for connector shorts
if len(connector.shorts) > 0: if len(connector.shorts) > 0:
dot.attr("edge", color="#000000") dot.attr("edge", color="#000000", href='')
shorts = gv_connector_shorts(connector) shorts = gv_connector_shorts(connector)
for head, tail, color in shorts: for head, tail, color in shorts:
dot.edge(head, tail, dot.edge(head, tail,
@ -352,12 +353,22 @@ class Harness:
label=f"<\n{gv_html}\n>", label=f"<\n{gv_html}\n>",
shape="box", shape="box",
style=style, style=style,
href=cable.url if isinstance(cable.url, str) else '',
) )
# generate wire edges between component nodes and cable nodes # generate wire edges between component nodes and cable nodes
for connection in cable._connections: for connection in cable._connections:
color, l1, l2, r1, r2 = gv_edge_wire(self, cable, connection) color, l1, l2, r1, r2 = gv_edge_wire(self, cable, connection)
dot.attr("edge", color=color) # determine per-wire URL for clickable edges
wire_url = ''
if connection.via is not None:
wire_idx = connection.via.index
if isinstance(cable.url, list):
wire_url = cable.url[wire_idx] if wire_idx < len(cable.url) else ''
wire_url = wire_url or ''
elif isinstance(cable.url, str):
wire_url = cable.url
dot.attr("edge", color=color, href=wire_url)
if not (l1, l2) == (None, None): if not (l1, l2) == (None, None):
dot.edge(l1, l2) dot.edge(l1, l2)
if not (r1, r2) == (None, None): if not (r1, r2) == (None, None):
@ -365,11 +376,11 @@ class Harness:
for color, we, ww in gv_edge_wire_inside(cable): for color, we, ww in gv_edge_wire_inside(cable):
if not (we, ww) == (None, None): if not (we, ww) == (None, None):
dot.edge(we, ww, color=color, straight="straight") dot.edge(we, ww, color=color, straight="straight", href='')
for mate in self.mates: for mate in self.mates:
color, dir, code_from, code_to = gv_edge_mate(mate) color, dir, code_from, code_to = gv_edge_mate(mate)
dot.attr("edge", color=color, style="dashed", dir=dir) dot.attr("edge", color=color, style="dashed", dir=dir, href='')
dot.edge(code_from, code_to) dot.edge(code_from, code_to)
apply_dot_tweaks(dot, self.tweak) apply_dot_tweaks(dot, self.tweak)

View File

@ -127,7 +127,10 @@ def generate_html_output(
row_html = " <tr>\n" row_html = " <tr>\n"
for i, item in enumerate(row): for i, item in enumerate(row):
td_class = f"bom_col_{bom[0][i].lower()}" td_class = f"bom_col_{bom[0][i].lower()}"
row_html = f'{row_html} <td class="{td_class}">{item if item is not None else ""}</td>\n' cell_value = item if item is not None else ""
if bom[0][i] == "URL" and cell_value:
cell_value = f'<a href="{cell_value}">{cell_value}</a>'
row_html = f'{row_html} <td class="{td_class}">{cell_value}</td>\n'
row_html = f"{row_html} </tr>\n" row_html = f"{row_html} </tr>\n"
bom_contents.append(row_html) bom_contents.append(row_html)

314
tests/test_url_href.py Normal file
View File

@ -0,0 +1,314 @@
# -*- coding: utf-8 -*-
"""Tests for URL/href support (port of upstream PR #168).
Covers:
- url field on Connector, Cable, AdditionalBomItem, WireClass
- BOM hash differentiation by URL
- BOM list URL column presence/absence
- GraphViz href on nodes and edges
- HTML output <a> tag wrapping
"""
import re
import pytest
from wireviz.wv_bom import BomHash, bom_list
from wireviz.wv_dataclasses import (
AdditionalBomItem,
Cable,
Connector,
Metadata,
Options,
Tweak,
)
def make_connector(**kwargs):
args = {"designator": "X1", "pins": [1, 2, 3]}
args.update(kwargs)
return Connector(**args)
def make_cable(**kwargs):
args = {"designator": "W1", "wirecount": 3, "colors": ["BK", "RD", "GN"]}
args.update(kwargs)
return Cable(**args)
def make_harness():
from wireviz.wv_harness import Harness
return Harness(
metadata=Metadata({}),
options=Options(),
tweak=Tweak(),
)
# --- Data model ---
class TestUrlDataModel:
def test_connector_url_default_none(self):
c = make_connector()
assert c.url is None
def test_connector_url_scalar(self):
c = make_connector(url="https://example.com/connector")
assert c.url == "https://example.com/connector"
def test_cable_url_default_none(self):
c = make_cable()
assert c.url is None
def test_cable_url_scalar(self):
c = make_cable(url="https://example.com/cable")
assert c.url == "https://example.com/cable"
def test_bundle_url_list(self):
urls = [
"https://example.com/wire1",
"https://example.com/wire2",
"https://example.com/wire3",
]
c = make_cable(category="bundle", url=urls)
assert c.url == urls
# per-wire URLs should be propagated to WireClass objects
for i, wire in enumerate(c.wire_objects.values()):
if hasattr(wire, "url"):
assert wire.url == urls[i]
def test_bundle_url_scalar_applies_to_all_wires(self):
c = make_cable(category="bundle", url="https://example.com/all")
for wire in c.wire_objects.values():
if hasattr(wire, "url"):
assert wire.url == "https://example.com/all"
def test_bundle_url_list_wrong_length_raises(self):
with pytest.raises(Exception, match="URL list length must match wirecount"):
make_cable(category="bundle", url=["a", "b"]) # wirecount=3
def test_non_bundle_url_list_raises(self):
with pytest.raises(Exception, match="URL lists are only supported for bundles"):
make_cable(url=["a", "b", "c"]) # not a bundle
def test_additional_bom_item_url(self):
item = AdditionalBomItem(
type="Heatshrink",
url="https://example.com/heatshrink",
)
assert item.url == "https://example.com/heatshrink"
# --- BOM hashing ---
class TestUrlBomHash:
def test_different_urls_different_hashes(self):
c1 = make_connector(url="https://example.com/a")
c2 = make_connector(url="https://example.com/b")
assert c1.bom_hash != c2.bom_hash
def test_same_urls_same_hashes(self):
c1 = make_connector(url="https://example.com/same")
c2 = make_connector(url="https://example.com/same")
assert c1.bom_hash == c2.bom_hash
def test_url_vs_no_url_different_hashes(self):
c1 = make_connector(url="https://example.com/a")
c2 = make_connector()
assert c1.bom_hash != c2.bom_hash
def test_wire_class_url_in_hash(self):
c1 = make_cable(
category="bundle",
url=["https://a.com", "https://b.com", "https://c.com"],
)
c2 = make_cable(
category="bundle",
url=["https://x.com", "https://b.com", "https://c.com"],
)
# First wire has different URL, so first wire's hash should differ
wire1_c1 = list(c1.wire_objects.values())[0]
wire1_c2 = list(c2.wire_objects.values())[0]
assert wire1_c1.bom_hash != wire1_c2.bom_hash
# Second wire has same URL, should match
wire2_c1 = list(c1.wire_objects.values())[1]
wire2_c2 = list(c2.wire_objects.values())[1]
assert wire2_c1.bom_hash == wire2_c2.bom_hash
# --- BOM list ---
class TestUrlBomList:
def _make_bom_with_url(self, url=None):
harness = make_harness()
harness.add_connector("X1", pins=[1], url=url)
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 1, "W1", 1, None, None)
harness.populate_bom()
return bom_list(harness.bom)
def test_url_column_present_when_used(self):
rows = self._make_bom_with_url("https://example.com")
headers = rows[0]
assert "URL" in headers
url_idx = headers.index("URL")
# at least one row should have the URL value
assert any(row[url_idx] == "https://example.com" for row in rows[1:])
def test_url_column_absent_when_unused(self):
rows = self._make_bom_with_url(None)
headers = rows[0]
assert "URL" not in headers
# --- GraphViz href ---
class TestUrlGraphviz:
def test_connector_node_href(self):
harness = make_harness()
harness.add_connector("X1", pins=[1], url="https://example.com/x1")
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 1, "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
assert 'href="https://example.com/x1"' in gv
def test_cable_node_href(self):
harness = make_harness()
harness.add_connector("X1", pins=[1])
harness.add_cable("W1", wirecount=1, colors=["BK"], url="https://example.com/w1")
harness.connect("X1", 1, "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
assert 'href="https://example.com/w1"' in gv
def test_no_url_produces_empty_href(self):
harness = make_harness()
harness.add_connector("X1", pins=[1])
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 1, "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
# all href attributes should be empty (no real URLs in href values)
hrefs = re.findall(r'href="([^"]*)"', gv)
assert all(h == '' for h in hrefs), f"Expected all empty hrefs, got: {hrefs}"
def test_wire_edge_href_scalar(self):
harness = make_harness()
harness.add_connector("X1", pins=[1])
harness.add_cable("W1", wirecount=1, colors=["BK"], url="https://example.com/wire")
harness.connect("X1", 1, "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
# The wire edge should have the cable URL as href
# Look for href in edge attributes
edge_hrefs = re.findall(r'href="(https://example\.com/wire)"', gv)
assert len(edge_hrefs) >= 1
def test_loop_edges_clear_href(self):
harness = make_harness()
harness.add_connector(
"X1",
pins=[1, 2, 3],
url="https://example.com/x1",
loops=[[1, 2]],
)
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 3, "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
# Loop edge attr should clear href
# The connector node should have the real href
assert 'href="https://example.com/x1"' in gv
def test_mate_edges_clear_href(self):
harness = make_harness()
harness.add_connector("X1", pins=[1], url="https://example.com/x1")
harness.add_connector("X2", pins=[1], url="https://example.com/x2")
harness.add_mate_component("X1", "X2", "->")
graph = harness.create_graph()
gv = graph.source
# Both connectors should have hrefs, mate edge should not leak them
assert 'href="https://example.com/x1"' in gv
assert 'href="https://example.com/x2"' in gv
# --- HTML output ---
class TestUrlHtmlOutput:
def test_url_wrapped_in_anchor(self):
"""URL values in BOM should be wrapped in <a> tags in HTML."""
harness = make_harness()
harness.add_connector("X1", pins=[1], url="https://example.com/part")
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 1, "W1", 1, None, None)
harness.populate_bom()
bomlist = bom_list(harness.bom)
# Find URL column index
headers = bomlist[0]
assert "URL" in headers
url_idx = headers.index("URL")
# The connector row should have the URL
url_values = [row[url_idx] for row in bomlist[1:] if row[url_idx]]
assert "https://example.com/part" in url_values
def test_empty_url_not_wrapped(self):
"""Rows without URLs should not get anchor tags."""
harness = make_harness()
harness.add_connector("X1", pins=[1], url="https://example.com/part")
harness.add_connector("X2", pins=[1]) # no URL
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 1, "W1", 1, "X2", 1)
harness.populate_bom()
bomlist = bom_list(harness.bom)
headers = bomlist[0]
if "URL" in headers:
url_idx = headers.index("URL")
# At least one row should have None/empty URL
null_urls = [row[url_idx] for row in bomlist[1:] if not row[url_idx]]
assert len(null_urls) >= 1
# --- Backward compatibility ---
class TestBackwardCompatibility:
def test_connector_without_url(self):
c = make_connector()
assert c.bom_hash is not None
assert c.url is None
def test_cable_without_url(self):
c = make_cable()
assert c.bom_hash is not None
assert c.url is None
def test_bundle_without_url(self):
c = make_cable(category="bundle")
for wire in c.wire_objects.values():
assert wire.bom_hash is not None
if hasattr(wire, "url"):
assert wire.url is None
def test_full_harness_without_urls(self):
harness = make_harness()
harness.add_connector("X1", pins=[1, 2])
harness.add_connector("X2", pins=[1, 2])
harness.add_cable("W1", wirecount=2, colors=["BK", "RD"])
harness.connect("X1", 1, "W1", 1, "X2", 1)
harness.connect("X1", 2, "W1", 2, "X2", 2)
harness.populate_bom()
bomlist = bom_list(harness.bom)
headers = bomlist[0]
# URL column should be auto-removed when no URLs are used
assert "URL" not in headers