diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index d919c809..e665bc07 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +import sys from dataclasses import InitVar, dataclass, field from enum import Enum, auto from pathlib import Path @@ -280,7 +280,7 @@ def __post_init__(self) -> None: self.gauge = g if self.gauge_unit is not None: - print( + sys.stderr.write( f"Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}" ) if u.upper() == "AWG": @@ -304,7 +304,7 @@ def __post_init__(self) -> None: ) self.length = L if self.length_unit is not None: - print( + sys.stderr.write( f"Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}" ) self.length_unit = u diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index de7163a2..3efde0c2 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- import re +import sys from collections import Counter from dataclasses import dataclass from itertools import zip_longest from pathlib import Path -from typing import Any, List, Union +from typing import Any, List, Union, Optional from graphviz import Graph @@ -20,13 +21,12 @@ Tweak, Side, ) -from wireviz.svgembed import embed_svg_images_file +from wireviz.svgembed import embed_svg_images 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, @@ -543,11 +543,11 @@ def typecheck(name: str, value: Any, expect: type) -> None: f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', "", entry ) if n_subs < 1: - print( + sys.stderr.write( f"Harness.create_graph() warning: {attr} not found in {keyword}!" ) elif n_subs > 1: - print( + sys.stderr.write( f"Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!" ) continue @@ -562,7 +562,7 @@ def typecheck(name: str, value: Any, expect: type) -> None: # If attr not found, then append it entry = re.sub(r"\]$", f" {attr}={value}]", entry) elif n_subs > 1: - print( + sys.stderr.write( f"Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!" ) @@ -650,55 +650,61 @@ 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"), + self, + output_dir: Optional[Union[str, Path]], + output_name: Optional[Union[str, Path]], + formats: List[str] | tuple[str] ) -> 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: + + if "csv" in formats: + # TODO: implement CSV output (preferably using CSV library) + sys.stderr.write("CSV output is not yet supported") + if "pdf" in formats: # 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") + sys.stderr.write("PDF output is not yet supported") + + outputs = {} + if "svg" in formats or "html" in formats: + # embed images into SVG output + outputs["svg"] = embed_svg_images(graph.pipe(format="svg", encoding="utf8")) + + if "png" in formats: + outputs["png"] = graph.pipe(format="png") + + # GraphViz output + if "gv" in formats: + outputs["gv"] = graph.pipe(format="gv") + + if "tsv" in formats or "html" in formats: + bomlist = bom_list(self.bom()) + # BOM output + if "tsv" in formats: + outputs["tsv"] = tuplelist2tsv(bomlist) + + # HTML output + if "html" in formats and "svg" in outputs: + outputs["html"] = generate_html_output(outputs["svg"], output_dir, bomlist, self.metadata, self.options) + + # print to stdout or write files in order + for f in formats: + if f in outputs: + output = outputs[f] + if output_dir is None or output_name is None: + if isinstance(output, (bytes, bytearray)): + sys.stdout.buffer.write(output) + else: + sys.stdout.write(output) + else: + file = f"{output_dir}/{output_name}.{f}" + if isinstance(output, (bytes, bytearray)): + with open(file, "wb") as binary_file: + binary_file.write(output) + else: + with open(file, "w") as binary_file: + binary_file.write(output) def bom(self): if not self._bom: diff --git a/src/wireviz/__init__.py b/src/wireviz/__init__.py index 08f7167a..88dde961 100644 --- a/src/wireviz/__init__.py +++ b/src/wireviz/__init__.py @@ -4,5 +4,5 @@ __version__ = "0.4-dev" CMD_NAME = "wireviz" # Lower case command and module name -APP_NAME = "WireViz" # Application name in texts meant to be human readable -APP_URL = "https://github.com/formatc1702/WireViz" +APP_NAME = "WireViz" # Application name in texts meant to be human-readable +APP_URL = "https://github.com/wireviz/WireViz" diff --git a/src/wireviz/svgembed.py b/src/wireviz/svgembed.py index ab6b9f1e..2e26ae47 100644 --- a/src/wireviz/svgembed.py +++ b/src/wireviz/svgembed.py @@ -38,15 +38,3 @@ def get_mime_subtype(filename: Union[str, Path]) -> str: 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/wireviz.py b/src/wireviz/wireviz.py index 11016192..7c7d0461 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -58,7 +58,7 @@ def parse( return_types (optional): One of the supported return types (see above), or a tuple of multiple return types. If set to None, no output is returned by the function. - output_formats (optional): + output_formats (Tuple[str], optional): One of the supported output types (see above), or a tuple of multiple output formats. If set to None, no files are generated. output_dir (Path | str, optional): @@ -87,15 +87,18 @@ def parse( yaml_data, yaml_file = _get_yaml_data_and_path(inp) if output_formats: - # need to write data to file, determine output directory and filename - output_dir = _get_output_dir(yaml_file, output_dir) - output_name = _get_output_name(yaml_file, output_name) - output_file = output_dir / output_name + if str(output_dir) == "-": + # write to stdout + output_dir = None + else: + # write to directory + output_dir = _get_output_dir(yaml_file, output_dir) + output_name = _get_output_name(yaml_file, output_name) if yaml_file: # 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]: + if default_image_path not in [Path(x).resolve() for x in image_paths]: image_paths.append(default_image_path) # define variables ========================================================= @@ -362,11 +365,11 @@ def alternate_type(): # flip between connector and cable/arrow harness.add_bom_item(line) if output_formats: - harness.output(filename=output_file, fmt=output_formats, view=False) + harness.output(formats=output_formats, output_dir=output_dir, output_name=output_name) if return_types: returns = [] - if isinstance(return_types, str): # only one return type speficied + if isinstance(return_types, str): # only one return type specified return_types = [return_types] return_types = [t.lower() for t in return_types] @@ -390,10 +393,11 @@ def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> (Dict, Path): # 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 63] + # if inp is a long YAML string, Pathlib will raise OSError: [Errno 63]. # when trying to expand and resolve it as a path. - # Catch this error, but raise any others - if type(e) is OSError and e.errno != 63: + # it can also raise OSError: [Errno 36] File name too long. + # Catch these errors, but raise any others. + if type(e) is OSError and e.errno != 63 and e.errno != 36: raise e # file does not exist; assume inp is a YAML string yaml_str = inp @@ -417,7 +421,7 @@ def _get_output_dir(input_file: Path, default_output_dir: Path) -> Path: return output_dir.resolve() -def _get_output_name(input_file: Path, default_output_name: Path) -> str: +def _get_output_name(input_file: Path, default_output_name: Union[None, str]) -> str: if default_output_name: # user-specified output name output_name = default_output_name else: # auto-determine appropriate output name @@ -429,7 +433,7 @@ def _get_output_name(input_file: Path, default_output_name: Path) -> str: def main(): - print("When running from the command line, please use wv_cli.py instead.") + sys.stderr.write("When running from the command line, please use wv_cli.py instead.") if __name__ == "__main__": diff --git a/src/wireviz/wv_cli.py b/src/wireviz/wv_cli.py index 73150720..27a59788 100644 --- a/src/wireviz/wv_cli.py +++ b/src/wireviz/wv_cli.py @@ -3,6 +3,7 @@ import os import sys from pathlib import Path +from typing import Union import click @@ -67,12 +68,11 @@ default=False, help=f"Output {APP_NAME} version and exit.", ) -def wireviz(file, format, prepend, output_dir, output_name, version): +def wireviz(file, format, prepend, output_dir: Union[Path, None], output_name, version): """ Parses the provided FILE and generates the specified outputs. """ - print() - print(f"{APP_NAME} {__version__}") + sys.stderr.write(f"{APP_NAME} {__version__}\n") if version: return # print version number only and exit @@ -88,10 +88,13 @@ def wireviz(file, format, prepend, output_dir, output_name, version): output_formats = [] for code in format: if code in format_codes: - output_formats.append(format_codes[code]) + output_format: str = format_codes[code] + # unique + if output_format not in output_formats: + output_formats.append(output_format) else: raise Exception(f"Unknown output format: {code}") - output_formats = tuple(sorted(set(output_formats))) + output_formats_str = ( f'[{"|".join(output_formats)}]' if len(output_formats) > 1 @@ -105,44 +108,63 @@ 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}") - print("Prepend file:", prepend_file) + + sys.stderr.write(f"Prepend file: {prepend_file}") prepend_input += open_file_read(prepend_file).read() + "\n" else: prepend_input = "" - # run WireVIz on each input file - for file in filepaths: - file = Path(file) - if not file.exists(): - raise Exception(f"File does not exist:\n{file}") + output_stdout = str(output_dir) == "-" or output_name == "-" + if output_stdout and len(filepaths) == 0: + wv.parse( + sys.stdin.read(), + output_dir="-", + output_formats=tuple(output_formats), + ) - # file_out = file.with_suffix("") if not output_file else output_file - _output_dir = file.parent if not output_dir else output_dir - _output_name = file.stem if not output_name else output_name + # run WireViz on each input file + for file in filepaths: + if file == "-": + # read from stdin + yaml_input = prepend_input + sys.stdin.read() + image_paths = [] + else: + file = Path(file) + if not file.exists(): + raise Exception(f"File does not exist:\n{file}") - print("Input file: ", file) - print( - "Output file: ", f"{Path(_output_dir / _output_name)}.{output_formats_str}" - ) + sys.stderr.write(f"Input file: {file}\n") + yaml_input = open_file_read(file).read() + file_dir = file.parent - yaml_input = open_file_read(file).read() - file_dir = file.parent + yaml_input = prepend_input + yaml_input + image_paths = {file_dir} - yaml_input = prepend_input + yaml_input - image_paths = {file_dir} for p in prepend: image_paths.add(Path(p).parent) + # file_out = file.with_suffix("") if not output_file else output_file + if output_stdout or file == "-": + sys.stderr.write(f"Output: .{output_formats_str}\n") + _output_dir = "-" + _output_name = None + else: + _output_dir = file.parent if not output_dir else output_dir + _output_name = file.stem if not output_name else output_name + sys.stderr.write( + f"Output file: {Path(_output_dir / _output_name)}.{output_formats_str}" + ) + wv.parse( yaml_input, - output_formats=output_formats, + output_formats=tuple(output_formats), output_dir=_output_dir, output_name=_output_name, image_paths=list(image_paths), ) - print() + sys.stderr.write('') if __name__ == "__main__": diff --git a/src/wireviz/wv_colors.py b/src/wireviz/wv_colors.py index 857f3077..31471fee 100644 --- a/src/wireviz/wv_colors.py +++ b/src/wireviz/wv_colors.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +import sys from typing import Dict, List COLOR_CODES = { @@ -138,7 +138,7 @@ def get_color_hex(input: Colors, pad: bool = False) -> List[str]: 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}") + sys.stderr.write(f"Invalid hex color: {c}") output[i] = color_default else: # Color name(s) @@ -148,7 +148,7 @@ def lookup(c: str) -> str: except KeyError: if c != input: c += f" in input: {input}" - print(f"Unknown color name: {c}") + sys.stderr.write(f"Unknown color name: {c}") return color_default output = [lookup(input[i : i + 2]) for i in range(0, len(input), 2)] diff --git a/src/wireviz/wv_helper.py b/src/wireviz/wv_helper.py index a10f6acf..6e7015a1 100644 --- a/src/wireviz/wv_helper.py +++ b/src/wireviz/wv_helper.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import re +import sys from pathlib import Path from typing import Dict, List @@ -147,10 +148,10 @@ def aspect_ratio(image_src): image = Image.open(image_src) if image.width > 0 and image.height > 0: return image.width / image.height - print(f"aspect_ratio(): Invalid image size {image.width} x {image.height}") + sys.stderr.write(f"aspect_ratio(): Invalid image size {image.width} x {image.height}") # ModuleNotFoundError and FileNotFoundError are the most expected, but all are handled equally. except Exception as error: - print(f"aspect_ratio(): {type(error).__name__}: {error}") + sys.stderr.write(f"aspect_ratio(): {type(error).__name__}: {error}") return 1 # Assume 1:1 when unable to read actual image size diff --git a/src/wireviz/wv_html.py b/src/wireviz/wv_html.py index 15342668..dccbe874 100644 --- a/src/wireviz/wv_html.py +++ b/src/wireviz/wv_html.py @@ -2,7 +2,7 @@ import re from pathlib import Path -from typing import Dict, List, Union +from typing import Dict, List, Optional from wireviz import APP_NAME, APP_URL, __version__, wv_colors from wireviz.DataClasses import Metadata, Options @@ -10,45 +10,46 @@ from wireviz.wv_helper import ( flatten2d, open_file_read, - open_file_write, smart_file_resolve, ) def generate_html_output( - filename: Union[str, Path], + svg_input: str, + output_dir: Optional[Path], bom_list: List[List[str]], metadata: Metadata, options: Options, -): - +) -> str: # 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"], - ) + template_name = metadata.get("template", {}).get("name") + builtin_template_directory = Path(__file__).parent / "templates" # built-in template directory + if template_name: + possible_paths = [] + # if relative path to template was provided, check directory of YAML file first + if output_dir is not None: + possible_paths.append(output_dir) + + possible_paths.append(builtin_template_directory) # fallback + template_file = smart_file_resolve(f"{template_name}.html", possible_paths) else: - # fall back to built-in simple template if no template was provided - templatefile = Path(__file__).parent / "templates/simple.html" + # fallback to built-in simple template if no template was provided + template_file = builtin_template_directory / "simple.html" - html = open_file_read(templatefile).read() + html = open_file_read(template_file).read() # embed SVG diagram - with open_file_read(f"{filename}.tmp.svg") as file: - svgdata = re.sub( - "^<[?]xml [^?>]*[?]>[^<]*]*>", - "", - file.read(), - 1, - ) + svg_data = re.sub( + "^<[?]xml [^?>]*[?]>[^<]*]*>", + "", + svg_input, + 1, + ) # generate BOM table bom = flatten2d(bom_list) - # generate BOM header (may be at the top or bottom of the table) + # generate BOM header (might be at the top or bottom of the table) bom_header_html = " \n" for item in bom[0]: th_class = f"bom_col_{item.lower()}" @@ -80,7 +81,7 @@ def generate_html_output( "": f"{APP_NAME} {__version__} - {APP_URL}", "": options.fontname, "": wv_colors.translate_color(options.bgcolor, "hex"), - "": svgdata, + "": svg_data, "": bom_html, "": bom_html_reversed, "": "1", # TODO: handle multi-page documents @@ -114,6 +115,4 @@ def generate_html_output( replacements_sorted = sorted(replacements, key=len, reverse=True) replacements_escaped = map(re.escape, replacements_sorted) pattern = re.compile("|".join(replacements_escaped)) - html = pattern.sub(lambda match: replacements[match.group(0)], html) - - open_file_write(f"{filename}.html").write(html) + return pattern.sub(lambda match: replacements[match.group(0)], html)