diff --git a/README.md b/README.md index e973b634..b445ae2e 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,11 @@ $ wireviz ~/path/to/file/mywire.yml This will output the following files ``` -mywire.gv GraphViz output +mywire.gv Raw GraphViz DOT file output of wiring diagram mywire.svg Wiring diagram as vector image mywire.png Wiring diagram as raster image mywire.bom.tsv BOM (bill of materials) as tab-separated text file +mywire.bom.csv BOM (bill of materials) as comma-separated, excel-format text file mywire.html HTML page with wiring diagram and BOM embedded ``` diff --git a/requirements.txt b/requirements.txt index 93394812..5a6667c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ . +click graphviz pyyaml setuptools \ No newline at end of file diff --git a/setup.py b/setup.py index 09621bb7..f642a8be 100644 --- a/setup.py +++ b/setup.py @@ -6,6 +6,12 @@ project_name = 'wireviz' +# utility function to read the requirements.txt file +with open('requirements.txt', 'r') as f: + requirements = f.readlines() + requirements = [r.strip() for r in requirements] + requirements = [r for r in requirements if r != '.'] + # Utility function to read the README file. # Used for the long_description. It's nice, because now 1) we have a top level # README file and 2) it's easier to type in the README file than to put a raw @@ -13,6 +19,7 @@ def read(fname): return open(os.path.join(os.path.dirname(__file__), fname)).read() + setup( name=project_name, version='0.1', @@ -21,10 +28,7 @@ def read(fname): description='Easily document cables and wiring harnesses', long_description=read(os.path.join(os.path.dirname(__file__), 'README.md')), long_description_content_type='text/markdown', - install_requires=[ - 'pyyaml', - 'graphviz', - ], + install_requires=requirements, license='GPLv3', keywords='cable connector hardware harness wiring wiring-diagram wiring-harness', url='https://github.com/formatc1702/WireViz', diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 9cc6b96d..9f668316 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -3,9 +3,9 @@ from wireviz.DataClasses import Connector, Cable from graphviz import Graph -from wireviz import wv_colors, wv_helper +from wireviz import wv_colors, wv_helper, bom_helper from wireviz.wv_colors import get_color_hex -from wireviz.wv_helper import awg_equiv, mm2_equiv, tuplelist2tsv, \ +from wireviz.wv_helper import awg_equiv, mm2_equiv, \ nested_html_table, flatten2d, index_if_list, html_line_breaks, \ graphviz_line_breaks, remove_line_breaks, open_file_read, open_file_write, \ manufacturer_info_field @@ -68,7 +68,7 @@ def create_graph(self) -> Graph: font = 'arial' dot.attr('graph', rankdir='LR', ranksep='2', - bgcolor='white', + bgcolor=wv_colors.COLOR_BACKGROUND, nodesep='0.33', fontname=font) dot.attr('node', shape='record', @@ -276,6 +276,11 @@ def create_graph(self) -> Graph: return dot + @property + def gv(self): + # self.create_graph().save() + raise NotImplementedError + @property def png(self): from io import BytesIO @@ -294,50 +299,80 @@ def svg(self): data.seek(0) return data.read() - def output(self, filename: (str, Path), view: bool = False, cleanup: bool = True, fmt: tuple = ('pdf', )) -> None: - # graphical output - graph = self.create_graph() - for f in fmt: - graph.format = f - graph.render(filename=filename, view=view, cleanup=cleanup) - graph.save(filename=f'{filename}.gv') - # bom output - bom_list = self.bom_list() - with open_file_write(f'{filename}.bom.tsv') as file: - file.write(tuplelist2tsv(bom_list)) - # HTML output - with open_file_write(f'{filename}.html') as file: - file.write('\n') - file.write('') - - file.write('

Diagram

') - with open_file_read(f'{filename}.svg') as svg: - file.write(re.sub( - '^<[?]xml [^?>]*[?]>[^<]*]*>', - '', - svg.read(1024), 1)) - for svgdata in svg: - file.write(svgdata) - - file.write('

Bill of Materials

') - listy = flatten2d(bom_list) - file.write('') - file.write('') - for item in listy[0]: - file.write(f'') - file.write('') - for row in listy[1:]: - file.write('') - for i, item in enumerate(row): - item_str = item.replace('\u00b2', '²') - align = 'align="right"' if listy[0][i] == 'Qty' else '' - file.write(f'') - file.write('') - file.write('
{item}
{item_str}
') - - file.write('') - - def bom(self): + @property + def html(self): + bom_list = self._bom_list() + string = '' + + string += '\n' + string += f'' + + string += '

