diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d539aa2..fe576b0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,11 +22,11 @@ jobs: python -m pip install --upgrade pip pip install . - name: Create Examples - run: PYTHONPATH=$(pwd)/src:$PYTHONPATH cd src/wireviz/ && python build_examples.py + run: PYTHONPATH=$(pwd)/src:$PYTHONPATH && python src/wireviz/tools/build_examples.py - name: Upload examples, demos, and tutorials uses: actions/upload-artifact@v2 with: name: examples-and-tutorials path: | examples/ - tutorial/ \ No newline at end of file + tutorial/ diff --git a/.gitignore b/.gitignore index 7484ecb..dc58d27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,21 @@ +# OS-specific files .DS_Store +desktop.ini +Thumbs.db + +# Development aids .idea/ -.eggs -__pycache__ -.*.swp -*.egg-info -*.pyc -build -data -dist +.vscode/ +temp/ venv/ .venv/ -desktop.ini -thumbs.db -temp/ + +# Build/compile/release artifacts +build/ +dist/ +*.egg-info +*.pyc + +# Other temporary files +__pycache__ +.*.swp diff --git a/examples/demo01.html b/examples/demo01.html index c7b557b..8dcb550 100644 --- a/examples/demo01.html +++ b/examples/demo01.html @@ -41,6 +41,7 @@ X1 + X1 @@ -162,6 +163,7 @@ X2 + X2 diff --git a/examples/demo01.png b/examples/demo01.png index 75ac333..179147d 100644 Binary files a/examples/demo01.png and b/examples/demo01.png differ diff --git a/examples/demo01.svg b/examples/demo01.svg index 7f284b7..03b2a4b 100644 --- a/examples/demo01.svg +++ b/examples/demo01.svg @@ -12,6 +12,7 @@ X1 + X1 @@ -133,6 +134,7 @@ X2 + X2 diff --git a/examples/demo02.html b/examples/demo02.html index 1002edd..8fe7c47 100644 --- a/examples/demo02.html +++ b/examples/demo02.html @@ -199,6 +199,7 @@ X1 + X1 @@ -445,6 +446,7 @@ X2 + X2 @@ -474,6 +476,7 @@ X3 + X3 @@ -503,6 +506,7 @@ X4 + X4 @@ -536,6 +540,7 @@ AUTOGENERATED_F_1 + Crimp ferrule @@ -581,6 +586,7 @@ AUTOGENERATED_F_2 + Crimp ferrule diff --git a/examples/demo02.png b/examples/demo02.png index 6672677..f7dd691 100644 Binary files a/examples/demo02.png and b/examples/demo02.png differ diff --git a/examples/demo02.svg b/examples/demo02.svg index a23c9f6..130922b 100644 --- a/examples/demo02.svg +++ b/examples/demo02.svg @@ -12,6 +12,7 @@ X1 + X1 @@ -258,6 +259,7 @@ X2 + X2 @@ -287,6 +289,7 @@ X3 + X3 @@ -316,6 +319,7 @@ X4 + X4 @@ -349,6 +353,7 @@ AUTOGENERATED_F_1 + Crimp ferrule @@ -394,6 +399,7 @@ AUTOGENERATED_F_2 + Crimp ferrule diff --git a/requirements.txt b/requirements.txt index 07564c3..9405dd1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ graphviz pillow pyyaml setuptools +tabulate diff --git a/setup.py b/setup.py index 6ce5013..8ef73a6 100644 --- a/setup.py +++ b/setup.py @@ -15,13 +15,14 @@ setup( author="Daniel Rojas", # author_email='', description="Easily document cables and wiring harnesses", - long_description=open(README_PATH).read(), + long_description=README_PATH.read_text(), long_description_content_type="text/markdown", install_requires=[ "click", - "pyyaml", - "pillow", "graphviz", + "pillow", + "pyyaml", + "tabulate", ], license="GPLv3", keywords="cable connector hardware harness wiring wiring-diagram wiring-harness", diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py deleted file mode 100644 index cdefddd..0000000 --- a/src/wireviz/DataClasses.py +++ /dev/null @@ -1,441 +0,0 @@ -# -*- coding: utf-8 -*- - -from dataclasses import InitVar, dataclass, field -from enum import Enum, auto -from pathlib import Path -from typing import Dict, List, Optional, Tuple, Union - -from wireviz.wv_colors import COLOR_CODES, Color, ColorMode, Colors, ColorScheme -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 -) - -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'] -) -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 -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', ...] - - -Side = Enum("Side", "LEFT RIGHT") - -AUTOGENERATED_PREFIX = "AUTOGENERATED_" - - -class Metadata(dict): - pass - - -@dataclass -class Options: - 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" - mini_bom_mode: bool = True - template_separator: str = "." - - def __post_init__(self): - if not self.bgcolor_node: - self.bgcolor_node = self.bgcolor - if not self.bgcolor_connector: - self.bgcolor_connector = self.bgcolor_node - if not self.bgcolor_cable: - self.bgcolor_cable = self.bgcolor_node - if not self.bgcolor_bundle: - self.bgcolor_bundle = self.bgcolor_cable - - -@dataclass -class Tweak: - override: Optional[Dict[Designator, Dict[str, Optional[str]]]] = None - append: Union[str, List[str], None] = None - - -@dataclass -class Image: - # Attributes of the image object : - src: str - scale: Optional[ImageScale] = None - # Attributes of the image cell containing the image: - width: Optional[int] = None - height: Optional[int] = None - fixedsize: Optional[bool] = None - bgcolor: Optional[Color] = None - # Contents of the text cell just below the image cell: - caption: Optional[MultilineHypertext] = None - # See also HTML doc at https://graphviz.org/doc/info/shapes.html#html - - def __post_init__(self): - - if self.fixedsize is None: - # Default True if any dimension specified unless self.scale also is specified. - self.fixedsize = (self.width or self.height) and self.scale is None - - if self.scale is None: - if not self.width and not self.height: - self.scale = "false" - elif self.width and self.height: - self.scale = "both" - else: - self.scale = "true" # When only one dimension is specified. - - if self.fixedsize: - # If only one dimension is specified, compute the other - # because Graphviz requires both when fixedsize=True. - if self.height: - if not self.width: - self.width = self.height * aspect_ratio(self.src) - else: - if self.width: - self.height = self.width / aspect_ratio(self.src) - - -@dataclass -class AdditionalComponent: - type: MultilineHypertext - subtype: Optional[MultilineHypertext] = None - manufacturer: Optional[MultilineHypertext] = None - mpn: Optional[MultilineHypertext] = None - supplier: Optional[MultilineHypertext] = None - spn: Optional[MultilineHypertext] = None - pn: Optional[Hypertext] = None - qty: float = 1 - unit: Optional[str] = None - qty_multiplier: Union[ConnectorMultiplier, CableMultiplier, None] = None - bgcolor: Optional[Color] = None - - @property - def description(self) -> str: - s = self.type.rstrip() + f", {self.subtype.rstrip()}" if self.subtype else "" - return s - - -@dataclass -class Connector: - name: Designator - bgcolor: Optional[Color] = None - bgcolor_title: Optional[Color] = None - manufacturer: Optional[MultilineHypertext] = None - mpn: Optional[MultilineHypertext] = None - supplier: Optional[MultilineHypertext] = None - spn: Optional[MultilineHypertext] = None - pn: Optional[Hypertext] = None - style: Optional[str] = None - category: Optional[str] = None - type: Optional[MultilineHypertext] = None - subtype: Optional[MultilineHypertext] = None - pincount: Optional[int] = None - image: Optional[Image] = None - notes: Optional[MultilineHypertext] = None - pins: List[Pin] = field(default_factory=list) - pinlabels: List[Pin] = field(default_factory=list) - pincolors: List[Color] = field(default_factory=list) - color: Optional[Color] = None - show_name: Optional[bool] = None - show_pincount: Optional[bool] = None - hide_disconnected_pins: bool = False - loops: List[List[Pin]] = field(default_factory=list) - ignore_in_bom: bool = False - additional_components: List[AdditionalComponent] = field(default_factory=list) - - @property - def is_autogenerated(self): - return self.name.startswith(AUTOGENERATED_PREFIX) - - def __post_init__(self) -> None: - - if isinstance(self.image, dict): - self.image = Image(**self.image) - - self.ports_left = False - self.ports_right = False - self.visible_pins = {} - - if self.style == "simple": - if self.pincount and self.pincount > 1: - 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) - ) - if not self.pincount: - 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") - - if self.show_name is None: - self.show_name = self.style != "simple" and not self.is_autogenerated - - if self.show_pincount is None: - # hide pincount for simple (1 pin) connectors by default - self.show_pincount = self.style != "simple" - - for loop in self.loops: - # 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!") - for pin in loop: - if pin not in self.pins: - raise Exception(f'Unknown loop pin "{pin}" for connector "{self.name}"!') - # Make sure loop connected pins are not hidden. - self.activate_pin(pin) - - for i, item in enumerate(self.additional_components): - if isinstance(item, dict): - self.additional_components[i] = AdditionalComponent(**item) - - def activate_pin(self, pin: Pin, side: Side) -> None: - self.visible_pins[pin] = True - if side == Side.LEFT: - self.ports_left = True - elif side == Side.RIGHT: - self.ports_right = True - - def get_qty_multiplier(self, qty_multiplier: Optional[ConnectorMultiplier]) -> int: - if not qty_multiplier: - return 1 - elif qty_multiplier == "pincount": - return self.pincount - elif qty_multiplier == "populated": - return sum(self.visible_pins.values()) - else: - raise ValueError( - f"invalid qty multiplier parameter for connector {qty_multiplier}" - ) - - -@dataclass -class Cable: - name: Designator - bgcolor: Optional[Color] = None - bgcolor_title: Optional[Color] = None - manufacturer: Union[MultilineHypertext, List[MultilineHypertext], None] = None - mpn: Union[MultilineHypertext, List[MultilineHypertext], None] = None - supplier: Union[MultilineHypertext, List[MultilineHypertext], None] = None - spn: Union[MultilineHypertext, List[MultilineHypertext], None] = None - pn: Union[Hypertext, List[Hypertext], None] = None - category: Optional[str] = None - type: Optional[MultilineHypertext] = None - gauge: Optional[float] = None - gauge_unit: Optional[str] = None - show_equiv: bool = False - length: float = 0 - length_unit: Optional[str] = None - color: Optional[Color] = None - wirecount: Optional[int] = None - shield: Union[bool, Color] = False - image: Optional[Image] = None - notes: Optional[MultilineHypertext] = None - colors: List[Colors] = field(default_factory=list) - wirelabels: List[Wire] = field(default_factory=list) - color_code: Optional[ColorScheme] = None - show_name: Optional[bool] = None - show_wirecount: bool = True - show_wirenumbers: Optional[bool] = None - ignore_in_bom: bool = False - additional_components: List[AdditionalComponent] = field(default_factory=list) - - @property - def is_autogenerated(self): - return self.name.startswith(AUTOGENERATED_PREFIX) - - def __post_init__(self) -> None: - - if isinstance(self.image, dict): - self.image = Image(**self.image) - - if isinstance(self.gauge, str): # gauge and unit specified - try: - 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" - ) - 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": - self.gauge_unit = u.upper() - else: - 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" - else: - pass # gauge not specified - - if isinstance(self.length, str): # length and unit specified - try: - 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" - ) - 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}" - ) - 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") - elif self.length_unit is None: - 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) - if self.color_code not in COLOR_CODES: - raise Exception("Unknown color code") - self.colors = COLOR_CODES[self.color_code] - else: # no colors defined, add dummy colors - self.colors = [""] * self.wirecount - - # make color code loop around if more wires than colors - if self.wirecount > len(self.colors): - m = self.wirecount // len(self.colors) + 1 - self.colors = self.colors * int(m) - # cut off excess after looping - 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)" - ) - 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 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]: - if isinstance(idfield, list): - if self.category == "bundle": - # check the length - if len(idfield) != self.wirecount: - raise Exception("lists of part data must match wirecount") - else: - raise Exception("lists of part data are only supported for bundles") - - if self.show_name is None: - self.show_name = not self.is_autogenerated - - if self.show_wirenumbers is None: - # by default, show wire numbers for cables, hide for bundles - self.show_wirenumbers = self.category != "bundle" - - 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: - - 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") - for i, _ in enumerate(from_pin): - 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": - return self.wirecount - elif qty_multiplier == "terminations": - return len(self.connections) - elif qty_multiplier == "length": - return self.length - elif qty_multiplier == "total_length": - return self.length * self.wirecount - else: - raise ValueError( - f"invalid qty multiplier parameter for cable {qty_multiplier}" - ) - - -@dataclass -class Connection: - from_name: Optional[Designator] - from_pin: Optional[Pin] - via_port: Wire - to_name: Optional[Designator] - to_pin: Optional[Pin] - - -@dataclass -class MatePin: - from_name: Designator - from_pin: Pin - to_name: Designator - to_pin: Pin - shape: str - - -@dataclass -class MateComponent: - from_name: Designator - to_name: Designator - shape: str diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py deleted file mode 100644 index d99786f..0000000 --- a/src/wireviz/Harness.py +++ /dev/null @@ -1,705 +0,0 @@ -# -*- coding: utf-8 -*- - -import re -from collections import Counter -from dataclasses import dataclass -from itertools import zip_longest -from pathlib import Path -from typing import Any, List, Union - -from graphviz import Graph - -from wireviz import APP_NAME, APP_URL, __version__, wv_colors -from wireviz.DataClasses import ( - Cable, - Connector, - MateComponent, - MatePin, - Metadata, - Options, - Side, - Tweak, -) -from wireviz.svgembed import embed_svg_images_file -from wireviz.wv_bom import ( - HEADER_MPN, - HEADER_PN, - HEADER_SPN, - bom_list, - component_table_entry, - generate_bom, - get_additional_component_table, - pn_info_string, -) -from wireviz.wv_colors import get_color_hex, translate_color -from wireviz.wv_gv_html import ( - html_bgcolor, - html_bgcolor_attr, - html_caption, - html_colorbar, - html_image, - html_line_breaks, - nested_html_table, - remove_links, -) -from wireviz.wv_helper import ( - awg_equiv, - flatten2d, - is_arrow, - mm2_equiv, - open_file_read, - open_file_write, - tuplelist2tsv, -) -from wireviz.wv_html import generate_html_output - - -@dataclass -class Harness: - metadata: Metadata - options: Options - tweak: Tweak - - def __post_init__(self): - self.connectors = {} - self.cables = {} - self.mates = [] - self._bom = [] # Internal Cache for generated bom - self.additional_bom_items = [] - - def add_connector(self, name: str, *args, **kwargs) -> None: - self.connectors[name] = Connector(name, *args, **kwargs) - - def add_cable(self, name: str, *args, **kwargs) -> None: - self.cables[name] = Cable(name, *args, **kwargs) - - def add_mate_pin(self, from_name, from_pin, to_name, to_pin, arrow_type) -> None: - self.mates.append(MatePin(from_name, from_pin, to_name, to_pin, arrow_type)) - self.connectors[from_name].activate_pin(from_pin, Side.RIGHT) - self.connectors[to_name].activate_pin(to_pin, Side.LEFT) - - def add_mate_component(self, from_name, to_name, arrow_type) -> None: - self.mates.append(MateComponent(from_name, to_name, arrow_type)) - - 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: - # 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: - connector = self.connectors[name] - # 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." - ) - # 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.") - index = connector.pinlabels.index(pin) - pin = connector.pins[index] # map pin name to pin number - if name == from_name: - from_pin = pin - if name == to_name: - to_pin = pin - if not pin in connector.pins: - raise Exception(f"{name}:{pin} not found.") - - # check via cable - if via_name in self.cables: - cable = self.cables[via_name] - # 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." - ) - # 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." - ) - # list index starts at 0, wire IDs start at 1 - via_wire = cable.colors.index(via_wire) + 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 - - # perform the actual connection - self.cables[via_name].connect(from_name, from_pin, via_wire, to_name, to_pin) - if from_name in self.connectors: - self.connectors[from_name].activate_pin(from_pin, Side.RIGHT) - 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__}\n") - dot.body.append(f"// {APP_URL}\n") - 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", - fillcolor=wv_colors.translate_color(self.options.bgcolor_node, "HEX"), - fontname=self.options.fontname, - ) - dot.attr("edge", style="bold", fontname=self.options.fontname) - - for connector in self.connectors.values(): - - # If no wires connected (except maybe loop wires)? - if not (connector.ports_left or connector.ports_right): - connector.ports_left = True # Use left side pins. - - html = [] - # fmt: off - 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)], - '' if connector.style != 'simple' else None, - [html_image(connector.image)], - [html_caption(connector.image)]] - # fmt: on - - 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": - pinhtml = [] - pinhtml.append( - '' - ) - - 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(" ") - if connector.ports_left: - pinhtml.append(f' ') - if pinlabel: - pinhtml.append(f" ") - if connector.pincolors: - if pincolor in wv_colors._color_hex.keys(): - # fmt: off - pinhtml.append(f' ') - pinhtml.append( ' ') - # fmt: on - else: - pinhtml.append(' ') - - if connector.ports_right: - pinhtml.append(f' ') - pinhtml.append(" ") - - pinhtml.append("
{pinname}{pinlabel}{translate_color(pincolor, self.options.color_mode)}') - pinhtml.append( ' ') - pinhtml.append(f' ') - pinhtml.append( '
') - pinhtml.append( '
{pinname}
") - - html = [ - row.replace("", "\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"), - ) - - if len(connector.loops) > 0: - dot.attr("edge", color="#000000:#ffffff:#000000") - if connector.ports_left: - loop_side = "l" - loop_dir = "w" - elif connector.ports_right: - loop_side = "r" - loop_dir = "e" - else: - 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}", - ) - - # 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 - ) - - for cable in self.cables.values(): - - html = [] - - 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)" - - # fmt: off - 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)], - '', - [html_image(cable.image)], - [html_caption(cable.image)]] - # fmt: on - - 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 = [] - # conductor table - wirehtml.append('') - wirehtml.append(" ") - - for i, (connection_color, wirelabel) in enumerate( - zip_longest(cable.colors, cable.wirelabels), 1 - ): - wirehtml.append(" ") - wirehtml.append(f" ") - wirehtml.append(f" ") - wirehtml.append(f" ") - wirehtml.append(" ") - - # fmt: off - bgcolors = ['#000000'] + get_color_hex(connection_color, pad=pad) + ['#000000'] - wirehtml.append(f" ") - wirehtml.append(f' ") - wirehtml.append(" ") - # fmt: on - - # for bundles, individual wires can have part information - if cable.category == "bundle": - # 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, - ) - if manufacturer_info: - wireidentification.append(html_line_breaks(manufacturer_info)) - if supplier_info: - wireidentification.append(html_line_breaks(supplier_info)) - # print parameters into a table row under the wire - if len(wireidentification) > 0: - # fmt: off - wirehtml.append(' ") - # fmt: on - - if cable.shield: - wirehtml.append(" ") # spacer - wirehtml.append(" ") - wirehtml.append(" ") - wirehtml.append(" ") - wirehtml.append(" ") - wirehtml.append(" ") - 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"' - ) - else: - # shield is shown as a thin black wire - attributes = f'height="2" bgcolor="#000000" border="0"' - # fmt: off - wirehtml.append(f' ') - # fmt: on - - wirehtml.append(" ") - wirehtml.append("
 
