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
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).
217 lines
7.5 KiB
Python
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
|