WireViz/tests/test_png_metadata.py
Ryan Malloy 9ba17ef621
Some checks are pending
Create Examples / build (ubuntu-22.04, 3.7) (push) Waiting to run
Create Examples / build (ubuntu-22.04, 3.8) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.10) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.11) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.12) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.9) (push) Waiting to run
Address code review findings for YAML-in-PNG feature
Critical fixes:
- C1: Preserve existing PNG text metadata when embedding YAML
- C2: Exception handling — metadata failure no longer crashes parse()
- C3: Atomic write via tempfile+rename prevents corruption

Important fixes:
- I1: Size guard (10MB) on read_yaml_from_png for untrusted PNGs
- I2: Existence check and meaningful errors in read_yaml_from_png
- I3: ParsedInput NamedTuple replaces raw 3-tuple return
- I4: Normalize output_formats to tuple to prevent substring matching
- I5: 19-test suite covering round-trip, edge cases, and error paths

Also: removed dead prepend_input parameter (S3), added version tag (S1).
2026-02-13 00:37:19 -07:00

217 lines
7.5 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for wv_png_metadata — YAML embedding in PNG iTXT chunks."""
from pathlib import Path
import pytest
from PIL import Image
from wireviz.wv_png_metadata import (
MAX_YAML_SIZE,
PNG_KEY_YAML,
PNG_KEY_VERSION,
has_yaml_metadata,
read_yaml_from_png,
save_yaml_to_png,
)
SAMPLE_YAML = """\
connectors:
X1:
pincount: 4
X2:
pincount: 4
cables:
W1:
wirecount: 4
length: 1
connections:
-
- X1: [1-4]
- W1: [1-4]
- X2: [1-4]
"""
def _create_test_png(path: Path, width: int = 10, height: int = 10) -> Path:
"""Create a minimal valid PNG file for testing."""
im = Image.new("RGB", (width, height), color="white")
im.save(path)
return path
# === Round-trip tests ===
class TestRoundTrip:
def test_basic_round_trip(self, tmp_path):
"""Write YAML to PNG, read it back, verify exact match."""
png = _create_test_png(tmp_path / "test.png")
assert save_yaml_to_png(png, SAMPLE_YAML) is True
result = read_yaml_from_png(png)
assert result == SAMPLE_YAML
def test_unicode_round_trip(self, tmp_path):
"""YAML with unicode characters survives embedding."""
yaml_unicode = "# Kabelbaum\nconnectors:\n X1:\n type: 'Stecker \u00f6\u00e4\u00fc\u00df'\n pincount: 2\n"
png = _create_test_png(tmp_path / "unicode.png")
assert save_yaml_to_png(png, yaml_unicode) is True
result = read_yaml_from_png(png)
assert result == yaml_unicode
assert "\u00f6\u00e4\u00fc\u00df" in result
def test_large_yaml_round_trip(self, tmp_path):
"""YAML at a realistic large size survives embedding."""
large_yaml = SAMPLE_YAML + (" # padding line\n" * 10000)
png = _create_test_png(tmp_path / "large.png")
assert save_yaml_to_png(png, large_yaml) is True
result = read_yaml_from_png(png)
assert result == large_yaml
def test_multiline_yaml_round_trip(self, tmp_path):
"""YAML with various whitespace patterns survives."""
yaml_ws = "connectors:\n X1:\n notes: |\n Line 1\n Line 2\n\n Line 4 after blank\n pincount: 2\n"
png = _create_test_png(tmp_path / "multiline.png")
assert save_yaml_to_png(png, yaml_ws) is True
assert read_yaml_from_png(png) == yaml_ws
# === save_yaml_to_png tests ===
class TestSave:
def test_returns_false_for_missing_file(self, tmp_path):
"""save_yaml_to_png on missing file returns False."""
missing = tmp_path / "nope.png"
assert save_yaml_to_png(missing, SAMPLE_YAML) is False
def test_auto_appends_png_suffix(self, tmp_path):
"""Passing path without .png suffix still works."""
_create_test_png(tmp_path / "test.png")
assert save_yaml_to_png(tmp_path / "test", SAMPLE_YAML) is True
assert has_yaml_metadata(tmp_path / "test.png")
def test_preserves_existing_metadata(self, tmp_path):
"""Existing PNG text metadata is not destroyed."""
from PIL.PngImagePlugin import PngInfo
png_path = tmp_path / "meta.png"
im = Image.new("RGB", (10, 10), color="white")
existing = PngInfo()
existing.add_text("Software", "GraphViz 14.1")
existing.add_text("Author", "WireViz")
im.save(png_path, pnginfo=existing)
save_yaml_to_png(png_path, SAMPLE_YAML)
with Image.open(png_path) as im:
im.load()
assert im.text.get("Software") == "GraphViz 14.1"
assert im.text.get("Author") == "WireViz"
assert PNG_KEY_YAML in im.text
def test_writes_version_tag(self, tmp_path):
"""Metadata includes a version tag for future compatibility."""
png = _create_test_png(tmp_path / "ver.png")
save_yaml_to_png(png, SAMPLE_YAML)
with Image.open(png) as im:
im.load()
assert im.text.get(PNG_KEY_VERSION) == "1"
def test_overwrites_previous_yaml(self, tmp_path):
"""Calling save twice replaces the embedded YAML."""
png = _create_test_png(tmp_path / "overwrite.png")
save_yaml_to_png(png, "first: version\n")
save_yaml_to_png(png, "second: version\n")
result = read_yaml_from_png(png)
assert result == "second: version\n"
def test_atomic_write_no_corruption_on_success(self, tmp_path):
"""File is valid PNG after successful save."""
png = _create_test_png(tmp_path / "atomic.png")
save_yaml_to_png(png, SAMPLE_YAML)
# verify it's still a valid, openable PNG
with Image.open(png) as im:
im.load()
assert im.size == (10, 10)
# === read_yaml_from_png tests ===
class TestRead:
def test_raises_file_not_found(self, tmp_path):
"""read_yaml_from_png on missing file raises FileNotFoundError."""
with pytest.raises(FileNotFoundError, match="PNG not found"):
read_yaml_from_png(tmp_path / "nope.png")
def test_raises_for_corrupt_file(self, tmp_path):
"""read_yaml_from_png on non-PNG file raises ValueError."""
corrupt = tmp_path / "corrupt.png"
corrupt.write_text("this is not a png")
with pytest.raises(ValueError, match="Cannot read YAML metadata"):
read_yaml_from_png(corrupt)
def test_raises_for_png_without_metadata(self, tmp_path):
"""read_yaml_from_png on plain PNG raises ValueError."""
png = _create_test_png(tmp_path / "plain.png")
with pytest.raises(ValueError, match="does not contain WireViz YAML"):
read_yaml_from_png(png)
def test_raises_for_oversized_yaml(self, tmp_path):
"""read_yaml_from_png rejects YAML exceeding the size limit.
Pillow's own MAX_TEXT_CHUNK guard fires at the decompression level
before our application-level size check — defense in depth.
Both produce ValueError, just with different messages.
"""
png = _create_test_png(tmp_path / "bomb.png")
from PIL.PngImagePlugin import PngInfo
with Image.open(png) as im:
im.load()
txt = PngInfo()
txt.add_itxt(PNG_KEY_YAML, "x" * (MAX_YAML_SIZE + 1), zip=True)
im.save(png, pnginfo=txt)
# either our size check or Pillow's decompression limit will fire
with pytest.raises(ValueError):
read_yaml_from_png(png)
def test_auto_appends_png_suffix(self, tmp_path):
"""Passing path without .png suffix still works."""
png = _create_test_png(tmp_path / "test.png")
save_yaml_to_png(png, SAMPLE_YAML)
result = read_yaml_from_png(tmp_path / "test")
assert result == SAMPLE_YAML
# === has_yaml_metadata tests ===
class TestHasMetadata:
def test_true_after_embedding(self, tmp_path):
"""has_yaml_metadata returns True after embedding."""
png = _create_test_png(tmp_path / "test.png")
save_yaml_to_png(png, SAMPLE_YAML)
assert has_yaml_metadata(png) is True
def test_false_for_nonexistent_file(self, tmp_path):
"""has_yaml_metadata returns False for missing file."""
assert has_yaml_metadata(tmp_path / "nope.png") is False
def test_false_for_plain_png(self, tmp_path):
"""has_yaml_metadata returns False for PNG without metadata."""
png = _create_test_png(tmp_path / "plain.png")
assert has_yaml_metadata(png) is False
def test_false_for_corrupt_file(self, tmp_path):
"""has_yaml_metadata returns False for non-PNG file."""
corrupt = tmp_path / "corrupt.png"
corrupt.write_text("not a png")
assert has_yaml_metadata(corrupt) is False