") - - wireinfo = [] - if cable.show_wirenumbers: - wireinfo.append(str(i)) - 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 "") - wirehtml.append(f' {":".join(wireinfo)}') - - wirehtml.append(f"
') - wirehtml.append(' ') - for j, bgcolor in enumerate(bgcolors[::-1]): # Reverse to match the curved wires when more than 2 colors - wirehtml.append(f' ') - wirehtml.append("
") - wirehtml.append("
') - wirehtml.append(' ') - for attrib in wireidentification: - wirehtml.append(f" ") - wirehtml.append("
{attrib}
") - wirehtml.append("
 
Shield
 
") - - html = [ - row.replace("", "\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"] - ), - ) - 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", - ) - 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" - dot.edge(code_left_1, code_left_2) - if from_connector.show_name: - from_info = [ - str(connection.from_name), - str(connection.from_pin), - ] - if from_connector.pinlabels: - pinlabel = from_connector.pinlabels[from_pin_index] - if pinlabel != "": - from_info.append(pinlabel) - from_string = ":".join(from_info) - else: - from_string = "" - html = [ - row.replace(f"", 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" - 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 != "": - to_info.append(pinlabel) - to_string = ":".join(to_info) - else: - to_string = "" - html = [ - row.replace(f"", 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"), - ) - - 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}" - ) - - # TODO?: Differ between override attributes and HTML? - if self.tweak.override is not None: - 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) - 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))) - - # 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 - ) - 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 - ) - if n_subs < 1: - 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}!" - ) - continue - - 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 - ) - if n_subs < 1: - # If attr not found, then append it - 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}!" - ) - - 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) - dot.body.extend(self.tweak.append) - else: - 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" - else: - dir = "none" - - if isinstance(mate, MatePin): - color = "#000000" - elif isinstance(mate, MateComponent): - color = "#000000:#000000" - else: - 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" - ): - from_pin_index = from_connector.pins.index(mate.from_pin) - 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" - ): - 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 "" - ) - else: # MateComponent or style == 'simple' - 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" - - dot.attr("edge", color=color, style="dashed", dir=dir) - dot.edge(code_from, code_to) - - return dot - - # cache for the GraphViz Graph object - # do not access directly, use self.graph instead - _graph = None - - @property - def graph(self): - if not self._graph: # no cached graph exists, generate one - self._graph = self.create_graph() - return self._graph # return cached graph - - @property - def png(self): - from io import BytesIO - - graph = self.graph - data = BytesIO() - data.write(graph.pipe(format="png")) - data.seek(0) - return data.read() - - @property - def svg(self): - graph = self.graph - return embed_svg_images(graph.pipe(format="svg").decode("utf-8"), Path.cwd()) - - 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 - # 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 - # SVG file will be renamed/deleted later - _filename = f"{filename}.tmp" if f == "svg" else filename - # TODO: prevent rendering SVG twice when both SVG and HTML are specified - graph.format = f - graph.render(filename=_filename, view=view, cleanup=cleanup) - # embed images into SVG output - if "svg" in fmt or "html" in fmt: - embed_svg_images_file(f"{filename}.tmp.svg") - # GraphViz output - if "gv" in fmt: - graph.save(filename=f"{filename}.gv") - # BOM output - bomlist = bom_list(self.bom()) - if "tsv" in fmt: - open_file_write(f"{filename}.bom.tsv").write(tuplelist2tsv(bomlist)) - if "csv" in fmt: - # TODO: implement CSV output (preferrably using CSV library) - print("CSV output is not yet supported") - # HTML output - if "html" in fmt: - generate_html_output(filename, bomlist, self.metadata, self.options) - # PDF output - if "pdf" in fmt: - # TODO: implement PDF output - print("PDF output is not yet supported") - # delete SVG if not needed - if "html" in fmt and not "svg" in fmt: - # SVG file was just needed to generate HTML - Path(f"{filename}.tmp.svg").unlink() - elif "svg" in fmt: - Path(f"{filename}.tmp.svg").replace(f"{filename}.svg") - - def bom(self): - if not self._bom: - self._bom = generate_bom(self) - return self._bom diff --git a/src/wireviz/__init__.py b/src/wireviz/__init__.py index 08f7167..68cde20 100644 --- a/src/wireviz/__init__.py +++ b/src/wireviz/__init__.py @@ -1,7 +1,7 @@ # -*- 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-refactored" CMD_NAME = "wireviz" # Lower case command and module name APP_NAME = "WireViz" # Application name in texts meant to be human readable diff --git a/src/wireviz/svgembed.py b/src/wireviz/svgembed.py deleted file mode 100644 index ab6b9f1..0000000 --- a/src/wireviz/svgembed.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- - -import base64 -import re -from pathlib import Path -from typing import Union - -mime_subtype_replacements = {"jpg": "jpeg", "tif": "tiff"} - - -def embed_svg_images(svg_in: str, base_path: Union[str, Path] = Path.cwd()) -> str: - images_b64 = {} # cache of base64-encoded images - - def image_tag(pre: str, url: str, post: str) -> str: - return f'' - - def replace(match: re.Match) -> str: - imgurl = match["URL"] - if not imgurl in images_b64: # only encode/cache every unique URL once - imgurl_abs = (Path(base_path) / imgurl).resolve() - image = imgurl_abs.read_bytes() - images_b64[imgurl] = base64.b64encode(image).decode("utf-8") - return image_tag( - match["PRE"] or "", - f"data:image/{get_mime_subtype(imgurl)};base64, {images_b64[imgurl]}", - match["POST"] or "", - ) - - pattern = re.compile( - image_tag(r"(?P
 [^>]*?)?", r'(?P[^"]*?)', r"(?P [^>]*?)?"),
-        re.IGNORECASE,
-    )
-    return pattern.sub(replace, svg_in)
-
-
-def get_mime_subtype(filename: Union[str, Path]) -> str:
-    mime_subtype = Path(filename).suffix.lstrip(".").lower()
-    if mime_subtype in mime_subtype_replacements:
-        mime_subtype = mime_subtype_replacements[mime_subtype]
-    return mime_subtype
-
-
-def embed_svg_images_file(
-    filename_in: Union[str, Path], overwrite: bool = True
-) -> None:
-    filename_in = Path(filename_in).resolve()
-    filename_out = filename_in.with_suffix(".b64.svg")
-    filename_out.write_text(
-        embed_svg_images(filename_in.read_text(), filename_in.parent)
-    )
-    if overwrite:
-        filename_out.replace(filename_in)
diff --git a/src/wireviz/build_examples.py b/src/wireviz/tools/build_examples.py
similarity index 96%
rename from src/wireviz/build_examples.py
rename to src/wireviz/tools/build_examples.py
index e54d0f5..fe9ba4a 100755
--- a/src/wireviz/build_examples.py
+++ b/src/wireviz/tools/build_examples.py
@@ -7,13 +7,12 @@ import sys
 from pathlib import Path
 
 script_path = Path(__file__).absolute()
-
-sys.path.insert(0, str(script_path.parent.parent))  # to find wireviz module
-from wv_helper import open_file_append, open_file_read, open_file_write
+sys.path.insert(0, str(script_path.parent.parent.parent))  # to find wireviz module
 
 from wireviz import APP_NAME, __version__, wireviz
+from wireviz.wv_utils import open_file_append, open_file_read, open_file_write
 
