Merge 76fa38857fe71224d6d0353d41e3eae8c96d7e26 into f9d1dd0148d922d1303098172a2fd5fbcba5c0fb

This commit is contained in:
Guillaume Grossetie 2023-08-29 18:07:40 +02:00 committed by GitHub
commit 0ba83de65a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 158 additions and 138 deletions

View File

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import sys
from dataclasses import InitVar, dataclass, field from dataclasses import InitVar, dataclass, field
from enum import Enum, auto from enum import Enum, auto
from pathlib import Path from pathlib import Path
@ -280,7 +280,7 @@ class Cable:
self.gauge = g self.gauge = g
if self.gauge_unit is not None: 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}" f"Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}"
) )
if u.upper() == "AWG": if u.upper() == "AWG":
@ -304,7 +304,7 @@ class Cable:
) )
self.length = L self.length = L
if self.length_unit is not None: 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}" f"Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}"
) )
self.length_unit = u self.length_unit = u

View File

@ -1,11 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re import re
import sys
from collections import Counter from collections import Counter
from dataclasses import dataclass from dataclasses import dataclass
from itertools import zip_longest from itertools import zip_longest
from pathlib import Path from pathlib import Path
from typing import Any, List, Union from typing import Any, List, Union, Optional
from graphviz import Graph from graphviz import Graph
@ -20,13 +21,12 @@ from wireviz.DataClasses import (
Tweak, Tweak,
Side, Side,
) )
from wireviz.svgembed import embed_svg_images_file from wireviz.svgembed import embed_svg_images
from wireviz.wv_bom import ( from wireviz.wv_bom import (
HEADER_MPN, HEADER_MPN,
HEADER_PN, HEADER_PN,
HEADER_SPN, HEADER_SPN,
bom_list, bom_list,
component_table_entry,
generate_bom, generate_bom,
get_additional_component_table, get_additional_component_table,
pn_info_string, pn_info_string,
@ -543,11 +543,11 @@ class Harness:
f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', "", entry f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', "", entry
) )
if n_subs < 1: if n_subs < 1:
print( sys.stderr.write(
f"Harness.create_graph() warning: {attr} not found in {keyword}!" f"Harness.create_graph() warning: {attr} not found in {keyword}!"
) )
elif n_subs > 1: elif n_subs > 1:
print( sys.stderr.write(
f"Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!" f"Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!"
) )
continue continue
@ -562,7 +562,7 @@ class Harness:
# If attr not found, then append it # If attr not found, then append it
entry = re.sub(r"\]$", f" {attr}={value}]", entry) entry = re.sub(r"\]$", f" {attr}={value}]", entry)
elif n_subs > 1: elif n_subs > 1:
print( sys.stderr.write(
f"Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!" f"Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!"
) )
@ -650,55 +650,61 @@ class Harness:
graph = self.graph graph = self.graph
return embed_svg_images(graph.pipe(format="svg").decode("utf-8"), Path.cwd()) return embed_svg_images(graph.pipe(format="svg").decode("utf-8"), Path.cwd())
def output( def output(
self, self,
filename: (str, Path), output_dir: Optional[Union[str, Path]],
view: bool = False, output_name: Optional[Union[str, Path]],
cleanup: bool = True, formats: List[str] | tuple[str]
fmt: tuple = ("html", "png", "svg", "tsv"),
) -> None: ) -> None:
# graphical output # graphical output
graph = self.graph graph = self.graph
svg_already_exists = Path(
f"{filename}.svg" if "csv" in formats:
).exists() # if SVG already exists, do not delete later # TODO: implement CSV output (preferably using CSV library)
# graphical output sys.stderr.write("CSV output is not yet supported")
for f in fmt: if "pdf" in formats:
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 # TODO: implement PDF output
print("PDF output is not yet supported") sys.stderr.write("PDF output is not yet supported")
# delete SVG if not needed
if "html" in fmt and not "svg" in fmt: outputs = {}
# SVG file was just needed to generate HTML if "svg" in formats or "html" in formats:
Path(f"{filename}.tmp.svg").unlink() # embed images into SVG output
elif "svg" in fmt: outputs["svg"] = embed_svg_images(graph.pipe(format="svg", encoding="utf8"))
Path(f"{filename}.tmp.svg").replace(f"{filename}.svg")
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): def bom(self):
if not self._bom: if not self._bom:

