Rename modules, adjust imports, move build_examples.py

This commit is contained in:
Daniel Rojas 2021-10-21 22:41:24 +02:00 committed by KV
parent 7111a3375f
commit fb91be402a
13 changed files with 299 additions and 312 deletions

View File

@ -22,7 +22,7 @@ jobs:
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install . pip install .
- name: Create Examples - 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 - name: Upload examples, demos, and tutorials
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:

View File

@ -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'<image{pre} xlink:href="{url}"{post}>'
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<PRE> [^>]*?)?", r'(?P<URL>[^"]*?)', r"(?P<POST> [^>]*?)?"),
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)

View File

@ -7,13 +7,12 @@ import sys
from pathlib import Path from pathlib import Path
script_path = Path(__file__).absolute() script_path = Path(__file__).absolute()
sys.path.insert(0, str(script_path.parent.parent.parent)) # to find wireviz module
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
from wireviz import APP_NAME, __version__, wireviz 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" readme = "readme.md"
groups = { groups = {
"examples": { "examples": {

View File

@ -10,9 +10,9 @@ import yaml
if __name__ == "__main__": if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).parent.parent)) # add src/wireviz to PATH 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.wv_dataclasses import AUTOGENERATED_PREFIX, Metadata, Options, Tweak
from wireviz.Harness import Harness from wireviz.wv_harness import Harness
from wireviz.wv_helper import ( from wireviz.wv_utils import (
expand, expand,
get_single_key_and_value, get_single_key_and_value,
is_arrow, is_arrow,
@ -396,20 +396,12 @@ def parse(
def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> (Dict, Path): def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> (Dict, Path):
# determine whether inp is a file path, a YAML string, or a Dict # determine whether inp is a file path, a YAML string, or a Dict
if not isinstance(inp, Dict): # received a str or a Path 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) yaml_path = Path(inp).expanduser().resolve(strict=True)
# 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: else:
# 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
yaml_path = None yaml_path = None
yaml_str = inp
yaml_data = yaml.safe_load(yaml_str) yaml_data = yaml.safe_load(yaml_str)
else: else:
# received a Dict, use as-is # received a Dict, use as-is

View File

@ -5,7 +5,7 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import List, Optional, Union from typing import List, Optional, Union
from wireviz.wv_helper import html_line_breaks from wireviz.wv_utils import html_line_breaks
BOM_HASH_FIELDS = "description unit partnumbers" BOM_HASH_FIELDS = "description unit partnumbers"
BomHash = namedtuple("BomHash", BOM_HASH_FIELDS) BomHash = namedtuple("BomHash", BOM_HASH_FIELDS)

View File

@ -11,7 +11,7 @@ if __name__ == "__main__":
import wireviz.wireviz as wv import wireviz.wireviz as wv
from wireviz import APP_NAME, __version__ from wireviz import APP_NAME, __version__
from wireviz.wv_helper import open_file_read from wireviz.wv_utils import open_file_read
format_codes = { format_codes = {
"c": "csv", "c": "csv",
@ -23,10 +23,11 @@ format_codes = {
"t": "tsv", "t": "tsv",
} }
epilog = ( epilog = (
"The -f or --format option accepts a string containing one or more of the " "The -f or --format option accepts a string containing one or more of the "
"following characters to specify which file types to output:\n" "following characters to specify which file types to output:\n"
f", ".join([f"{key} ({value.upper()})" for key, value in format_codes.items()]) + f", ".join([f"{key} ({value.upper()})" for key, value in format_codes.items()])
) )

View File

@ -14,7 +14,7 @@ from wireviz.wv_colors import (
SingleColor, SingleColor,
get_color_by_colorcode_index, get_color_by_colorcode_index,
) )
from wireviz.wv_helper import aspect_ratio, awg_equiv, mm2_equiv, remove_links from wireviz.wv_utils import aspect_ratio, awg_equiv, mm2_equiv, remove_links
# Each type alias have their legal values described in comments # Each type alias have their legal values described in comments
# - validation might be implemented in the future # - validation might be implemented in the future

View File

@ -5,7 +5,8 @@ from itertools import zip_longest
from typing import Any, List, Union from typing import Any, List, Union
from wireviz import APP_NAME, APP_URL, __version__ from wireviz import APP_NAME, APP_URL, __version__
from wireviz.DataClasses import ( from wireviz.wv_bom import partnumbers_to_list
from wireviz.wv_dataclasses import (
ArrowDirection, ArrowDirection,
ArrowWeight, ArrowWeight,
Cable, Cable,
@ -18,9 +19,8 @@ from wireviz.DataClasses import (
ShieldClass, ShieldClass,
WireClass, WireClass,
) )
from wireviz.wv_bom import partnumbers_to_list from wireviz.wv_html import Img, Table, Td, Tr
from wireviz.wv_helper import html_line_breaks, remove_links from wireviz.wv_utils import html_line_breaks, remove_links
from wireviz.wv_table_util import * # TODO: explicitly import each needed tag later
def gv_node_component(component: Component) -> Table: def gv_node_component(component: Component) -> Table:

View File

@ -8,7 +8,7 @@ from typing import List
from graphviz import Graph from graphviz import Graph
import wireviz.wv_colors import wireviz.wv_colors
from wireviz.DataClasses import ( from wireviz.wv_dataclasses import (
AUTOGENERATED_PREFIX, AUTOGENERATED_PREFIX,
AdditionalComponent, AdditionalComponent,
Arrow, Arrow,
@ -23,8 +23,7 @@ from wireviz.DataClasses import (
Side, Side,
Tweak, Tweak,
) )
from wireviz.svgembed import embed_svg_images_file from wireviz.wv_graphviz import (
from wireviz.wv_gv_html import (
apply_dot_tweaks, apply_dot_tweaks,
calculate_node_bgcolor, calculate_node_bgcolor,
gv_connector_loops, gv_connector_loops,
@ -34,8 +33,8 @@ from wireviz.wv_gv_html import (
parse_arrow_str, parse_arrow_str,
set_dot_basics, set_dot_basics,
) )
from wireviz.wv_helper import open_file_write, tuplelist2tsv from wireviz.wv_output import embed_svg_images_file, generate_html_output
from wireviz.wv_html import generate_html_output from wireviz.wv_utils import open_file_write, tuplelist2tsv
@dataclass @dataclass

View File

@ -1,120 +1,125 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re from collections.abc import Iterable
from pathlib import Path from dataclasses import dataclass, field
from typing import Dict, List, Union from typing import Dict
from wireviz import APP_NAME, APP_URL, __version__, wv_colors indent_count = 1
from wireviz.DataClasses import Metadata, Options
from wireviz.wv_helper import (
flatten2d,
html_line_breaks,
open_file_read,
open_file_write,
smart_file_resolve,
)
def generate_html_output( class Attribs(Dict):
filename: Union[str, Path], def __repr__(self):
bom_list: List[List[str]], if len(self) == 0:
metadata: Metadata, return ""
options: Options,
):
# load HTML template html = []
templatename = metadata.get("template", {}).get("name") for k, v in self.items():
if templatename: if v is not None:
# if relative path to template was provided, html.append(f' {k}="{v}"')
# check directory of YAML file first, fall back to built-in template directory # else:
templatefile = smart_file_resolve( # html.append(f" {k}")
f"{templatename}.html", return "".join(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 = open_file_read(templatefile).read()
# embed SVG diagram @dataclass
with open_file_read(f"{filename}.tmp.svg") as file: class Tag:
svgdata = re.sub( contents = None
"^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>", attribs: Attribs = field(default_factory=Attribs)
"<!-- XML and DOCTYPE declarations from SVG file removed -->", flat: bool = None
file.read(), delete_if_empty: bool = False
1,
)
# generate BOM table def __init__(self, contents, flat=None, delete_if_empty=False, **kwargs):
bom = flatten2d(bom_list) 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) def update_attribs(self, **kwargs):
bom_header_html = " <tr>\n" for k, v in kwargs.items():
for item in bom[0]: self.attribs[k] = v
th_class = f"bom_col_{item.lower()}"
bom_header_html = f'{bom_header_html} <th class="{th_class}">{item}</th>\n'
bom_header_html = f"{bom_header_html} </tr>\n"
# generate BOM contents @property
bom_contents = [] def tagname(self):
for row in bom[1:]: return type(self).__name__.lower()
row_html = " <tr>\n"
for i, item in enumerate(row):
td_class = f"bom_col_{bom[0][i].lower()}"
row_html = f'{row_html} <td class="{td_class}">{item}</td>\n'
row_html = f"{row_html} </tr>\n"
bom_contents.append(row_html)
bom_html = ( @property
'<table class="bom">\n' + bom_header_html + "".join(bom_contents) + "</table>\n" def auto_flat(self):
) if self.flat is not None: # user specified
bom_html_reversed = ( return self.flat
'<table class="bom">\n' if not _is_iterable_not_str(self.contents): # catch str, int, float, ...
+ "".join(list(reversed(bom_contents))) if not isinstance(self.contents, Tag): # avoid recursion
+ bom_header_html return not "\n" in str(self.contents) # flatten if single line
+ "</table>\n"
)
# prepare simple replacements @property
replacements = { def is_empty(self):
"<!-- %generator% -->": f"{APP_NAME} {__version__} - {APP_URL}", return self.get_contents(force_flat=True) == ""
"<!-- %fontname% -->": options.fontname,
"<!-- %bgcolor% -->": options.bgcolor.html,
"<!-- %diagram% -->": svgdata,
"<!-- %bom% -->": bom_html,
"<!-- %bom_reversed% -->": bom_html_reversed,
"<!-- %sheet_current% -->": "1", # TODO: handle multi-page documents
"<!-- %sheet_total% -->": "1", # TODO: handle multi-page documents
}
# prepare metadata replacements def indent_lines(self, lines, force_flat=False):
if metadata: if self.auto_flat or force_flat:
for item, contents in metadata.items(): return lines
if isinstance(contents, (str, int, float)): else:
replacements[f"<!-- %{item}% -->"] = html_line_breaks(str(contents)) indenter = " " * indent_count
elif isinstance(contents, Dict): # useful for authors, revisions return "\n".join(f"{indenter}{line}" for line in lines.split("\n"))
for index, (category, entry) in enumerate(contents.items()):
if isinstance(entry, Dict):
replacements[f"<!-- %{item}_{index+1}% -->"] = str(category)
for entry_key, entry_value in entry.items():
replacements[
f"<!-- %{item}_{index+1}_{entry_key}% -->"
] = html_line_breaks(str(entry_value))
replacements['"sheetsize_default"'] = '"{}"'.format( def get_contents(self, force_flat=False):
metadata.get("template", {}).get("sheetsize", "") separator = "" if self.auto_flat or force_flat else "\n"
) if _is_iterable_not_str(self.contents):
# include quotes so no replacement happens within <style> definition return separator.join(
[
self.indent_lines(str(c), force_flat)
for c in self.contents
if c is not None
]
)
elif self.contents is None:
return ""
else: # str, int, float, etc.
return self.indent_lines(str(self.contents), force_flat)
# perform replacements def __repr__(self):
# regex replacement adapted from: separator = "" if self.auto_flat else "\n"
# https://gist.github.com/bgusach/a967e0587d6e01e889fd1d776c5f3729 if self.delete_if_empty and self.is_empty:
return ""
else:
html = [
f"<{self.tagname}{str(self.attribs)}>",
f"{self.get_contents()}",
f"</{self.tagname}>",
]
html_joined = separator.join(html)
return html_joined
# longer replacements first, just in case
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) @dataclass
class TagSingleton(Tag):
def __init__(self, **kwargs):
self.attribs = Attribs({**kwargs})
def __repr__(self):
return f"<{self.tagname}{str(self.attribs)} />"
def _is_iterable_not_str(inp):
# str is iterable, but should be treated as not iterable
return isinstance(inp, Iterable) and not isinstance(inp, str)
@dataclass
class Br(TagSingleton):
pass
class Img(TagSingleton):
pass
class Td(Tag):
pass
class Tr(Tag):
pass
class Table(Tag):
pass

168
src/wireviz/wv_output.py Normal file
View File

@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
import base64
import re
from pathlib import Path
from typing import Dict, List, Union
import wireviz # for doing wireviz.__file__
from wireviz import APP_NAME, APP_URL, __version__
from wireviz.wv_dataclasses import Metadata, Options
from wireviz.wv_utils import (
flatten2d,
html_line_breaks,
open_file_read,
open_file_write,
smart_file_resolve,
)
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'<image{pre} xlink:href="{url}"{post}>'
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<PRE> [^>]*?)?", r'(?P<URL>[^"]*?)', r"(?P<POST> [^>]*?)?"),
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)
def generate_html_output(
filename: Union[str, Path],
bom_list: List[List[str]],
metadata: Metadata,
options: Options,
):
# load HTML template
templatename = metadata.get("template", {}).get("name")
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(wireviz.__file__).parent / "templates/simple.html"
html = open_file_read(templatefile).read()
# embed SVG diagram
with open_file_read(f"{filename}.tmp.svg") as file:
svgdata = re.sub(
"^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>",
"<!-- XML and DOCTYPE declarations from SVG file removed -->",
file.read(),
1,
)
# generate BOM table
bom = flatten2d(bom_list)
# generate BOM header (may be at the top or bottom of the table)
bom_header_html = " <tr>\n"
for item in bom[0]:
th_class = f"bom_col_{item.lower()}"
bom_header_html = f'{bom_header_html} <th class="{th_class}">{item}</th>\n'
bom_header_html = f"{bom_header_html} </tr>\n"
# generate BOM contents
bom_contents = []
for row in bom[1:]:
row_html = " <tr>\n"
for i, item in enumerate(row):
td_class = f"bom_col_{bom[0][i].lower()}"
row_html = f'{row_html} <td class="{td_class}">{item}</td>\n'
row_html = f"{row_html} </tr>\n"
bom_contents.append(row_html)
bom_html = (
'<table class="bom">\n' + bom_header_html + "".join(bom_contents) + "</table>\n"
)
bom_html_reversed = (
'<table class="bom">\n'
+ "".join(list(reversed(bom_contents)))
+ bom_header_html
+ "</table>\n"
)
# prepare simple replacements
replacements = {
"<!-- %generator% -->": f"{APP_NAME} {__version__} - {APP_URL}",
"<!-- %fontname% -->": options.fontname,
"<!-- %bgcolor% -->": options.bgcolor.html,
"<!-- %diagram% -->": svgdata,
"<!-- %bom% -->": bom_html,
"<!-- %bom_reversed% -->": bom_html_reversed,
"<!-- %sheet_current% -->": "1", # TODO: handle multi-page documents
"<!-- %sheet_total% -->": "1", # TODO: handle multi-page documents
}
# prepare metadata replacements
if metadata:
for item, contents in metadata.items():
if isinstance(contents, (str, int, float)):
replacements[f"<!-- %{item}% -->"] = html_line_breaks(str(contents))
elif isinstance(contents, Dict): # useful for authors, revisions
for index, (category, entry) in enumerate(contents.items()):
if isinstance(entry, Dict):
replacements[f"<!-- %{item}_{index+1}% -->"] = str(category)
for entry_key, entry_value in entry.items():
replacements[
f"<!-- %{item}_{index+1}_{entry_key}% -->"
] = html_line_breaks(str(entry_value))
replacements['"sheetsize_default"'] = '"{}"'.format(
metadata.get("template", {}).get("sheetsize", "")
)
# include quotes so no replacement happens within <style> definition
# perform replacements
# regex replacement adapted from:
# https://gist.github.com/bgusach/a967e0587d6e01e889fd1d776c5f3729
# longer replacements first, just in case
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)

View File

@ -1,125 +0,0 @@
# -*- coding: utf-8 -*-
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Dict
indent_count = 1
class Attribs(Dict):
def __repr__(self):
if len(self) == 0:
return ""
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)
@dataclass
class Tag:
contents = None
attribs: Attribs = field(default_factory=Attribs)
flat: bool = None
delete_if_empty: bool = False
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})
def update_attribs(self, **kwargs):
for k, v in kwargs.items():
self.attribs[k] = v
@property
def tagname(self):
return type(self).__name__.lower()
@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
@property
def is_empty(self):
return self.get_contents(force_flat=True) == ""
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"))
def get_contents(self, force_flat=False):
separator = "" if self.auto_flat or force_flat else "\n"
if _is_iterable_not_str(self.contents):
return separator.join(
[
self.indent_lines(str(c), force_flat)
for c in self.contents
if c is not None
]
)
elif self.contents is None:
return ""
else: # str, int, float, etc.
return self.indent_lines(str(self.contents), force_flat)
def __repr__(self):
separator = "" if self.auto_flat else "\n"
if self.delete_if_empty and self.is_empty:
return ""
else:
html = [
f"<{self.tagname}{str(self.attribs)}>",
f"{self.get_contents()}",
f"</{self.tagname}>",
]
html_joined = separator.join(html)
return html_joined
@dataclass
class TagSingleton(Tag):
def __init__(self, **kwargs):
self.attribs = Attribs({**kwargs})
def __repr__(self):
return f"<{self.tagname}{str(self.attribs)} />"
def _is_iterable_not_str(inp):
# str is iterable, but should be treated as not iterable
return isinstance(inp, Iterable) and not isinstance(inp, str)
@dataclass
class Br(TagSingleton):
pass
class Img(TagSingleton):
pass
class Td(Tag):
pass
class Tr(Tag):
pass
class Table(Tag):
pass