-dir = script_path.parent.parent.parent
+dir = script_path.parent.parent.parent.parent
 readme = "readme.md"
 groups = {
     "examples": {
@@ -98,7 +97,7 @@ def clean_generated(groupkeys):
         for filename in collect_filenames("Cleaning", key, generated_extensions):
             if filename.is_file():
                 print(f'  rm "{filename}"')
-                Path(filename).unlink()
+                filename.unlink()
 
 
 def compare_generated(groupkeys, branch="", include_graphviz_output=False):
diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py
index 05369ae..dce05ad 100755
--- a/src/wireviz/wireviz.py
+++ b/src/wireviz/wireviz.py
@@ -10,9 +10,9 @@ import yaml
 if __name__ == "__main__":
     sys.path.insert(0, str(Path(__file__).parent.parent))  # add src/wireviz to PATH
 
-from wireviz.DataClasses import AUTOGENERATED_PREFIX, Metadata, Options, Tweak
-from wireviz.Harness import Harness
-from wireviz.wv_helper import (
+from wireviz.wv_dataclasses import AUTOGENERATED_PREFIX, Metadata, Options, Tweak
+from wireviz.wv_harness import Harness
+from wireviz.wv_utils import (
     expand,
     get_single_key_and_value,
     is_arrow,
@@ -34,7 +34,7 @@ def parse(
     and outputs the result as one or more files and/or as a function return value
 
     Accepted inputs:
-        * A path to a YAML source file to parse
+        * A Path object or a path-like string pointing to a YAML source file to parse
         * A string containing the YAML data to parse
         * A Python Dict containing the pre-parsed YAML data
 
@@ -93,7 +93,8 @@ def parse(
         output_file = output_dir / output_name
 
     if yaml_file:
-        # if reading from file, ensure that input file's parent directory is included in image_paths
+        # if reading from file, ensure that input file's parent directory
+        # is included in image_paths
         default_image_path = yaml_file.parent.resolve()
         if not default_image_path in [Path(x).resolve() for x in image_paths]:
             image_paths.append(default_image_path)
@@ -128,7 +129,8 @@ def parse(
             if len(yaml_data[sec]) > 0:  # section has contents
                 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.
+                        # The Image dataclass might need to open
+                        # an image file with a relative path.
                         image = attribs.get("image")
                         if isinstance(image, dict):
                             image_path = image["src"]
@@ -164,12 +166,16 @@ def parse(
                 autogenerated_designators[template] = (
                     autogenerated_designators.get(template, 0) + 1
                 )
-                designator = f"{AUTOGENERATED_PREFIX}{template}_{autogenerated_designators[template]}"
+                designator = (
+                    f"{AUTOGENERATED_PREFIX}"
+                    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}"
+                        f"Trying to redefine {designator}"
+                        f" from {designators_and_templates[designator]} to {template}"
                     )
             else:
                 designators_and_templates[designator] = template
@@ -201,7 +207,6 @@ def parse(
         expected_type = alternating_types[1 - alternating_types.index(expected_type)]
 
     for connection_set in connection_sets:
-
         # figure out number of parallel connections within this set
         connectioncount = []
         for entry in connection_set:
@@ -282,7 +287,7 @@ def parse(
                     # generate new connector instance from template
                     check_type(designator, template, "connector")
                     harness.add_connector(
-                        name=designator, **template_connectors[template]
+                        designator=designator, **template_connectors[template]
                     )
 
                 elif designator in harness.cables:  # existing cable instance
@@ -290,7 +295,9 @@ def parse(
                 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])
+                    harness.add_cable(
+                        designator=designator, **template_cables[template]
+                    )
 
                 elif is_arrow(designator):
                     check_type(designator, template, "cable/arrow")
@@ -300,7 +307,8 @@ def parse(
                         f"{template} is an unknown template/designator/arrow."
                     )
 
-            alternate_type()  # entries in connection set must alternate between connectors and cables/arrows
+            # entries in connection set must alternate between connectors and cables/arrows
+            alternate_type()
 
         # transpose connection set list
         # before: one item per component, one subitem per connection in set
@@ -355,11 +363,13 @@ def parse(
                         # mate two connectors as a whole
                         harness.add_mate_component(from_name, to_name, designator)
 
-    # harness population completed =============================================
-
     if "additional_bom_items" in yaml_data:
         for line in yaml_data["additional_bom_items"]:
-            harness.add_bom_item(line)
+            harness.add_additional_bom_item(line)
+
+    # harness population completed =============================================
+
+    harness.populate_bom()
 
     if output_formats:
         harness.output(filename=output_file, fmt=output_formats, view=False)
@@ -382,23 +392,15 @@ def parse(
         return tuple(returns) if len(returns) != 1 else returns[0]
 
 
-def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> (Dict, Path):
+def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> Tuple[Dict, Path]:
     # determine whether inp is a file path, a YAML string, or a Dict
     if not isinstance(inp, Dict):  # received a str or a Path
-        try:
+        if isinstance(inp, Path) or (isinstance(inp, str) and not "\n" in inp):
             yaml_path = Path(inp).expanduser().resolve(strict=True)
-            # if no FileNotFoundError exception happens, get file contents
             yaml_str = open_file_read(yaml_path).read()
-        except (FileNotFoundError, OSError) as e:
-            # if inp is a long YAML string, Pathlib will raise OSError: [errno.ENAMETOOLONG]
-            # when trying to expand and resolve it as a path.
-            # Catch this error, but raise any others
-            from errno import ENAMETOOLONG
-            if type(e) is OSError and e.errno != ENAMETOOLONG:
-                raise e
-            # file does not exist; assume inp is a YAML string
-            yaml_str = inp
+        else:
             yaml_path = None
+            yaml_str = inp
         yaml_data = yaml.safe_load(yaml_str)
     else:
         # received a Dict, use as-is
diff --git a/src/wireviz/wv_bom.py b/src/wireviz/wv_bom.py
index 6689d79..01fbac3 100644
--- a/src/wireviz/wv_bom.py
+++ b/src/wireviz/wv_bom.py
@@ -1,266 +1,87 @@
 # -*- coding: utf-8 -*-
 
-from dataclasses import asdict
-from itertools import groupby
-from typing import Any, Dict, List, Optional, Tuple, Union
+from collections import namedtuple
+from dataclasses import dataclass
+from enum import Enum, IntEnum
+from typing import List, Optional, Union
 
-from wireviz.DataClasses import AdditionalComponent, Cable, Color, Connector
-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
+import tabulate as tabulate_module
 
-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
+from wireviz.wv_utils import html_line_breaks
 
-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]]
+BOM_HASH_FIELDS = "description qty_unit amount partnumbers"
 
 
-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}
+BomEntry = namedtuple("BomEntry", "category qty designators")
+BomHash = namedtuple("BomHash", BOM_HASH_FIELDS)
+BomHashList = namedtuple("BomHashList", BOM_HASH_FIELDS)
+PartNumberInfo = namedtuple("PartNumberInfo", "pn manufacturer mpn supplier spn")
+
+# TODO: different BOM modes
+# BomMode
+# "normal"  # no bubbles, full PN info in GV node
+# "bubbles"  # = "full" -> maximum info in GV node
+# "hide PN info"
+# "PN crossref" = "PN bubbles" + "hide PN info"
+# "additionally: BOM table in GV graph label (#227)"
+# "title block in GV graph label"
 
 
-def get_additional_component_table(
-    harness: "Harness", component: Union[Connector, Cable]
+BomCategory = IntEnum(  # to enforce ordering in BOM
+    "BomEntry", "CONNECTOR CABLE WIRE ADDITIONAL_INSIDE ADDITIONAL_OUTSIDE"
+)
+QtyMultiplierConnector = Enum(
+    "QtyMultiplierConnector", "PINCOUNT POPULATED CONNECTIONS"
+)
+QtyMultiplierCable = Enum(
+    "QtyMultiplierCable", "WIRECOUNT TERMINATION LENGTH TOTAL_LENGTH"
+)
+
+PART_NUMBER_HEADERS = PartNumberInfo(
+    pn="P/N", manufacturer=None, mpn="MPN", supplier=None, spn="SPN"
+)
+
+
+def partnumbers2list(
+    partnumbers: PartNumberInfo, parent_partnumbers: PartNumberInfo = None
 ) -> List[str]:
-    """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,
-            }
-            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
-                    )
-                )
-            else:
-                rows.append(
-                    component_table_entry(
-                        part.description, **common_args, **optional_fields(part)
-                    )
-                )
-    return rows
+    if parent_partnumbers is None:
+        _is_toplevel = True
+        parent_partnumbers = partnumbers
+    else:
+        _is_toplevel = False
 
+    # Note: != operator used as XOR in the following section (https://stackoverflow.com/a/433161)
 
-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,
-                **optional_fields(part),
-            }
+    if _is_toplevel != isinstance(parent_partnumbers.pn, List):
+        # top level and not a list, or wire level and list
+        cell_pn = pn_info_string(PART_NUMBER_HEADERS.pn, None, partnumbers.pn)
+    else:
+        # top level and list -> do per wire later
+        # wire level and not list -> already done at top level
+        cell_pn = None
+
+    if _is_toplevel != isinstance(parent_partnumbers.mpn, List):
+        # TODO: edge case: different manufacturers, but same MPN?
+        cell_mpn = pn_info_string(
+            PART_NUMBER_HEADERS.mpn, partnumbers.manufacturer, partnumbers.mpn
         )
-    return bom_entries
+    else:
+        cell_mpn = None
 
-
-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
+    if _is_toplevel != isinstance(parent_partnumbers.spn, List):
+        # TODO: edge case: different suppliers, but same SPN?
+        cell_spn = pn_info_string(
+            PART_NUMBER_HEADERS.spn, partnumbers.supplier, partnumbers.spn
         )
-    return entry["key"]
+    else:
+        cell_spn = None
 
-
-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,
-                    **optional_fields(connector),
-                }
-            )
-
-        # add connectors aditional components to bom
-        bom_entries.extend(get_additional_component_bom(connector))
-
-    # cables
-    # 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":
-                # 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,
-                        **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()
-                            },
-                        }
-                    )
-
-        # add cable/bundles aditional components to bom
-        bom_entries.extend(get_additional_component_bom(cable))
-
-    # add harness aditional components to bom directly, as they both are 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
-    ]
-
-    # 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)),
-            }
-        )
-
-    # add an incrementing id to each bom entry
-    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))
-
-
-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.
-        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,
-    }
-    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,
-    qty: Union[int, float],
-    unit: Optional[str] = None,
-    bgcolor: Optional[Color] = None,
-    pn: Optional[str] = None,
-    manufacturer: Optional[str] = None,
-    mpn: Optional[str] = None,
-    supplier: Optional[str] = None,
-    spn: Optional[str] = None,
-) -> str:
-    """Return a diagram node table row string with an additional component."""
-    part_number_list = [
-        pn_info_string(HEADER_PN, None, pn),
-        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}"
-        + ("
" 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""" - -
{html_line_breaks(output)}
""" + cell_contents = [cell_pn, cell_mpn, cell_spn] + if any(cell_contents): + return [html_line_breaks(cell) for cell in cell_contents] + else: + return None def pn_info_string( @@ -274,16 +95,51 @@ def pn_info_string( 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 bom_list(bom): + headers = ( + "# Qty Unit Description Amount Unit Designators " + "P/N Manufacturer MPN Supplier SPN Category".split(" ") + ) + rows = [] + rows.append(headers) + # fill rows + for hash, entry in bom.items(): + cells = [ + entry["id"], + entry["qty"], + hash.qty_unit, + hash.description, + hash.amount.number if hash.amount else None, + hash.amount.unit if hash.amount else None, + ", ".join(sorted(entry["designators"])), + ] + if hash.partnumbers: + cells.extend( + [ + hash.partnumbers.pn, + hash.partnumbers.manufacturer, + hash.partnumbers.mpn, + hash.partnumbers.supplier, + hash.partnumbers.spn, + ] + ) + else: + cells.extend([None, None, None, None, None]) + # cells.extend([f"{entry['category']} ({entry['category'].name})"]) # for debugging + rows.append(cells) + # remove empty columns + transposed = list(map(list, zip(*rows))) + transposed = [ + column + for column in transposed + if any([cell is not None for cell in column[1:]]) + # ^ ignore header cell in check + ] + rows = list(map(list, zip(*transposed))) + return rows -def 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)) +def print_bom_table(bom): + print() + print(tabulate_module.tabulate(bom_list(bom), headers="firstrow")) + print() diff --git a/src/wireviz/wv_cli.py b/src/wireviz/wv_cli.py index 7315072..7acff8a 100644 --- a/src/wireviz/wv_cli.py +++ b/src/wireviz/wv_cli.py @@ -11,7 +11,7 @@ if __name__ == "__main__": import wireviz.wireviz as wv from wireviz import APP_NAME, __version__ -from wireviz.wv_helper import open_file_read +from wireviz.wv_utils import open_file_read format_codes = { "c": "csv", @@ -23,9 +23,12 @@ format_codes = { "t": "tsv", } -epilog = "The -f or --format option accepts a string containing one or more of the " -epilog += "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" + + f", ".join([f"{key} ({value.upper()})" for key, value in format_codes.items()]) +) @click.command(epilog=epilog, no_args_is_help=True) @@ -58,7 +61,10 @@ epilog += ", ".join([f"{key} ({value.upper()})" for key, value in format_codes.i "--output-name", default=None, type=str, - help="File name (without extension) to use for output files, if different from input file name.", + help=( + "File name (without extension) to use for output files, " + "if different from input file name." + ), ) @click.option( "-V", @@ -71,7 +77,7 @@ def wireviz(file, format, prepend, output_dir, output_name, version): """ Parses the provided FILE and generates the specified outputs. """ - print() + print() # blank line before execution print(f"{APP_NAME} {__version__}") if version: return # print version number only and exit @@ -105,6 +111,8 @@ def wireviz(file, format, prepend, output_dir, output_name, version): prepend_file = Path(prepend_file) if not prepend_file.exists(): raise Exception(f"File does not exist:\n{prepend_file}") + if not prepend_file.is_file(): + raise Exception(f"Path is not a file:\n{prepend_file}") print("Prepend file:", prepend_file) prepend_input += open_file_read(prepend_file).read() + "\n" @@ -116,6 +124,8 @@ def wireviz(file, format, prepend, output_dir, output_name, version): file = Path(file) if not file.exists(): raise Exception(f"File does not exist:\n{file}") + if not file.is_file(): + raise Exception(f"Path is not a file:\n{file}") # file_out = file.with_suffix("") if not output_file else output_file _output_dir = file.parent if not output_dir else output_dir @@ -142,7 +152,7 @@ def wireviz(file, format, prepend, output_dir, output_name, version): image_paths=list(image_paths), ) - print() + print() # blank line after execution if __name__ == "__main__": diff --git a/src/wireviz/wv_colors.py b/src/wireviz/wv_colors.py index 857f307..919ff0d 100644 --- a/src/wireviz/wv_colors.py +++ b/src/wireviz/wv_colors.py @@ -1,6 +1,198 @@ # -*- coding: utf-8 -*- -from typing import Dict, List +from collections import namedtuple +from dataclasses import dataclass, field +from enum import Enum +from typing import List + +padding_amount = 1 + +ColorOutputMode = Enum( + "ColorOutputMode", "EN_LOWER EN_UPPER DE_LOWER DE_UPPER HTML_LOWER HTML_UPPER" +) + +color_output_mode = ColorOutputMode.EN_UPPER + +KnownColor = namedtuple("KnownColor", "html code_de full_en full_de") + +known_colors = { # v--------v--------- for future use + "BK": KnownColor("#000000", "sw", "black", "schwarz"), + "WH": KnownColor("#ffffff", "ws", "white", "weiß"), + "GY": KnownColor("#999999", "gr", "grey", "grau"), + "PK": KnownColor("#ff66cc", "rs", "pink", "rosa"), + "RD": KnownColor("#ff0000", "rt", "red", "rot"), + "OG": KnownColor("#ff8000", "or", "orange", "orange"), + "YE": KnownColor("#ffff00", "ge", "yellow", "gelb"), + "OL": KnownColor("#708000", "ol", "olive green", "olivgrün"), + "GN": KnownColor("#00aa00", "gn", "green", "grün"), + "TQ": KnownColor("#00ffff", "tk", "turquoise", "türkis"), + "LB": KnownColor("#a0dfff", "hb", "light blue", "hellblau"), + "BU": KnownColor("#0066ff", "bl", "blue", "blau"), + "VT": KnownColor("#8000ff", "vi", "violet", "violett"), + "BN": KnownColor("#895956", "br", "brown", "braun"), + "BG": KnownColor("#ceb673", "bg", "beige", "beige"), + "IV": KnownColor("#f5f0d0", "eb", "ivory", "elfenbein"), + "SL": KnownColor("#708090", "si", "slate", "schiefer"), + "CU": KnownColor("#d6775e", "ku", "copper", "Kupfer"), + "SN": KnownColor("#aaaaaa", "vz", "tin", "verzinkt"), + "SR": KnownColor("#84878c", "ag", "silver", "Silber"), + "GD": KnownColor("#ffcf80", "au", "gold", "Gold"), +} + + +def convert_case(inp): + if "_LOWER" in color_output_mode.name: + return inp.lower() + elif "_UPPER" in color_output_mode.name: + return inp.upper() + else: # currently not used + return inp + + +def get_color_by_colorcode_index(color_code: str, index: int) -> str: + num_colors_in_code = len(COLOR_CODES[color_code]) + actual_index = index % num_colors_in_code # wrap around if index is out of bounds + return COLOR_CODES[color_code][actual_index] + + +@dataclass +class SingleColor: + _code_en: str + _html: str + + @property + def code_en(self): + return convert_case(self._code_en) if self._code_en else None + + @property + def code_de(self): + return ( + convert_case(known_colors[self._code_en.upper()].code_de) + if self._code_en + else None + ) + + @property + def html(self): + return convert_case(self._html) if self._code_en else None + + @property + def known(self): + # treat None as a known color + return self.code_en.upper() in known_colors.keys() if self._code_en else True + + def __init__(self, inp): + if inp is None: + self._html = None + self._code_en = None + elif isinstance(inp, int): + hex_str = f"#{inp:06x}" + self._html = hex_str + self._code_en = hex_str # do not perform reverse lookup + elif inp.upper() in known_colors.keys(): + inp_upper = inp.upper() + self._code_en = inp_upper + self._html = known_colors[inp_upper].html + else: # assume it's a valid HTML color name + self._html = inp + self._code_en = inp + + @property + def html_padded(self): + return ":".join([self.html] * padding_amount) + + def __bool__(self): + return self._code_en is not None + + def __str__(self): + if self._html is None: + return "" + elif self.known and "EN_" in color_output_mode.name: + return self.code_en + elif self.known and "DE_" in color_output_mode.name: + return self.code_de + else: + return self.html + + +@dataclass +class MultiColor: + colors: List[SingleColor] = field(default_factory=list) + + def __init__(self, inp): + self.colors = [] + if inp is None: + pass + elif isinstance(inp, List): # input is already a list + for item in inp: + if item is None: + pass + elif isinstance(item, SingleColor): + self.colors.append(item) + else: # string + self.colors.append(SingleColor(item)) + elif isinstance(inp, SingleColor): # single color + self.colors = [inp] + else: # split input into list + if ":" in str(inp): + self.colors = [SingleColor(item) for item in inp.split(":")] + else: + if isinstance(inp, int): + self.colors = [SingleColor(inp)] + elif len(inp) % 2 == 0: + items = [inp[i : i + 2] for i in range(0, len(inp), 2)] + known = [item.upper() in known_colors.keys() for item in items] + if all(known): + self.colors = [SingleColor(item) for item in items] + else: # assume it's a valud HTML color name + self.colors = [SingleColor(inp)] + else: # assume it's a valid HTML color name + self.colors = [SingleColor(inp)] + + def __len__(self): + return len(self.colors) + + def __bool__(self): + return len(self.colors) >= 1 + + def __str__(self): + if "EN_" in color_output_mode.name or "DE_" in color_output_mode.name: + joiner = "" if self.all_known else ":" + elif "HTML_" in color_output_mode.name: + joiner = ":" + else: + joiner = "???" + return joiner.join([str(color) for color in self.colors]) + + @property + def all_known(self): + return all([color.known for color in self.colors]) + + @property + def html(self): + return ":".join([color.html for color in self.colors]) + + @property + def html_padded_list(self): + # padding only properly works for padding_amount 1 or 3 + if padding_amount == 1: + out = [color.html for color in self.colors] + elif len(self) == 0: + out = [] + elif len(self) == 1: + out = [self.colors[0].html for i in range(3)] + elif len(self) == 2: + out = [self.colors[0].html, self.colors[1].html, self.colors[0].html] + elif len(self) == 3: + out = [color.html for color in self.colors] + else: + raise Exception(f"Padding not supported for len {len(self)}") + return [str(color) for color in out] + + @property + def html_padded(self): + return ":".join(self.html_padded_list) + COLOR_CODES = { # fmt: off @@ -38,164 +230,3 @@ COLOR_CODES = { "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 -} - -_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", -} - -_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 -} - - -color_default = "#ffffff" - -_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'] -) -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 == "": - return [color_default] - 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 != input: - 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}") - return color_default - - output = [lookup(input[i : i + 2]) for i in range(0, len(input), 2)] - - if len(output) == 2: # Give wires with EXACTLY 2 colors that striped look. - output += output[:1] - elif pad and len(output) == 1: # Hacky style fix: Give single color wires - output *= 3 # a triple-up so that wires are the same size. - - return output - - -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)] - ) - - -def translate_color(input: Colors, color_mode: ColorMode) -> str: - 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") - - color_mode = color_mode.lower() - 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": - output = "".join(get_color_translation(_color_ger, input)) - elif color_mode == "short": - output = input - else: - raise Exception("Unknown color mode") - if upper: - return output.upper() - else: - return output.lower() diff --git a/src/wireviz/wv_dataclasses.py b/src/wireviz/wv_dataclasses.py new file mode 100644 index 0000000..3e08375 --- /dev/null +++ b/src/wireviz/wv_dataclasses.py @@ -0,0 +1,815 @@ +# -*- coding: utf-8 -*- + +from collections import namedtuple +from dataclasses import dataclass, field +from enum import Enum +from itertools import zip_longest +from typing import Any, Dict, List, Optional, Tuple, Union + +from wireviz.wv_bom import ( + BomHash, + BomHashList, + PartNumberInfo, + QtyMultiplierCable, + QtyMultiplierConnector, +) +from wireviz.wv_colors import ( + COLOR_CODES, + ColorOutputMode, + MultiColor, + SingleColor, + get_color_by_colorcode_index, +) +from wireviz.wv_utils import aspect_ratio, awg_equiv, mm2_equiv, remove_links + +# 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 +) + +Designator = PlainText # Case insensitive unique name of connector or cable + +# Literal type aliases below are commented to avoid requiring python 3.8 +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 +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', ...] + + +Side = Enum("Side", "LEFT RIGHT") +ArrowDirection = Enum("ArrowDirection", "NONE BACK FORWARD BOTH") +ArrowWeight = Enum("ArrowWeight", "SINGLE DOUBLE") +NumberAndUnit = namedtuple("NumberAndUnit", "number unit") + +AUTOGENERATED_PREFIX = "AUTOGENERATED_" + + +@dataclass +class Arrow: + direction: ArrowDirection + weight: ArrowWeight + + +class Metadata(dict): + pass + + +@dataclass +class Options: + fontname: PlainText = "arial" + bgcolor: SingleColor = "WH" # will be converted to SingleColor in __post_init__ + bgcolor_node: SingleColor = "WH" + bgcolor_connector: SingleColor = None + bgcolor_cable: SingleColor = None + bgcolor_bundle: SingleColor = None + color_output_mode: ColorOutputMode = ColorOutputMode.EN_UPPER + mini_bom_mode: bool = True + template_separator: str = "." + _pad: int = 0 + # TODO: resolve template and image paths during rendering, not during YAML parsing + _template_paths: List = field(default_factory=list) + _image_paths: List = field(default_factory=list) + + def __post_init__(self): + self.bgcolor = SingleColor(self.bgcolor) + self.bgcolor_node = SingleColor(self.bgcolor_node) + self.bgcolor_connector = SingleColor(self.bgcolor_connector) + self.bgcolor_cable = SingleColor(self.bgcolor_cable) + self.bgcolor_bundle = SingleColor(self.bgcolor_bundle) + + if not self.bgcolor_node: + self.bgcolor_node = self.bgcolor + if not self.bgcolor_connector: + self.bgcolor_connector = self.bgcolor_node + if not self.bgcolor_cable: + self.bgcolor_cable = self.bgcolor_node + if not self.bgcolor_bundle: + self.bgcolor_bundle = self.bgcolor_cable + + +@dataclass +class Tweak: + override: Optional[Dict[Designator, Dict[str, Optional[str]]]] = None + append: Union[str, List[str], None] = None + + +@dataclass +class Image: + # Attributes of the image object : + src: str + scale: Optional[ImageScale] = None + # Attributes of the image cell containing the image: + width: Optional[int] = None + height: Optional[int] = None + fixedsize: Optional[bool] = None + bgcolor: SingleColor = None + # Contents of the text cell just below the image cell: + caption: Optional[MultilineHypertext] = None + # See also HTML doc at https://graphviz.org/doc/info/shapes.html#html + + def __post_init__(self): + self.bgcolor = SingleColor(self.bgcolor) + + if self.fixedsize is None: + # Default True if any dimension specified unless self.scale also is specified. + self.fixedsize = (self.width or self.height) and self.scale is None + + if self.scale is None: + if not self.width and not self.height: + self.scale = "false" + elif self.width and self.height: + self.scale = "both" + else: + self.scale = "true" # When only one dimension is specified. + + if self.fixedsize: + # If only one dimension is specified, compute the other + # because Graphviz requires both when fixedsize=True. + if self.height: + if not self.width: + self.width = self.height * aspect_ratio(self.src) + else: + if self.width: + self.height = self.width / aspect_ratio(self.src) + + +@dataclass +class PinClass: + index: int + id: str + label: str + color: MultiColor + parent: str # designator of parent connector + _num_connections = 0 # incremented in Connector.connect() + _anonymous: bool = False # true for pins on autogenerated connectors + _simple: bool = False # true for simple connector + + def __str__(self): + snippets = [ # use str() for each in case they are int or other non-str + str(self.parent) if not self._anonymous else "", + str(self.id) if not self._anonymous and not self._simple else "", + str(self.label) if self.label else "", + ] + return ":".join([snip for snip in snippets if snip != ""]) + + +@dataclass +class Component: + category: Optional[str] = None # currently only used by cables, to define bundles + type: Union[MultilineHypertext, List[MultilineHypertext]] = None + subtype: Union[MultilineHypertext, List[MultilineHypertext]] = None + + # part number + partnumbers: PartNumberInfo = None # filled by fill_partnumbers() + # the following are provided for user convenience and should not be accessed later. + # their contents are loaded into partnumbers during the child class __post_init__() + pn: str = None + manufacturer: str = None + mpn: str = None + supplier: str = None + spn: str = None + # BOM info + qty: NumberAndUnit = NumberAndUnit(1, None) + amount: Optional[NumberAndUnit] = None + sum_amounts_in_bom: bool = True + ignore_in_bom: bool = False + bom_id: Optional[str] = None # to be filled after harness is built + + def fill_partnumbers(self): + partnos = [self.pn, self.manufacturer, self.mpn, self.supplier, self.spn] + partnos = [remove_links(entry) for entry in partnos] + partnos = tuple(partnos) + self.partnumbers = PartNumberInfo(*partnos) + + def parse_number_and_unit( + self, + inp: Optional[Union[NumberAndUnit, float, int, str]], + default_unit: Optional[str] = None, + ) -> Optional[NumberAndUnit]: + if inp is None: + return None + elif isinstance(inp, NumberAndUnit): + return inp + elif isinstance(inp, float) or isinstance(inp, int): + return NumberAndUnit(float(inp), default_unit) + elif isinstance(inp, str): + if " " in inp: + number, unit = inp.split(" ", 1) + else: + number, unit = inp, default_unit + try: + number = float(number) + except ValueError: + raise Exception( + f"{inp} is not a valid number and unit.\n" + "It must be a number, or a number and unit separated by a space." + ) + else: + return NumberAndUnit(number, unit) + + @property + def bom_hash(self) -> BomHash: + if self.sum_amounts_in_bom: + _hash = BomHash( + description=self.description, + qty_unit=self.amount.unit if self.amount else None, + amount=None, + partnumbers=self.partnumbers, + ) + else: + _hash = BomHash( + description=self.description, + qty_unit=self.qty.unit, + amount=self.amount, + partnumbers=self.partnumbers, + ) + return _hash + + @property + def bom_qty(self) -> float: + if self.sum_amounts_in_bom: + if self.amount: + return self.qty.number * self.amount.number + else: + return self.qty.number + else: + return self.qty.number + + def bom_amount(self) -> NumberAndUnit: + if self.sum_amounts_in_bom: + return NumberAndUnit(None, None) + else: + return self.amount + + @property + def has_pn_info(self) -> bool: + return any([self.pn, self.manufacturer, self.mpn, self.supplier, self.spn]) + + +@dataclass +class AdditionalComponent(Component): + qty_multiplier: Union[QtyMultiplierConnector, QtyMultiplierCable, int] = 1 + _qty_multiplier_computed: Union[int, float] = 1 + designators: Optional[str] = None # used for components definedi in the + # additional_bom_items section within another component + bgcolor: SingleColor = None # ^ same here + note: str = None + + def __post_init__(self): + super().fill_partnumbers() + self.bgcolor = SingleColor(self.bgcolor) + self.qty = self.parse_number_and_unit(self.qty, None) + self.amount = self.parse_number_and_unit(self.amount, None) + + if isinstance(self.qty_multiplier, float) or isinstance( + self.qty_multiplier, int + ): + pass + else: + self.qty_multiplier = self.qty_multiplier.upper() + if self.qty_multiplier in QtyMultiplierConnector.__members__.keys(): + self.qty_multiplier = QtyMultiplierConnector[self.qty_multiplier] + elif self.qty_multiplier in QtyMultiplierCable.__members__.keys(): + self.qty_multiplier = QtyMultiplierCable[self.qty_multiplier] + else: + raise Exception(f"Unknown qty multiplier: {self.qty_multiplier}") + + @property + def additional_components(self): + # an additional component may not have further nested additional comonents + return [] + + @property + def bom_qty(self): + return self.qty.number * self._qty_multiplier_computed + + @property + def description(self) -> str: + return f"{self.type}{', ' + self.subtype if self.subtype else ''}" + + +@dataclass +class GraphicalComponent(Component): # abstract class, for future use + bgcolor: Optional[SingleColor] = None + + +@dataclass +class TopLevelGraphicalComponent(GraphicalComponent): # abstract class + # component properties + designator: Designator = None + color: Optional[MultiColor] = None + image: Optional[Image] = None + additional_parameters: Optional[Dict] = None + additional_components: List[AdditionalComponent] = field(default_factory=list) + notes: Optional[MultilineHypertext] = None + # BOM options + add_up_in_bom: Optional[bool] = None + # rendering options + bgcolor_title: Optional[SingleColor] = None + show_name: Optional[bool] = None + + +@dataclass +class Connector(TopLevelGraphicalComponent): + # connector-specific properties + style: Optional[str] = None + category: Optional[str] = None + loops: List[List[Pin]] = field(default_factory=list) + # pin information in particular + pincount: Optional[int] = None + pins: List[Pin] = field(default_factory=list) # legacy + pinlabels: List[Pin] = field(default_factory=list) # legacy + pincolors: List[str] = field(default_factory=list) # legacy + pin_objects: Dict[Any, PinClass] = field(default_factory=dict) # new + # rendering option + show_pincount: Optional[bool] = None + hide_disconnected_pins: bool = False + + @property + def is_autogenerated(self): + return self.designator.startswith(AUTOGENERATED_PREFIX) + + @property + def description(self) -> str: + substrs = [ + "Connector", + self.type, + self.subtype, + f"{self.pincount} pins" if self.show_pincount else None, + str(self.color) if self.color else None, + ] + return ", ".join([str(s) for s in substrs if s is not None and s != ""]) + + def should_show_pin(self, pin_id): + return ( + not self.hide_disconnected_pins + or self.pin_objects[pin_id]._num_connections > 0 + ) + + @property + def unit(self): # for compatibility with BOM hashing + return None # connectors do not support units. + + def __post_init__(self) -> None: + super().fill_partnumbers() + + self.bgcolor = SingleColor(self.bgcolor) + self.bgcolor_title = SingleColor(self.bgcolor_title) + self.color = MultiColor(self.color) + + # connectors do not support custom qty or amount + self.qty = NumberAndUnit(1, None) + self.amount = None + + if isinstance(self.image, dict): + self.image = Image(**self.image) + + self.ports_left = False + self.ports_right = False + self.visible_pins = {} + + if self.style == "simple": + if self.pincount and self.pincount > 1: + 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) + ) + if not self.pincount: + 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") + + # all checks have passed + pin_tuples = zip_longest( + self.pins, + self.pinlabels, + self.pincolors, + ) + for pin_index, (pin_id, pin_label, pin_color) in enumerate(pin_tuples): + self.pin_objects[pin_id] = PinClass( + index=pin_index, + id=pin_id, + label=pin_label, + color=MultiColor(pin_color), + parent=self.designator, + _anonymous=self.is_autogenerated, + _simple=self.style == "simple", + ) + + if self.show_name is None: + self.show_name = self.style != "simple" and not self.is_autogenerated + + if self.show_pincount is None: + # hide pincount for simple (1 pin) connectors by default + self.show_pincount = self.style != "simple" + + for loop in self.loops: + # 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!") + for pin in loop: + if pin not in self.pins: + raise Exception(f'Unknown loop pin "{pin}" for connector "{self.name}"!') + # Make sure loop connected pins are not hidden. + # side=None, determine side to show loops during rendering + self.activate_pin(pin, side=None, is_connection=True) + + for i, item in enumerate(self.additional_components): + if isinstance(item, dict): + self.additional_components[i] = AdditionalComponent(**item) + + def activate_pin(self, pin_id, side: Side = None, is_connection=True) -> None: + if is_connection: + self.pin_objects[pin_id]._num_connections += 1 + if side == Side.LEFT: + self.ports_left = True + elif side == Side.RIGHT: + self.ports_right = True + + def compute_qty_multipliers(self): + # do not run before all connections in harness have been made! + num_populated_pins = len( + [pin for pin in self.pin_objects.values() if pin._num_connections > 0] + ) + num_connections = sum( + [pin._num_connections for pin in self.pin_objects.values()] + ) + qty_multipliers_computed = { + "PINCOUNT": self.pincount, + "POPULATED": num_populated_pins, + "CONNECTIONS": num_connections, + } + for subitem in self.additional_components: + if isinstance(subitem.qty_multiplier, QtyMultiplierConnector): + computed_factor = qty_multipliers_computed[subitem.qty_multiplier.name] + elif isinstance(subitem.qty_multiplier, QtyMultiplierCable): + raise Exception("Used a cable multiplier in a connector!") + else: # int or float + computed_factor = subitem.qty_multiplier + subitem._qty_multiplier_computed = computed_factor + + +@dataclass +class WireClass: + parent: str # designator of parent cable/bundle + # wire-specific properties + index: int + id: str + label: str + color: MultiColor + # ... + bom_id: Optional[str] = None # to be filled after harness is built + # inheritable from parent cable + type: Union[MultilineHypertext, List[MultilineHypertext]] = None + subtype: Union[MultilineHypertext, List[MultilineHypertext]] = None + gauge: Optional[NumberAndUnit] = None + length: Optional[NumberAndUnit] = None + ignore_in_bom: Optional[bool] = False + sum_amounts_in_bom: bool = True + partnumbers: PartNumberInfo = None + + @property + def bom_hash(self) -> BomHash: + if self.sum_amounts_in_bom: + _hash = BomHash( + description=self.description, + qty_unit=self.length.unit if self.length else None, + amount=None, + partnumbers=self.partnumbers, + ) + else: + _hash = BomHash( + description=self.description, + qty_unit=None, + amount=self.length, + partnumbers=self.partnumbers, + ) + return _hash + + @property + def gauge_str(self): + if not self.gauge: + return None + actual_gauge = f"{self.gauge.number} {self.gauge.unit}" + actual_gauge = actual_gauge.replace("mm2", "mm\u00B2") + return actual_gauge + + @property + def description(self) -> str: + substrs = [ + "Wire", + self.type, + self.subtype, + self.gauge_str, + str(self.color) if self.color else None, + ] + desc = ", ".join([s for s in substrs if s is not None and s != ""]) + return desc + + +@dataclass +class ShieldClass(WireClass): + pass # TODO, for wires with multiple shields more shield details, ... + + +@dataclass +class Connection: + from_: PinClass = None + via: Union[WireClass, ShieldClass] = None + to: PinClass = None + + +@dataclass +class Cable(TopLevelGraphicalComponent): + # cable-specific properties + gauge: Optional[NumberAndUnit] = None + length: Optional[NumberAndUnit] = None + color_code: Optional[str] = None + # wire information in particular + wirecount: Optional[int] = None + shield: Union[bool, MultiColor] = False + colors: List[str] = field(default_factory=list) # legacy + wirelabels: List[Wire] = field(default_factory=list) # legacy + wire_objects: Dict[Any, WireClass] = field(default_factory=dict) # new + # internal + _connections: List[Connection] = field(default_factory=list) + # rendering options + show_name: Optional[bool] = None + show_equiv: bool = False + show_wirecount: bool = True + show_wirenumbers: Optional[bool] = None + + @property + def is_autogenerated(self): + return self.designator.startswith(AUTOGENERATED_PREFIX) + + @property + def unit(self): # for compatibility with parent class + return self.length + + @property + def gauge_str(self): + if not self.gauge: + return None + actual_gauge = f"{self.gauge.number} {self.gauge.unit}" + actual_gauge = actual_gauge.replace("mm2", "mm\u00B2") + return actual_gauge + + @property + def gauge_str_with_equiv(self): + if not self.gauge: + return None + actual_gauge = self.gauge_str + equivalent_gauge = "" + if self.show_equiv: + # convert unit if known + if self.gauge.unit == "mm2": + equivalent_gauge = f" ({awg_equiv(self.gauge.number)} AWG)" + elif self.gauge.unit.upper() == "AWG": + equivalent_gauge = f" ({mm2_equiv(self.gauge.number)} mm2)" + out = f"{actual_gauge}{equivalent_gauge}" + out = out.replace("mm2", "mm\u00B2") + return out + + @property + def length_str(self): + if not self.length: + return None + out = f"{self.length.number} {self.length.unit}" + return out + + @property + def bom_hash(self): + if self.category == "bundle": + raise Exception("Do this at the wire level!") # TODO + else: + return super().bom_hash + + @property + def description(self) -> str: + if self.category == "bundle": + raise Exception("Do this at the wire level!") # TODO + else: + substrs = [ + ("", "Cable"), + (", ", self.type), + (", ", self.subtype), + (", ", self.wirecount), + (" ", f"x {self.gauge_str}" if self.gauge else "wires"), + (" ", "shielded" if self.shield else None), + (", ", str(self.color) if self.color else None), + ] + desc = "".join( + [f"{s[0]}{s[1]}" for s in substrs if s[1] is not None and s[1] != ""] + ) + return desc + + def _get_wire_partnumber(self, idx) -> PartNumberInfo: + def _get_correct_element(inp, idx): + return inp[idx] if isinstance(inp, List) else inp + + # TODO: possibly make more robust/elegant + if self.category == "bundle": + return PartNumberInfo( + _get_correct_element(self.partnumbers.pn, idx), + _get_correct_element(self.partnumbers.manufacturer, idx), + _get_correct_element(self.partnumbers.mpn, idx), + _get_correct_element(self.partnumbers.supplier, idx), + _get_correct_element(self.partnumbers.spn, idx), + ) + else: + return None # non-bundles do not support lists of part data + + def __post_init__(self) -> None: + super().fill_partnumbers() + + self.bgcolor = SingleColor(self.bgcolor) + self.bgcolor_title = SingleColor(self.bgcolor_title) + self.color = MultiColor(self.color) + + if isinstance(self.image, dict): + self.image = Image(**self.image) + + # TODO: + # allow gauge, length, and other fields to be lists too (like part numbers), + # and assign them the same way to bundles. + + self.gauge = self.parse_number_and_unit(self.gauge, "mm2") + self.length = self.parse_number_and_unit(self.length, "m") + self.amount = self.length # for BOM + + if self.wirecount: # number of wires explicitly defined + if self.colors: # use custom color palette (partly or looped if needed) + self.colors = [ + self.colors[i % len(self.colors)] for i in range(self.wirecount) + ] + 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") + self.colors = [ + get_color_by_colorcode_index(self.color_code, i) + for i in range(self.wirecount) + ] + else: # no colors defined, add dummy 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)" + ) + 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 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]: + if isinstance(idfield, list): + if self.category == "bundle": + # check the length + if len(idfield) != self.wirecount: + raise Exception("lists of part data must match wirecount") + else: + raise Exception("lists of part data are only supported for bundles") + + # all checks have passed + wire_tuples = zip_longest( + # TODO: self.wire_ids + self.colors, + self.wirelabels, + ) + for wire_index, (wire_color, wire_label) in enumerate(wire_tuples): + id = wire_index + 1 + self.wire_objects[id] = WireClass( + parent=self.designator, + # wire-specific properties + index=wire_index, # TODO: wire_id + id=id, # TODO: wire_id + label=wire_label, + color=MultiColor(wire_color), + # inheritable from parent cable + type=self.type, + subtype=self.subtype, + gauge=self.gauge, + length=self.length, + sum_amounts_in_bom=self.sum_amounts_in_bom, + ignore_in_bom=self.ignore_in_bom, + partnumbers=self._get_wire_partnumber(wire_index), + ) + + if self.shield: + index_offset = len(self.wire_objects) + # TODO: add support for multiple shields + id = "s" + self.wire_objects[id] = ShieldClass( + index=index_offset, + id=id, + label="Shield", + color=MultiColor(self.shield) + if isinstance(self.shield, str) + else MultiColor(None), + parent=self.designator, + ) + + if self.show_name is None: + self.show_name = not self.is_autogenerated + + if self.show_wirenumbers is None: + # by default, show wire numbers for cables, hide for bundles + self.show_wirenumbers = self.category != "bundle" + + for i, item in enumerate(self.additional_components): + if isinstance(item, dict): + self.additional_components[i] = AdditionalComponent(**item) + + def _connect( + self, + from_pin_obj: List[PinClass], + via_wire_id: str, + to_pin_obj: List[PinClass], + ) -> None: + via_wire_obj = self.wire_objects[via_wire_id] + self._connections.append(Connection(from_pin_obj, via_wire_obj, to_pin_obj)) + + def compute_qty_multipliers(self): + # do not run before all connections in harness have been made! + total_length = sum( + [ + wire.length.number if wire.length else 0 + for wire in self.wire_objects.values() + ] + ) + qty_multipliers_computed = { + "WIRECOUNT": len(self.wire_objects), + "TERMINATIONS": 999, # TODO + "LENGTH": self.length.number if self.length else 0, + "TOTAL_LENGTH": total_length, + } + for subitem in self.additional_components: + if isinstance(subitem.qty_multiplier, QtyMultiplierCable): + computed_factor = qty_multipliers_computed[subitem.qty_multiplier.name] + # inherit component's length unit if appropriate + if subitem.qty_multiplier.name in ["LENGTH", "TOTAL_LENGTH"]: + if subitem.qty.unit is not None: + raise Exception( + f"No unit may be specified when using" + f"{subitem.qty_multiplier} as a multiplier" + ) + subitem.qty = NumberAndUnit(subitem.qty.number, self.length.unit) + + elif isinstance(subitem.qty_multiplier, QtyMultiplierConnector): + raise Exception("Used a connector multiplier in a cable!") + else: # int or float + computed_factor = subitem.qty_multiplier + subitem._qty_multiplier_computed = computed_factor + + +@dataclass +class MatePin: + from_: PinClass + to: PinClass + arrow: Arrow + + +@dataclass +class MateComponent: + from_: str # Designator + to: str # Designator + arrow: Arrow diff --git a/src/wireviz/wv_graphviz.py b/src/wireviz/wv_graphviz.py new file mode 100644 index 0000000..e1c5d03 --- /dev/null +++ b/src/wireviz/wv_graphviz.py @@ -0,0 +1,618 @@ +# -*- coding: utf-8 -*- + +import re +from itertools import zip_longest +from typing import Any, List, Optional, Tuple, Union + +from wireviz import APP_NAME, APP_URL, __version__ +from wireviz.wv_bom import partnumbers2list +from wireviz.wv_colors import MultiColor +from wireviz.wv_dataclasses import ( + ArrowDirection, + ArrowWeight, + Cable, + Component, + Connector, + MateComponent, + MatePin, + Options, + PartNumberInfo, + ShieldClass, + WireClass, +) +from wireviz.wv_html import Img, Table, Td, Tr +from wireviz.wv_utils import html_line_breaks, remove_links + + +def gv_node_component(component: Component) -> Table: + # If no wires connected (except maybe loop wires)? + if isinstance(component, Connector): + if not (component.ports_left or component.ports_right): + component.ports_left = True # Use left side pins by default + + # generate all rows to be shown in the node + if component.show_name: + str_name = f"{remove_links(component.designator)}" + line_name = Td(str_name, bgcolor=component.bgcolor_title.html) + else: + line_name = None + + line_pn = partnumbers2list(component.partnumbers) + + is_simple_connector = ( + isinstance(component, Connector) and component.style == "simple" + ) + + if isinstance(component, Connector): + line_info = [ + bom_bubble(component.bom_id), + html_line_breaks(component.type), + html_line_breaks(component.subtype), + f"{component.pincount}-pin" if component.show_pincount else None, + str(component.color) if component.color else None, + ] + elif isinstance(component, Cable): + line_info = [ + bom_bubble(component.bom_id) if component.category != "bundle" else None, + html_line_breaks(component.type), + f"{component.wirecount}x" if component.show_wirecount else None, + component.gauge_str_with_equiv, + "+ S" if component.shield else None, + component.length_str, + str(component.color) if component.color else None, + ] + + if component.additional_parameters: + line_additional_parameters = nested_table_dict(component.additional_parameters) + else: + line_additional_parameters = [] + + if component.color: + line_info.extend(colorbar_cells(component.color)) + + line_image, line_image_caption = image_and_caption_cells(component) + line_additional_component_table = gv_additional_component_table(component) + line_notes = [Td(html_line_breaks(component.notes), balign="left")] + + if isinstance(component, Connector): + if component.style != "simple": + line_ports = gv_pin_table(component) + else: + line_ports = None + elif isinstance(component, Cable): + line_ports = gv_conductor_table(component) + + lines = [ + line_name, + line_pn, + line_info, + line_additional_parameters, + line_ports, + line_image, + line_image_caption, + line_additional_component_table, + line_notes, + ] + + tbl = nested_table(lines) + if is_simple_connector: + # Simple connectors have no pin table, and therefore, no ports to attach wires to. + # Manually assign left and right ports here if required. + # Use table itself for right port, and the first cell for left port. + # Even if the table only has one cell, two separate ports can still be assigned. + tbl.update_attribs(port="p1r") + first_cell_in_tbl = tbl.contents[0].contents + first_cell_in_tbl.update_attribs(port="p1l") + + return tbl + + +def gv_additional_component_table(component): + if not component.additional_components: + return None + + rows = [] + for subitem in component.additional_components: + firstline = [ + Td(bom_bubble(subitem.bom_id)), + Td(f"{subitem.bom_qty}", align="right"), + Td(f"{subitem.qty.unit if subitem.qty.unit else 'x'}", align="left"), + Td(f"{subitem.description}", align="left"), + Td(f"{subitem.note if subitem.note else ''}", align="left"), + ] + rows.append(Tr(firstline)) + + if subitem.has_pn_info: + secondline = [ + Td("", colspan=3), + Td(f"# TODO PN string", align="left"), # TODO + Td(""), + ] + rows.append(Tr(secondline)) + + return Table(rows, border=1, cellborder=0, cellpadding=3, cellspacing=0) + + +def calculate_node_bgcolor(component, harness_options): + # assign component node bgcolor at the GraphViz node level + # instead of at the HTML table level for better rendering of node outline + if component.bgcolor: + return component.bgcolor.html + elif isinstance(component, Connector) and harness_options.bgcolor_connector: + return harness_options.bgcolor_connector.html + elif ( + isinstance(component, Cable) + and component.category == "bundle" + and harness_options.bgcolor_bundle + ): + return harness_options.bgcolor_bundle.html + elif isinstance(component, Cable) and harness_options.bgcolor_cable: + return harness_options.bgcolor_cable.html + + +def bom_bubble(id) -> Table: + if id is None: + return None + else: + # TODO: activate BOM bubbles + return None + # size and style of BOM bubble is optimized to be a rounded square, + # big enough to hold any two-digit ID without GraphViz warnings + text = id + # text = f'{id}' + return Table( + Tr( + Td( + text, + border=1, + cellpadding=0, + fixedsize="true", + style="rounded", + height=20, + width=20, + # bgcolor="#000000", + ) + ), + border=0, + ) + + +def make_list_of_cells(inp) -> List[Td]: + # inp may be List, + if isinstance(inp, List): + # ensure all list items are Td + list_out = [item if isinstance(item, Td) else Td(item) for item in inp] + return list_out + else: + if inp is None: + return [] + if isinstance(inp, Td): + return [inp] + else: + return [Td(inp)] + + +def nested_table(lines: List[Td]) -> Table: + cell_lists = [make_list_of_cells(line) for line in lines] + rows = [] + + for lst in cell_lists: + if len(lst) == 0: + continue # no cells in list + cells = [item for item in lst if item.contents is not None] + if len(cells) == 0: + continue # no cells in list, or all cells are None + if ( + len(cells) == 1 + and isinstance(cells[0].contents, Table) + and not "!" in cells[0].contents.attribs.get("id", "") + ): + # cell content is already a table, no need to re-wrap it; + # unless explicitly asked to by a "!" in the ID field + # as used by image_and_caption_cells() + inner_table = cells[0].contents + else: + # nest cell content inside a table + inner_table = Table( + Tr(cells), border=0, cellborder=1, cellpadding=3, cellspacing=0 + ) + rows.append(Tr(Td(inner_table))) + + if len(rows) == 0: # create dummy row to avoid GraphViz errors due to empty + inner_table = Table( + Tr(Td("")), border=0, cellborder=1, cellpadding=3, cellspacing=0 + ) + rows = [Tr(Td(inner_table))] + tbl = Table(rows, border=0, cellspacing=0, cellpadding=0) + return tbl + + +def nested_table_dict(d: dict) -> Table: + rows = [] + for k, v in d.items(): + rows.append( + Tr( + [ + Td(k, align="left", balign="left", valign="top"), + Td(html_line_breaks(v), align="left", balign="left"), + ] + ) + ) + return Table(rows, border=0, cellborder=1, cellpadding=3, cellspacing=0) + + +def gv_pin_table(component) -> Table: + pin_rows = [] + for pin in component.pin_objects.values(): + if component.should_show_pin(pin.id): + pin_rows.append(gv_pin_row(pin, component)) + if len(pin_rows) == 0: + # TODO: write test for empty pin tables, and for unconnected connectors that hide disconnected pins + pass + tbl = Table(pin_rows, border=0, cellborder=1, cellpadding=3, cellspacing=0) + return tbl + + +def gv_pin_row(pin, connector) -> Tr: + # ports in GraphViz are 1-indexed for more natural maping to pin/wire numbers + has_pincolors = any([_pin.color for _pin in connector.pin_objects.values()]) + cells = [ + Td(pin.id, port=f"p{pin.index+1}l") if connector.ports_left else None, + Td(pin.label, delete_if_empty=True), + Td(str(pin.color) if pin.color else "", sides="TBL") if has_pincolors else None, + Td(color_minitable(pin.color), sides="TBR") if has_pincolors else None, + Td(pin.id, port=f"p{pin.index+1}r") if connector.ports_right else None, + ] + return Tr(cells) + + +def gv_connector_loops(connector: Connector) -> List: + loop_edges = [] + if connector.ports_left: + loop_side = "l" + loop_dir = "w" + elif connector.ports_right: + loop_side = "r" + loop_dir = "e" + else: + raise Exception("No side for loops") + for loop in connector.loops: + head = f"{connector.designator}:p{loop[0]}{loop_side}:{loop_dir}" + tail = f"{connector.designator}:p{loop[1]}{loop_side}:{loop_dir}" + loop_edges.append((head, tail)) + return loop_edges + + +def gv_conductor_table(cable) -> Table: + rows = [] + rows.append(Tr(Td(" "))) # spacer row on top + + inserted_break_inbetween = False + for wire in cable.wire_objects.values(): + # insert blank space between wires and shields + if isinstance(wire, ShieldClass) and not inserted_break_inbetween: + rows.append(Tr(Td(" "))) # spacer row between wires and shields + inserted_break_inbetween = True + + # row above the wire + wireinfo = [] + if cable.show_wirenumbers and not isinstance(wire, ShieldClass): + wireinfo.append(str(wire.id)) + wireinfo.append(str(wire.color)) + wireinfo.append(wire.label) + + ins, outs = [], [] + for conn in cable._connections: + if conn.via.id == wire.id: + if conn.from_ is not None: + ins.append(str(conn.from_)) + if conn.to is not None: + outs.append(str(conn.to)) + + cells_above = [ + Td(" " + ", ".join(ins), align="left"), + Td(" "), # increase cell spacing here + Td(bom_bubble(wire.bom_id)) if cable.category == "bundle" else None, + Td(":".join([wi for wi in wireinfo if wi is not None and wi != ""])), + Td(" "), # increase cell spacing here + Td(", ".join(outs) + " ", align="right"), + ] + cells_above = [cell for cell in cells_above if cell is not None] + rows.append(Tr(cells_above)) + + # the wire itself + rows.append(Tr(gv_wire_cell(wire, len(cells_above)))) + + # row below the wire + if wire.partnumbers: + cells_below = partnumbers2list( + wire.partnumbers, parent_partnumbers=cable.partnumbers + ) + if cells_below is not None and len(cells_below) > 0: + table_below = ( + Table( + Tr([Td(cell) for cell in cells_below]), + border=0, + cellborder=0, + cellspacing=0, + ), + ) + rows.append(Tr(Td(table_below, colspan=len(cells_above)))) + + rows.append(Tr(Td(" "))) # spacer row on bottom + tbl = Table(rows, border=0, cellborder=0, cellspacing=0) + return tbl + + +def gv_wire_cell(wire: Union[WireClass, ShieldClass], colspan: int) -> Td: + if wire.color: + color_list = ["#000000"] + wire.color.html_padded_list + ["#000000"] + else: + color_list = ["#000000"] + + wire_inner_rows = [] + for j, bgcolor in enumerate(color_list[::-1]): + wire_inner_cell_attribs = { + "bgcolor": bgcolor if bgcolor != "" else "#000000", + "border": 0, + "cellpadding": 0, + "colspan": colspan, + "height": 2, + } + wire_inner_rows.append(Tr(Td("", **wire_inner_cell_attribs))) + wire_inner_table = Table(wire_inner_rows, border=0, cellborder=0, cellspacing=0) + wire_outer_cell_attribs = { + "border": 0, + "cellspacing": 0, + "cellpadding": 0, + "colspan": colspan, + "height": 2 * len(color_list), + "port": f"w{wire.index+1}", + } + # ports in GraphViz are 1-indexed for more natural maping to pin/wire numbers + wire_outer_cell = Td(wire_inner_table, **wire_outer_cell_attribs) + + return wire_outer_cell + + +def gv_edge_wire(harness, cable, connection) -> Tuple[str, str, str, str, str]: + if connection.via.color: + # check if it's an actual wire and not a shield + color = f"#000000:{connection.via.color.html_padded}:#000000" + else: # it's a shield connection + color = "#000000" + + if connection.from_ is not None: # connect to left + from_port_str = ( + f":p{connection.from_.index+1}r" + if harness.connectors[connection.from_.parent].style != "simple" + else "" + ) + code_left_1 = f"{connection.from_.parent}{from_port_str}:e" + code_left_2 = f"{connection.via.parent}:w{connection.via.index+1}:w" + # ports in GraphViz are 1-indexed for more natural maping to pin/wire numbers + else: + code_left_1, code_left_2 = None, None + + if connection.to is not None: # connect to right + to_port_str = ( + f":p{connection.to.index+1}l" + if harness.connectors[connection.to.parent].style != "simple" + else "" + ) + code_right_1 = f"{connection.via.parent}:w{connection.via.index+1}:e" + code_right_2 = f"{connection.to.parent}{to_port_str}:w" + else: + code_right_1, code_right_2 = None, None + + return color, code_left_1, code_left_2, code_right_1, code_right_2 + + +def parse_arrow_str(inp: str) -> ArrowDirection: + if inp[0] == "<" and inp[-1] == ">": + return ArrowDirection.BOTH + elif inp[0] == "<": + return ArrowDirection.BACK + elif inp[-1] == ">": + return ArrowDirection.FORWARD + else: + return ArrowDirection.NONE + + +def gv_edge_mate(mate) -> Tuple[str, str, str, str]: + if mate.arrow.weight == ArrowWeight.SINGLE: + color = "#000000" + elif mate.arrow.weight == ArrowWeight.DOUBLE: + color = "#000000:#000000" + + dir = mate.arrow.direction.name.lower() + + if isinstance(mate, MatePin): + from_pin_index = mate.from_.index + from_port_str = f":p{from_pin_index+1}r" + from_designator = mate.from_.parent + to_pin_index = mate.to.index + to_port_str = f":p{to_pin_index+1}l" + to_designator = mate.to.parent + elif isinstance(mate, MateComponent): + from_designator = mate.from_ + from_port_str = "" + to_designator = mate.to + to_port_str = "" + else: + raise Exception(f"Unknown type of mate:\n{mate}") + + code_from = f"{from_designator}{from_port_str}:e" + code_to = f"{to_designator}{to_port_str}:w" + + return color, dir, code_from, code_to + + +def colorbar_cells(color, mini=False) -> List[Td]: + cells = [] + mini = {"height": 8, "width": 8, "fixedsize": "true"} if mini else {} + for index, subcolor in enumerate(color.colors): + sides_l = "L" if index == 0 else "" + sides_r = "R" if index == len(color.colors) - 1 else "" + sides = "TB" + sides_l + sides_r + cells.append(Td("", bgcolor=subcolor.html, sides=sides, **mini)) + return cells + + +def color_minitable(color: Optional[MultiColor]) -> Union[Table, str]: + if color is None or len(color) == 0: + return "" + + cells = colorbar_cells(color, mini=True) + + return Table( + Tr(cells), + border=0, + cellborder=1, + cellspacing=0, + height=8, + width=8 * len(cells), + fixedsize="true", + ) + + +def image_and_caption_cells(component: Component) -> Tuple[Td, Td]: + if not component.image: + return (None, None) + + image_tag = Img(scale=component.image.scale, src=component.image.src) + image_cell_inner = Td(image_tag, flat=True) + if component.image.fixedsize: + # further nest the image in a table with width/height/fixedsize parameters, + # and place that table in a cell + image_cell_inner.update_attribs(**html_size_attr_dict(component.image)) + image_cell = Td( + Table(Tr(image_cell_inner), border=0, cellborder=0, cellspacing=0, id="!") + ) + else: + image_cell = image_cell_inner + + image_cell.update_attribs( + balign="left", + bgcolor=component.image.bgcolor.html, + sides="TLR" if component.image.caption else None, + ) + + if component.image.caption: + caption_cell = Td( + f"{html_line_breaks(component.image.caption)}", balign="left", sides="BLR" + ) + else: + caption_cell = None + return (image_cell, caption_cell) + + +def html_size_attr_dict(image): + # Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object + pass + + attr_dict = {} + if image: + if image.width: + attr_dict["width"] = image.width + if image.height: + attr_dict["height"] = image.height + if image.fixedsize: + attr_dict["fixedsize"] = "true" + return attr_dict + + +def set_dot_basics(dot, options): + dot.body.append(f"// Graph generated by {APP_NAME} {__version__}\n") + dot.body.append(f"// {APP_URL}\n") + dot.attr( + "graph", + rankdir="LR", + ranksep="2", + bgcolor=options.bgcolor.html, + nodesep="0.33", + fontname=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=options.bgcolor_node.html, + fontname=options.fontname, + ) + dot.attr("edge", style="bold", fontname=options.fontname) + + +def apply_dot_tweaks(dot, tweak): + def typecheck(name: str, value: Any, expect: type) -> None: + if not isinstance(value, expect): + raise Exception( + f"Unexpected value type of {name}: " + f"Expected {expect}, got {type(value)}\n{value}" + ) + + # TODO?: Differ between override attributes and HTML? + if tweak.override is not None: + typecheck("tweak.override", tweak.override, dict) + for k, d in tweak.override.items(): + 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))) + + # Override generated attributes of selected entries matching tweak.override. + for i, entry in enumerate(dot.body): + if not isinstance(entry, str): + continue + # Find a possibly quoted keyword after leading TAB(s) and followed by [ ]. + match = re.match(r'^\t*(")?((?(1)[^"]|[^ "])+)(?(1)") \[.*\]$', entry, re.S) + keyword = match and match[2] + if not keyword in tweak.override.keys(): + continue + + for attr, value in tweak.override[keyword].items(): + if value is None: + entry, n_subs = re.subn( + f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', "", entry + ) + if n_subs < 1: + print( + "Harness.create_graph() warning: " + f"{attr} not found in {keyword}!" + ) + elif n_subs > 1: + print( + "Harness.create_graph() warning: " + f"{attr} removed {n_subs} times in {keyword}!" + ) + continue + + 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 + ) + if n_subs < 1: + # If attr not found, then append it + entry = re.sub(r"\]$", f" {attr}={value}]", entry) + elif n_subs > 1: + print( + "Harness.create_graph() warning: " + f"{attr} overridden {n_subs} times in {keyword}!" + ) + + dot.body[i] = entry + + if tweak.append is not None: + if isinstance(tweak.append, list): + for i, element in enumerate(tweak.append, 1): + typecheck(f"tweak.append[{i}]", element, str) + dot.body.extend(tweak.append) + else: + typecheck("tweak.append", tweak.append, str) + dot.body.append(tweak.append) diff --git a/src/wireviz/wv_gv_html.py b/src/wireviz/wv_gv_html.py deleted file mode 100644 index ec80aa7..0000000 --- a/src/wireviz/wv_gv_html.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- coding: utf-8 -*- - -import re -from typing import List, Optional, Union - -from wireviz.DataClasses import Color -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: - # 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 inside a list are injected into to the preceeding \n" - for item in bom[0]: - th_class = f"bom_col_{item.lower()}" - bom_header_html = f'{bom_header_html} \n' - bom_header_html = f"{bom_header_html} \n" + def update_attribs(self, **kwargs): + for k, v in kwargs.items(): + self.attribs[k] = v - # generate BOM contents - bom_contents = [] - for row in bom[1:]: - row_html = " \n" - for i, item in enumerate(row): - td_class = f"bom_col_{bom[0][i].lower()}" - row_html = f'{row_html} \n' - row_html = f"{row_html} \n" - bom_contents.append(row_html) + @property + def tagname(self): + return type(self).__name__.lower() - bom_html = ( - '
tag - html = [] - html.append( - f'' - ) - - num_rows = 0 - for row in rows: - if isinstance(row, List): - if len(row) > 0 and any(row): - html.append(" ") - num_rows = num_rows + 1 - elif row is not None: - html.append(" ") - num_rows = num_rows + 1 - if num_rows == 0: # empty table - # generate empty cell to avoid GraphViz errors - html.append("") - html.append("
") - # fmt: off - html.append(' ') - # fmt: on - for cell in row: - if cell is not None: - # Inject attributes to the preceeding '.replace(">
tag where needed - # fmt: off - html.append(f' {cell}
") - html.append("
") - html.append(f" {row}") - html.append("
") - 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 "" - - -def html_bgcolor(color: Color, _extra_attr: str = "") -> str: - """Return
attributes prefix for bgcolor or '' if no color.""" - return f"" if color else "" - - -def html_colorbar(color: Color) -> str: - """Return 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. - html = f'{html_size_attr(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"""> - - -
- """ - return f"""{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 "" - ) - - -def html_line_breaks(inp): - return remove_links(inp).replace("\n", "
") if isinstance(inp, str) else inp diff --git a/src/wireviz/wv_harness.py b/src/wireviz/wv_harness.py new file mode 100644 index 0000000..bda2891 --- /dev/null +++ b/src/wireviz/wv_harness.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- + +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Union + +from graphviz import Graph + +import wireviz.wv_colors +from wireviz.wv_bom import BomCategory, BomEntry, bom_list, print_bom_table +from wireviz.wv_dataclasses import ( + AUTOGENERATED_PREFIX, + AdditionalComponent, + Arrow, + ArrowWeight, + Cable, + Component, + Connector, + MateComponent, + MatePin, + Metadata, + Options, + Side, + TopLevelGraphicalComponent, + Tweak, +) +from wireviz.wv_graphviz import ( + apply_dot_tweaks, + calculate_node_bgcolor, + gv_connector_loops, + gv_edge_mate, + gv_edge_wire, + gv_node_component, + parse_arrow_str, + set_dot_basics, +) +from wireviz.wv_output import ( + embed_svg_images, + embed_svg_images_file, + generate_html_output, +) +from wireviz.wv_utils import bom2tsv, open_file_write + + +@dataclass +class Harness: + metadata: Metadata + options: Options + tweak: Tweak + additional_bom_items: List[AdditionalComponent] = field(default_factory=list) + + def __post_init__(self): + self.connectors = {} + self.cables = {} + self.mates = [] + self.bom = defaultdict(dict) + self.additional_bom_items = [] + + def add_connector(self, designator: str, *args, **kwargs) -> None: + conn = Connector(designator=designator, *args, **kwargs) + self.connectors[designator] = conn + + def add_cable(self, designator: str, *args, **kwargs) -> None: + cbl = Cable(designator=designator, *args, **kwargs) + self.cables[designator] = cbl + + def add_additional_bom_item(self, item: dict) -> None: + new_item = AdditionalComponent(**item) + self.additional_bom_items.append(new_item) + + def add_mate_pin(self, from_name, from_pin, to_name, to_pin, arrow_str) -> None: + from_con = self.connectors[from_name] + from_pin_obj = from_con.pin_objects[from_pin] + to_con = self.connectors[to_name] + to_pin_obj = to_con.pin_objects[to_pin] + arrow = Arrow(direction=parse_arrow_str(arrow_str), weight=ArrowWeight.SINGLE) + + self.mates.append(MatePin(from_pin_obj, to_pin_obj, arrow)) + self.connectors[from_name].activate_pin( + from_pin, Side.RIGHT, is_connection=False + ) + self.connectors[to_name].activate_pin(to_pin, Side.LEFT, is_connection=False) + + def add_mate_component(self, from_name, to_name, arrow_str) -> None: + arrow = Arrow(direction=parse_arrow_str(arrow_str), weight=ArrowWeight.SINGLE) + self.mates.append(MateComponent(from_name, to_name, arrow)) + + def populate_bom(self): + # helper lists + all_toplevel_items = ( + list(self.connectors.values()) + + list(self.cables.values()) + + self.additional_bom_items + ) + all_subitems = [ + subitem + for item in all_toplevel_items + for subitem in item.additional_components + ] + all_bom_relevant_items = ( + list(self.connectors.values()) + + [cable for cable in self.cables.values() if cable.category != "bundle"] + + [ + wire + for cable in self.cables.values() + if cable.category == "bundle" + for wire in cable.wire_objects.values() + ] + + all_subitems + ) + + # add items to BOM + for item in all_toplevel_items: + self._add_to_internal_bom(item) # nested subitems are also handled + # sort BOM by category first, then alphabetically by description within category + self.bom = dict( + sorted( + self.bom.items(), + key=lambda x: ( + x[1]["category"], + x[0].description, + ), # x[0] = key, x[1] = value + ) + ) + # assign BOM IDs + for id, key in enumerate(self.bom.keys(), 1): + self.bom[key]["id"] = id + # set BOM IDs within components (for BOM bubbles) + for item in all_bom_relevant_items: + if item.ignore_in_bom: + continue + if not item.bom_hash in self.bom: + print(f"{item}'s hash' not found in BOM dict.") + continue + item.bom_id = self.bom[item.bom_hash]["id"] + + # print_bom_table(self.bom) # for debugging + + def _add_to_internal_bom(self, item: Component): + if item.ignore_in_bom: + return + + def _add(hash, qty, designator=None, category=None): + bom_entry = self.bom[hash] + # initialize missing fields + if not "qty" in bom_entry: + bom_entry["qty"] = 0 + if not "designators" in bom_entry: + bom_entry["designators"] = set() + # update fields + bom_entry["qty"] += qty + if designator is None: + designator_list = [] + elif isinstance(designator, list): + designator_list = designator + else: + designator_list = [designator] + for des in designator_list: + if des and not des.startswith(AUTOGENERATED_PREFIX): + bom_entry["designators"].add(des) + bom_entry["category"] = category + + if isinstance(item, TopLevelGraphicalComponent): + if isinstance(item, Connector): + cat = BomCategory.CONNECTOR + elif isinstance(item, Cable): + if item.category == "bundle": + cat = BomCategory.WIRE + else: + cat = BomCategory.CABLE + else: + cat = "" + + if item.category == "bundle": + for subitem in item.wire_objects.values(): + _add( + hash=subitem.bom_hash, + qty=item.bom_qty, # should be 1 + designator=item.designator, # inherit from parent item + category=cat, + ) + else: + _add( + hash=item.bom_hash, + qty=item.bom_qty, + designator=item.designator, + category=cat, + ) + if item.additional_components: + if item.category == "bundle": + pass # TODO + item.compute_qty_multipliers() + for comp in item.additional_components: + if comp.ignore_in_bom: + continue + _add( + hash=comp.bom_hash, + designator=item.designator, + qty=comp.bom_qty, + category=BomCategory.ADDITIONAL_INSIDE, + ) + elif isinstance(item, AdditionalComponent): + cat = BomCategory.ADDITIONAL_OUTSIDE + _add( + hash=item.bom_hash, + qty=item.bom_qty, + designator=None, + category=cat, + ) + else: + raise Exception(f"Unknown type of item:\n{item}") + + def connect( + self, + from_name: str, + from_pin: Union[int, str], + via_name: str, + via_wire: Union[int, str], + to_name: str, + to_pin: Union[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: + connector = self.connectors[name] + # 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." + ) + # 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.") + index = connector.pinlabels.index(pin) + pin = connector.pins[index] # map pin name to pin number + if name == from_name: + from_pin = pin + if name == to_name: + to_pin = pin + if not pin in connector.pins: + raise Exception(f"{name}:{pin} not found.") + + # check via cable + if via_name in self.cables: + cable = self.cables[via_name] + # 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." + ) + # 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." + ) + # list index starts at 0, wire IDs start at 1 + via_wire = cable.colors.index(via_wire) + 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 + + # perform the actual connection + if from_name is not None: + from_con = self.connectors[from_name] + from_pin_obj = from_con.pin_objects[from_pin] + else: + from_pin_obj = None + if to_name is not None: + to_con = self.connectors[to_name] + to_pin_obj = to_con.pin_objects[to_pin] + else: + to_pin_obj = None + + self.cables[via_name]._connect(from_pin_obj, via_wire, to_pin_obj) + if from_name in self.connectors: + self.connectors[from_name].activate_pin(from_pin, Side.RIGHT) + if to_name in self.connectors: + self.connectors[to_name].activate_pin(to_pin, Side.LEFT) + + def create_graph(self) -> Graph: + dot = Graph() + set_dot_basics(dot, self.options) + + for connector in self.connectors.values(): + # generate connector node + gv_html = gv_node_component(connector) + gv_html.update_attribs( + bgcolor=calculate_node_bgcolor(connector, self.options) + ) + dot.node( + connector.designator, + label=f"<\n{gv_html}\n>", + shape="box", + style="filled", + ) + # generate edges for connector loops + if len(connector.loops) > 0: + dot.attr("edge", color="#000000") + loops = gv_connector_loops(connector) + for head, tail in loops: + dot.edge(head, tail) + + # 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 + wire_is_multicolor = [ + len(wire.color) > 1 + for cable in self.cables.values() + for wire in cable.wire_objects.values() + ] + if any(wire_is_multicolor): + wireviz.wv_colors.padding_amount = 3 + else: + wireviz.wv_colors.padding_amount = 1 + + for cable in self.cables.values(): + # generate cable node + # TODO: PN info for bundles (per wire) + gv_html = gv_node_component(cable) + gv_html.update_attribs(bgcolor=calculate_node_bgcolor(cable, self.options)) + style = "filled,dashed" if cable.category == "bundle" else "filled" + dot.node( + cable.designator, + label=f"<\n{gv_html}\n>", + shape="box", + style=style, + ) + + # generate wire edges between component nodes and cable nodes + for connection in cable._connections: + color, l1, l2, r1, r2 = gv_edge_wire(self, cable, connection) + dot.attr("edge", color=color) + if not (l1, l2) == (None, None): + dot.edge(l1, l2) + if not (r1, r2) == (None, None): + dot.edge(r1, r2) + + for mate in self.mates: + color, dir, code_from, code_to = gv_edge_mate(mate) + + dot.attr("edge", color=color, style="dashed", dir=dir) + dot.edge(code_from, code_to) + + apply_dot_tweaks(dot, self.tweak) + + return dot + + # cache for the GraphViz Graph object + # do not access directly, use self.graph instead + _graph = None + + @property + def graph(self): + if not self._graph: # no cached graph exists, generate one + self._graph = self.create_graph() + return self._graph # return cached graph + + @property + def png(self): + from io import BytesIO + + graph = self.graph + data = BytesIO() + data.write(graph.pipe(format="png")) + data.seek(0) + return data.read() + + @property + def svg(self): + graph = self.graph + return embed_svg_images(graph.pipe(format="svg").decode("utf-8"), Path.cwd()) + + def output( + self, + filename: Union[str, Path], + view: bool = False, + cleanup: bool = True, + fmt: tuple = ("html", "png", "svg", "tsv"), + ) -> None: + # graphical output + graph = self.graph + 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 + # SVG file will be renamed/deleted later + _filename = f"{filename}.tmp" if f == "svg" else filename + # TODO: prevent rendering SVG twice when both SVG and HTML are specified + graph.format = f + graph.render(filename=_filename, view=view, cleanup=cleanup) + # embed images into SVG output + if "svg" in fmt or "html" in fmt: + embed_svg_images_file(f"{filename}.tmp.svg") + # GraphViz output + if "gv" in fmt: + graph.save(filename=f"{filename}.gv") + # BOM output + bomlist = bom_list(self.bom) + # bomlist = [[]] + if "tsv" in fmt: + tsv = bom2tsv(bomlist) + open_file_write(f"{filename}.tsv").write(tsv) + if "csv" in fmt: + # TODO: implement CSV output (preferrably using CSV library) + print("CSV output is not yet supported") + # HTML output + if "html" in fmt: + generate_html_output(filename, bomlist, self.metadata, self.options) + # PDF output + if "pdf" in fmt: + # TODO: implement PDF output + print("PDF output is not yet supported") + # delete SVG if not needed + if "html" in fmt and not "svg" in fmt: + # SVG file was just needed to generate HTML + Path(f"{filename}.tmp.svg").unlink() + elif "svg" in fmt: + Path(f"{filename}.tmp.svg").replace(f"{filename}.svg") diff --git a/src/wireviz/wv_html.py b/src/wireviz/wv_html.py index 1534266..1c4b750 100644 --- a/src/wireviz/wv_html.py +++ b/src/wireviz/wv_html.py @@ -1,119 +1,125 @@ # -*- coding: utf-8 -*- -import re -from pathlib import Path -from typing import Dict, List, Union +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import Dict -from wireviz import APP_NAME, APP_URL, __version__, wv_colors -from wireviz.DataClasses import Metadata, Options -from wireviz.wv_gv_html import html_line_breaks -from wireviz.wv_helper import ( - flatten2d, - open_file_read, - open_file_write, - smart_file_resolve, -) +indent_count = 1 -def generate_html_output( - filename: Union[str, Path], - bom_list: List[List[str]], - metadata: Metadata, - options: Options, -): +class Attribs(Dict): + def __repr__(self): + if len(self) == 0: + return "" - # load HTML template - 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"], - ) - else: - # fall back to built-in simple template if no template was provided - templatefile = Path(__file__).parent / "templates/simple.html" + html = [] + for k, v in self.items(): + if v is not None: + html.append(f' {k}="{v}"') + # else: + # html.append(f" {k}") + return "".join(html) - html = open_file_read(templatefile).read() - # embed SVG diagram - with open_file_read(f"{filename}.tmp.svg") as file: - svgdata = re.sub( - "^<[?]xml [^?>]*[?]>[^<]*]*>", - "", - file.read(), - 1, - ) +@dataclass +class Tag: + contents = None + attribs: Attribs = field(default_factory=Attribs) + flat: bool = None + delete_if_empty: bool = False - # generate BOM table - bom = flatten2d(bom_list) + def __init__(self, contents, flat=None, delete_if_empty=False, **kwargs): + self.contents = contents + self.flat = flat + self.delete_if_empty = delete_if_empty + self.attribs = Attribs({**kwargs}) - # generate BOM header (may be at the top or bottom of the table) - bom_header_html = "
{item}
{item}
\n' + bom_header_html + "".join(bom_contents) + "
\n" - ) - bom_html_reversed = ( - '\n' - + "".join(list(reversed(bom_contents))) - + bom_header_html - + "
\n" - ) + @property + def auto_flat(self): + if self.flat is not None: # user specified + return self.flat + if not _is_iterable_not_str(self.contents): # catch str, int, float, ... + if not isinstance(self.contents, Tag): # avoid recursion + return not "\n" in str(self.contents) # flatten if single line - # prepare simple replacements - replacements = { - "": f"{APP_NAME} {__version__} - {APP_URL}", - "": options.fontname, - "": wv_colors.translate_color(options.bgcolor, "hex"), - "": svgdata, - "": bom_html, - "": bom_html_reversed, - "": "1", # TODO: handle multi-page documents - "": "1", # TODO: handle multi-page documents - } + @property + def is_empty(self): + return self.get_contents(force_flat=True) == "" - # prepare metadata replacements - if metadata: - for item, contents in metadata.items(): - if isinstance(contents, (str, int, float)): - replacements[f""] = 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""] = str(category) - for entry_key, entry_value in entry.items(): - replacements[ - f"" - ] = html_line_breaks(str(entry_value)) + def indent_lines(self, lines, force_flat=False): + if self.auto_flat or force_flat: + return lines + else: + indenter = " " * indent_count + return "\n".join(f"{indenter}{line}" for line in lines.split("\n")) - replacements['"sheetsize_default"'] = '"{}"'.format( - metadata.get("template", {}).get("sheetsize", "") - ) - # include quotes so no replacement happens within