diff --git a/src/wireviz/wv_bom.py b/src/wireviz/wv_bom.py
index cf5c04f..f7d97e9 100644
--- a/src/wireviz/wv_bom.py
+++ b/src/wireviz/wv_bom.py
@@ -9,7 +9,7 @@ import tabulate as tabulate_module
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")
@@ -98,7 +98,7 @@ def pn_info_string(
def bom_list(bom):
headers = (
"# 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.append(headers)
@@ -125,6 +125,7 @@ def bom_list(bom):
)
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
diff --git a/src/wireviz/wv_dataclasses.py b/src/wireviz/wv_dataclasses.py
index 49242cf..3890e2d 100644
--- a/src/wireviz/wv_dataclasses.py
+++ b/src/wireviz/wv_dataclasses.py
@@ -191,6 +191,8 @@ class Component:
mpn: str = None
supplier: str = None
spn: str = None
+ # URL (makes nodes/edges clickable in SVG, appears in BOM)
+ url: Optional[Union[str, List[str]]] = None
# BOM info
qty: Optional[Union[None, int, float]] = None
amount: Optional[NumberAndUnit] = None
@@ -212,12 +214,15 @@ class Component:
else:
_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:
_hash = BomHash(
description=self.description,
qty_unit=_amount.unit if _amount else None,
amount=None,
partnumbers=self.partnumbers,
+ url=_url,
)
else:
_hash = BomHash(
@@ -225,6 +230,7 @@ class Component:
qty_unit=None,
amount=_amount,
partnumbers=self.partnumbers,
+ url=_url,
)
return _hash
@@ -630,6 +636,7 @@ class WireClass:
ignore_in_bom: Optional[bool] = False
sum_amounts_in_bom: bool = True
partnumbers: PartNumberInfo = None
+ url: Optional[str] = None
@property
def bom_hash(self) -> BomHash:
@@ -639,6 +646,7 @@ class WireClass:
qty_unit=self.length.unit if self.length else None,
amount=None,
partnumbers=self.partnumbers,
+ url=self.url,
)
else:
_hash = BomHash(
@@ -646,6 +654,7 @@ class WireClass:
qty_unit=None,
amount=self.length,
partnumbers=self.partnumbers,
+ url=self.url,
)
return _hash
@@ -786,6 +795,14 @@ class Cable(TopLevelGraphicalComponent):
else:
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:
super().__post_init__()
@@ -851,6 +868,14 @@ class Cable(TopLevelGraphicalComponent):
else:
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
wire_tuples = zip_longest(
# TODO: self.wire_ids
@@ -874,6 +899,7 @@ class Cable(TopLevelGraphicalComponent):
sum_amounts_in_bom=self.sum_amounts_in_bom,
ignore_in_bom=self.ignore_in_bom,
partnumbers=self._get_wire_partnumber(wire_index),
+ url=self._get_wire_url(wire_index),
)
if self.shield:
diff --git a/src/wireviz/wv_harness.py b/src/wireviz/wv_harness.py
index 6c51e89..d148fc1 100644
--- a/src/wireviz/wv_harness.py
+++ b/src/wireviz/wv_harness.py
@@ -309,17 +309,18 @@ class Harness:
label=f"<\n{gv_html}\n>",
shape="box",
style="filled",
+ href=connector.url if isinstance(connector.url, str) else '',
)
# generate edges for connector loops
if len(connector.loops) > 0:
- dot.attr("edge", color="#000000")
+ dot.attr("edge", color="#000000", href='')
loops = gv_connector_loops(connector)
for head, tail, color in loops:
dot.edge(head, tail, color = color, label = " ", noLabel="noLabel")
# generate edges for connector shorts
if len(connector.shorts) > 0:
- dot.attr("edge", color="#000000")
+ dot.attr("edge", color="#000000", href='')
shorts = gv_connector_shorts(connector)
for head, tail, color in shorts:
dot.edge(head, tail,
@@ -352,12 +353,22 @@ class Harness:
label=f"<\n{gv_html}\n>",
shape="box",
style=style,
+ href=cable.url if isinstance(cable.url, str) else '',
)
# generate wire edges between component nodes and cable nodes
for connection in cable._connections:
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):
dot.edge(l1, l2)
if not (r1, r2) == (None, None):
@@ -365,11 +376,11 @@ class Harness:
for color, we, ww in gv_edge_wire_inside(cable):
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:
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)
apply_dot_tweaks(dot, self.tweak)
diff --git a/src/wireviz/wv_output.py b/src/wireviz/wv_output.py
index 07c9b46..1db85d3 100644
--- a/src/wireviz/wv_output.py
+++ b/src/wireviz/wv_output.py
@@ -127,7 +127,10 @@ def generate_html_output(
row_html = "
\n"
for i, item in enumerate(row):
td_class = f"bom_col_{bom[0][i].lower()}"
- row_html = f'{row_html} | {item if item is not None else ""} | \n'
+ cell_value = item if item is not None else ""
+ if bom[0][i] == "URL" and cell_value:
+ cell_value = f'{cell_value}'
+ row_html = f'{row_html} {cell_value} | \n'
row_html = f"{row_html}
\n"
bom_contents.append(row_html)
diff --git a/tests/test_url_href.py b/tests/test_url_href.py
new file mode 100644
index 0000000..dad2f8e
--- /dev/null
+++ b/tests/test_url_href.py
@@ -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 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 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