Apply black

This commit is contained in:
Daniel Rojas 2021-10-15 17:11:11 +02:00
parent 344615483a
commit f92985a61c
12 changed files with 1474 additions and 744 deletions

View File

@ -7,40 +7,39 @@ from setuptools import find_packages, setup
from src.wireviz import APP_URL, CMD_NAME, __version__
README_PATH = Path(__file__).parent / 'docs' / 'README.md'
README_PATH = Path(__file__).parent / "docs" / "README.md"
setup(
name=CMD_NAME,
version=__version__,
author='Daniel Rojas',
author="Daniel Rojas",
# author_email='',
description='Easily document cables and wiring harnesses',
description="Easily document cables and wiring harnesses",
long_description=open(README_PATH).read(),
long_description_content_type='text/markdown',
long_description_content_type="text/markdown",
install_requires=[
'click',
'pyyaml',
'pillow',
'graphviz',
"click",
"pyyaml",
"pillow",
"graphviz",
],
license='GPLv3',
keywords='cable connector hardware harness wiring wiring-diagram wiring-harness',
license="GPLv3",
keywords="cable connector hardware harness wiring wiring-diagram wiring-harness",
url=APP_URL,
package_dir={'': 'src'},
packages=find_packages('src'),
package_dir={"": "src"},
packages=find_packages("src"),
entry_points={
'console_scripts': [
'wireviz=wireviz.wv_cli:wireviz',
"console_scripts": [
"wireviz=wireviz.wv_cli:wireviz",
],
},
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Console',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Topic :: Utilities',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
"Development Status :: 4 - Beta",
"Environment :: Console",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Topic :: Utilities",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
],
)

View File

@ -11,42 +11,53 @@ from wireviz.wv_helper import aspect_ratio, int2tuple
# Each type alias have their legal values described in comments - validation might be implemented in the future
PlainText = str # Text not containing HTML tags nor newlines
Hypertext = str # Text possibly including HTML hyperlinks that are removed in all outputs except HTML output
MultilineHypertext = str # Hypertext possibly also including newlines to break lines in diagram output
MultilineHypertext = (
str # Hypertext possibly also including newlines to break lines in diagram output
)
Designator = PlainText # Case insensitive unique name of connector or cable
# Literal type aliases below are commented to avoid requiring python 3.8
ConnectorMultiplier = PlainText # = Literal['pincount', 'populated']
CableMultiplier = PlainText # = Literal['wirecount', 'terminations', 'length', 'total_length']
CableMultiplier = (
PlainText # = Literal['wirecount', 'terminations', 'length', 'total_length']
)
ImageScale = PlainText # = Literal['false', 'true', 'width', 'height', 'both']
# Type combinations
Pin = Union[int, PlainText] # Pin identifier
PinIndex = int # Zero-based pin index
Wire = Union[int, PlainText] # Wire number or Literal['s'] for shield
NoneOrMorePins = Union[Pin, Tuple[Pin, ...], None] # None, one, or a tuple of pin identifiers
NoneOrMorePinIndices = Union[PinIndex, Tuple[PinIndex, ...], None] # None, one, or a tuple of zero-based pin indices
NoneOrMorePins = Union[
Pin, Tuple[Pin, ...], None
] # None, one, or a tuple of pin identifiers
NoneOrMorePinIndices = Union[
PinIndex, Tuple[PinIndex, ...], None
] # None, one, or a tuple of zero-based pin indices
OneOrMoreWires = Union[Wire, Tuple[Wire, ...]] # One or a tuple of wires
# Metadata can contain whatever is needed by the HTML generation/template.
MetadataKeys = PlainText # Literal['title', 'description', 'notes', ...]
class Side(Enum):
LEFT = auto()
RIGHT = auto()
class Metadata(dict):
pass
@dataclass
class Options:
fontname: PlainText = 'arial'
bgcolor: Color = 'WH'
bgcolor_node: Optional[Color] = 'WH'
fontname: PlainText = "arial"
bgcolor: Color = "WH"
bgcolor_node: Optional[Color] = "WH"
bgcolor_connector: Optional[Color] = None
bgcolor_cable: Optional[Color] = None
bgcolor_bundle: Optional[Color] = None
color_mode: ColorMode = 'SHORT'
color_mode: ColorMode = "SHORT"
mini_bom_mode: bool = True
def __post_init__(self):
@ -87,9 +98,13 @@ class Image:
self.fixedsize = (self.width or self.height) and self.scale is None
if self.scale is None:
self.scale = "false" if not self.width and not self.height \
else "both" if self.width and self.height \
else "true" # When only one dimension is specified.
self.scale = (
"false"
if not self.width and not self.height
else "both"
if self.width and self.height
else "true"
) # When only one dimension is specified.
if self.fixedsize:
# If only one dimension is specified, compute the other
@ -118,7 +133,9 @@ class AdditionalComponent:
@property
def description(self) -> str:
return self.type.rstrip() + (f', {self.subtype.rstrip()}' if self.subtype else '')
return self.type.rstrip() + (
f", {self.subtype.rstrip()}" if self.subtype else ""
)
@dataclass
@ -158,36 +175,44 @@ class Connector:
self.ports_right = False
self.visible_pins = {}
if self.style == 'simple':
if self.style == "simple":
if self.pincount and self.pincount > 1:
raise Exception('Connectors with style set to simple may only have one pin')
raise Exception(
"Connectors with style set to simple may only have one pin"
)
self.pincount = 1
if not self.pincount:
self.pincount = max(len(self.pins), len(self.pinlabels), len(self.pincolors))
self.pincount = max(
len(self.pins), len(self.pinlabels), len(self.pincolors)
)
if not self.pincount:
raise Exception('You need to specify at least one, pincount, pins, pinlabels, or pincolors')
raise Exception(
"You need to specify at least one, pincount, pins, pinlabels, or pincolors"
)
# create default list for pins (sequential) if not specified
if not self.pins:
self.pins = list(range(1, self.pincount + 1))
if len(self.pins) != len(set(self.pins)):
raise Exception('Pins are not unique')
raise Exception("Pins are not unique")
if self.show_name is None:
# hide designators for simple and for auto-generated connectors by default
self.show_name = (self.style != 'simple' and self.name[0:2] != '__')
self.show_name = self.style != "simple" and self.name[0:2] != "__"
if self.show_pincount is None:
self.show_pincount = self.style != 'simple' # hide pincount for simple (1 pin) connectors by default
self.show_pincount = (
self.style != "simple"
) # hide pincount for simple (1 pin) connectors by default
for loop in self.loops:
# TODO: check that pins to connect actually exist
# TODO: allow using pin labels in addition to pin numbers, just like when defining regular connections
# TODO: include properties of wire used to create the loop
if len(loop) != 2:
raise Exception('Loops must be between exactly two pins!')
raise Exception("Loops must be between exactly two pins!")
for i, item in enumerate(self.additional_components):
if isinstance(item, dict):
@ -203,12 +228,14 @@ class Connector:
def get_qty_multiplier(self, qty_multiplier: Optional[ConnectorMultiplier]) -> int:
if not qty_multiplier:
return 1
elif qty_multiplier == 'pincount':
elif qty_multiplier == "pincount":
return self.pincount
elif qty_multiplier == 'populated':
elif qty_multiplier == "populated":
return sum(self.visible_pins.values())
else:
raise ValueError(f'invalid qty multiplier parameter for connector {qty_multiplier}')
raise ValueError(
f"invalid qty multiplier parameter for connector {qty_multiplier}"
)
@dataclass
@ -249,50 +276,60 @@ class Cable:
if isinstance(self.gauge, str): # gauge and unit specified
try:
g, u = self.gauge.split(' ')
g, u = self.gauge.split(" ")
except Exception:
raise Exception(f'Cable {self.name} gauge={self.gauge} - Gauge must be a number, or number and unit separated by a space')
raise Exception(
f"Cable {self.name} gauge={self.gauge} - Gauge must be a number, or number and unit separated by a space"
)
self.gauge = g
if self.gauge_unit is not None:
print(f'Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}')
if u.upper() == 'AWG':
print(
f"Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}"
)
if u.upper() == "AWG":
self.gauge_unit = u.upper()
else:
self.gauge_unit = u.replace('mm2', 'mm\u00B2')
self.gauge_unit = u.replace("mm2", "mm\u00B2")
elif self.gauge is not None: # gauge specified, assume mm2
if self.gauge_unit is None:
self.gauge_unit = 'mm\u00B2'
self.gauge_unit = "mm\u00B2"
else:
pass # gauge not specified
if isinstance(self.length, str): # length and unit specified
try:
L, u = self.length.split(' ')
L, u = self.length.split(" ")
L = float(L)
except Exception:
raise Exception(f'Cable {self.name} length={self.length} - Length must be a number, or number and unit separated by a space')
raise Exception(
f"Cable {self.name} length={self.length} - Length must be a number, or number and unit separated by a space"
)
self.length = L
if self.length_unit is not None:
print(f'Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}')
print(
f"Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}"
)
self.length_unit = u
elif not any(isinstance(self.length, t) for t in [int, float]):
raise Exception(f'Cable {self.name} length has a non-numeric value')
raise Exception(f"Cable {self.name} length has a non-numeric value")
elif self.length_unit is None:
self.length_unit = 'm'
self.length_unit = "m"
self.connections = []
if self.wirecount: # number of wires explicitly defined
if self.colors: # use custom color palette (partly or looped if needed)
pass
elif self.color_code: # use standard color palette (partly or looped if needed)
elif (
self.color_code
): # use standard color palette (partly or looped if needed)
if self.color_code not in COLOR_CODES:
raise Exception('Unknown color code')
raise Exception("Unknown color code")
self.colors = COLOR_CODES[self.color_code]
else: # no colors defined, add dummy colors
self.colors = [''] * self.wirecount
self.colors = [""] * self.wirecount
# make color code loop around if more wires than colors
if self.wirecount > len(self.colors):
@ -302,12 +339,16 @@ class Cable:
self.colors = self.colors[: self.wirecount]
else: # wirecount implicit in length of color list
if not self.colors:
raise Exception('Unknown number of wires. Must specify wirecount or colors (implicit length)')
raise Exception(
"Unknown number of wires. Must specify wirecount or colors (implicit length)"
)
self.wirecount = len(self.colors)
if self.wirelabels:
if self.shield and 's' in self.wirelabels:
raise Exception('"s" may not be used as a wire label for a shielded cable.')
if self.shield and "s" in self.wirelabels:
raise Exception(
'"s" may not be used as a wire label for a shielded cable.'
)
# if lists of part numbers are provided check this is a bundle and that it matches the wirecount.
for idfield in [self.manufacturer, self.mpn, self.supplier, self.spn, self.pn]:
@ -315,44 +356,58 @@ class Cable:
if self.category == "bundle":
# check the length
if len(idfield) != self.wirecount:
raise Exception('lists of part data must match wirecount')
raise Exception("lists of part data must match wirecount")
else:
raise Exception('lists of part data are only supported for bundles')
raise Exception("lists of part data are only supported for bundles")
if self.show_name is None:
self.show_name = self.name[0:2] != '__' # hide designators for auto-generated cables by default
self.show_name = (
self.name[0:2] != "__"
) # hide designators for auto-generated cables by default
if not self.show_wirenumbers:
self.show_wirenumbers = self.category != 'bundle' # by default, show wire numbers for cables, hide for bundles
self.show_wirenumbers = (
self.category != "bundle"
) # by default, show wire numbers for cables, hide for bundles
for i, item in enumerate(self.additional_components):
if isinstance(item, dict):
self.additional_components[i] = AdditionalComponent(**item)
# The *_pin arguments accept a tuple, but it seems not in use with the current code.
def connect(self, from_name: Optional[Designator], from_pin: NoneOrMorePinIndices, via_wire: OneOrMoreWires,
to_name: Optional[Designator], to_pin: NoneOrMorePinIndices) -> None:
def connect(
self,
from_name: Optional[Designator],
from_pin: NoneOrMorePinIndices,
via_wire: OneOrMoreWires,
to_name: Optional[Designator],
to_pin: NoneOrMorePinIndices,
) -> None:
from_pin = int2tuple(from_pin)
via_wire = int2tuple(via_wire)
to_pin = int2tuple(to_pin)
if len(from_pin) != len(to_pin):
raise Exception('from_pin must have the same number of elements as to_pin')
raise Exception("from_pin must have the same number of elements as to_pin")
for i, _ in enumerate(from_pin):
self.connections.append(Connection(from_name, from_pin[i], via_wire[i], to_name, to_pin[i]))
self.connections.append(
Connection(from_name, from_pin[i], via_wire[i], to_name, to_pin[i])
)
def get_qty_multiplier(self, qty_multiplier: Optional[CableMultiplier]) -> float:
if not qty_multiplier:
return 1
elif qty_multiplier == 'wirecount':
elif qty_multiplier == "wirecount":
return self.wirecount
elif qty_multiplier == 'terminations':
elif qty_multiplier == "terminations":
return len(self.connections)
elif qty_multiplier == 'length':
elif qty_multiplier == "length":
return self.length
elif qty_multiplier == 'total_length':
elif qty_multiplier == "total_length":
return self.length * self.wirecount
else:
raise ValueError(f'invalid qty multiplier parameter for cable {qty_multiplier}')
raise ValueError(
f"invalid qty multiplier parameter for cable {qty_multiplier}"
)
@dataclass
@ -363,6 +418,7 @@ class Connection:
to_name: Optional[Designator]
to_pin: Optional[Pin]
@dataclass
class MatePin:
from_name: Designator
@ -371,6 +427,7 @@ class MatePin:
to_pin: Pin
shape: str
@dataclass
class MateComponent:
from_name: Designator

View File