View File

@ -4,5 +4,5 @@
__version__ = "0.4-dev" __version__ = "0.4-dev"
CMD_NAME = "wireviz" # Lower case command and module name CMD_NAME = "wireviz" # Lower case command and module name
APP_NAME = "WireViz" # Application name in texts meant to be human readable APP_NAME = "WireViz" # Application name in texts meant to be human-readable
APP_URL = "https://github.com/formatc1702/WireViz" APP_URL = "https://github.com/wireviz/WireViz"

View File

@ -38,15 +38,3 @@ def get_mime_subtype(filename: Union[str, Path]) -> str:
if mime_subtype in mime_subtype_replacements: if mime_subtype in mime_subtype_replacements:
mime_subtype = mime_subtype_replacements[mime_subtype] mime_subtype = mime_subtype_replacements[mime_subtype]
return 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)

View File

@ -58,7 +58,7 @@ def parse(
return_types (optional): return_types (optional):
One of the supported return types (see above), or a tuple of multiple return types. 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. 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. One of the supported output types (see above), or a tuple of multiple output formats.
If set to None, no files are generated. If set to None, no files are generated.
output_dir (Path | str, optional): output_dir (Path | str, optional):
@ -87,15 +87,18 @@ def parse(
yaml_data, yaml_file = _get_yaml_data_and_path(inp) yaml_data, yaml_file = _get_yaml_data_and_path(inp)
if output_formats: if output_formats:
# need to write data to file, determine output directory and filename if str(output_dir) == "-":
# write to stdout
output_dir = None
else:
# write to directory
output_dir = _get_output_dir(yaml_file, output_dir) output_dir = _get_output_dir(yaml_file, output_dir)
output_name = _get_output_name(yaml_file, output_name) output_name = _get_output_name(yaml_file, output_name)
output_file = output_dir / output_name
if yaml_file: 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() 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) image_paths.append(default_image_path)
# define variables ========================================================= # define variables =========================================================
@ -362,11 +365,11 @@ def parse(
harness.add_bom_item(line) harness.add_bom_item(line)
if output_formats: 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: if return_types:
returns = [] 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 = [return_types]
return_types = [t.lower() for t in 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 # if no FileNotFoundError exception happens, get file contents
yaml_str = open_file_read(yaml_path).read() yaml_str = open_file_read(yaml_path).read()
except (FileNotFoundError, OSError) as e: 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. # when trying to expand and resolve it as a path.
# Catch this error, but raise any others # it can also raise OSError: [Errno 36] File name too long.
if type(e) is OSError and e.errno != 63: # Catch these errors, but raise any others.
if type(e) is OSError and e.errno != 63 and e.errno != 36:
raise e raise e
# file does not exist; assume inp is a YAML string # file does not exist; assume inp is a YAML string
yaml_str = inp yaml_str = inp
@ -417,7 +421,7 @@ def _get_output_dir(input_file: Path, default_output_dir: Path) -> Path:
return output_dir.resolve() 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 if default_output_name: # user-specified output name
output_name = default_output_name output_name = default_output_name
else: # auto-determine appropriate 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(): 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__": if __name__ == "__main__":

View File

@ -3,6 +3,7 @@
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Union
import click import click
@ -67,12 +68,11 @@ epilog += ", ".join([f"{key} ({value.upper()})" for key, value in format_codes.i
default=False, default=False,
help=f"Output {APP_NAME} version and exit.", 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. Parses the provided FILE and generates the specified outputs.
""" """
print() sys.stderr.write(f"{APP_NAME} {__version__}\n")
print(f"{APP_NAME} {__version__}")
if version: if version:
return # print version number only and exit return # print version number only and exit
@ -88,10 +88,13 @@ def wireviz(file, format, prepend, output_dir, output_name, version):
output_formats = [] output_formats = []
for code in format: for code in format:
if code in format_codes: 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: else:
raise Exception(f"Unknown output format: {code}") raise Exception(f"Unknown output format: {code}")
output_formats = tuple(sorted(set(output_formats)))
output_formats_str = ( output_formats_str = (
f'[{"|".join(output_formats)}]' f'[{"|".join(output_formats)}]'
if len(output_formats) > 1 if len(output_formats) > 1
@ -105,44 +108,63 @@ def wireviz(file, format, prepend, output_dir, output_name, version):
prepend_file = Path(prepend_file) prepend_file = Path(prepend_file)
if not prepend_file.exists(): if not prepend_file.exists():
raise Exception(f"File does not exist:\n{prepend_file}") 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" prepend_input += open_file_read(prepend_file).read() + "\n"
else: else:
prepend_input = "" prepend_input = ""
# run WireVIz on each input 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),
)
# run WireViz on each input file
for file in filepaths: for file in filepaths:
if file == "-":
# read from stdin
yaml_input = prepend_input + sys.stdin.read()
image_paths = []
else:
file = Path(file) file = Path(file)
if not file.exists(): if not file.exists():
raise Exception(f"File does not exist:\n{file}") raise Exception(f"File does not exist:\n{file}")
# file_out = file.with_suffix("") if not output_file else output_file sys.stderr.write(f"Input file: {file}\n")
_output_dir = file.parent if not output_dir else output_dir
_output_name = file.stem if not output_name else output_name
print("Input file: ", file)
print(
"Output file: ", f"{Path(_output_dir / _output_name)}.{output_formats_str}"
)
yaml_input = open_file_read(file).read() yaml_input = open_file_read(file).read()
file_dir = file.parent file_dir = file.parent
yaml_input = prepend_input + yaml_input yaml_input = prepend_input + yaml_input
image_paths = {file_dir} image_paths = {file_dir}
for p in prepend: for p in prepend:
image_paths.add(Path(p).parent) 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: <stdout>.{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( wv.parse(
yaml_input, yaml_input,
output_formats=output_formats, output_formats=tuple(output_formats),
output_dir=_output_dir, output_dir=_output_dir,
output_name=_output_name, output_name=_output_name,
image_paths=list(image_paths), image_paths=list(image_paths),
) )
print() sys.stderr.write('')
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import sys
from typing import Dict, List from typing import Dict, List
COLOR_CODES = { 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[0] != "#" or not all(d in _hex_digits for d in c[1:]):
if c != input: if c != input:
c += f" in input: {input}" c += f" in input: {input}"
print(f"Invalid hex color: {c}") sys.stderr.write(f"Invalid hex color: {c}")
output[i] = color_default output[i] = color_default
else: # Color name(s) else: # Color name(s)
@ -148,7 +148,7 @@ def get_color_hex(input: Colors, pad: bool = False) -> List[str]:
except KeyError: except KeyError:
if c != input: if c != input:
c += f" in input: {input}" c += f" in input: {input}"
print(f"Unknown color name: {c}") sys.stderr.write(f"Unknown color name: {c}")
return color_default return color_default
output = [lookup(input[i : i + 2]) for i in range(0, len(input), 2)] output = [lookup(input[i : i + 2]) for i in range(0, len(input), 2)]

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re import re
import sys
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List
@ -147,10 +148,10 @@ def aspect_ratio(image_src):
image = Image.open(image_src) image = Image.open(image_src)
if image.width > 0 and image.height > 0: if image.width > 0 and image.height > 0:
return image.width / image.height 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. # ModuleNotFoundError and FileNotFoundError are the most expected, but all are handled equally.
except Exception as error: 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 return 1 # Assume 1:1 when unable to read actual image size

View File

@ -2,7 +2,7 @@
import re import re
from pathlib import Path 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 import APP_NAME, APP_URL, __version__, wv_colors
from wireviz.DataClasses import Metadata, Options from wireviz.DataClasses import Metadata, Options
@ -10,45 +10,46 @@ from wireviz.wv_gv_html import html_line_breaks
from wireviz.wv_helper import ( from wireviz.wv_helper import (
flatten2d, flatten2d,
open_file_read, open_file_read,
open_file_write,
smart_file_resolve, smart_file_resolve,
) )
def generate_html_output( def generate_html_output(
filename: Union[str, Path], svg_input: str,
output_dir: Optional[Path],
bom_list: List[List[str]], bom_list: List[List[str]],
metadata: Metadata, metadata: Metadata,
options: Options, options: Options,
): ) -> str:
# load HTML template # load HTML template
templatename = metadata.get("template", {}).get("name") template_name = metadata.get("template", {}).get("name")
if templatename: builtin_template_directory = Path(__file__).parent / "templates" # built-in template directory
# if relative path to template was provided, check directory of YAML file first, fall back to built-in template directory if template_name:
templatefile = smart_file_resolve( possible_paths = []
f"{templatename}.html", # if relative path to template was provided, check directory of YAML file first
[Path(filename).parent, Path(__file__).parent / "templates"], if output_dir is not None:
) possible_paths.append(output_dir)
else:
# fall back to built-in simple template if no template was provided
templatefile = Path(__file__).parent / "templates/simple.html"
html = open_file_read(templatefile).read() possible_paths.append(builtin_template_directory) # fallback
template_file = smart_file_resolve(f"{template_name}.html", possible_paths)
else:
# fallback to built-in simple template if no template was provided
template_file = builtin_template_directory / "simple.html"
html = open_file_read(template_file).read()
# embed SVG diagram # embed SVG diagram
with open_file_read(f"{filename}.tmp.svg") as file: svg_data = re.sub(
svgdata = re.sub(
"^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>", "^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>",
"<!-- XML and DOCTYPE declarations from SVG file removed -->", "<!-- XML and DOCTYPE declarations from SVG file removed -->",
file.read(), svg_input,
1, 1,
) )
# generate BOM table # generate BOM table
bom = flatten2d(bom_list) 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 = " <tr>\n" bom_header_html = " <tr>\n"
for item in bom[0]: for item in bom[0]:
th_class = f"bom_col_{item.lower()}" th_class = f"bom_col_{item.lower()}"
@ -80,7 +81,7 @@ def generate_html_output(
"<!-- %generator% -->": f"{APP_NAME} {__version__} - {APP_URL}", "<!-- %generator% -->": f"{APP_NAME} {__version__} - {APP_URL}",
"<!-- %fontname% -->": options.fontname, "<!-- %fontname% -->": options.fontname,
"<!-- %bgcolor% -->": wv_colors.translate_color(options.bgcolor, "hex"), "<!-- %bgcolor% -->": wv_colors.translate_color(options.bgcolor, "hex"),
"<!-- %diagram% -->": svgdata, "<!-- %diagram% -->": svg_data,
"<!-- %bom% -->": bom_html, "<!-- %bom% -->": bom_html,
"<!-- %bom_reversed% -->": bom_html_reversed, "<!-- %bom_reversed% -->": bom_html_reversed,
"<!-- %sheet_current% -->": "1", # TODO: handle multi-page documents "<!-- %sheet_current% -->": "1", # TODO: handle multi-page documents
@ -114,6 +115,4 @@ def generate_html_output(
replacements_sorted = sorted(replacements, key=len, reverse=True) replacements_sorted = sorted(replacements, key=len, reverse=True)
replacements_escaped = map(re.escape, replacements_sorted) replacements_escaped = map(re.escape, replacements_sorted)
pattern = re.compile("|".join(replacements_escaped)) pattern = re.compile("|".join(replacements_escaped))
html = pattern.sub(lambda match: replacements[match.group(0)], html) return pattern.sub(lambda match: replacements[match.group(0)], html)
open_file_write(f"{filename}.html").write(html)