WireViz/src/wireviz/wireviz.py
Ryan Malloy 65af27e0da Add notebook-ready API: graph cache invalidation, structured BOM, fragment merging
Three additions to support interactive/notebook-style harness building:

- Graph cache invalidation: _invalidate_graph() called from all mutating
  methods so svg/png output reflects latest state after mutations
- bom_list_dicts(): JSON-serializable BOM export as list of dicts
- parse(harness=, populate_bom=): append YAML fragments to existing
  harness for cell-by-cell building with deferred BOM population

Templates persist on the Harness object across parse() calls so
component definitions in one fragment are available to connections
in later fragments.

Includes 24 new tests covering all three features plus full incremental
workflow simulation. All 122 tests pass.
2026-02-13 07:08:21 -07:00

521 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import platform
import sys
from errno import EINVAL, ENAMETOOLONG
from pathlib import Path
from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union
import yaml
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).parent.parent)) # add src/wireviz to PATH
from wireviz.wv_dataclasses import AUTOGENERATED_PREFIX, Metadata, Options, Tweak
from wireviz.wv_harness import Harness
from wireviz.wv_png_metadata import save_yaml_to_png
from wireviz.wv_utils import (
expand,
file_read_text,
get_single_key_and_value,
is_arrow,
smart_file_resolve,
)
from . import APP_NAME
class ParsedInput(NamedTuple):
"""Result of parsing the input argument to parse()."""
data: Dict # parsed YAML as a dict
source_path: Optional[Path] # file path if input was a file, else None
raw_yaml: Optional[str] # raw YAML string, None when input was a Dict
def parse(
inp: Union[Path, str, Dict],
return_types: Union[None, str, Tuple[str]] = None,
output_formats: Union[None, str, Tuple[str]] = None,
output_dir: Union[str, Path] = None,
output_name: Union[None, str] = None,
image_paths: Union[Path, str, List] = [],
harness: "Harness" = None,
populate_bom: bool = True,
) -> Any:
"""
This function takes an input, parses it as a WireViz Harness file,
and outputs the result as one or more files and/or as a function return value
Accepted inputs:
* 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
Supported return types:
* "png": the diagram as raw PNG data
* "svg": the diagram as raw SVG data
* "harness": the diagram as a Harness Python object
Supported output formats:
* "gv": the diagram, as a GraphViz source file
* "html": the diagram and (depending on the template) the BOM, as a HTML file
* "pdf": the diagram and (depending on the template) the BOM, as a PDF file
* "svg": the diagram, as a SVG vector image
* "tsv": the BOM, as a tab-separated text file
Args:
inp (Path | str | Dict):
The input to be parsed (see above for accepted inputs).
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):
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):
The directory to place the generated output files.
Defaults to inp's parent directory, or cwd if inp is not a path.
output_name (str, optional):
The name to use for the generated output files (without extension).
Defaults to inp's file name (without extension).
Required parameter if inp is not a path.
image_paths (Path | str | List, optional):
Paths to use when resolving any image paths included in the data.
Note: If inp is a path to a YAML file,
its parent directory will automatically be included in the list.
harness (Harness, optional):
An existing Harness object to append components/connections to.
When provided, metadata/options/tweak from inp are ignored and
the existing harness is reused. Enables cell-by-cell building.
populate_bom (bool, optional):
Whether to call harness.populate_bom() after parsing.
Defaults to True. Set to False for intermediate fragments
when building incrementally, then call harness.populate_bom()
explicitly or pass True on the final fragment.
Returns:
Depending on the return_types parameter, may return:
* None
* one of the following, or a tuple containing two or more of the following:
* PNG data
* SVG data
* a Harness object
"""
# TODO: add CSV and PDF to docstring once they are supported
if not output_formats and not return_types:
raise Exception("No output formats or return types specified")
# normalize output_formats to a tuple to prevent substring matching bugs
# ("png" in "pngsvg" == True, but "png" in ("pngsvg",) == False)
if isinstance(output_formats, str):
output_formats = (output_formats,)
parsed = _parse_input(inp)
if not isinstance(parsed.data, dict):
raise TypeError(
f"Expected a dict as top-level YAML input, but got: {type(parsed.data)}"
)
yaml_data = parsed.data
if output_formats:
# need to write data to file, determine output directory and filename
output_dir = _get_output_dir(parsed.source_path, output_dir)
output_name = _get_output_name(parsed.source_path, output_name)
output_file = output_dir / output_name
if parsed.source_path:
# if reading from file, ensure that input file's parent directory
# is included in image_paths
default_image_path = parsed.source_path.parent.resolve()
if not default_image_path in [Path(x).resolve() for x in image_paths]:
image_paths.append(default_image_path)
# define variables =========================================================
# containers for parsed component data and connection sets
# when reusing a harness, start from its persistent templates
template_connectors = dict(harness._template_connectors) if harness is not None else {}
template_cables = dict(harness._template_cables) if harness is not None else {}
connection_sets = []
# actual harness — create new or reuse existing
if harness is None:
harness = Harness(
metadata=Metadata(**yaml_data.get("metadata", {})),
options=Options(**yaml_data.get("options", {})),
tweak=Tweak(**yaml_data.get("tweak", {})),
)
# When title is not given, either deduce it from filename, or use default text.
if "title" not in harness.metadata:
harness.metadata["title"] = output_name or f"{APP_NAME} diagram and BOM"
# store mapping of components to their respective template
designators_and_templates = {}
# pre-populate from existing components so re-referencing works
for des in harness.connectors:
designators_and_templates[des] = des
for des in harness.cables:
designators_and_templates[des] = des
# keep track of auto-generated designators to avoid duplicates
autogenerated_designators = {}
# add items
# parse YAML input file ====================================================
sections = ["connectors", "cables", "connections"]
types = [dict, dict, list]
for sec, ty in zip(sections, types):
if sec in yaml_data and type(yaml_data[sec]) == ty: # section exists
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.
image = attribs.get("image")
if isinstance(image, dict):
image_path = image["src"]
if image_path and not Path(image_path).is_absolute():
# resolve relative image path
image["src"] = smart_file_resolve(
image_path, image_paths
)
if sec == "connectors":
template_connectors[key] = attribs
elif sec == "cables":
template_cables[key] = attribs
else: # section exists but is empty
pass
else: # section does not exist, create empty section
if ty == dict:
yaml_data[sec] = {}
elif ty == list:
yaml_data[sec] = []
connection_sets = yaml_data["connections"]
# persist templates on harness for future fragment merges
harness._template_connectors.update(template_connectors)
harness._template_cables.update(template_cables)
# go through connection sets, generate and connect components ==============
template_separator_char = harness.options.template_separator
def resolve_designator(inp, separator):
if separator in inp: # generate a new instance of an item
if inp.count(separator) > 1:
raise Exception(f"{inp} - Found more than one separator ({separator})")
template, designator = inp.split(separator)
if designator == "":
autogenerated_designators[template] = (
autogenerated_designators.get(template, 0) + 1
)
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}"
f" from {designators_and_templates[designator]} to {template}"
)
else:
designators_and_templates[designator] = template
else:
template, designator = (inp, inp)
if designator in designators_and_templates:
pass # referencing an exiting connector, no need to add again
else:
designators_and_templates[designator] = template
return (template, designator)
# utilities to check for alternating connectors and cables/arrows ==========
alternating_types = ["connector", "cable/arrow"]
expected_type = None
def check_type(designator, template, actual_type):
nonlocal expected_type
if not expected_type: # each connection set may start with either section
expected_type = actual_type
if actual_type != expected_type: # did not alternate
raise Exception(
f'Expected {expected_type}, but "{designator}" ("{template}") is {actual_type}'
)
def alternate_type(): # flip between connector and cable/arrow
nonlocal expected_type
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:
if isinstance(entry, list):
connectioncount.append(len(entry))
elif isinstance(entry, dict):
connectioncount.append(len(expand(list(entry.values())[0])))
# e.g.: - X1: [1-4,6] yields 5
else:
pass # strings do not reveal connectioncount
if not any(connectioncount):
# no item in the list revealed connection count;
# assume connection count is 1
connectioncount = [1]
# Example: The following is a valid connection set,
# even though no item reveals the connection count;
# the count is not needed because only a component-level mate happens.
# -
# - CONNECTOR
# - ==>
# - CONNECTOR
# check that all entries are the same length
if len(set(connectioncount)) > 1:
raise Exception(
"All items in connection set must reference the same number of connections"
)
# all entries are the same length, connection count is set
connectioncount = connectioncount[0]
# expand string entries to list entries of correct length
for index, entry in enumerate(connection_set):
if isinstance(entry, str):
connection_set[index] = [entry] * connectioncount
# resolve all designators
for index, entry in enumerate(connection_set):
if isinstance(entry, list):
for subindex, item in enumerate(entry):
template, designator = resolve_designator(
item, template_separator_char
)
connection_set[index][subindex] = designator
elif isinstance(entry, dict):
key = list(entry.keys())[0]
template, designator = resolve_designator(key, template_separator_char)
value = entry[key]
connection_set[index] = {designator: value}
else:
pass # string entries have been expanded in previous step
# expand all pin lists
for index, entry in enumerate(connection_set):
if isinstance(entry, list):
connection_set[index] = [{designator: 1} for designator in entry]
elif isinstance(entry, dict):
designator = list(entry.keys())[0]
pinlist = expand(entry[designator])
connection_set[index] = [{designator: pin} for pin in pinlist]
else:
pass # string entries have been expanded in previous step
# Populate wiring harness ==============================================
expected_type = None # reset check for alternating types
# at the beginning of every connection set
# since each set may begin with either type
# generate components
for entry in connection_set:
for item in entry:
designator = list(item.keys())[0]
template = designators_and_templates[designator]
if designator in harness.connectors: # existing connector instance
check_type(designator, template, "connector")
elif template in template_connectors.keys():
# generate new connector instance from template
check_type(designator, template, "connector")
harness.add_connector(
designator=designator, **template_connectors[template]
)
elif designator in harness.cables: # existing cable instance
check_type(designator, template, "cable/arrow")
elif template in template_cables.keys():
# generate new cable instance from template
check_type(designator, template, "cable/arrow")
harness.add_cable(
designator=designator, **template_cables[template]
)
elif is_arrow(designator):
check_type(designator, template, "cable/arrow")
# arrows do not need to be generated here
else:
raise Exception(
f"{template} is an unknown template/designator/arrow."
)
# 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
# after: one item per connection in set, one subitem per component
connection_set = list(map(list, zip(*connection_set)))
# connect components
for index_entry, entry in enumerate(connection_set):
for index_item, item in enumerate(entry):
designator = list(item.keys())[0]
if designator in harness.cables:
if index_item == 0:
# list started with a cable, no connector to join on left side
from_name, from_pin = (None, None)
else:
from_name, from_pin = get_single_key_and_value(
entry[index_item - 1]
)
via_name, via_pin = (designator, item[designator])
if index_item == len(entry) - 1:
# list ends with a cable, no connector to join on right side
to_name, to_pin = (None, None)
else:
to_name, to_pin = get_single_key_and_value(
entry[index_item + 1]
)
harness.connect(
from_name, from_pin, via_name, via_pin, to_name, to_pin
)
elif is_arrow(designator):
if index_item == 0: # list starts with an arrow
raise Exception(
"An arrow cannot be at the start of a connection set"
)
elif index_item == len(entry) - 1: # list ends with an arrow
raise Exception(
"An arrow cannot be at the end of a connection set"
)
from_name, from_pin = get_single_key_and_value(
entry[index_item - 1]
)
via_name, via_pin = (designator, None)
to_name, to_pin = get_single_key_and_value(entry[index_item + 1])
if "-" in designator: # mate pin by pin
harness.add_mate_pin(
from_name, from_pin, to_name, to_pin, designator
)
elif "=" in designator and index_entry == 0:
# mate two connectors as a whole
harness.add_mate_component(from_name, to_name, designator)
# warn about unused templates
proposed_components = list(template_connectors.keys()) + list(
template_cables.keys()
)
used_components = set(designators_and_templates.values())
forgotten_components = [c for c in proposed_components if not c in used_components]
if len(forgotten_components) > 0:
print(
"Warning: The following components are not referenced in any connection set:"
)
print(", ".join(forgotten_components))
if "additional_bom_items" in yaml_data:
for line in yaml_data["additional_bom_items"]:
harness.add_additional_bom_item(line)
# harness population completed =============================================
if populate_bom:
harness.populate_bom()
if output_formats:
harness.output(filename=output_file, fmt=output_formats, view=False)
# embed YAML source into PNG for round-trip capability
# failure here is non-fatal — PNG diagram output remains valid
if "png" in output_formats and parsed.raw_yaml:
save_yaml_to_png(output_file, parsed.raw_yaml)
if return_types:
returns = []
if isinstance(return_types, str): # only one return type speficied
return_types = [return_types]
return_types = [t.lower() for t in return_types]
for rt in return_types:
if rt == "png":
returns.append(harness.png)
if rt == "svg":
returns.append(harness.svg)
if rt == "harness":
returns.append(harness)
return tuple(returns) if len(returns) != 1 else returns[0]
def _parse_input(inp: Union[str, Path, Dict]) -> ParsedInput:
"""Parse the input argument into YAML data, source path, and raw string.
When inp is a Dict, source_path and raw_yaml will be None.
When inp is a YAML string, source_path will be None.
When inp is a file path, all three fields will be populated.
"""
if not isinstance(inp, Dict): # received a str or a Path
try:
yaml_path = Path(inp).expanduser().resolve(strict=True)
# if no FileNotFoundError exception happens, get file contents
yaml_str = file_read_text(yaml_path)
except (FileNotFoundError, OSError, ValueError) as e:
# if inp is a long YAML string, Pathlib will normally raise
# FileNotFoundError or OSError(errno = ENAMETOOLONG) when
# trying to expand and resolve it as a path, but in Windows
# might ValueError or OSError(errno = EINVAL or None) be raised
# instead in some cases (depending on the Python version).
# Catch these specific errors, but raise any others.
if type(e) is OSError and e.errno not in (EINVAL, ENAMETOOLONG, None):
print(
f"OSError(errno={e.errno}) in Python {sys.version} at {platform.platform()}"
)
raise e
# file does not exist; assume inp is a YAML string
yaml_str = inp
yaml_path = None
yaml_data = yaml.safe_load(yaml_str)
else:
# received a Dict, use as-is
yaml_data = inp
yaml_path = None
yaml_str = None
return ParsedInput(data=yaml_data, source_path=yaml_path, raw_yaml=yaml_str)
def _get_output_dir(input_file: Path, default_output_dir: Path) -> Path:
if default_output_dir: # user-specified output directory
output_dir = Path(default_output_dir)
else: # auto-determine appropriate output directory
if input_file: # input comes from a file; place output in same directory
output_dir = input_file.parent
else: # input comes from str or Dict; fall back to cwd
output_dir = Path.cwd()
return output_dir.resolve()
def _get_output_name(input_file: Path, default_output_name: Path) -> str:
if default_output_name: # user-specified output name
output_name = default_output_name
else: # auto-determine appropriate output name
if input_file: # input comes from a file; use same file stem
output_name = input_file.stem
else: # input comes from str or Dict; no fallback available
raise Exception("No output file name provided")
return output_name
def main():
print("When running from the command line, please use wv_cli.py instead.")
if __name__ == "__main__":
main()