@ -83,7 +83,15 @@ class Harness:
def add_bom_item(self, item: dict) -> None:
self.additional_bom_items.append(item)
def connect(self, from_name: str, from_pin: (int, str), via_name: str, via_wire: (int, str), to_name: str, to_pin: (int, str)) -> None:
def connect(
self,
from_name: str,
from_pin: (int, str),
via_name: str,
via_wire: (int, str),
to_name: str,
to_pin: (int, str),
) -> None:
# check from and to connectors
for (name, pin) in zip([from_name, to_name], [from_pin, to_pin]):
if name is not None and name in self.connectors:
@ -91,11 +99,13 @@ class Harness:
# check if provided name is ambiguous
if pin in connector.pins and pin in connector.pinlabels:
if connector.pins.index(pin) != connector.pinlabels.index(pin):
raise Exception(f'{name}:{pin} is defined both in pinlabels and pins, for different pins.')
raise Exception(
f"{name}:{pin} is defined both in pinlabels and pins, for different pins."
)
# TODO: Maybe issue a warning if present in both lists but referencing the same pin?
if pin in connector.pinlabels:
if connector.pinlabels.count(pin) > 1:
raise Exception(f'{name}:{pin} is defined more than once.')
raise Exception(f"{name}:{pin} is defined more than once.")
index = connector.pinlabels.index(pin)
pin = connector.pins[index] # map pin name to pin number
if name == from_name:
@ -103,7 +113,7 @@ class Harness:
if name == to_name:
to_pin = pin
if not pin in connector.pins:
raise Exception(f'{name}:{pin} not found.')
raise Exception(f"{name}:{pin} not found.")
# check via cable
if via_name in self.cables:
@ -111,16 +121,26 @@ class Harness:
# check if provided name is ambiguous
if via_wire in cable.colors and via_wire in cable.wirelabels:
if cable.colors.index(via_wire) != cable.wirelabels.index(via_wire):
raise Exception(f'{via_name}:{via_wire} is defined both in colors and wirelabels, for different wires.')
raise Exception(
f"{via_name}:{via_wire} is defined both in colors and wirelabels, for different wires."
)
# TODO: Maybe issue a warning if present in both lists but referencing the same wire?
if via_wire in cable.colors:
if cable.colors.count(via_wire) > 1:
raise Exception(f'{via_name}:{via_wire} is used for more than one wire.')
via_wire = cable.colors.index(via_wire) + 1 # list index starts at 0, wire IDs start at 1
raise Exception(
f"{via_name}:{via_wire} is used for more than one wire."
)
via_wire = (
cable.colors.index(via_wire) + 1
) # list index starts at 0, wire IDs start at 1
elif via_wire in cable.wirelabels:
if cable.wirelabels.count(via_wire) > 1:
raise Exception(f'{via_name}:{via_wire} is used for more than one wire.')
via_wire = cable.wirelabels.index(via_wire) + 1 # list index starts at 0, wire IDs start at 1
raise Exception(
f"{via_name}:{via_wire} is used for more than one wire."
)
via_wire = (
cable.wirelabels.index(via_wire) + 1
) # list index starts at 0, wire IDs start at 1
# perform the actual connection
self.cables[via_name].connect(from_name, from_pin, via_wire, to_name, to_pin)
@ -129,24 +149,29 @@ class Harness:
if to_name in self.connectors:
self.connectors[to_name].activate_pin(to_pin, Side.LEFT)
def create_graph(self) -> Graph:
dot = Graph()
dot.body.append(f'// Graph generated by {APP_NAME} {__version__}')
dot.body.append(f'// {APP_URL}')
dot.attr('graph', rankdir='LR',
ranksep='2',
dot.body.append(f"// Graph generated by {APP_NAME} {__version__}")
dot.body.append(f"// {APP_URL}")
dot.attr(
"graph",
rankdir="LR",
ranksep="2",
bgcolor=wv_colors.translate_color(self.options.bgcolor, "HEX"),
nodesep='0.33',
fontname=self.options.fontname)
dot.attr('node',
shape='none',
width='0', height='0', margin='0', # Actual size of the node is entirely determined by the label.
style='filled',
nodesep="0.33",
fontname=self.options.fontname,
)
dot.attr(
"node",
shape="none",
width="0",
height="0",
margin="0", # Actual size of the node is entirely determined by the label.
style="filled",
fillcolor=wv_colors.translate_color(self.options.bgcolor_node, "HEX"),
fontname=self.options.fontname)
dot.attr('edge', style='bold',
fontname=self.options.fontname)
fontname=self.options.fontname,
)
dot.attr("edge", style="bold", fontname=self.options.fontname)
for connector in self.connectors.values():
@ -156,159 +181,258 @@ class Harness:
html = []
rows = [[f'{html_bgcolor(connector.bgcolor_title)}{remove_links(connector.name)}'
if connector.show_name else None],
[pn_info_string(HEADER_PN, None, remove_links(connector.pn)),
html_line_breaks(pn_info_string(HEADER_MPN, connector.manufacturer, connector.mpn)),
html_line_breaks(pn_info_string(HEADER_SPN, connector.supplier, connector.spn))],
[html_line_breaks(connector.type),
rows = [
[
f"{html_bgcolor(connector.bgcolor_title)}{remove_links(connector.name)}"
if connector.show_name
else None
],
[
pn_info_string(HEADER_PN, None, remove_links(connector.pn)),
html_line_breaks(
pn_info_string(
HEADER_MPN, connector.manufacturer, connector.mpn
)
),
html_line_breaks(
pn_info_string(HEADER_SPN, connector.supplier, connector.spn)
),
],
[
html_line_breaks(connector.type),
html_line_breaks(connector.subtype),
f'{connector.pincount}-pin' if connector.show_pincount else None,
translate_color(connector.color, self.options.color_mode) if connector.color else None,
html_colorbar(connector.color)],
'<!-- connector table -->' if connector.style != 'simple' else None,
f"{connector.pincount}-pin" if connector.show_pincount else None,
translate_color(connector.color, self.options.color_mode)
if connector.color
else None,
html_colorbar(connector.color),
],
"<!-- connector table -->" if connector.style != "simple" else None,
[html_image(connector.image)],
[html_caption(connector.image)]]
[html_caption(connector.image)],
]
rows.extend(get_additional_component_table(self, connector))
rows.append([html_line_breaks(connector.notes)])
html.extend(nested_html_table(rows, html_bgcolor_attr(connector.bgcolor)))
if connector.style != 'simple':
if connector.style != "simple":
pinhtml = []
pinhtml.append('<table border="0" cellspacing="0" cellpadding="3" cellborder="1">')
pinhtml.append(
'<table border="0" cellspacing="0" cellpadding="3" cellborder="1">'
)
for pinindex, (pinname, pinlabel, pincolor) in enumerate(zip_longest(connector.pins, connector.pinlabels, connector.pincolors)):
if connector.hide_disconnected_pins and not connector.visible_pins.get(pinname, False):
for pinindex, (pinname, pinlabel, pincolor) in enumerate(
zip_longest(
connector.pins, connector.pinlabels, connector.pincolors
)
):
if (
connector.hide_disconnected_pins
and not connector.visible_pins.get(pinname, False)
):
continue
pinhtml.append(' <tr>')
pinhtml.append(" <tr>")
if connector.ports_left:
pinhtml.append(f' <td port="p{pinindex+1}l">{pinname}</td>')
if pinlabel:
pinhtml.append(f' <td>{pinlabel}</td>')
pinhtml.append(f" <td>{pinlabel}</td>")
if connector.pincolors:
if pincolor in wv_colors._color_hex.keys():
pinhtml.append(f' <td sides="tbl">{translate_color(pincolor, self.options.color_mode)}</td>')
pinhtml.append(
f' <td sides="tbl">{translate_color(pincolor, self.options.color_mode)}</td>'
)
pinhtml.append(' <td sides="tbr">')
pinhtml.append(' <table border="0" cellborder="1"><tr>')
pinhtml.append(f' <td bgcolor="{wv_colors.translate_color(pincolor, "HEX")}" width="8" height="8" fixedsize="true"></td>')
pinhtml.append( ' </tr></table>')
pinhtml.append( ' </td>')
pinhtml.append(
f' <td bgcolor="{wv_colors.translate_color(pincolor, "HEX")}" width="8" height="8" fixedsize="true"></td>'
)
pinhtml.append(" </tr></table>")
pinhtml.append(" </td>")
else:
pinhtml.append(' <td colspan="2"></td>')
if connector.ports_right:
pinhtml.append(f' <td port="p{pinindex+1}r">{pinname}</td>')
pinhtml.append(' </tr>')
pinhtml.append(" </tr>")
pinhtml.append(' </table>')
pinhtml.append(" </table>")
html = [row.replace('<!-- connector table -->', '\n'.join(pinhtml)) for row in html]
html = [
row.replace("<!-- connector table -->", "\n".join(pinhtml))
for row in html
]
html = '\n'.join(html)
dot.node(connector.name, label=f'<\n{html}\n>', shape='box', style='filled',
fillcolor=translate_color(self.options.bgcolor_connector, "HEX"))
html = "\n".join(html)
dot.node(
connector.name,
label=f"<\n{html}\n>",
shape="box",
style="filled",
fillcolor=translate_color(self.options.bgcolor_connector, "HEX"),
)
if len(connector.loops) > 0:
dot.attr('edge', color='#000000:#ffffff:#000000')
dot.attr("edge", color="#000000:#ffffff:#000000")
if connector.ports_left:
loop_side = 'l'
loop_dir = 'w'
loop_side = "l"
loop_dir = "w"
elif connector.ports_right:
loop_side = 'r'
loop_dir = 'e'
loop_side = "r"
loop_dir = "e"
else:
raise Exception('No side for loops')
raise Exception("No side for loops")
for loop in connector.loops:
dot.edge(f'{connector.name}:p{loop[0]}{loop_side}:{loop_dir}',
f'{connector.name}:p{loop[1]}{loop_side}:{loop_dir}')
dot.edge(
f"{connector.name}:p{loop[0]}{loop_side}:{loop_dir}",
f"{connector.name}:p{loop[1]}{loop_side}:{loop_dir}",
)
# determine if there are double- or triple-colored wires in the harness;
# if so, pad single-color wires to make all wires of equal thickness
pad = any(len(colorstr) > 2 for cable in self.cables.values() for colorstr in cable.colors)
pad = any(
len(colorstr) > 2
for cable in self.cables.values()
for colorstr in cable.colors
)
for cable in self.cables.values():
html = []
awg_fmt = ''
awg_fmt = ""
if cable.show_equiv:
# Only convert units we actually know about, i.e. currently
# mm2 and awg --- other units _are_ technically allowed,
# and passed through as-is.
if cable.gauge_unit =='mm\u00B2':
awg_fmt = f' ({awg_equiv(cable.gauge)} AWG)'
elif cable.gauge_unit.upper() == 'AWG':
awg_fmt = f' ({mm2_equiv(cable.gauge)} mm\u00B2)'
if cable.gauge_unit == "mm\u00B2":
awg_fmt = f" ({awg_equiv(cable.gauge)} AWG)"
elif cable.gauge_unit.upper() == "AWG":
awg_fmt = f" ({mm2_equiv(cable.gauge)} mm\u00B2)"
rows = [[f'{html_bgcolor(cable.bgcolor_title)}{remove_links(cable.name)}'
if cable.show_name else None],
[pn_info_string(HEADER_PN, None,
remove_links(cable.pn)) if not isinstance(cable.pn, list) else None,
html_line_breaks(pn_info_string(HEADER_MPN,
cable.manufacturer if not isinstance(cable.manufacturer, list) else None,
cable.mpn if not isinstance(cable.mpn, list) else None)),
html_line_breaks(pn_info_string(HEADER_SPN,
cable.supplier if not isinstance(cable.supplier, list) else None,
cable.spn if not isinstance(cable.spn, list) else None))],
[html_line_breaks(cable.type),
f'{cable.wirecount}x' if cable.show_wirecount else None,
f'{cable.gauge} {cable.gauge_unit}{awg_fmt}' if cable.gauge else None,
'+ S' if cable.shield else None,
f'{cable.length} {cable.length_unit}' if cable.length > 0 else None,
translate_color(cable.color, self.options.color_mode) if cable.color else None,
html_colorbar(cable.color)],
'<!-- wire table -->',
rows = [
[
f"{html_bgcolor(cable.bgcolor_title)}{remove_links(cable.name)}"
if cable.show_name
else None
],
[
pn_info_string(HEADER_PN, None, remove_links(cable.pn))
if not isinstance(cable.pn, list)
else None,
html_line_breaks(
pn_info_string(
HEADER_MPN,
cable.manufacturer
if not isinstance(cable.manufacturer, list)
else None,
cable.mpn if not isinstance(cable.mpn, list) else None,
)
),
html_line_breaks(
pn_info_string(
HEADER_SPN,
cable.supplier
if not isinstance(cable.supplier, list)
else None,
cable.spn if not isinstance(cable.spn, list) else None,
)
),
],
[
html_line_breaks(cable.type),
f"{cable.wirecount}x" if cable.show_wirecount else None,
f"{cable.gauge} {cable.gauge_unit}{awg_fmt}"
if cable.gauge
else None,
"+ S" if cable.shield else None,
f"{cable.length} {cable.length_unit}" if cable.length > 0 else None,
translate_color(cable.color, self.options.color_mode)
if cable.color
else None,
html_colorbar(cable.color),
],
"<!-- wire table -->",
[html_image(cable.image)],
[html_caption(cable.image)]]
[html_caption(cable.image)],
]
rows.extend(get_additional_component_table(self, cable))
rows.append([html_line_breaks(cable.notes)])
html.extend(nested_html_table(rows, html_bgcolor_attr(cable.bgcolor)))
wirehtml = []
wirehtml.append('<table border="0" cellspacing="0" cellborder="0">') # conductor table
wirehtml.append(' <tr><td>&nbsp;</td></tr>')
wirehtml.append(
'<table border="0" cellspacing="0" cellborder="0">'
) # conductor table
wirehtml.append(" <tr><td>&nbsp;</td></tr>")
for i, (connection_color, wirelabel) in enumerate(zip_longest(cable.colors, cable.wirelabels), 1):
wirehtml.append(' <tr>')
wirehtml.append(f' <td><!-- {i}_in --></td>')
wirehtml.append(f' <td>')
for i, (connection_color, wirelabel) in enumerate(
zip_longest(cable.colors, cable.wirelabels), 1
):
wirehtml.append(" <tr>")
wirehtml.append(f" <td><!-- {i}_in --></td>")
wirehtml.append(f" <td>")
wireinfo = []
if cable.show_wirenumbers:
wireinfo.append(str(i))
colorstr = wv_colors.translate_color(connection_color, self.options.color_mode)
colorstr = wv_colors.translate_color(
connection_color, self.options.color_mode
)
if colorstr:
wireinfo.append(colorstr)
if cable.wirelabels:
wireinfo.append(wirelabel if wirelabel is not None else '')
wireinfo.append(wirelabel if wirelabel is not None else "")
wirehtml.append(f' {":".join(wireinfo)}')
wirehtml.append(f' </td>')
wirehtml.append(f' <td><!-- {i}_out --></td>')
wirehtml.append(' </tr>')
wirehtml.append(f" </td>")
wirehtml.append(f" <td><!-- {i}_out --></td>")
wirehtml.append(" </tr>")
bgcolors = ['#000000'] + get_color_hex(connection_color, pad=pad) + ['#000000']
wirehtml.append(f' <tr>')
wirehtml.append(f' <td colspan="3" border="0" cellspacing="0" cellpadding="0" port="w{i}" height="{(2 * len(bgcolors))}">')
wirehtml.append(' <table cellspacing="0" cellborder="0" border="0">')
for j, bgcolor in enumerate(bgcolors[::-1]): # Reverse to match the curved wires when more than 2 colors
wirehtml.append(f' <tr><td colspan="3" cellpadding="0" height="2" bgcolor="{bgcolor if bgcolor != "" else wv_colors.default_color}" border="0"></td></tr>')
wirehtml.append(' </table>')
wirehtml.append(' </td>')
wirehtml.append(' </tr>')
if cable.category == 'bundle': # for bundles individual wires can have part information
bgcolors = (
["#000000"] + get_color_hex(connection_color, pad=pad) + ["#000000"]
)
wirehtml.append(f" <tr>")
wirehtml.append(
f' <td colspan="3" border="0" cellspacing="0" cellpadding="0" port="w{i}" height="{(2 * len(bgcolors))}">'
)
wirehtml.append(
' <table cellspacing="0" cellborder="0" border="0">'
)
for j, bgcolor in enumerate(
bgcolors[::-1]
): # Reverse to match the curved wires when more than 2 colors
wirehtml.append(
f' <tr><td colspan="3" cellpadding="0" height="2" bgcolor="{bgcolor if bgcolor != "" else wv_colors.default_color}" border="0"></td></tr>'
)
wirehtml.append(" </table>")
wirehtml.append(" </td>")
wirehtml.append(" </tr>")
if (
cable.category == "bundle"
): # for bundles individual wires can have part information
# create a list of wire parameters
wireidentification = []
if isinstance(cable.pn, list):
wireidentification.append(pn_info_string(HEADER_PN, None, remove_links(cable.pn[i - 1])))
manufacturer_info = pn_info_string(HEADER_MPN,
cable.manufacturer[i - 1] if isinstance(cable.manufacturer, list) else None,
cable.mpn[i - 1] if isinstance(cable.mpn, list) else None)
supplier_info = pn_info_string(HEADER_SPN,
cable.supplier[i - 1] if isinstance(cable.supplier, list) else None,
cable.spn[i - 1] if isinstance(cable.spn, list) else None)
wireidentification.append(
pn_info_string(
HEADER_PN, None, remove_links(cable.pn[i - 1])
)
)
manufacturer_info = pn_info_string(
HEADER_MPN,
cable.manufacturer[i - 1]
if isinstance(cable.manufacturer, list)
else None,
cable.mpn[i - 1] if isinstance(cable.mpn, list) else None,
)
supplier_info = pn_info_string(
HEADER_SPN,
cable.supplier[i - 1]
if isinstance(cable.supplier, list)
else None,
cable.spn[i - 1] if isinstance(cable.spn, list) else None,
)
if manufacturer_info:
wireidentification.append(html_line_breaks(manufacturer_info))
if supplier_info:
@ -316,165 +440,237 @@ class Harness:
# print parameters into a table row under the wire
if len(wireidentification) > 0:
wirehtml.append(' <tr><td colspan="3">')
wirehtml.append(' <table border="0" cellspacing="0" cellborder="0"><tr>')
wirehtml.append(
' <table border="0" cellspacing="0" cellborder="0"><tr>'
)
for attrib in wireidentification:
wirehtml.append(f' <td>{attrib}</td>')
wirehtml.append(' </tr></table>')
wirehtml.append(' </td></tr>')
wirehtml.append(f" <td>{attrib}</td>")
wirehtml.append(" </tr></table>")
wirehtml.append(" </td></tr>")
if cable.shield:
wirehtml.append(' <tr><td>&nbsp;</td></tr>') # spacer
wirehtml.append(' <tr>')
wirehtml.append(' <td><!-- s_in --></td>')
wirehtml.append(' <td>Shield</td>')
wirehtml.append(' <td><!-- s_out --></td>')
wirehtml.append(' </tr>')
wirehtml.append(" <tr><td>&nbsp;</td></tr>") # spacer
wirehtml.append(" <tr>")
wirehtml.append(" <td><!-- s_in --></td>")
wirehtml.append(" <td>Shield</td>")
wirehtml.append(" <td><!-- s_out --></td>")
wirehtml.append(" </tr>")
if isinstance(cable.shield, str):
# shield is shown with specified color and black borders
shield_color_hex = wv_colors.get_color_hex(cable.shield)[0]
attributes = f'height="6" bgcolor="{shield_color_hex}" border="2" sides="tb"'
attributes = (
f'height="6" bgcolor="{shield_color_hex}" border="2" sides="tb"'
)
else:
# shield is shown as a thin black wire
attributes = f'height="2" bgcolor="#000000" border="0"'
wirehtml.append(f' <tr><td colspan="3" cellpadding="0" {attributes} port="ws"></td></tr>')
wirehtml.append(
f' <tr><td colspan="3" cellpadding="0" {attributes} port="ws"></td></tr>'
)
wirehtml.append(' <tr><td>&nbsp;</td></tr>')
wirehtml.append(' </table>')
wirehtml.append(" <tr><td>&nbsp;</td></tr>")
wirehtml.append(" </table>")
html = [row.replace('<!-- wire table -->', '\n'.join(wirehtml)) for row in html]
html = [
row.replace("<!-- wire table -->", "\n".join(wirehtml)) for row in html
]
# connections
for connection in cable.connections:
if isinstance(connection.via_port, int): # check if it's an actual wire and not a shield
dot.attr('edge', color=':'.join(['#000000'] + wv_colors.get_color_hex(cable.colors[connection.via_port - 1], pad=pad) + ['#000000']))
if isinstance(
connection.via_port, int
): # check if it's an actual wire and not a shield
dot.attr(
"edge",
color=":".join(
["#000000"]
+ wv_colors.get_color_hex(
cable.colors[connection.via_port - 1], pad=pad
)
+ ["#000000"]
),
)
else: # it's a shield connection
# shield is shown with specified color and black borders, or as a thin black wire otherwise
dot.attr('edge', color=':'.join(['#000000', shield_color_hex, '#000000']) if isinstance(cable.shield, str) else '#000000')
dot.attr(
"edge",
color=":".join(["#000000", shield_color_hex, "#000000"])
if isinstance(cable.shield, str)
else "#000000",
)
if connection.from_pin is not None: # connect to left
from_connector = self.connectors[connection.from_name]
from_pin_index = from_connector.pins.index(connection.from_pin)
from_port_str = f':p{from_pin_index+1}r' if from_connector.style != 'simple' else ''
code_left_1 = f'{connection.from_name}{from_port_str}:e'
code_left_2 = f'{cable.name}:w{connection.via_port}:w'
from_port_str = (
f":p{from_pin_index+1}r"
if from_connector.style != "simple"
else ""
)
code_left_1 = f"{connection.from_name}{from_port_str}:e"
code_left_2 = f"{cable.name}:w{connection.via_port}:w"
dot.edge(code_left_1, code_left_2)
if from_connector.show_name:
from_info = [str(connection.from_name), str(connection.from_pin)]
from_info = [
str(connection.from_name),
str(connection.from_pin),
]
if from_connector.pinlabels:
pinlabel = from_connector.pinlabels[from_pin_index]
if pinlabel != '':
if pinlabel != "":
from_info.append(pinlabel)
from_string = ':'.join(from_info)
from_string = ":".join(from_info)
else:
from_string = ''
html = [row.replace(f'<!-- {connection.via_port}_in -->', from_string) for row in html]
from_string = ""
html = [
row.replace(f"<!-- {connection.via_port}_in -->", from_string)
for row in html
]
if connection.to_pin is not None: # connect to right
to_connector = self.connectors[connection.to_name]
to_pin_index = to_connector.pins.index(connection.to_pin)
to_port_str = f':p{to_pin_index+1}l' if to_connector.style != 'simple' else ''
code_right_1 = f'{cable.name}:w{connection.via_port}:e'
code_right_2 = f'{connection.to_name}{to_port_str}:w'
to_port_str = (
f":p{to_pin_index+1}l" if to_connector.style != "simple" else ""
)
code_right_1 = f"{cable.name}:w{connection.via_port}:e"
code_right_2 = f"{connection.to_name}{to_port_str}:w"
dot.edge(code_right_1, code_right_2)
if to_connector.show_name:
to_info = [str(connection.to_name), str(connection.to_pin)]
if to_connector.pinlabels:
pinlabel = to_connector.pinlabels[to_pin_index]
if pinlabel != '':
if pinlabel != "":
to_info.append(pinlabel)
to_string = ':'.join(to_info)
to_string = ":".join(to_info)
else:
to_string = ''
html = [row.replace(f'<!-- {connection.via_port}_out -->', to_string) for row in html]
to_string = ""
html = [
row.replace(f"<!-- {connection.via_port}_out -->", to_string)
for row in html
]
style, bgcolor = ('filled,dashed', self.options.bgcolor_bundle) if cable.category == 'bundle' else \
('filled', self.options.bgcolor_cable)
html = '\n'.join(html)
dot.node(cable.name, label=f'<\n{html}\n>', shape='box',
style=style, fillcolor=translate_color(bgcolor, "HEX"))
style, bgcolor = (
("filled,dashed", self.options.bgcolor_bundle)
if cable.category == "bundle"
else ("filled", self.options.bgcolor_cable)
)
html = "\n".join(html)
dot.node(
cable.name,
label=f"<\n{html}\n>",
shape="box",
style=style,
fillcolor=translate_color(bgcolor, "HEX"),
)
def typecheck(name: str, value: Any, expect: type) -> None:
if not isinstance(value, expect):
raise Exception(f'Unexpected value type of {name}: Expected {expect}, got {type(value)}\n{value}')
raise Exception(
f"Unexpected value type of {name}: Expected {expect}, got {type(value)}\n{value}"
)
# TODO?: Differ between override attributes and HTML?
if self.tweak.override is not None:
typecheck('tweak.override', self.tweak.override, dict)
typecheck("tweak.override", self.tweak.override, dict)
for k, d in self.tweak.override.items():
typecheck(f'tweak.override.{k} key', k, str)
typecheck(f'tweak.override.{k} value', d, dict)
typecheck(f"tweak.override.{k} key", k, str)
typecheck(f"tweak.override.{k} value", d, dict)
for a, v in d.items():
typecheck(f'tweak.override.{k}.{a} key', a, str)
typecheck(f'tweak.override.{k}.{a} value', v, (str, type(None)))
typecheck(f"tweak.override.{k}.{a} key", a, str)
typecheck(f"tweak.override.{k}.{a} value", v, (str, type(None)))
# Override generated attributes of selected entries matching tweak.override.
for i, entry in enumerate(dot.body):
if isinstance(entry, str):
# Find a possibly quoted keyword after leading TAB(s) and followed by [ ].
match = re.match(r'^\t*(")?((?(1)[^"]|[^ "])+)(?(1)") \[.*\]$', entry, re.S)
match = re.match(
r'^\t*(")?((?(1)[^"]|[^ "])+)(?(1)") \[.*\]$', entry, re.S
)
keyword = match and match[2]
if keyword in self.tweak.override.keys():
for attr, value in self.tweak.override[keyword].items():
if value is None:
entry, n_subs = re.subn(f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', '', entry)
entry, n_subs = re.subn(
f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', "", entry
)
if n_subs < 1:
print(f'Harness.create_graph() warning: {attr} not found in {keyword}!')
print(
f"Harness.create_graph() warning: {attr} not found in {keyword}!"
)
elif n_subs > 1:
print(f'Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!')
print(
f"Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!"
)
continue
if len(value) == 0 or ' ' in value:
value = value.replace('"', r'\"')
if len(value) == 0 or " " in value:
value = value.replace('"', r"\"")
value = f'"{value}"'
entry, n_subs = re.subn(f'{attr}=("[^"]*"|[^] ]*)', f'{attr}={value}', entry)
entry, n_subs = re.subn(
f'{attr}=("[^"]*"|[^] ]*)', f"{attr}={value}", entry
)
if n_subs < 1:
# If attr not found, then append it
entry = re.sub(r'\]$', f' {attr}={value}]', entry)
entry = re.sub(r"\]$", f" {attr}={value}]", entry)
elif n_subs > 1:
print(f'Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!')
print(
f"Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!"
)
dot.body[i] = entry
if self.tweak.append is not None:
if isinstance(self.tweak.append, list):
for i, element in enumerate(self.tweak.append, 1):
typecheck(f'tweak.append[{i}]', element, str)
typecheck(f"tweak.append[{i}]", element, str)
dot.body.extend(self.tweak.append)
else:
typecheck('tweak.append', self.tweak.append, str)
typecheck("tweak.append", self.tweak.append, str)
dot.body.append(self.tweak.append)
for mate in self.mates:
if mate.shape[0] == '<' and mate.shape[-1] == '>':
dir = 'both'
elif mate.shape[0] == '<':
dir = 'back'
elif mate.shape[-1] == '>':
dir = 'forward'
if mate.shape[0] == "<" and mate.shape[-1] == ">":
dir = "both"
elif mate.shape[0] == "<":
dir = "back"
elif mate.shape[-1] == ">":
dir = "forward"
else:
dir = 'none'
dir = "none"
if isinstance(mate, MatePin):
color = '#000000'
color = "#000000"
elif isinstance(mate, MateComponent):
color = '#000000:#000000'
color = "#000000:#000000"
else:
raise Exception(f'{mate} is an unknown mate')
raise Exception(f"{mate} is an unknown mate")
from_connector = self.connectors[mate.from_name]
if isinstance(mate, MatePin) and self.connectors[mate.from_name].style != 'simple':
if (
isinstance(mate, MatePin)
and self.connectors[mate.from_name].style != "simple"
):
from_pin_index = from_connector.pins.index(mate.from_pin)
from_port_str = f':p{from_pin_index+1}r'
from_port_str = f":p{from_pin_index+1}r"
else: # MateComponent or style == 'simple'
from_port_str = ''
if isinstance(mate, MatePin) and self.connectors[mate.to_name].style != 'simple':
from_port_str = ""
if (
isinstance(mate, MatePin)
and self.connectors[mate.to_name].style != "simple"
):
to_pin_index = to_connector.pins.index(mate.to_pin)
to_port_str = f':p{to_pin_index+1}l' if isinstance(mate, MatePin) and self.connectors[mate.to_name].style != 'simple' else ''
to_port_str = (
f":p{to_pin_index+1}l"
if isinstance(mate, MatePin)
and self.connectors[mate.to_name].style != "simple"
else ""
)
else: # MateComponent or style == 'simple'
to_port_str = ''
code_from = f'{mate.from_name}{from_port_str}:e'
to_port_str = ""
code_from = f"{mate.from_name}{from_port_str}:e"
to_connector = self.connectors[mate.to_name]
code_to = f'{mate.to_name}{to_port_str}:w'
code_to = f"{mate.to_name}{to_port_str}:w"
dot.attr('edge', color=color, style='dashed', dir=dir)
dot.attr("edge", color=color, style="dashed", dir=dir)
dot.edge(code_from, code_to)
return dot
@ -492,52 +688,64 @@ class Harness:
@property
def png(self):
from io import BytesIO
graph = self.graph
data = BytesIO()
data.write(graph.pipe(format='png'))
data.write(graph.pipe(format="png"))
data.seek(0)
return data.read()
@property
def svg(self):
from io import BytesIO
graph = self.graph
data = BytesIO()
data.write(graph.pipe(format='svg'))
data.write(graph.pipe(format="svg"))
data.seek(0)
return data.read()
def output(self, filename: (str, Path), view: bool = False, cleanup: bool = True, fmt: tuple = ('html','png','svg','tsv')) -> None:
def output(
self,
filename: (str, Path),
view: bool = False,
cleanup: bool = True,
fmt: tuple = ("html", "png", "svg", "tsv"),
) -> None:
# graphical output
graph = self.graph
svg_already_exists = Path(f'{filename}.svg').exists() # if SVG already exists, do not delete later
svg_already_exists = Path(
f"{filename}.svg"
).exists() # if SVG already exists, do not delete later
# graphical output
for f in fmt:
if f in ('png', 'svg', 'html'):
if f == 'html': # if HTML format is specified,
f = 'svg' # generate SVG for embedding into HTML
if f in ("png", "svg", "html"):
if f == "html": # if HTML format is specified,
f = "svg" # generate SVG for embedding into HTML
# TODO: prevent rendering SVG twice when both SVG and HTML are specified
graph.format = f
graph.render(filename=filename, view=view, cleanup=cleanup)
# GraphViz output
if 'gv' in fmt:
graph.save(filename=f'{filename}.gv')
if "gv" in fmt:
graph.save(filename=f"{filename}.gv")
# BOM output
bomlist = bom_list(self.bom())
if 'tsv' in fmt:
with open_file_write(f'{filename}.bom.tsv') as file:
if "tsv" in fmt:
with open_file_write(f"{filename}.bom.tsv") as file:
file.write(tuplelist2tsv(bomlist))
if 'csv' in fmt:
print('CSV output is not yet supported') # TODO: implement CSV output (preferrably using CSV library)
if "csv" in fmt:
print(
"CSV output is not yet supported"
) # TODO: implement CSV output (preferrably using CSV library)
# HTML output
if 'html' in fmt:
if "html" in fmt:
generate_html_output(filename, bomlist, self.metadata, self.options)
# PDF output
if 'pdf' in fmt:
print('PDF output is not yet supported') # TODO: implement PDF output
if "pdf" in fmt:
print("PDF output is not yet supported") # TODO: implement PDF output
# delete SVG if not needed
if 'html' in fmt and not 'svg' in fmt and not svg_already_exists:
Path(f'{filename}.svg').unlink()
if "html" in fmt and not "svg" in fmt and not svg_already_exists:
Path(f"{filename}.svg").unlink()
def bom(self):
if not self._bom:

View File

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# Please don't import anything in this file to avoid issues when it is imported in setup.py
__version__ = '0.4-dev'
__version__ = "0.4-dev"
CMD_NAME = 'wireviz' # Lower case command and module name
APP_NAME = 'WireViz' # Application name in texts meant to be human readable
APP_URL = 'https://github.com/formatc1702/WireViz'
CMD_NAME = "wireviz" # Lower case command and module name
APP_NAME = "WireViz" # Application name in texts meant to be human readable
APP_URL = "https://github.com/formatc1702/WireViz"

View File

@ -14,34 +14,36 @@ from wv_helper import open_file_append, open_file_read, open_file_write
from wireviz import APP_NAME, __version__, wireviz
dir = script_path.parent.parent.parent
readme = 'readme.md'
readme = "readme.md"
groups = {
'examples': {
'path': dir / 'examples',
'prefix': 'ex',
"examples": {
"path": dir / "examples",
"prefix": "ex",
readme: [], # Include no files
'title': 'Example Gallery',
"title": "Example Gallery",
},
'tutorial' : {
'path': dir / 'tutorial',
'prefix': 'tutorial',
readme: ['md', 'yml'], # Include .md and .yml files
'title': f'{APP_NAME} Tutorial',
"tutorial": {
"path": dir / "tutorial",
"prefix": "tutorial",
readme: ["md", "yml"], # Include .md and .yml files
"title": f"{APP_NAME} Tutorial",
},
'demos' : {
'path': dir / 'examples',
'prefix': 'demo',
"demos": {
"path": dir / "examples",
"prefix": "demo",
},
}
input_extensions = ['.yml']
extensions_not_containing_graphviz_output = ['.gv', '.bom.tsv']
extensions_containing_graphviz_output = ['.png', '.svg', '.html']
generated_extensions = extensions_not_containing_graphviz_output + extensions_containing_graphviz_output
input_extensions = [".yml"]
extensions_not_containing_graphviz_output = [".gv", ".bom.tsv"]
extensions_containing_graphviz_output = [".png", ".svg", ".html"]
generated_extensions = (
extensions_not_containing_graphviz_output + extensions_containing_graphviz_output
)
def collect_filenames(description, groupkey, ext_list):
path = groups[groupkey]['path']
path = groups[groupkey]["path"]
patterns = [f"{groups[groupkey]['prefix']}*{ext}" for ext in ext_list]
if ext_list != input_extensions and readme in groups[groupkey]:
patterns.append(readme)
@ -52,107 +54,141 @@ def collect_filenames(description, groupkey, ext_list):
def build_generated(groupkeys):
for key in groupkeys:
# preparation
path = groups[key]['path']
path = groups[key]["path"]
build_readme = readme in groups[key]
if build_readme:
include_readme = 'md' in groups[key][readme]
include_source = 'yml' in groups[key][readme]
include_readme = "md" in groups[key][readme]
include_source = "yml" in groups[key][readme]
with open_file_write(path / readme) as out:
out.write(f'# {groups[key]["title"]}\n\n')
# collect and iterate input YAML files
for yaml_file in collect_filenames('Building', key, input_extensions):
for yaml_file in collect_filenames("Building", key, input_extensions):
print(f' "{yaml_file}"')
wireviz.parse_file(yaml_file)
if build_readme:
i = ''.join(filter(str.isdigit, yaml_file.stem))
i = "".join(filter(str.isdigit, yaml_file.stem))
with open_file_append(path / readme) as out:
if include_readme:
with open_file_read(yaml_file.with_suffix('.md')) as info:
with open_file_read(yaml_file.with_suffix(".md")) as info:
for line in info:
out.write(line.replace('## ', f'## {i} - '))
out.write('\n\n')
out.write(line.replace("## ", f"## {i} - "))
out.write("\n\n")
else:
out.write(f'## Example {i}\n')
out.write(f"## Example {i}\n")
if include_source:
with open_file_read(yaml_file) as src:
out.write('```yaml\n')
out.write("```yaml\n")
for line in src:
out.write(line)
out.write('```\n')
out.write('\n')
out.write("```\n")
out.write("\n")
out.write(f'![]({yaml_file.stem}.png)\n\n')
out.write(f'[Source]({yaml_file.name}) - [Bill of Materials]({yaml_file.stem}.bom.tsv)\n\n\n')
out.write(f"![]({yaml_file.stem}.png)\n\n")
out.write(
f"[Source]({yaml_file.name}) - [Bill of Materials]({yaml_file.stem}.bom.tsv)\n\n\n"
)
def clean_generated(groupkeys):
for key in groupkeys:
# collect and remove files
for filename in collect_filenames('Cleaning', key, generated_extensions):
for filename in collect_filenames("Cleaning", key, generated_extensions):
if filename.is_file():
print(f' rm "{filename}"')
Path(filename).unlink()
def compare_generated(groupkeys, branch = '', include_graphviz_output = False):
def compare_generated(groupkeys, branch="", include_graphviz_output=False):
if branch:
branch = f' {branch.strip()}'
compare_extensions = generated_extensions if include_graphviz_output else extensions_not_containing_graphviz_output
branch = f" {branch.strip()}"
compare_extensions = (
generated_extensions
if include_graphviz_output
else extensions_not_containing_graphviz_output
)
for key in groupkeys:
# collect and compare files
for filename in collect_filenames('Comparing', key, compare_extensions):
for filename in collect_filenames("Comparing", key, compare_extensions):
cmd = f'git --no-pager diff{branch} -- "{filename}"'
print(f' {cmd}')
print(f" {cmd}")
os.system(cmd)
def restore_generated(groupkeys, branch = ''):
def restore_generated(groupkeys, branch=""):
if branch:
branch = f' {branch.strip()}'
branch = f" {branch.strip()}"
for key in groupkeys:
# collect input YAML files
filename_list = collect_filenames('Restoring', key, input_extensions)
filename_list = collect_filenames("Restoring", key, input_extensions)
# collect files to restore
filename_list = [fn.with_suffix(ext) for fn in filename_list for ext in generated_extensions]
filename_list = [
fn.with_suffix(ext) for fn in filename_list for ext in generated_extensions
]
if readme in groups[key]:
filename_list.append(groups[key]['path'] / readme)
filename_list.append(groups[key]["path"] / readme)
# restore files
for filename in filename_list:
cmd = f'git checkout{branch} -- "{filename}"'
print(f' {cmd}')
print(f" {cmd}")
os.system(cmd)
def parse_args():
parser = argparse.ArgumentParser(description=f'{APP_NAME} Example Manager',)
parser.add_argument('-V', '--version', action='version', version=f'%(prog)s - {APP_NAME} {__version__}')
parser.add_argument('action', nargs='?', action='store',
choices=['build','clean','compare','diff','restore'], default='build',
help='what to do with the generated files (default: build)')
parser.add_argument('-c', '--compare-graphviz-output', action='store_true',
help='the Graphviz output is also compared (default: False)')
parser.add_argument('-b', '--branch', action='store', default='',
help='branch or commit to compare with or restore from')
parser.add_argument('-g', '--groups', nargs='+',
choices=groups.keys(), default=groups.keys(),
help='the groups of generated files (default: all)')
parser = argparse.ArgumentParser(
description=f"{APP_NAME} Example Manager",
)
parser.add_argument(
"-V",
"--version",
action="version",
version=f"%(prog)s - {APP_NAME} {__version__}",
)
parser.add_argument(
"action",
nargs="?",
action="store",
choices=["build", "clean", "compare", "diff", "restore"],
default="build",
help="what to do with the generated files (default: build)",
)
parser.add_argument(
"-c",
"--compare-graphviz-output",
action="store_true",
help="the Graphviz output is also compared (default: False)",
)
parser.add_argument(
"-b",
"--branch",
action="store",
default="",
help="branch or commit to compare with or restore from",
)
parser.add_argument(
"-g",
"--groups",
nargs="+",
choices=groups.keys(),
default=groups.keys(),
help="the groups of generated files (default: all)",
)
return parser.parse_args()
def main():
args = parse_args()
if args.action == 'build':
if args.action == "build":
build_generated(args.groups)
elif args.action == 'clean':
elif args.action == "clean":
clean_generated(args.groups)
elif args.action == 'compare' or args.action == 'diff':
elif args.action == "compare" or args.action == "diff":
compare_generated(args.groups, args.branch, args.compare_graphviz_output)
elif args.action == 'restore':
elif args.action == "restore":
restore_generated(args.groups, args.branch)
if __name__ == '__main__':
if __name__ == "__main__":
main()

View File

@ -7,7 +7,7 @@ from typing import Any, Dict, List, Tuple
import yaml
if __name__ == '__main__':
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).parent.parent)) # add src/wireviz to PATH
from wireviz.DataClasses import Metadata, Options, Tweak
@ -21,7 +21,13 @@ from wireviz.wv_helper import (
)
def parse_text(yaml_str: str, file_out: (str, Path) = None, output_formats: (None, str, Tuple[str]) = ('html','png','svg','tsv'), return_types: (None, str, Tuple[str]) = None, image_paths: List = []) -> Any:
def parse_text(
yaml_str: str,
file_out: (str, Path) = None,
output_formats: (None, str, Tuple[str]) = ("html", "png", "svg", "tsv"),
return_types: (None, str, Tuple[str]) = None,
image_paths: List = [],
) -> Any:
"""
Parses a YAML input string and does the high-level harness conversion
@ -37,9 +43,22 @@ def parse_text(yaml_str: str, file_out: (str, Path) = None, output_formats: (Non
- "harness" - will return the `Harness` instance
"""
yaml_data = yaml.safe_load(yaml_str)
return parse(yaml_data=yaml_data, file_out=file_out, output_formats=output_formats, return_types=return_types, image_paths=image_paths)
return parse(
yaml_data=yaml_data,
file_out=file_out,
output_formats=output_formats,
return_types=return_types,
image_paths=image_paths,
)
def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None, str, Tuple[str]) = ('html','png','svg','tsv'), return_types: (None, str, Tuple[str]) = None, image_paths: List = []) -> Any:
def parse(
yaml_data: Dict,
file_out: (str, Path) = None,
output_formats: (None, str, Tuple[str]) = ("html", "png", "svg", "tsv"),
return_types: (None, str, Tuple[str]) = None,
image_paths: List = [],
) -> Any:
"""
Parses a YAML dictionary and does the high-level harness conversion
@ -55,7 +74,6 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
- "harness" - will return the `Harness` instance
"""
# define variables =========================================================
# containers for parsed component data and connection sets
template_connectors = {}
@ -63,21 +81,25 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
connection_sets = []
# actual harness
harness = Harness(
metadata = Metadata(**yaml_data.get('metadata', {})),
options = Options(**yaml_data.get('options', {})),
tweak = Tweak(**yaml_data.get('tweak', {})),
metadata=Metadata(**yaml_data.get("metadata", {})),
options=Options(**yaml_data.get("options", {})),
tweak=Tweak(**yaml_data.get("tweak", {})),
)
# others
designators_and_templates = {} # store mapping of components to their respective template
autogenerated_designators = {} # keep track of auto-generated designators to avoid duplicates
designators_and_templates = (
{}
) # store mapping of components to their respective template
autogenerated_designators = (
{}
) # keep track of auto-generated designators to avoid duplicates
if 'title' not in harness.metadata:
harness.metadata['title'] = Path(file_out).stem
if "title" not in harness.metadata:
harness.metadata["title"] = Path(file_out).stem
# add items
# parse YAML input file ====================================================
sections = ['connectors', 'cables', 'connections']
sections = ["connectors", "cables", "connections"]
types = [dict, dict, list]
for sec, ty in zip(sections, types):
if sec in yaml_data and type(yaml_data[sec]) == ty: # section exists
@ -85,14 +107,18 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
if ty == dict:
for key, attribs in yaml_data[sec].items():
# The Image dataclass might need to open an image file with a relative path.
image = attribs.get('image')
image = attribs.get("image")
if isinstance(image, dict):
image_path = image['src']
if image_path and not Path(image_path).is_absolute(): # resolve relative image path
image['src'] = smart_file_resolve(image_path, image_paths)
if sec == 'connectors':
image_path = image["src"]
if (
image_path and not Path(image_path).is_absolute()
): # resolve relative image path
image["src"] = smart_file_resolve(
image_path, image_paths
)
if sec == "connectors":
template_connectors[key] = attribs
elif sec == 'cables':
elif sec == "cables":
template_cables[key] = attribs
else: # section exists but is empty
pass
@ -102,24 +128,28 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
elif ty == list:
yaml_data[sec] = []
connection_sets = yaml_data['connections']
connection_sets = yaml_data["connections"]
# go through connection sets, generate and connect components ==============
template_separator_char = '.' # TODO: make user-configurable (in case user wants to use `.` as part of their template/component names)
template_separator_char = "." # TODO: make user-configurable (in case user wants to use `.` as part of their template/component names)
def resolve_designator(inp, separator):
if separator in inp: # generate a new instance of an item
if inp.count(separator) > 1:
raise Exception(f'{inp} - Found more than one separator ({separator})')
raise Exception(f"{inp} - Found more than one separator ({separator})")
template, designator = inp.split(separator)
if designator == '':
autogenerated_designators[template] = autogenerated_designators.get(template, 0) + 1
designator = f'__{template}_{autogenerated_designators[template]}'
if designator == "":
autogenerated_designators[template] = (
autogenerated_designators.get(template, 0) + 1
)
designator = f"__{template}_{autogenerated_designators[template]}"
# check if redefining existing component to different template
if designator in designators_and_templates:
if designators_and_templates[designator] != template:
raise Exception(f'Trying to redefine {designator} from {designators_and_templates[designator]} to {template}')
raise Exception(
f"Trying to redefine {designator} from {designators_and_templates[designator]} to {template}"
)
else:
designators_and_templates[designator] = template
else:
@ -132,7 +162,7 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
# utilities to check for alternating connectors and cables/arrows ==========
alternating_types = ['connector','cable/arrow']
alternating_types = ["connector", "cable/arrow"]
expected_type = None
def check_type(designator, template, actual_type):
@ -141,7 +171,9 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
expected_type = actual_type
if actual_type != expected_type: # did not alternate
raise Exception(f'Expected {expected_type}, but "{designator}" ("{template}") is {actual_type}')
raise Exception(
f'Expected {expected_type}, but "{designator}" ("{template}") is {actual_type}'
)
def alternate_type(): # flip between connector and cable/arrow
nonlocal expected_type
@ -155,7 +187,9 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
if isinstance(entry, list):
connectioncount.append(len(entry))
elif isinstance(entry, dict):
connectioncount.append(len(expand(list(entry.values())[0]))) # - X1: [1-4,6] yields 5
connectioncount.append(
len(expand(list(entry.values())[0]))
) # - X1: [1-4,6] yields 5
else:
pass # strings do not reveal connectioncount
if not any(connectioncount):
@ -172,7 +206,9 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
# check that all entries are the same length
if len(set(connectioncount)) > 1:
raise Exception('All items in connection set must reference the same number of connections')
raise Exception(
"All items in connection set must reference the same number of connections"
)
# all entries are the same length, connection count is set
connectioncount = connectioncount[0]
@ -185,7 +221,9 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
for index, entry in enumerate(connection_set):
if isinstance(entry, list):
for subindex, item in enumerate(entry):
template, designator = resolve_designator(item, template_separator_char)
template, designator = resolve_designator(
item, template_separator_char
)
connection_set[index][subindex] = designator
elif isinstance(entry, dict):
key = list(entry.keys())[0]
@ -219,22 +257,30 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
template = designators_and_templates[designator]
if designator in harness.connectors: # existing connector instance
check_type(designator, template, 'connector')
elif template in template_connectors.keys(): # generate new connector instance from template
check_type(designator, template, 'connector')
harness.add_connector(name = designator, **template_connectors[template])
check_type(designator, template, "connector")
elif (
template in template_connectors.keys()
): # generate new connector instance from template
check_type(designator, template, "connector")
harness.add_connector(
name=designator, **template_connectors[template]
)
elif designator in harness.cables: # existing cable instance
check_type(designator, template, 'cable/arrow')
elif template in template_cables.keys(): # generate new cable instance from template
check_type(designator, template, 'cable/arrow')
check_type(designator, template, "cable/arrow")
elif (
template in template_cables.keys()
): # generate new cable instance from template
check_type(designator, template, "cable/arrow")
harness.add_cable(name=designator, **template_cables[template])
elif is_arrow(designator):
check_type(designator, template, 'cable/arrow')
check_type(designator, template, "cable/arrow")
# arrows do not need to be generated here
else:
raise Exception(f'{template} is an unknown template/designator/arrow.')
raise Exception(
f"{template} is an unknown template/designator/arrow."
)
alternate_type() # entries in connection set must alternate between connectors and cables/arrows
@ -249,29 +295,49 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
designator = list(item.keys())[0]
if designator in harness.cables:
if index_item == 0: # list started with a cable, no connector to join on left side
if (
index_item == 0
): # list started with a cable, no connector to join on left side
from_name, from_pin = (None, None)
else:
from_name, from_pin = get_single_key_and_value(entry[index_item-1])
from_name, from_pin = get_single_key_and_value(
entry[index_item - 1]
)
via_name, via_pin = (designator, item[designator])
if index_item == len(entry) - 1: # list ends with a cable, no connector to join on right side
if (
index_item == len(entry) - 1
): # list ends with a cable, no connector to join on right side
to_name, to_pin = (None, None)
else:
to_name, to_pin = get_single_key_and_value(entry[index_item+1])
harness.connect(from_name, from_pin, via_name, via_pin, to_name, to_pin)
to_name, to_pin = get_single_key_and_value(
entry[index_item + 1]
)
harness.connect(
from_name, from_pin, via_name, via_pin, to_name, to_pin
)
elif is_arrow(designator):
if index_item == 0: # list starts with an arrow
raise Exception('An arrow cannot be at the start of a connection set')
raise Exception(
"An arrow cannot be at the start of a connection set"
)
elif index_item == len(entry) - 1: # list ends with an arrow
raise Exception('An arrow cannot be at the end of a connection set')
raise Exception(
"An arrow cannot be at the end of a connection set"
)
from_name, from_pin = get_single_key_and_value(entry[index_item-1])
from_name, from_pin = get_single_key_and_value(
entry[index_item - 1]
)
via_name, via_pin = (designator, None)
to_name, to_pin = get_single_key_and_value(entry[index_item + 1])
if '-' in designator: # mate pin by pin
harness.add_mate_pin(from_name, from_pin, to_name, to_pin, designator)
elif '=' in designator and index_entry == 0: # mate two connectors as a whole
if "-" in designator: # mate pin by pin
harness.add_mate_pin(
from_name, from_pin, to_name, to_pin, designator
)
elif (
"=" in designator and index_entry == 0
): # mate two connectors as a whole
harness.add_mate_component(from_name, to_name, designator)
# harness population completed =============================================
@ -291,11 +357,11 @@ def parse(yaml_data: Dict, file_out: (str, Path) = None, output_formats: (None,
return_types = [t.lower() for t in return_types]
for rt in return_types:
if rt == 'png':
if rt == "png":
returns.append(harness.png)
if rt == 'svg':
if rt == "svg":
returns.append(harness.svg)
if rt == 'harness':
if rt == "harness":
returns.append(harness)
return tuple(returns) if len(returns) != 1 else returns[0]
@ -314,8 +380,10 @@ def parse_file(yaml_file: str, file_out: (str, Path) = None) -> None:
parse_text(yaml_str, file_out=file_out, image_paths=[Path(yaml_file).parent])
def main():
print('When running from the command line, please use wv_cli.py instead.')
if __name__ == '__main__':
def main():
print("When running from the command line, please use wv_cli.py instead.")
if __name__ == "__main__":
main()

View File

@ -9,76 +9,108 @@ from wireviz.wv_colors import translate_color
from wireviz.wv_gv_html import html_bgcolor_attr, html_line_breaks
from wireviz.wv_helper import clean_whitespace
BOM_COLUMNS_ALWAYS = ('id', 'description', 'qty', 'unit', 'designators')
BOM_COLUMNS_OPTIONAL = ('pn', 'manufacturer', 'mpn', 'supplier', 'spn')
BOM_COLUMNS_IN_KEY = ('description', 'unit') + BOM_COLUMNS_OPTIONAL
BOM_COLUMNS_ALWAYS = ("id", "description", "qty", "unit", "designators")
BOM_COLUMNS_OPTIONAL = ("pn", "manufacturer", "mpn", "supplier", "spn")
BOM_COLUMNS_IN_KEY = ("description", "unit") + BOM_COLUMNS_OPTIONAL
HEADER_PN = 'P/N'
HEADER_MPN = 'MPN'
HEADER_SPN = 'SPN'
HEADER_PN = "P/N"
HEADER_MPN = "MPN"
HEADER_SPN = "SPN"
BOMKey = Tuple[str, ...]
BOMColumn = str # = Literal[*BOM_COLUMNS_ALWAYS, *BOM_COLUMNS_OPTIONAL]
BOMEntry = Dict[BOMColumn, Union[str, int, float, List[str], None]]
def optional_fields(part: Union[Connector, Cable, AdditionalComponent]) -> BOMEntry:
"""Return part field values for the optional BOM columns as a dict."""
part = asdict(part)
return {field: part.get(field) for field in BOM_COLUMNS_OPTIONAL}
def get_additional_component_table(harness: "Harness", component: Union[Connector, Cable]) -> List[str]:
def get_additional_component_table(
harness: "Harness", component: Union[Connector, Cable]
) -> List[str]:
"""Return a list of diagram node table row strings with additional components."""
rows = []
if component.additional_components:
rows.append(["Additional components"])
for part in component.additional_components:
common_args = {
'qty': part.qty * component.get_qty_multiplier(part.qty_multiplier),
'unit': part.unit,
'bgcolor': part.bgcolor,
"qty": part.qty * component.get_qty_multiplier(part.qty_multiplier),
"unit": part.unit,
"bgcolor": part.bgcolor,
}
if harness.options.mini_bom_mode:
id = get_bom_index(harness.bom(), bom_entry_key({**asdict(part), 'description': part.description}))
rows.append(component_table_entry(f'#{id} ({part.type.rstrip()})', **common_args))
id = get_bom_index(
harness.bom(),
bom_entry_key({**asdict(part), "description": part.description}),
)
rows.append(
component_table_entry(
f"#{id} ({part.type.rstrip()})", **common_args
)
)
else:
rows.append(component_table_entry(part.description, **common_args, **optional_fields(part)))
rows.append(
component_table_entry(
part.description, **common_args, **optional_fields(part)
)
)
return rows
def get_additional_component_bom(component: Union[Connector, Cable]) -> List[BOMEntry]:
"""Return a list of BOM entries with additional components."""
bom_entries = []
for part in component.additional_components:
bom_entries.append({
'description': part.description,
'qty': part.qty * component.get_qty_multiplier(part.qty_multiplier),
'unit': part.unit,
'designators': component.name if component.show_name else None,
bom_entries.append(
{
"description": part.description,
"qty": part.qty * component.get_qty_multiplier(part.qty_multiplier),
"unit": part.unit,
"designators": component.name if component.show_name else None,
**optional_fields(part),
})
}
)
return bom_entries
def bom_entry_key(entry: BOMEntry) -> BOMKey:
"""Return a tuple of string values from the dict that must be equal to join BOM entries."""
if 'key' not in entry:
entry['key'] = tuple(clean_whitespace(make_str(entry.get(c))) for c in BOM_COLUMNS_IN_KEY)
return entry['key']
if "key" not in entry:
entry["key"] = tuple(
clean_whitespace(make_str(entry.get(c))) for c in BOM_COLUMNS_IN_KEY
)
return entry["key"]
def generate_bom(harness: "Harness") -> List[BOMEntry]:
"""Return a list of BOM entries generated from the harness."""
from wireviz.Harness import Harness # Local import to avoid circular imports
bom_entries = []
# connectors
for connector in harness.connectors.values():
if not connector.ignore_in_bom:
description = ('Connector'
+ (f', {connector.type}' if connector.type else '')
+ (f', {connector.subtype}' if connector.subtype else '')
+ (f', {connector.pincount} pins' if connector.show_pincount else '')
+ (f', {translate_color(connector.color, harness.options.color_mode)}' if connector.color else ''))
bom_entries.append({
'description': description, 'designators': connector.name if connector.show_name else None,
description = (
"Connector"
+ (f", {connector.type}" if connector.type else "")
+ (f", {connector.subtype}" if connector.subtype else "")
+ (f", {connector.pincount} pins" if connector.show_pincount else "")
+ (
f", {translate_color(connector.color, harness.options.color_mode)}"
if connector.color
else ""
)
)
bom_entries.append(
{
"description": description,
"designators": connector.name if connector.show_name else None,
**optional_fields(connector),
})
}
)
# add connectors aditional components to bom
bom_entries.extend(get_additional_component_bom(connector))
@ -87,29 +119,58 @@ def generate_bom(harness: "Harness") -> List[BOMEntry]:
# TODO: If category can have other non-empty values than 'bundle', maybe it should be part of description?
for cable in harness.cables.values():
if not cable.ignore_in_bom:
if cable.category != 'bundle':
if cable.category != "bundle":
# process cable as a single entity
description = ('Cable'
+ (f', {cable.type}' if cable.type else '')
+ (f', {cable.wirecount}')
+ (f' x {cable.gauge} {cable.gauge_unit}' if cable.gauge else ' wires')
+ ( ' shielded' if cable.shield else '')
+ (f', {translate_color(cable.color, harness.options.color_mode)}' if cable.color else ''))
bom_entries.append({
'description': description, 'qty': cable.length, 'unit': cable.length_unit, 'designators': cable.name if cable.show_name else None,
description = (
"Cable"
+ (f", {cable.type}" if cable.type else "")
+ (f", {cable.wirecount}")
+ (
f" x {cable.gauge} {cable.gauge_unit}"
if cable.gauge
else " wires"
)
+ (" shielded" if cable.shield else "")
+ (
f", {translate_color(cable.color, harness.options.color_mode)}"
if cable.color
else ""
)
)
bom_entries.append(
{
"description": description,
"qty": cable.length,
"unit": cable.length_unit,
"designators": cable.name if cable.show_name else None,
**optional_fields(cable),
})
}
)
else:
# add each wire from the bundle to the bom
for index, color in enumerate(cable.colors):
description = ('Wire'
+ (f', {cable.type}' if cable.type else '')
+ (f', {cable.gauge} {cable.gauge_unit}' if cable.gauge else '')
+ (f', {translate_color(color, harness.options.color_mode)}' if color else ''))
bom_entries.append({
'description': description, 'qty': cable.length, 'unit': cable.length_unit, 'designators': cable.name if cable.show_name else None,
**{k: index_if_list(v, index) for k, v in optional_fields(cable).items()},
})
description = (
"Wire"
+ (f", {cable.type}" if cable.type else "")
+ (f", {cable.gauge} {cable.gauge_unit}" if cable.gauge else "")
+ (
f", {translate_color(color, harness.options.color_mode)}"
if color
else ""
)
)
bom_entries.append(
{
"description": description,
"qty": cable.length,
"unit": cable.length_unit,
"designators": cable.name if cable.show_name else None,
**{
k: index_if_list(v, index)
for k, v in optional_fields(cable).items()
},
}
)
# add cable/bundles aditional components to bom
bom_entries.extend(get_additional_component_bom(cable))
@ -118,41 +179,61 @@ def generate_bom(harness: "Harness") -> List[BOMEntry]:
bom_entries.extend(harness.additional_bom_items)
# remove line breaks if present and cleanup any resulting whitespace issues
bom_entries = [{k: clean_whitespace(v) for k, v in entry.items()} for entry in bom_entries]
bom_entries = [
{k: clean_whitespace(v) for k, v in entry.items()} for entry in bom_entries
]
# deduplicate bom
bom = []
for _, group in groupby(sorted(bom_entries, key=bom_entry_key), key=bom_entry_key):
group_entries = list(group)
designators = sum((make_list(entry.get('designators')) for entry in group_entries), [])
total_qty = sum(entry.get('qty', 1) for entry in group_entries)
bom.append({**group_entries[0], 'qty': round(total_qty, 3), 'designators': sorted(set(designators))})
designators = sum(
(make_list(entry.get("designators")) for entry in group_entries), []
)
total_qty = sum(entry.get("qty", 1) for entry in group_entries)
bom.append(
{
**group_entries[0],
"qty": round(total_qty, 3),
"designators": sorted(set(designators)),
}
)
# add an incrementing id to each bom entry
return [{**entry, 'id': index} for index, entry in enumerate(bom, 1)]
return [{**entry, "id": index} for index, entry in enumerate(bom, 1)]
def get_bom_index(bom: List[BOMEntry], target: BOMKey) -> int:
"""Return id of BOM entry or raise exception if not found."""
for entry in bom:
if bom_entry_key(entry) == target:
return entry['id']
raise Exception('Internal error: No BOM entry found matching: ' + '|'.join(target))
return entry["id"]
raise Exception("Internal error: No BOM entry found matching: " + "|".join(target))
def bom_list(bom: List[BOMEntry]) -> List[List[str]]:
"""Return list of BOM rows as lists of column strings with headings in top row."""
keys = list(BOM_COLUMNS_ALWAYS) # Always include this fixed set of BOM columns.
for fieldname in BOM_COLUMNS_OPTIONAL: # Include only those optional BOM columns that are in use.
for (
fieldname
) in (
BOM_COLUMNS_OPTIONAL
): # Include only those optional BOM columns that are in use.
if any(entry.get(fieldname) for entry in bom):
keys.append(fieldname)
# Custom mapping from internal name to BOM column headers.
# Headers not specified here are generated by capitilising the internal name.
bom_headings = {
'pn': HEADER_PN,
'mpn': HEADER_MPN,
'spn': HEADER_SPN,
"pn": HEADER_PN,
"mpn": HEADER_MPN,
"spn": HEADER_SPN,
}
return ([[bom_headings.get(k, k.capitalize()) for k in keys]] + # Create header row with key names
[[make_str(entry.get(k)) for k in keys] for entry in bom]) # Create string list for each entry row
return [
[bom_headings.get(k, k.capitalize()) for k in keys]
] + [ # Create header row with key names
[make_str(entry.get(k)) for k in keys] for entry in bom
] # Create string list for each entry row
def component_table_entry(
type: str,
@ -171,33 +252,41 @@ def component_table_entry(
pn_info_string(HEADER_MPN, manufacturer, mpn),
pn_info_string(HEADER_SPN, supplier, spn),
]
output = (f'{qty}'
+ (f' {unit}' if unit else '')
+ f' x {type}'
+ ('<br/>' if any(part_number_list) else '')
+ (', '.join([pn for pn in part_number_list if pn])))
output = (
f"{qty}"
+ (f" {unit}" if unit else "")
+ f" x {type}"
+ ("<br/>" if any(part_number_list) else "")
+ (", ".join([pn for pn in part_number_list if pn]))
)
# format the above output as left aligned text in a single visible cell
# indent is set to two to match the indent in the generated html table
return f'''<table border="0" cellspacing="0" cellpadding="3" cellborder="1"{html_bgcolor_attr(bgcolor)}><tr>
return f"""<table border="0" cellspacing="0" cellpadding="3" cellborder="1"{html_bgcolor_attr(bgcolor)}><tr>
<td align="left" balign="left">{html_line_breaks(output)}</td>
</tr></table>'''
</tr></table>"""
def pn_info_string(header: str, name: Optional[str], number: Optional[str]) -> Optional[str]:
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 ''
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 index_if_list(value: Any, index: int) -> Any:
"""Return the value indexed if it is a list, or simply the value otherwise."""
return value[index] if isinstance(value, list) else value
def make_list(value: Any) -> list:
"""Return value if a list, empty list if None, or single element list otherwise."""
return value if isinstance(value, list) else [] if value is None else [value]
def make_str(value: Any) -> str:
"""Return comma separated elements if a list, empty string if None, or value as a string otherwise."""
return ', '.join(str(element) for element in make_list(value))
return ", ".join(str(element) for element in make_list(value))

View File

@ -6,30 +6,64 @@ from pathlib import Path
import click
if __name__ == '__main__':
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
if __name__ == "__main__":
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import wireviz.wireviz as wv
from wireviz import APP_NAME, __version__
from wireviz.wv_helper import open_file_read
format_codes = {'c': 'csv', 'g': 'gv', 'h': 'html', 'p': 'png', 'P': 'pdf', 's': 'svg', 't': 'tsv'}
format_codes = {
"c": "csv",
"g": "gv",
"h": "html",
"p": "png",
"P": "pdf",
"s": "svg",
"t": "tsv",
}
epilog = "The -f or --format option accepts a string containing one or more of the following characters to specify which file types to output:\n"
epilog += ", ".join([f"{key} ({value.upper()})" for key, value in format_codes.items()])
epilog = 'The -f or --format option accepts a string containing one or more of the following characters to specify which file types to output:\n'
epilog += ', '.join([f'{key} ({value.upper()})' for key, value in format_codes.items()])
@click.command(epilog=epilog, no_args_is_help=True)
@click.argument('file', nargs=-1)
@click.option('-f', '--format', default='hpst', type=str, show_default=True, help='Output formats (see below).')
@click.option('-p', '--prepend', default=None, type=Path, help='YAML file to prepend to the input file (optional).')
@click.option('-o', '--output-file', default=None, type=Path, help='File name (without extension) to use for output, if different from input file name.')
@click.option('-V', '--version', is_flag=True, default=False, help=f'Output {APP_NAME} version and exit.')
@click.argument("file", nargs=-1)
@click.option(
"-f",
"--format",
default="hpst",
type=str,
show_default=True,
help="Output formats (see below).",
)
@click.option(
"-p",
"--prepend",
default=None,
type=Path,
help="YAML file to prepend to the input file (optional).",
)
@click.option(
"-o",
"--output-file",
default=None,
type=Path,
help="File name (without extension) to use for output, if different from input file name.",
)
@click.option(
"-V",
"--version",
is_flag=True,
default=False,
help=f"Output {APP_NAME} version and exit.",
)
def wireviz(file, format, prepend, output_file, version):
"""
Parses the provided FILE and generates the specified outputs.
"""
print()
print(f'{APP_NAME} {__version__}')
print(f"{APP_NAME} {__version__}")
if version:
return # print version number only and exit
@ -47,35 +81,39 @@ def wireviz(file, format, prepend, output_file, version):
if code in format_codes:
output_formats.append(format_codes[code])
else:
raise Exception(f'Unknown output format: {code}')
raise Exception(f"Unknown output format: {code}")
output_formats = tuple(sorted(set(output_formats)))
output_formats_str = f'[{"|".join(output_formats)}]' if len(output_formats) > 1 else output_formats[0]
output_formats_str = (
f'[{"|".join(output_formats)}]'
if len(output_formats) > 1
else output_formats[0]
)
image_paths = []
# check prepend file
if prepend:
prepend = Path(prepend)
if not prepend.exists():
raise Exception(f'File does not exist:\n{prepend}')
print('Prepend file:', prepend)
raise Exception(f"File does not exist:\n{prepend}")
print("Prepend file:", prepend)
with open_file_read(prepend) as file_handle:
prepend_input = file_handle.read() + '\n'
prepend_input = file_handle.read() + "\n"
prepend_dir = prepend.parent
else:
prepend_input = ''
prepend_input = ""
prepend_dir = None
# run WireVIz on each input file
for file in filepaths:
file = Path(file)
if not file.exists():
raise Exception(f'File does not exist:\n{file}')
raise Exception(f"File does not exist:\n{file}")
file_out = file.with_suffix('') if not output_file else output_file
file_out = file.with_suffix("") if not output_file else output_file
print('Input file: ', file)
print('Output file: ', f'{file_out}.{output_formats_str}')
print("Input file: ", file)
print("Output file: ", f"{file_out}.{output_formats_str}")
with open_file_read(file) as file_handle:
yaml_input = file_handle.read()
@ -83,9 +121,15 @@ def wireviz(file, format, prepend, output_file, version):
yaml_input = prepend_input + yaml_input
wv.parse_text(yaml_input, file_out=file_out, output_formats=output_formats, image_paths=[file_dir, prepend_dir])
wv.parse_text(
yaml_input,
file_out=file_out,
output_formats=output_formats,
image_paths=[file_dir, prepend_dir],
)
print()
if __name__ == '__main__':
if __name__ == "__main__":
wireviz()

View File

@ -3,139 +3,291 @@
from typing import Dict, List
COLOR_CODES = {
'DIN': ['WH', 'BN', 'GN', 'YE', 'GY', 'PK', 'BU', 'RD', 'BK', 'VT', 'GYPK', 'RDBU', 'WHGN', 'BNGN', 'WHYE', 'YEBN',
'WHGY', 'GYBN', 'WHPK', 'PKBN', 'WHBU', 'BNBU', 'WHRD', 'BNRD', 'WHBK', 'BNBK', 'GYGN', 'YEGY', 'PKGN',
'YEPK', 'GNBU', 'YEBU', 'GNRD', 'YERD', 'GNBK', 'YEBK', 'GYBU', 'PKBU', 'GYRD', 'PKRD', 'GYBK', 'PKBK',
'BUBK', 'RDBK', 'WHBNBK', 'YEGNBK', 'GYPKBK', 'RDBUBK', 'WHGNBK', 'BNGNBK', 'WHYEBK', 'YEBNBK', 'WHGYBK',
'GYBNBK', 'WHPKBK', 'PKBNBK', 'WHBUBK', 'BNBUBK', 'WHRDBK', 'BNRDBK'],
'IEC': ['BN', 'RD', 'OG', 'YE', 'GN', 'BU', 'VT', 'GY', 'WH', 'BK'],
'BW': ['BK', 'WH'],
"DIN": [
"WH",
"BN",
"GN",
"YE",
"GY",
"PK",
"BU",
"RD",
"BK",
"VT",
"GYPK",
"RDBU",
"WHGN",
"BNGN",
"WHYE",
"YEBN",
"WHGY",
"GYBN",
"WHPK",
"PKBN",
"WHBU",
"BNBU",
"WHRD",
"BNRD",
"WHBK",
"BNBK",
"GYGN",
"YEGY",
"PKGN",
"YEPK",
"GNBU",
"YEBU",
"GNRD",
"YERD",
"GNBK",
"YEBK",
"GYBU",
"PKBU",
"GYRD",
"PKRD",
"GYBK",
"PKBK",
"BUBK",
"RDBK",
"WHBNBK",
"YEGNBK",
"GYPKBK",
"RDBUBK",
"WHGNBK",
"BNGNBK",
"WHYEBK",
"YEBNBK",
"WHGYBK",
"GYBNBK",
"WHPKBK",
"PKBNBK",
"WHBUBK",
"BNBUBK",
"WHRDBK",
"BNRDBK",
],
"IEC": ["BN", "RD", "OG", "YE", "GN", "BU", "VT", "GY", "WH", "BK"],
"BW": ["BK", "WH"],
# 25-pair color code - see also https://en.wikipedia.org/wiki/25-pair_color_code
# 5 major colors (WH,RD,BK,YE,VT) combined with 5 minor colors (BU,OG,GN,BN,SL).
# Each POTS pair tip (+) had major/minor color, and ring (-) had minor/major color.
'TEL': [ # 25x2: Ring and then tip of each pair
'BUWH', 'WHBU', 'OGWH', 'WHOG', 'GNWH', 'WHGN', 'BNWH', 'WHBN', 'SLWH', 'WHSL',
'BURD', 'RDBU', 'OGRD', 'RDOG', 'GNRD', 'RDGN', 'BNRD', 'RDBN', 'SLRD', 'RDSL',
'BUBK', 'BKBU', 'OGBK', 'BKOG', 'GNBK', 'BKGN', 'BNBK', 'BKBN', 'SLBK', 'BKSL',
'BUYE', 'YEBU', 'OGYE', 'YEOG', 'GNYE', 'YEGN', 'BNYE', 'YEBN', 'SLYE', 'YESL',
'BUVT', 'VTBU', 'OGVT', 'VTOG', 'GNVT', 'VTGN', 'BNVT', 'VTBN', 'SLVT', 'VTSL'],
'TELALT': [ # 25x2: Tip and then ring of each pair
'WHBU', 'BU', 'WHOG', 'OG', 'WHGN', 'GN', 'WHBN', 'BN', 'WHSL', 'SL',
'RDBU', 'BURD', 'RDOG', 'OGRD', 'RDGN', 'GNRD', 'RDBN', 'BNRD', 'RDSL', 'SLRD',
'BKBU', 'BUBK', 'BKOG', 'OGBK', 'BKGN', 'GNBK', 'BKBN', 'BNBK', 'BKSL', 'SLBK',
'YEBU', 'BUYE', 'YEOG', 'OGYE', 'YEGN', 'GNYE', 'YEBN', 'BNYE', 'YESL', 'SLYE',
'VTBU', 'BUVT', 'VTOG', 'OGVT', 'VTGN', 'GNVT', 'VTBN', 'BNVT', 'VTSL', 'SLVT'],
'T568A': ['WHGN', 'GN', 'WHOG', 'BU', 'WHBU', 'OG', 'WHBN', 'BN'],
'T568B': ['WHOG', 'OG', 'WHGN', 'BU', 'WHBU', 'GN', 'WHBN', 'BN'],
"TEL": [ # 25x2: Ring and then tip of each pair
"BUWH",
"WHBU",
"OGWH",
"WHOG",
"GNWH",
"WHGN",
"BNWH",
"WHBN",
"SLWH",
"WHSL",
"BURD",
"RDBU",
"OGRD",
"RDOG",
"GNRD",
"RDGN",
"BNRD",
"RDBN",
"SLRD",
"RDSL",
"BUBK",
"BKBU",
"OGBK",
"BKOG",
"GNBK",
"BKGN",
"BNBK",
"BKBN",
"SLBK",
"BKSL",
"BUYE",
"YEBU",
"OGYE",
"YEOG",
"GNYE",
"YEGN",
"BNYE",
"YEBN",
"SLYE",
"YESL",
"BUVT",
"VTBU",
"OGVT",
"VTOG",
"GNVT",
"VTGN",
"BNVT",
"VTBN",
"SLVT",
"VTSL",
],
"TELALT": [ # 25x2: Tip and then ring of each pair
"WHBU",
"BU",
"WHOG",
"OG",
"WHGN",
"GN",
"WHBN",
"BN",
"WHSL",
"SL",
"RDBU",
"BURD",
"RDOG",
"OGRD",
"RDGN",
"GNRD",
"RDBN",
"BNRD",
"RDSL",
"SLRD",
"BKBU",
"BUBK",
"BKOG",
"OGBK",
"BKGN",
"GNBK",
"BKBN",
"BNBK",
"BKSL",
"SLBK",
"YEBU",
"BUYE",
"YEOG",
"OGYE",
"YEGN",
"GNYE",
"YEBN",
"BNYE",
"YESL",
"SLYE",
"VTBU",
"BUVT",
"VTOG",
"OGVT",
"VTGN",
"GNVT",
"VTBN",
"BNVT",
"VTSL",
"SLVT",
],
"T568A": ["WHGN", "GN", "WHOG", "BU", "WHBU", "OG", "WHBN", "BN"],
"T568B": ["WHOG", "OG", "WHGN", "BU", "WHBU", "GN", "WHBN", "BN"],
}
# Convention: Color names should be 2 letters long, to allow for multicolored wires
_color_hex = {
'BK': '#000000',
'WH': '#ffffff',
'GY': '#999999',
'PK': '#ff66cc',
'RD': '#ff0000',
'OG': '#ff8000',
'YE': '#ffff00',
'OL': '#708000', # olive green
'GN': '#00ff00',
'TQ': '#00ffff',
'LB': '#a0dfff', # light blue
'BU': '#0066ff',
'VT': '#8000ff',
'BN': '#895956',
'BG': '#ceb673', # beige
'IV': '#f5f0d0', # ivory
'SL': '#708090',
'CU': '#d6775e', # Faux-copper look, for bare CU wire
'SN': '#aaaaaa', # Silvery look for tinned bare wire
'SR': '#84878c', # Darker silver for silvered wire
'GD': '#ffcf80', # Golden color for gold
"BK": "#000000",
"WH": "#ffffff",
"GY": "#999999",
"PK": "#ff66cc",
"RD": "#ff0000",
"OG": "#ff8000",
"YE": "#ffff00",
"OL": "#708000", # olive green
"GN": "#00ff00",
"TQ": "#00ffff",
"LB": "#a0dfff", # light blue
"BU": "#0066ff",
"VT": "#8000ff",
"BN": "#895956",
"BG": "#ceb673", # beige
"IV": "#f5f0d0", # ivory
"SL": "#708090",
"CU": "#d6775e", # Faux-copper look, for bare CU wire
"SN": "#aaaaaa", # Silvery look for tinned bare wire
"SR": "#84878c", # Darker silver for silvered wire
"GD": "#ffcf80", # Golden color for gold
}
_color_full = {
'BK': 'black',
'WH': 'white',
'GY': 'grey',
'PK': 'pink',
'RD': 'red',
'OG': 'orange',
'YE': 'yellow',
'OL': 'olive green',
'GN': 'green',
'TQ': 'turquoise',
'LB': 'light blue',
'BU': 'blue',
'VT': 'violet',
'BN': 'brown',
'BG': 'beige',
'IV': 'ivory',
'SL': 'slate',
'CU': 'copper',
'SN': 'tin',
'SR': 'silver',
'GD': 'gold',
"BK": "black",
"WH": "white",
"GY": "grey",
"PK": "pink",
"RD": "red",
"OG": "orange",
"YE": "yellow",
"OL": "olive green",
"GN": "green",
"TQ": "turquoise",
"LB": "light blue",
"BU": "blue",
"VT": "violet",
"BN": "brown",
"BG": "beige",
"IV": "ivory",
"SL": "slate",
"CU": "copper",
"SN": "tin",
"SR": "silver",
"GD": "gold",
}
_color_ger = {
'BK': 'sw',
'WH': 'ws',
'GY': 'gr',
'PK': 'rs',
'RD': 'rt',
'OG': 'or',
'YE': 'ge',
'OL': 'ol', # olivgrün
'GN': 'gn',
'TQ': 'tk',
'LB': 'hb', # hellblau
'BU': 'bl',
'VT': 'vi',
'BN': 'br',
'BG': 'bg', # beige
'IV': 'eb', # elfenbeinfarben
'SL': 'si', # Schiefer
'CU': 'ku', # Kupfer
'SN': 'vz', # verzinkt
'SR': 'ag', # Silber
'GD': 'au', # Gold
"BK": "sw",
"WH": "ws",
"GY": "gr",
"PK": "rs",
"RD": "rt",
"OG": "or",
"YE": "ge",
"OL": "ol", # olivgrün
"GN": "gn",
"TQ": "tk",
"LB": "hb", # hellblau
"BU": "bl",
"VT": "vi",
"BN": "br",
"BG": "bg", # beige
"IV": "eb", # elfenbeinfarben
"SL": "si", # Schiefer
"CU": "ku", # Kupfer
"SN": "vz", # verzinkt
"SR": "ag", # Silber
"GD": "au", # Gold
}
color_default = '#ffffff'
color_default = "#ffffff"
_hex_digits = set('0123456789abcdefABCDEF')
_hex_digits = set("0123456789abcdefABCDEF")
# Literal type aliases below are commented to avoid requiring python 3.8
Color = str # Two-letter color name = Literal[_color_hex.keys()]
Colors = str # One or more two-letter color names (Color) concatenated into one string
ColorMode = str # = Literal['full', 'FULL', 'hex', 'HEX', 'short', 'SHORT', 'ger', 'GER']
ColorMode = (
str # = Literal['full', 'FULL', 'hex', 'HEX', 'short', 'SHORT', 'ger', 'GER']
)
ColorScheme = str # Color scheme name = Literal[COLOR_CODES.keys()]
def get_color_hex(input: Colors, pad: bool = False) -> List[str]:
"""Return list of hex colors from either a string of color names or :-separated hex colors."""
if input is None or input == '':
if input is None or input == "":
return [color_default]
elif input[0] == '#': # Hex color(s)
output = input.split(':')
elif input[0] == "#": # Hex color(s)
output = input.split(":")
for i, c in enumerate(output):
if c[0] != '#' or not all(d in _hex_digits for d in c[1:]):
if c[0] != "#" or not all(d in _hex_digits for d in c[1:]):
if c != input:
c += f' in input: {input}'
print(f'Invalid hex color: {c}')
c += f" in input: {input}"
print(f"Invalid hex color: {c}")
output[i] = color_default
else: # Color name(s)
def lookup(c: str) -> str:
try:
return _color_hex[c]
except KeyError:
if c != input:
c += f' in input: {input}'
print(f'Unknown color name: {c}')
c += f" in input: {input}"
print(f"Unknown color name: {c}")
return color_default
output = [lookup(input[i : i + 2]) for i in range(0, len(input), 2)]
@ -150,34 +302,38 @@ def get_color_hex(input: Colors, pad: bool = False) -> List[str]:
def get_color_translation(translate: Dict[Color, str], input: Colors) -> List[str]:
"""Return list of colors translations from either a string of color names or :-separated hex colors."""
def from_hex(hex_input: str) -> str:
for color, hex in _color_hex.items():
if hex == hex_input:
return translate[color]
return f'({",".join(str(int(hex_input[i:i+2], 16)) for i in range(1, 6, 2))})'
return [from_hex(h) for h in input.lower().split(':')] if input[0] == '#' else \
[translate.get(input[i:i+2], '??') for i in range(0, len(input), 2)]
return (
[from_hex(h) for h in input.lower().split(":")]
if input[0] == "#"
else [translate.get(input[i : i + 2], "??") for i in range(0, len(input), 2)]
)
def translate_color(input: Colors, color_mode: ColorMode) -> str:
if input == '' or input is None:
return ''
if input == "" or input is None:
return ""
upper = color_mode.isupper()
if not (color_mode.isupper() or color_mode.islower()):
raise Exception('Unknown color mode capitalization')
raise Exception("Unknown color mode capitalization")
color_mode = color_mode.lower()
if color_mode == 'full':
if color_mode == "full":
output = "/".join(get_color_translation(_color_full, input))
elif color_mode == 'hex':
output = ':'.join(get_color_hex(input, pad=False))
elif color_mode == 'ger':
elif color_mode == "hex":
output = ":".join(get_color_hex(input, pad=False))
elif color_mode == "ger":
output = "".join(get_color_translation(_color_ger, input))
elif color_mode == 'short':
elif color_mode == "short":
output = input
else:
raise Exception('Unknown color mode')
raise Exception("Unknown color mode")
if upper:
return output.upper()
else:

View File

@ -8,51 +8,66 @@ from wireviz.wv_colors import translate_color
from wireviz.wv_helper import remove_links
def nested_html_table(rows: List[Union[str, List[Optional[str]], None]], table_attrs: str = '') -> str:
def nested_html_table(
rows: List[Union[str, List[Optional[str]], None]], table_attrs: str = ""
) -> str:
# input: list, each item may be scalar or list
# output: a parent table with one child table per parent item that is list, and one cell per parent item that is scalar
# purpose: create the appearance of one table, where cell widths are independent between rows
# attributes in any leading <tdX> inside a list are injected into to the preceeding <td> tag
html = []
html.append(f'<table border="0" cellspacing="0" cellpadding="0"{table_attrs or ""}>')
html.append(
f'<table border="0" cellspacing="0" cellpadding="0"{table_attrs or ""}>'
)
num_rows = 0
for row in rows:
if isinstance(row, List):
if len(row) > 0 and any(row):
html.append(' <tr><td>')
html.append(' <table border="0" cellspacing="0" cellpadding="3" cellborder="1"><tr>')
html.append(" <tr><td>")
html.append(
' <table border="0" cellspacing="0" cellpadding="3" cellborder="1"><tr>'
)
for cell in row:
if cell is not None:
# Inject attributes to the preceeding <td> tag where needed
html.append(f' <td balign="left">{cell}</td>'.replace('><tdX', ''))
html.append(' </tr></table>')
html.append(' </td></tr>')
html.append(
f' <td balign="left">{cell}</td>'.replace("><tdX", "")
)
html.append(" </tr></table>")
html.append(" </td></tr>")
num_rows = num_rows + 1
elif row is not None:
html.append(' <tr><td>')
html.append(f' {row}')
html.append(' </td></tr>')
html.append(" <tr><td>")
html.append(f" {row}")
html.append(" </td></tr>")
num_rows = num_rows + 1
if num_rows == 0: # empty table
html.append('<tr><td></td></tr>') # generate empty cell to avoid GraphViz errors
html.append('</table>')
html.append(
"<tr><td></td></tr>"
) # generate empty cell to avoid GraphViz errors
html.append("</table>")
return html
def html_bgcolor_attr(color: Color) -> str:
"""Return attributes for bgcolor or '' if no color."""
return f' bgcolor="{translate_color(color, "HEX")}"' if color else ''
return f' bgcolor="{translate_color(color, "HEX")}"' if color else ""
def html_bgcolor(color: Color, _extra_attr: str = '') -> str:
def html_bgcolor(color: Color, _extra_attr: str = "") -> str:
"""Return <td> attributes prefix for bgcolor or '' if no color."""
return f'<tdX{html_bgcolor_attr(color)}{_extra_attr}>' if color else ''
return f"<tdX{html_bgcolor_attr(color)}{_extra_attr}>" if color else ""
def html_colorbar(color: Color) -> str:
"""Return <tdX> attributes prefix for bgcolor and minimum width or None if no color."""
return html_bgcolor(color, ' width="4"') if color else None
def html_image(image):
from wireviz.DataClasses import Image
if not image:
return None
# The leading attributes belong to the preceeding tag. See where used below.
@ -60,25 +75,38 @@ def html_image(image):
if image.fixedsize:
# Close the preceeding tag and enclose the image cell in a table without
# borders to avoid narrow borders when the fixed width < the node width.
html = f'''>
html = f""">
<table border="0" cellspacing="0" cellborder="0"><tr>
<td{html}</td>
</tr></table>
'''
return f'''<tdX{' sides="TLR"' if image.caption else ''}{html_bgcolor_attr(image.bgcolor)}{html}'''
"""
return f"""<tdX{' sides="TLR"' if image.caption else ''}{html_bgcolor_attr(image.bgcolor)}{html}"""
def html_caption(image):
from wireviz.DataClasses import Image
return (f'<tdX sides="BLR"{html_bgcolor_attr(image.bgcolor)}>{html_line_breaks(image.caption)}'
if image and image.caption else None)
return (
f'<tdX sides="BLR"{html_bgcolor_attr(image.bgcolor)}>{html_line_breaks(image.caption)}'
if image and image.caption
else None
)
def html_size_attr(image):
from wireviz.DataClasses import Image
# Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object
return ((f' width="{image.width}"' if image.width else '')
+ (f' height="{image.height}"' if image.height else '')
+ ( ' fixedsize="true"' if image.fixedsize else '')) if image else ''
return (
(
(f' width="{image.width}"' if image.width else "")
+ (f' height="{image.height}"' if image.height else "")
+ (' fixedsize="true"' if image.fixedsize else "")
)
if image
else ""
)
def html_line_breaks(inp):
return remove_links(inp).replace('\n', '<br />') if isinstance(inp, str) else inp
return remove_links(inp).replace("\n", "<br />") if isinstance(inp, str) else inp

View File

@ -5,31 +5,33 @@ from pathlib import Path
from typing import Dict, List
awg_equiv_table = {
'0.09': '28',
'0.14': '26',
'0.25': '24',
'0.34': '22',
'0.5': '21',
'0.75': '20',
'1': '18',
'1.5': '16',
'2.5': '14',
'4': '12',
'6': '10',
'10': '8',
'16': '6',
'25': '4',
'35': '2',
'50': '1',
"0.09": "28",
"0.14": "26",
"0.25": "24",
"0.34": "22",
"0.5": "21",
"0.75": "20",
"1": "18",
"1.5": "16",
"2.5": "14",
"4": "12",
"6": "10",
"10": "8",
"16": "6",
"25": "4",
"35": "2",
"50": "1",
}
mm2_equiv_table = {v: k for k, v in awg_equiv_table.items()}
def awg_equiv(mm2):
return awg_equiv_table.get(str(mm2), 'Unknown')
return awg_equiv_table.get(str(mm2), "Unknown")
def mm2_equiv(awg):
return mm2_equiv_table.get(str(awg), 'Unknown')
return mm2_equiv_table.get(str(awg), "Unknown")
def expand(yaml_data):
@ -42,8 +44,8 @@ def expand(yaml_data):
yaml_data = [yaml_data]
for e in yaml_data:
e = str(e)
if '-' in e:
a, b = e.split('-', 1)
if "-" in e:
a, b = e.split("-", 1)
try:
a = int(a)
b = int(b)
@ -56,7 +58,9 @@ def expand(yaml_data):
else: # a == b
output.append(a) # range of length 1
except:
output.append(e) # '-' was not a delimiter between two ints, pass e through unchanged
output.append(
e
) # '-' was not a delimiter between two ints, pass e through unchanged
else:
try:
x = int(e) # single int
@ -81,36 +85,46 @@ def int2tuple(inp):
def flatten2d(inp):
return [[str(item) if not isinstance(item, List) else ', '.join(item) for item in row] for row in inp]
return [
[str(item) if not isinstance(item, List) else ", ".join(item) for item in row]
for row in inp
]
def tuplelist2tsv(inp, header=None):
output = ''
output = ""
if header is not None:
inp.insert(0, header)
inp = flatten2d(inp)
for row in inp:
output = output + '\t'.join(str(remove_links(item)) for item in row) + '\n'
output = output + "\t".join(str(remove_links(item)) for item in row) + "\n"
return output
def remove_links(inp):
return re.sub(r'<[aA] [^>]*>([^<]*)</[aA]>', r'\1', inp) if isinstance(inp, str) else inp
return (
re.sub(r"<[aA] [^>]*>([^<]*)</[aA]>", r"\1", inp)
if isinstance(inp, str)
else inp
)
def clean_whitespace(inp):
return ' '.join(inp.split()).replace(' ,', ',') if isinstance(inp, str) else inp
return " ".join(inp.split()).replace(" ,", ",") if isinstance(inp, str) else inp
def open_file_read(filename):
# TODO: Intelligently determine encoding
return open(filename, 'r', encoding='UTF-8')
return open(filename, "r", encoding="UTF-8")
def open_file_write(filename):
return open(filename, 'w', encoding='UTF-8')
return open(filename, "w", encoding="UTF-8")
def open_file_append(filename):
return open(filename, 'a', encoding='UTF-8')
return open(filename, "a", encoding="UTF-8")
def is_arrow(inp):
"""
@ -122,18 +136,22 @@ def is_arrow(inp):
<==, ==, ==>, <=>
"""
# regex by @shiraneyo
return bool(re.match(r"^\s*(?P<leftHead><?)(?P<body>-+|=+)(?P<rightHead>>?)\s*$", inp))
return bool(
re.match(r"^\s*(?P<leftHead><?)(?P<body>-+|=+)(?P<rightHead>>?)\s*$", inp)
)
def aspect_ratio(image_src):
try:
from PIL import Image
image = Image.open(image_src)
if image.width > 0 and image.height > 0:
return image.width / image.height
print(f'aspect_ratio(): Invalid image size {image.width} x {image.height}')
print(f"aspect_ratio(): Invalid image size {image.width} x {image.height}")
# ModuleNotFoundError and FileNotFoundError are the most expected, but all are handled equally.
except Exception as error:
print(f'aspect_ratio(): {type(error).__name__}: {error}')
print(f"aspect_ratio(): {type(error).__name__}: {error}")
return 1 # Assume 1:1 when unable to read actual image size
@ -145,13 +163,17 @@ def smart_file_resolve(filename: str, possible_paths: (str, List[str])) -> Path:
if filename.exists():
return filename
else:
raise Exception(f'{filename} does not exist.')
raise Exception(f"{filename} does not exist.")
else: # search all possible paths in decreasing order of precedence
possible_paths = [Path(path).resolve() for path in possible_paths if path is not None]
possible_paths = [
Path(path).resolve() for path in possible_paths if path is not None
]
for possible_path in possible_paths:
resolved_path = (possible_path / filename).resolve()
if resolved_path.exists():
return resolved_path
else:
raise Exception(f'{filename} was not found in any of the following locations: \n' +
'\n'.join([str(x) for x in possible_paths]))
raise Exception(
f"{filename} was not found in any of the following locations: \n"
+ "\n".join([str(x) for x in possible_paths])
)

View File

@ -15,83 +15,106 @@ from wireviz.wv_helper import (
)
def generate_html_output(filename: Union[str, Path], bom_list: List[List[str]], metadata: Metadata, options: Options):
def generate_html_output(
filename: Union[str, Path],
bom_list: List[List[str]],
metadata: Metadata,
options: Options,
):
# load HTML template
templatename = metadata.get('template',{}).get('name')
templatename = metadata.get("template", {}).get("name")
if templatename:
# if relative path to template was provided, check directory of YAML file first, fall back to built-in template directory
templatefile = smart_file_resolve(f'{templatename}.html', [Path(filename).parent, Path(__file__).parent / 'templates'])
templatefile = smart_file_resolve(
f"{templatename}.html",
[Path(filename).parent, Path(__file__).parent / "templates"],
)
else:
# fall back to built-in simple template if no template was provided
templatefile = Path(__file__).parent / 'templates/simple.html'
templatefile = Path(__file__).parent / "templates/simple.html"
with open_file_read(templatefile) as file:
html = file.read()
# embed SVG diagram
with open_file_read(f'{filename}.svg') as file:
with open_file_read(f"{filename}.svg") as file:
svgdata = re.sub(
'^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>',
'<!-- XML and DOCTYPE declarations from SVG file removed -->',
file.read(), 1)
"^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>",
"<!-- XML and DOCTYPE declarations from SVG file removed -->",
file.read(),
1,
)
# generate BOM table
bom = flatten2d(bom_list)
# generate BOM header (may be at the top or bottom of the table)
bom_header_html = ' <tr>\n'
bom_header_html = " <tr>\n"
for item in bom[0]:
th_class = f'bom_col_{item.lower()}'
th_class = f"bom_col_{item.lower()}"
bom_header_html = f'{bom_header_html} <th class="{th_class}">{item}</th>\n'
bom_header_html = f'{bom_header_html} </tr>\n'
bom_header_html = f"{bom_header_html} </tr>\n"
# generate BOM contents
bom_contents = []
for row in bom[1:]:
row_html = ' <tr>\n'
row_html = " <tr>\n"
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}</td>\n'
row_html = f'{row_html} </tr>\n'
row_html = f"{row_html} </tr>\n"
bom_contents.append(row_html)
bom_html = '<table class="bom">\n' + bom_header_html + ''.join(bom_contents) + '</table>\n'
bom_html_reversed = '<table class="bom">\n' + ''.join(list(reversed(bom_contents))) + bom_header_html + '</table>\n'
bom_html = (
'<table class="bom">\n' + bom_header_html + "".join(bom_contents) + "</table>\n"
)
bom_html_reversed = (
'<table class="bom">\n'
+ "".join(list(reversed(bom_contents)))
+ bom_header_html
+ "</table>\n"
)
# prepare simple replacements
replacements = {
'<!-- %generator% -->': f'{APP_NAME} {__version__} - {APP_URL}',
'<!-- %fontname% -->': options.fontname,
'<!-- %bgcolor% -->': wv_colors.translate_color(options.bgcolor, "hex"),
'<!-- %diagram% -->': svgdata,
'<!-- %bom% -->': bom_html,
'<!-- %bom_reversed% -->': bom_html_reversed,
'<!-- %sheet_current% -->': '1', # TODO: handle multi-page documents
'<!-- %sheet_total% -->': '1', # TODO: handle multi-page documents
"<!-- %generator% -->": f"{APP_NAME} {__version__} - {APP_URL}",
"<!-- %fontname% -->": options.fontname,
"<!-- %bgcolor% -->": wv_colors.translate_color(options.bgcolor, "hex"),
"<!-- %diagram% -->": svgdata,
"<!-- %bom% -->": bom_html,
"<!-- %bom_reversed% -->": bom_html_reversed,
"<!-- %sheet_current% -->": "1", # TODO: handle multi-page documents
"<!-- %sheet_total% -->": "1", # TODO: handle multi-page documents
}
# prepare metadata replacements
if metadata:
for item, contents in metadata.items():
if isinstance(contents, (str, int, float)):
replacements[f'<!-- %{item}% -->'] = html_line_breaks(str(contents))
replacements[f"<!-- %{item}% -->"] = html_line_breaks(str(contents))
elif isinstance(contents, Dict): # useful for authors, revisions
for index, (category, entry) in enumerate(contents.items()):
if isinstance(entry, Dict):
replacements[f'<!-- %{item}_{index+1}% -->'] = str(category)
replacements[f"<!-- %{item}_{index+1}% -->"] = str(category)
for entry_key, entry_value in entry.items():
replacements[f'<!-- %{item}_{index+1}_{entry_key}% -->'] = html_line_breaks(str(entry_value))
replacements[
f"<!-- %{item}_{index+1}_{entry_key}% -->"
] = html_line_breaks(str(entry_value))
replacements['"sheetsize_default"'] = '"{}"'.format(metadata.get('template',{}).get('sheetsize', '')) # include quotes so no replacement happens within <style> definition
replacements['"sheetsize_default"'] = '"{}"'.format(
metadata.get("template", {}).get("sheetsize", "")
) # include quotes so no replacement happens within <style> definition
# perform replacements
# regex replacement adapted from:
# https://gist.github.com/bgusach/a967e0587d6e01e889fd1d776c5f3729
replacements_sorted = sorted(replacements, key=len, reverse=True) # longer replacements first, just in case
replacements_sorted = sorted(
replacements, key=len, reverse=True
) # longer replacements first, just in case
replacements_escaped = map(re.escape, replacements_sorted)
pattern = re.compile("|".join(replacements_escaped))
html = pattern.sub(lambda match: replacements[match.group(0)], html)
with open_file_write(f'{filename}.html') as file:
with open_file_write(f"{filename}.html") as file:
file.write(html)