Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Tests

on: [push, pull_request, workflow_dispatch]

jobs:
pytest:
strategy:
max-parallel: 6
matrix:
# Same Python matrix shape as the Create Examples workflow so
# we get coverage from the oldest officially supported runtime
# (3.7) up through the latest released (3.12).
os: [ubuntu-latest]
python-version: ["3.9", "3.10", "3.11", "3.12"]
include:
- os: ubuntu-22.04
python-version: "3.7"
- os: ubuntu-22.04
python-version: "3.8"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Setup Graphviz
uses: ts-graphviz/setup-graphviz@v2
- name: Install package + test dependencies
run: |
python -m pip install --upgrade pip
pip install .
pip install pytest
- name: Run pytest
run: pytest -v
37 changes: 34 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,33 @@ This repo is the **upstream Python CLI**. A separate GUI front-end is being buil

## Commands

WireViz has **no automated test suite**. The de-facto regression check is rebuilding the examples and diffing the output.
WireViz has both an **automated pytest suite** (`tests/`) and a separate **example-rebuild regression check** (`build_examples.py`). They serve complementary purposes — pytest validates the API and CLI surface; the example sweep validates rendered visual output across the gallery.

```bash
# Install for development (from repo root)
pip install -e .
pip install pytest

# Run the unit / integration test suite (~134 tests, ~5s)
pytest

# Run a single test file or test
pytest tests/test_regressions.py
pytest tests/test_cli.py::test_cli_template_dir

# Run the CLI on a YAML file (produces .gv .svg .png .html .bom.tsv next to input)
wireviz path/to/file.yml

# Limit output formats: g=gv h=html p=png s=svg t=tsv
# Limit output formats: g=gv h=html p=png s=svg t=tsv P=pdf
wireviz -f hps path/to/file.yml

# Stdin → stdout: pipe YAML in, get one rendered format out
cat harness.yml | wireviz -f s -O - - > harness.svg
cat harness.yml | wireviz -f p -O - - > harness.png

# .png input: extract the embedded YAML and re-render
wireviz harness.png

# Rebuild every demo, example, and tutorial (must cd into src/wireviz)
cd src/wireviz && python build_examples.py

Expand All @@ -40,7 +55,23 @@ cd src/wireviz && python build_examples.py compare -g examples tutorial demos

GraphViz must be installed as a system dep (`dot -V`). Code is formatted with `black` + `isort` (`isort` profile is `black`, configured in `pyproject.toml`).

CI (`.github/workflows/`) runs only `build_examples.py` across Python 3.7–3.12 — there is no `pytest`. If you add real tests, also wire them into CI.
CI (`.github/workflows/`) runs both `pytest` (the `Tests` workflow) and `build_examples.py` (the `Create Examples` workflow) across Python 3.7–3.12. Both must pass for a PR to be considered green.

## Test suite layout (`tests/`)

- **`tests/test_smoke.py`** — every output format renders without error, has the right magic bytes, and produces the expected basic structure.
- **`tests/test_parse.py`** — the `wireviz.parse()` library API: input shapes (Path / str / dict), output shapes, return_types, source_path auto-fill, embed_yaml flag.
- **`tests/test_cli.py`** — every CLI flag via Click's `CliRunner`. Covers stdin/stdout, `.png` input round-trip, `--no-embed-yaml`, `--template-dir`, `--prepend`, error paths.
- **`tests/test_harness.py`** — `Harness` public methods, the `_render` dict shape contract, file vs stdout dispatch.
- **`tests/test_dataclasses.py`** — `Connector` / `Cable` / `Tweak` / `Options` / `Image` coercion and validation logic.
- **`tests/test_colors.py`** — color schemes (DIN/IEC/T568/TEL), hex parsing, `get_color_hex` padding behavior.
- **`tests/test_bom.py`** — BOM aggregation: identical-component dedup, ignore_in_bom, additional_bom_items, bundle category, part-number columns.
- **`tests/test_regressions.py`** — **one test per upstream-PR port + every gemini review fix.** This is where every bug we ported a fix for gets pinned down so it can never silently regress. If you change behavior touched by any of those PRs, expect tests here to fail and update them deliberately.
- **`tests/test_round_trip.py`** — PNG embed/extract, stdin→stdout pipelines, dict-input no-mutation contract.

`tests/conftest.py` provides shared fixtures (paths to small targeted YAMLs in `tests/fixtures/`). The fixture YAMLs are deliberately separate from the gallery YAMLs in `examples/` so tests aren't coupled to visual gallery changes.

`pyproject.toml` configures pytest to treat unexpected warnings as errors (`filterwarnings = ["error", "ignore::SyntaxWarning"]`) so deprecations like the `re.sub(..., 1)` → `re.sub(..., count=1)` migration get caught early.

## Architecture

Expand Down
13 changes: 13 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,15 @@
[tool.isort]
profile = "black"

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
# Treat unexpected warnings as errors so dataclass / deprecation drift is caught
filterwarnings = [
"error",
# graphviz emits a SyntaxWarning on Python 3.12+ for an internal regex —
# not our problem and not worth blocking the suite on
"ignore::SyntaxWarning",
]
5 changes: 3 additions & 2 deletions src/wireviz/Harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ def _extend_tweak(self, node: Union[Connector, Cable]) -> None:
k, v = rph(k), rph(v)
if k in s_dict and v != s_dict[k]:
raise ValueError(
f"{node.name}.tweak.override.{ident}.{k} conflicts with another"
f"{node.name}.tweak.override.{ident}.{k}: new value "
f"{v!r} conflicts with existing {s_dict[k]!r}"
)
s_dict[k] = v
# Keep the empty dict rather than collapsing to None — the
Expand Down Expand Up @@ -935,7 +936,7 @@ def _render(
# rendered in this same call; otherwise let the template
# fall back to reading {output_dir}/{output_name}.png.
png_b64 = (
f"data:image/png;base64, {base64.b64encode(png_bytes).decode('utf-8')}"
f"data:image/png;base64,{base64.b64encode(png_bytes).decode('utf-8')}"
if png_bytes is not None
else None
)
Expand Down
4 changes: 2 additions & 2 deletions src/wireviz/svgembed.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def data_URI_base64(file: Union[str, Path], media: str = "image") -> str:
"""Return Base64-encoded data URI of input file."""
file = Path(file)
b64 = base64.b64encode(file.read_bytes()).decode("utf-8")
uri = f"data:{media}/{get_mime_subtype(file)};base64, {b64}"
uri = f"data:{media}/{get_mime_subtype(file)};base64,{b64}"
# print(f"data_URI_base64('{file}', '{media}') -> {len(uri)}-character URI")
if len(uri) > 65535:
print(
Expand All @@ -36,7 +36,7 @@ def replace(match: re.Match) -> str:
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]}",
f"data:image/{get_mime_subtype(imgurl)};base64,{images_b64[imgurl]}",
match["POST"] or "",
)

Expand Down
13 changes: 10 additions & 3 deletions src/wireviz/wireviz.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import copy
import platform
import sys
from pathlib import Path
from typing import Any, Dict, List, Tuple, Union
from typing import Any, Dict, List, Optional, Tuple, Union

import yaml

Expand Down Expand Up @@ -112,6 +113,10 @@ def parse(
raise TypeError(
f"Expected a dict as top-level YAML input, but got: {type(yaml_data)}"
)
# When inp was a Path, derive source_path automatically so callers
# don't have to pass it twice. Matches the docstring contract.
if source_path is None and yaml_file is not None:
source_path = yaml_file
write_to_stdout = (
output_formats and (str(output_dir) == "-" or str(output_name) == "-")
)
Expand Down Expand Up @@ -506,8 +511,10 @@ def _get_yaml_data_and_path(
yaml_data = yaml.safe_load(yaml_str)
else:
# received a Dict — serialize back to YAML so the caller has a
# text form for round-trip embedding into PNG output.
yaml_data = inp
# text form for round-trip embedding into PNG output, and
# deep-copy so the parsing pipeline's in-place expansion of
# the connections section doesn't leak back to the caller.
yaml_data = copy.deepcopy(inp)
yaml_path = None
yaml_str = yaml.safe_dump(inp, sort_keys=False, allow_unicode=True)
return yaml_data, yaml_path, yaml_str
Expand Down
40 changes: 27 additions & 13 deletions src/wireviz/wv_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ def wireviz(
--output-dir or --output-name to write a single rendered format to
stdout (e.g. ``cat harness.yml | wireviz -f s -O - -``).
"""
sys.stderr.write(f"\n{APP_NAME} {__version__}\n")
# Use ``click.echo(..., err=True)`` instead of ``sys.stderr.write``
# for status/log lines so they reach Click's captured-stderr stream
# uniformly across Click 8.1 (only captures click.echo) and 8.2+
# (captures sys.stderr.write too). The end-state is the same — log
# lines on stderr, render output on stdout.
click.echo(f"\n{APP_NAME} {__version__}", err=True)
if version:
return # print version number only and exit

Expand All @@ -116,7 +121,7 @@ def wireviz(
if fmt not in output_formats:
output_formats.append(fmt)
else:
raise Exception(f"Unknown output format: {code}")
raise click.UsageError(f"Unknown output format: {code}")
output_formats = tuple(output_formats)
output_formats_str = (
f'[{"|".join(output_formats)}]'
Expand All @@ -136,8 +141,10 @@ def wireviz(
for prepend_file in prepend:
prepend_file = Path(prepend_file)
if not prepend_file.exists():
raise Exception(f"File does not exist:\n{prepend_file}")
sys.stderr.write(f"Prepend file: {prepend_file}\n")
raise click.UsageError(
f"Prepend file does not exist: {prepend_file}"
)
click.echo(f"Prepend file: {prepend_file}", err=True)

prepend_input += file_read_text(prepend_file) + "\n"
else:
Expand All @@ -150,14 +157,18 @@ def wireviz(
for file in filepaths:
if str(file) == "-":
yaml_input = prepend_input + sys.stdin.read()
image_paths = set()
sys.stderr.write("Input: <stdin>\n")
# No source-file directory available, so any relative
# `image: src:` paths in the stdin YAML are resolved against
# the current working directory (matching how a typical
# piped invocation would be run from a project root).
image_paths = {Path.cwd()}
click.echo("Input: <stdin>", err=True)
_output_dir = output_dir if output_dir else "-"
_output_name = output_name if output_name else "stdin"
else:
file = Path(file)
if not file.exists():
raise Exception(f"File does not exist:\n{file}")
raise click.UsageError(f"Input file does not exist: {file}")

if file.suffix.lower() == ".png":
# PNG input: try to recover the YAML embedded by an
Expand All @@ -176,10 +187,10 @@ def wireviz(
f"'wireviz:yaml' iTXt chunk found)."
)
yaml_input = prepend_input + embedded
sys.stderr.write(f"Input file: {file} (extracted YAML)\n")
click.echo(f"Input file: {file} (extracted YAML)", err=True)
else:
yaml_input = prepend_input + file_read_text(file)
sys.stderr.write(f"Input file: {file}\n")
click.echo(f"Input file: {file}", err=True)
image_paths = {file.parent}
_output_dir = output_dir if output_dir else file.parent
_output_name = output_name if output_name else file.stem
Expand All @@ -188,10 +199,13 @@ def wireviz(
image_paths.add(Path(p).parent)

if write_to_stdout:
sys.stderr.write(f"Output: <stdout>.{output_formats_str}\n")
click.echo(
f"Output: <stdout>.{output_formats_str}", err=True
)
else:
sys.stderr.write(
f"Output file: {Path(_output_dir) / _output_name}.{output_formats_str}\n"
click.echo(
f"Output file: {Path(_output_dir) / _output_name}.{output_formats_str}",
err=True,
)

wv.parse(
Expand All @@ -205,7 +219,7 @@ def wireviz(
embed_yaml=embed_yaml,
)

sys.stderr.write("\n")
click.echo("", err=True)


if __name__ == "__main__":
Expand Down
2 changes: 1 addition & 1 deletion src/wireviz/wv_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def svgdata() -> str:
"^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>",
"<!-- XML and DOCTYPE declarations from SVG file removed -->",
svg_input or "",
1,
count=1,
)

# generate BOM table
Expand Down
Empty file added tests/__init__.py
Empty file.
Loading
Loading