Skip to content

Commit b38ea55

Browse files
Merge pull request #2 from ClassicMiniDIY/port-321-stdin-stdout
Add stdin/stdout streaming (port of upstream PR wireviz#321)
2 parents 8d6304a + c8d9469 commit b38ea55

8 files changed

Lines changed: 244 additions & 123 deletions

File tree

src/wireviz/DataClasses.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# -*- coding: utf-8 -*-
22

3+
import sys
34
from dataclasses import InitVar, dataclass, field
45
from enum import Enum, auto
56
from pathlib import Path
@@ -343,8 +344,8 @@ def __post_init__(self) -> None:
343344
self.gauge = g
344345

345346
if self.gauge_unit is not None:
346-
print(
347-
f"Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}"
347+
sys.stderr.write(
348+
f"Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}\n"
348349
)
349350
if u.upper() == "AWG":
350351
self.gauge_unit = u.upper()
@@ -367,8 +368,8 @@ def __post_init__(self) -> None:
367368
)
368369
self.length = L
369370
if self.length_unit is not None:
370-
print(
371-
f"Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}"
371+
sys.stderr.write(
372+
f"Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}\n"
372373
)
373374
self.length_unit = u
374375
elif not isinstance(self.length, (int, float)):

src/wireviz/Harness.py

Lines changed: 123 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# -*- coding: utf-8 -*-
22

3+
import base64
34
import re
5+
import sys
46
from collections import Counter
57
from dataclasses import dataclass
68
from itertools import zip_longest
79
from pathlib import Path
8-
from typing import Any, List, Union
10+
from typing import Any, Dict, List, Optional, Tuple, Union
911

1012
from graphviz import Graph
1113
from wireviz import APP_NAME, APP_URL, __version__, wv_colors
@@ -19,7 +21,7 @@
1921
Side,
2022
Tweak,
2123
)
22-
from wireviz.svgembed import embed_svg_images, embed_svg_images_file
24+
from wireviz.svgembed import embed_svg_images
2325
from wireviz.wv_bom import (
2426
HEADER_MPN,
2527
HEADER_PN,
@@ -603,12 +605,12 @@ def typecheck(name: str, value: Any, expect: type) -> None:
603605
f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', "", entry
604606
)
605607
if n_subs < 1:
606-
print(
607-
f"Harness.create_graph() warning: {attr} not found in {keyword}!"
608+
sys.stderr.write(
609+
f"Harness.create_graph() warning: {attr} not found in {keyword}!\n"
608610
)
609611
elif n_subs > 1:
610-
print(
611-
f"Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!"
612+
sys.stderr.write(
613+
f"Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!\n"
612614
)
613615
continue
614616

@@ -622,8 +624,8 @@ def typecheck(name: str, value: Any, expect: type) -> None:
622624
# If attr not found, then append it
623625
entry = re.sub(r"\]$", f" {attr}={value}]", entry)
624626
elif n_subs > 1:
625-
print(
626-
f"Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!"
627+
sys.stderr.write(
628+
f"Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!\n"
627629
)
628630

629631
dot.body[i] = entry
@@ -670,54 +672,125 @@ def svg(self): # TODO?: Verify xml encoding="utf-8" in SVG?
670672

671673
def output(
672674
self,
673-
filename: (str, Path),
675+
filename: Optional[Union[str, Path]],
676+
fmt: Union[str, Tuple[str, ...], List[str]] = ("html", "png", "svg", "tsv"),
674677
view: bool = False,
675678
cleanup: bool = True,
676-
fmt: tuple = ("html", "png", "svg", "tsv"),
679+
output_dir: Optional[Union[str, Path]] = None,
680+
output_name: Optional[str] = None,
677681
) -> None:
678-
# graphical output
679-
graph = self.graph
680-
svg_already_exists = Path(
681-
f"{filename}.svg"
682-
).exists() # if SVG already exists, do not delete later
683-
# graphical output
684-
for f in fmt:
685-
if f in ("png", "svg", "html"):
686-
if f == "html": # if HTML format is specified,
687-
f = "svg" # generate SVG for embedding into HTML
688-
# SVG file will be renamed/deleted later
689-
_filename = f"{filename}.tmp" if f == "svg" else filename
690-
# TODO: prevent rendering SVG twice when both SVG and HTML are specified
691-
graph.format = f
692-
graph.render(filename=_filename, view=view, cleanup=cleanup)
693-
# embed images into SVG output
694-
if "svg" in fmt or "html" in fmt:
695-
embed_svg_images_file(f"{filename}.tmp.svg")
696-
# GraphViz output
697-
if "gv" in fmt:
698-
graph.save(filename=f"{filename}.gv")
699-
# BOM output
700-
bomlist = bom_list(self.bom())
701-
if "tsv" in fmt:
702-
file_write_text(f"{filename}.bom.tsv", tuplelist2tsv(bomlist))
682+
"""Render the harness in the requested formats.
683+
684+
When ``filename`` is a path, each requested format is written to
685+
``{filename}.{ext}`` (with ``.bom.tsv`` for the BOM). When
686+
``filename`` is None, exactly one format must be requested and
687+
its bytes/text are written to stdout — supports piping the CLI
688+
into other tools.
689+
"""
690+
if isinstance(fmt, str):
691+
fmt = (fmt,)
692+
outputs: Dict[str, Union[str, bytes]] = self._render(
693+
fmt,
694+
output_dir=output_dir,
695+
output_name=output_name,
696+
)
697+
703698
if "csv" in fmt:
704-
# TODO: implement CSV output (preferrably using CSV library)
705-
print("CSV output is not yet supported")
706-
# HTML output
707-
if "html" in fmt:
708-
generate_html_output(
709-
filename, bomlist, self.metadata, self.options, self.source_path
710-
)
711-
# PDF output
699+
# TODO: implement CSV output (preferably using CSV library)
700+
sys.stderr.write("CSV output is not yet supported\n")
712701
if "pdf" in fmt:
713702
# TODO: implement PDF output
714-
print("PDF output is not yet supported")
715-
# delete SVG if not needed
716-
if "html" in fmt and not "svg" in fmt:
717-
# SVG file was just needed to generate HTML
718-
Path(f"{filename}.tmp.svg").unlink()
719-
elif "svg" in fmt:
720-
Path(f"{filename}.tmp.svg").replace(f"{filename}.svg")
703+
sys.stderr.write("PDF output is not yet supported\n")
704+
705+
if filename is None:
706+
# stdout mode — emit each rendered format in the user-requested order
707+
for f in fmt:
708+
content = outputs.get(f)
709+
if content is None:
710+
continue
711+
if isinstance(content, (bytes, bytearray)):
712+
sys.stdout.buffer.write(content)
713+
else:
714+
sys.stdout.write(content)
715+
return
716+
717+
suffix_map = {"tsv": "bom.tsv"}
718+
for f, content in outputs.items():
719+
ext = suffix_map.get(f, f)
720+
out_path = f"{filename}.{ext}"
721+
if isinstance(content, (bytes, bytearray)):
722+
Path(out_path).write_bytes(content)
723+
else:
724+
file_write_text(out_path, content)
725+
726+
def _render(
727+
self,
728+
fmt: Union[str, Tuple[str, ...], List[str]],
729+
output_dir: Optional[Union[str, Path]] = None,
730+
output_name: Optional[str] = None,
731+
) -> Dict[str, Union[str, bytes]]:
732+
"""Produce in-memory representations of each requested format.
733+
734+
Pipes graphviz once per binary output rather than via ``render()``
735+
+ temporary files so the caller can write files OR pipe to stdout
736+
without the SVG-file roundtrip the previous implementation used.
737+
"""
738+
if isinstance(fmt, str):
739+
fmt = (fmt,)
740+
graph = self.graph
741+
outputs: Dict[str, Union[str, bytes]] = {}
742+
743+
svg_str: Optional[str] = None
744+
if "svg" in fmt or "html" in fmt:
745+
# Resolve relative <image src=...> references against the YAML
746+
# source's directory when known; fall back to cwd. (In practice
747+
# wireviz.parse() rewrites relative image paths to absolute
748+
# during YAML parse, so this base path only matters for SVG
749+
# produced from already-rendered Harness objects or when a
750+
# tweak injects a post-parse relative path.)
751+
if self.source_path is not None and str(self.source_path) != "-":
752+
base_path: Path = Path(self.source_path).parent
753+
else:
754+
base_path = Path.cwd()
755+
svg_str = embed_svg_images(
756+
graph.pipe(format="svg").decode("utf-8"), base_path
757+
)
758+
if "svg" in fmt:
759+
outputs["svg"] = svg_str
760+
761+
png_bytes: Optional[bytes] = None
762+
if "png" in fmt:
763+
png_bytes = graph.pipe(format="png")
764+
outputs["png"] = png_bytes
765+
766+
if "gv" in fmt:
767+
outputs["gv"] = graph.source
768+
769+
if "tsv" in fmt or "html" in fmt:
770+
bomlist = bom_list(self.bom())
771+
if "tsv" in fmt:
772+
outputs["tsv"] = tuplelist2tsv(bomlist)
773+
if "html" in fmt:
774+
# Inline PNG as base64 in the HTML only when the PNG was
775+
# rendered in this same call; otherwise let the template
776+
# fall back to reading {output_dir}/{output_name}.png.
777+
png_b64 = (
778+
f"data:image/png;base64, {base64.b64encode(png_bytes).decode('utf-8')}"
779+
if png_bytes is not None
780+
else None
781+
)
782+
outputs["html"] = generate_html_output(
783+
svg_str,
784+
bomlist,
785+
self.metadata,
786+
self.options,
787+
output_dir=output_dir,
788+
output_name=output_name,
789+
png_b64=png_b64,
790+
source_path=self.source_path,
791+
)
792+
793+
return outputs
721794

722795
def bom(self):
723796
if not self._bom:

src/wireviz/svgembed.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,3 @@ def get_mime_subtype(filename: Union[str, Path]) -> str:
5454
return mime_subtype
5555

5656

57-
def embed_svg_images_file(
58-
filename_in: Union[str, Path], overwrite: bool = True
59-
) -> None:
60-
filename_in = Path(filename_in).resolve()
61-
filename_out = filename_in.with_suffix(".b64.svg")
62-
filename_out.write_text( # TODO?: Verify xml encoding="utf-8" in SVG?
63-
embed_svg_images(filename_in.read_text(), filename_in.parent)
64-
) # TODO: Use encoding="utf-8" in both read_text() and write_text()
65-
if overwrite:
66-
filename_out.replace(filename_in)

src/wireviz/wireviz.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,18 @@ def parse(
9494
raise TypeError(
9595
f"Expected a dict as top-level YAML input, but got: {type(yaml_data)}"
9696
)
97-
if output_formats:
97+
write_to_stdout = (
98+
output_formats and (str(output_dir) == "-" or str(output_name) == "-")
99+
)
100+
if output_formats and not write_to_stdout:
98101
# need to write data to file, determine output directory and filename
99102
output_dir = _get_output_dir(yaml_file, output_dir)
100103
output_name = _get_output_name(yaml_file, output_name)
101104
output_file = output_dir / output_name
105+
else:
106+
output_dir = None
107+
output_name = None
108+
output_file = None
102109

103110
if yaml_file:
104111
# if reading from file, ensure that input file's parent directory is included in image_paths
@@ -397,10 +404,10 @@ def alternate_type(): # flip between connector and cable/arrow
397404
used_components = set(designators_and_templates.values())
398405
forgotten_components = [c for c in proposed_components if not c in used_components]
399406
if len(forgotten_components) > 0:
400-
print(
401-
"Warning: The following components are not referenced in any connection set:"
407+
sys.stderr.write(
408+
"Warning: The following components are not referenced in any connection set:\n"
402409
)
403-
print(", ".join(forgotten_components))
410+
sys.stderr.write(", ".join(forgotten_components) + "\n")
404411

405412
# harness population completed =============================================
406413

@@ -409,7 +416,20 @@ def alternate_type(): # flip between connector and cable/arrow
409416
harness.add_bom_item(line)
410417

411418
if output_formats:
412-
harness.output(filename=output_file, fmt=output_formats, view=False)
419+
if write_to_stdout:
420+
if len(output_formats) != 1:
421+
raise ValueError(
422+
"Exactly one output format must be specified when writing to stdout."
423+
)
424+
harness.output(filename=None, fmt=output_formats, view=False)
425+
else:
426+
harness.output(
427+
filename=output_file,
428+
fmt=output_formats,
429+
view=False,
430+
output_dir=output_dir,
431+
output_name=output_name,
432+
)
413433

414434
if return_types:
415435
returns = []
@@ -447,8 +467,8 @@ def _get_yaml_data_and_path(inp: Union[str, Path, Dict]) -> (Dict, Path):
447467
from errno import EINVAL, ENAMETOOLONG
448468

449469
if type(e) is OSError and e.errno not in (EINVAL, ENAMETOOLONG, None):
450-
print(
451-
f"OSError(errno={e.errno}) in Python {sys.version} at {platform.platform()}"
470+
sys.stderr.write(
471+
f"OSError(errno={e.errno}) in Python {sys.version} at {platform.platform()}\n"
452472
)
453473
raise e
454474
# file does not exist; assume inp is a YAML string
@@ -485,7 +505,7 @@ def _get_output_name(input_file: Path, default_output_name: Path) -> str:
485505

486506

487507
def main():
488-
print("When running from the command line, please use wv_cli.py instead.")
508+
sys.stderr.write("When running from the command line, please use wv_cli.py instead.\n")
489509

490510

491511
if __name__ == "__main__":

0 commit comments

Comments
 (0)