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
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:
parent
3d639f6ec2
commit
cfc25dfabf
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -127,7 +127,10 @@ def generate_html_output(
|
||||
row_html = " <tr>\n"
|
||||
for i, item in enumerate(row):
|
||||
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"
|
||||
bom_contents.append(row_html)
|
||||
|
||||
|
||||
314
tests/test_url_href.py
Normal file
314
tests/test_url_href.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user