Diagram

' + + svg = self.svg.decode('utf-8') + svg = re.sub( + '^<[?]xml [^?>]*[?]>[^<]*]*>', + '', + svg) + for svgdata in svg: + string += svgdata + + string += '

Bill of Materials

' + listy = flatten2d(bom_list) + string += '' + string += '' + for item in listy[0]: + string += f'' + string += '' + for row in listy[1:]: + string += '' + for i, item in enumerate(row): + item_str = item.replace('\u00b2', '²') + align = 'align="right"' if listy[0][i] == 'Qty' else '' + string += f'' + string += '' + string += '
{item}
{item_str}
' + + string += '' + + return bytes(string, encoding='utf-8') + + @property + def csv(self): + return bom_helper.generate_bom_outputs( + self._bom_list(), + 'csv' + ) + + @property + def csv_excel(self): + return bom_helper.generate_bom_outputs( + self._bom_list(), + 'csv_excel' + ) + + @property + def csv_unix(self): + return bom_helper.generate_bom_outputs( + self._bom_list(), + 'csv_unix' + ) + + @property + def tsv(self): + return bom_helper.generate_bom_outputs( + self._bom_list(), + 'tsv' + ) + + @property + def tsv_excel(self): + return bom_helper.generate_bom_outputs( + self._bom_list(), + 'tsv_excel' + ) + + def _bom(self): bom = [] bom_connectors = [] bom_cables = [] @@ -417,8 +452,8 @@ def bom(self): bom.extend(bom_extra) return bom - def bom_list(self): - bom = self.bom() + def _bom_list(self): + bom = self._bom() keys = ['item', 'qty', 'unit', 'designators'] # these BOM columns will always be included for fieldname in ['pn', 'manufacturer', 'mpn']: # these optional BOM columns will only be included if at least one BOM item actually uses them if any(fieldname in x and x.get(fieldname, None) for x in bom): diff --git a/src/wireviz/bom_helper.py b/src/wireviz/bom_helper.py new file mode 100644 index 00000000..c6b3cbcd --- /dev/null +++ b/src/wireviz/bom_helper.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import csv +from io import StringIO + +from wireviz import wv_helper + +WIREVIZ_TSV = type('Wireviz BOM', (csv.Dialect, object), dict( + delimiter='\t', + doublequote=True, + escapechar=None, + lineterminator='\n', + quoting=0, + skipinitialspace=False, + strict=False, + quotechar='"' +)) +csv.register_dialect('Wireviz BOM', WIREVIZ_TSV) + + +def generate_bom_outputs(bomdata, dialect='tsv'): + dialect = dialect.strip().lower() + dialect_lookup = { + 'csv': csv.unix_dialect, + 'csv_unix': csv.unix_dialect, + 'csv_excel': csv.excel, + 'tsv': WIREVIZ_TSV, + 'tsv_excel': csv.excel_tab + } + valid_dialects = [k for k in dialect_lookup.keys()] + if dialect not in valid_dialects: + raise ValueError(f'dialect "{dialect}" not supported') + + output = StringIO() + writer = csv.writer(output, dialect=dialect_lookup[dialect]) + writer.writerows(wv_helper.flatten2d(bomdata)) + + output.seek(0) + + return bytes(output.read(), encoding='utf-8') + +# TODO: Possibly refactor other BOM output operations, such as HTML, into here? \ No newline at end of file diff --git a/src/wireviz/build_examples.py b/src/wireviz/build_examples.py index 10a4b1bf..6a4751e8 100755 --- a/src/wireviz/build_examples.py +++ b/src/wireviz/build_examples.py @@ -10,7 +10,7 @@ sys.path.insert(0, str(script_path.parent.parent)) # to find wireviz module from wireviz import wireviz -from wv_helper import open_file_write, open_file_read, open_file_append +from wireviz.wv_helper import open_file_write, open_file_read, open_file_append readme = 'readme.md' @@ -61,7 +61,7 @@ def build_generated(groupkeys): # collect and iterate input YAML files for yaml_file in collect_filenames('Building', key, input_extensions): print(f' "{yaml_file}"') - wireviz.parse_file(yaml_file) + wireviz.main(yaml_file, prepend=None, out=['png', 'svg', 'html', 'csv']) if build_readme: i = ''.join(filter(str.isdigit, yaml_file.stem)) diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index 3adc0782..d589a8ba 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -import argparse import os from pathlib import Path import sys from typing import Any, Tuple +import click import yaml if __name__ == '__main__': @@ -17,12 +17,11 @@ from wireviz.wv_helper import expand, open_file_read -def parse(yaml_input: str, file_out: (str, Path) = None, return_types: (None, str, Tuple[str]) = None) -> Any: +def parse(yaml_input: str, return_types: (None, str, Tuple[str]) = None) -> Any: """ Parses yaml input string and does the high-level harness conversion :param yaml_input: a string containing the yaml input data - :param file_out: :param return_types: if None, then returns None; if the value is a string, then a corresponding data format will be returned; if the value is a tuple of strings, then for every valid format in the `return_types` tuple, another return type @@ -57,14 +56,6 @@ def parse(yaml_input: str, file_out: (str, Path) = None, return_types: (None, st elif ty == list: yaml_data[sec] = [] - # add connections - - def check_designators(what, where): # helper function - for i, x in enumerate(what): - if x not in yaml_data[where[i]]: - return False - return True - autogenerated_ids = {} for connection in yaml_data['connections']: # find first component (potentially nested inside list or dict) @@ -174,9 +165,6 @@ def check_designators(what, where): # helper function for line in yaml_data["additional_bom_items"]: harness.add_bom_item(line) - if file_out is not None: - harness.output(filename=file_out, fmt=('png', 'svg'), view=False) - if return_types is not None: returns = [] if isinstance(return_types, str): # only one return type speficied @@ -187,66 +175,83 @@ def check_designators(what, where): # helper function for rt in return_types: if rt == 'png': returns.append(harness.png) - if rt == 'svg': + elif rt == 'svg': returns.append(harness.svg) - if rt == 'harness': + elif rt == 'html': + returns.append(harness.html) + elif rt == 'csv': + returns.append(harness.csv) + elif rt == 'csv_excel': + returns.append(harness.csv_excel) + elif rt == 'csv_unix': + returns.append(harness.csv_unix) + elif rt == 'tsv': + returns.append(harness.tsv) + elif rt == 'tsv_excel': + returns.append(harness.tsv_excel) + elif rt == 'gv': + returns.append(harness.gv) + elif rt == 'harness': returns.append(harness) + else: + raise ValueError(f'return type "{rt}" not supported') return tuple(returns) if len(returns) != 1 else returns[0] -def parse_file(yaml_file: str, file_out: (str, Path) = None) -> None: - with open_file_read(yaml_file) as file: - yaml_input = file.read() - - if not file_out: - fn, fext = os.path.splitext(yaml_file) - file_out = fn - file_out = os.path.abspath(file_out) - - parse(yaml_input, file_out=file_out) - - -def parse_cmdline(): - parser = argparse.ArgumentParser( - description='Generate cable and wiring harness documentation from YAML descriptions', - ) - parser.add_argument('input_file', action='store', type=str, metavar='YAML_FILE') - parser.add_argument('-o', '--output_file', action='store', type=str, metavar='OUTPUT') - parser.add_argument('--generate-bom', action='store_true', default=True) - parser.add_argument('--prepend-file', action='store', type=str, metavar='YAML_FILE') - return parser.parse_args() +@click.command() +@click.argument('input_file', required=True) +@click.option('--prepend', '-p', default=None, help='a YAML file containing a library of templates and parts that may be referenced in the `input_file`') +@click.option('--out', '-o', multiple=True, default=['png'], help='specify one or more output types to be generated; currently supports "png" "svg", "html", "csv", "csv_excel", "csv_unix", "tsv", "tsv_excel"') +def cli(input_file, prepend, out): + """ + Take a YAML file containing a harness specification and, utilizing GraphViz, + translate that specification into various output formats. + Example: -def main(): + $>wireviz my_input_file.yml -o png -o svg + """ + main(input_file, prepend, out) - args = parse_cmdline() - if not os.path.exists(args.input_file): - print(f'Error: input file {args.input_file} inaccessible or does not exist, check path') +def main(input_file, prepend, out): + if not os.path.exists(input_file): + print(f'Error: input file "{input_file}" inaccessible or does not exist, check path') sys.exit(1) - with open_file_read(args.input_file) as fh: + with open_file_read(input_file) as fh: yaml_input = fh.read() - if args.prepend_file: - if not os.path.exists(args.prepend_file): - print(f'Error: prepend input file {args.prepend_file} inaccessible or does not exist, check path') + if prepend: + if not os.path.exists(prepend): + print(f'Error: prepend input file {prepend} inaccessible or does not exist, check path') sys.exit(1) - with open_file_read(args.prepend_file) as fh: + with open_file_read(prepend) as fh: prepend = fh.read() yaml_input = prepend + yaml_input - if not args.output_file: - file_out = args.input_file - pre, _ = os.path.splitext(file_out) - file_out = pre # extension will be added by graphviz output function - else: - file_out = args.output_file - file_out = os.path.abspath(file_out) + input_file = Path(input_file) + base_file_name = input_file.name.replace(input_file.suffix, '') + + # the parse function may return a single instance or a tuple, thus, the + # if/then determines if there is only one thing that must be saved or multiple + filedatas = parse(yaml_input, return_types=out) + if not isinstance(filedatas, tuple): + filedatas = (filedatas, ) + + for ext, data in zip(out, filedatas): + if 'csv' in ext: + extension = 'bom.csv' + elif 'tsv' in ext: + extension = 'bom.tsv' + else: + extension = ext - parse(yaml_input, file_out=file_out) + fname = f'{base_file_name}.{extension}' + with open(fname, 'wb') as f: + f.write(data) if __name__ == '__main__': - main() + cli() diff --git a/src/wireviz/wv_colors.py b/src/wireviz/wv_colors.py index 41575437..c34e7fe0 100644 --- a/src/wireviz/wv_colors.py +++ b/src/wireviz/wv_colors.py @@ -98,12 +98,13 @@ } -color_default = '#ffffff' +COLOR_DEFAULT = '#ffffff' +COLOR_BACKGROUND = '#ffffff' def get_color_hex(input, pad=False): if input is None or input == '': - return [color_default] + return [COLOR_DEFAULT] if len(input) == 4: # give wires with EXACTLY 2 colors that striped/banded look input = input + input[:2] # hacky style fix: give single color wires a triple-up so that wires are the same size @@ -113,7 +114,7 @@ def get_color_hex(input, pad=False): output = [_color_hex[input[i:i + 2]] for i in range(0, len(input), 2)] except KeyError: print("Unknown color specified") - output = [color_default] + output = [COLOR_DEFAULT] return output diff --git a/tutorial/tutorial08.md b/tutorial/tutorial08.md index 1fc884e7..8369e90f 100644 --- a/tutorial/tutorial08.md +++ b/tutorial/tutorial08.md @@ -3,4 +3,3 @@ * Part number information can be added to parts * Only provided fields will be added to the diagram and bom * Bundles can have part information specified by wire -* Additional parts can be added to the bom diff --git a/tutorial/tutorial08.yml b/tutorial/tutorial08.yml index 2568f29b..56a38005 100644 --- a/tutorial/tutorial08.yml +++ b/tutorial/tutorial08.yml @@ -3,11 +3,11 @@ connectors: type: Molex KK 254 pincount: 4 subtype: female - manufacturer: Molex # set manufacter name - mpn: 22013047 # set manufacturer part number + manufacturer: Molex + manufacturer_part_number: 22013047 X2: <<: *template1 # reuse template - pn: CON4 # set an internal part number + internal_part_number: CON4 X3: <<: *template1 # reuse template @@ -18,16 +18,16 @@ cables: gauge: 0.25 mm2 color_code: IEC manufacturer: CablesCo - mpn: ABC123 - pn: CAB1 + manufacturer_part_number: ABC123 + internal_part_number: CAB1 W2: category: bundle length: 1 gauge: 0.25 mm2 colors: [YE, BK, BK, RD] - manufacturer: [WiresCo,WiresCo,WiresCo,WiresCo] # set a manufacter per wire - mpn: [W1-YE,W1-BK,W1-BK,W1-RD] - pn: [WIRE1,WIRE2,WIRE2,WIRE3] + manufacturer: [WiresCo,WiresCo,WiresCo,WiresCo] + manufacturer_part_number: [W1-YE,W1-BK,W1-BK,W1-RD] + internal_part_number: [WIRE1,WIRE2,WIRE2,WIRE3] connections: @@ -39,14 +39,3 @@ connections: - X1: [1-4] - W2: [1-4] - X3: [1-4] - -additional_bom_items: - - # define an additional item to add to the bill of materials - description: Label, pinout information - qty: 2 - designators: - - X2 - - X3 - manufacturer: generic company - mpn: Label1 - pn: Label-ID-1