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
32 changes: 29 additions & 3 deletions graphtage/graphtage.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,8 +727,6 @@ def write_end_quote(self, printer: Printer, edit: StringEdit):
"""Prints an ending quote for the string, if necessary"""
if edit.from_node.quoted:
self.is_quoted = True
if isinstance(edit.to_node, LeafNode) and isinstance(edit.to_node.object, bytes):
printer.write("b")
printer.write('"')
else:
self.is_quoted = False
Expand Down Expand Up @@ -883,6 +881,25 @@ def print_StringEdit(self, printer: Printer, edit: StringEdit):
self.write_end_quote(p, edit)


def _num_characters(string_like: str | bytes | int) -> int:
"""Returns the number of characters that a :class:`StringNode` object represents.

Iterating over :class:`bytes` yields :class:`int` byte values rather than length-one sequences, so the
per-character nodes that :func:`string_edit_distance` builds for a :class:`bytes` object each wrap an
:class:`int`. Such a node still represents exactly one character.

Args:
string_like: the object wrapped by a :class:`StringNode`.

Returns:
int: the number of characters that :obj:`string_like` represents.

"""
if isinstance(string_like, int):
return 1
return len(string_like)


class StringNode(LeafNode):
"""A node containing a string"""

Expand All @@ -897,11 +914,20 @@ def __init__(self, string_like: str | bytes, quoted=True):
super().__init__(string_like)
self.quoted = quoted

def calculate_total_size(self) -> int:
"""A string's size is its number of characters.

Returns:
int: the number of characters in the wrapped object.

"""
return _num_characters(self.object)

def edits(self, node: TreeNode) -> Edit:
if isinstance(node, StringNode):
if self.object == node.object:
return Match(self, node, 0)
elif len(self.object) == 1 and len(node.object) == 1:
elif _num_characters(self.object) == 1 and _num_characters(node.object) == 1:
return Match(self, node, 1)
return StringEdit(self, node)
else:
Expand Down
66 changes: 66 additions & 0 deletions test/test_graphtage.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,69 @@ def test_large_shuffle_is_fast(self):
edit = unordered(*values).edits(unordered(*shuffled))
self.assertIsInstance(edit, graphtage.Match)
self.assertEqual(0, edit.bounds().upper_bound)


class TestBytesStringNode(TestCase):
"""Covers :class:`graphtage.StringNode` objects that wrap :class:`bytes` instead of :class:`str`.

Iterating over :class:`bytes` yields :class:`int` byte values, so the per-character lattice that
:func:`graphtage.string_edit_distance` builds wraps each byte in a ``StringNode`` holding an ``int``.
Every test here used to raise ``TypeError: object of type 'int' has no len()``.

"""

def assert_exact_cost(self, expected: int, from_bytes: str | bytes, to_bytes: str | bytes):
edit = graphtage.StringNode(from_bytes).edits(graphtage.StringNode(to_bytes))
while edit.tighten_bounds():
pass
self.assertEqual(graphtage.Range(expected, expected), edit.bounds())

def test_substitution(self):
"""One differing byte costs one, the same as one differing character does."""
self.assert_exact_cost(1, b"hello", b"hellp")
self.assert_exact_cost(1, "hello", "hellp")

def test_equal_bytes_match_for_free(self):
edit = graphtage.StringNode(b"hello").edits(graphtage.StringNode(b"hello"))
self.assertIsInstance(edit, graphtage.Match)
self.assert_exact_cost(0, b"hello", b"hello")

def test_single_byte(self):
"""A one-byte value takes the single-character shortcut, which is where the crash originated."""
self.assert_exact_cost(1, b"a", b"b")
self.assert_exact_cost(0, b"a", b"a")

def test_empty_bytes(self):
"""An empty value on either side costs one per byte on the other side."""
self.assert_exact_cost(0, b"", b"")
self.assert_exact_cost(3, b"", b"abc")
self.assert_exact_cost(3, b"abc", b"")

def test_insertion_and_removal_cost_one_per_byte(self):
"""A byte node's total size is one, so inserting or removing it costs the same as a character."""
self.assertEqual(1, graphtage.StringNode(b"h").total_size)
self.assertEqual(5, graphtage.StringNode(b"hello").total_size)
self.assert_exact_cost(1, b"hello", b"hell")
self.assert_exact_cost(1, b"hell", b"hello")

def test_str_versus_bytes(self):
"""A ``str`` never equals ``bytes``, so every position counts as a substitution."""
self.assert_exact_cost(5, "hello", b"hello")
self.assert_exact_cost(5, b"hello", "hello")
self.assert_exact_cost(1, "a", b"a")

@staticmethod
def render(from_bytes: str | bytes, to_bytes: str | bytes) -> str:
out_stream = StringIO()
graphtage.StringNode(from_bytes).diff(graphtage.StringNode(to_bytes)).print(
Printer(ansi_color=False, out_stream=out_stream)
)
return out_stream.getvalue()

def test_rendering_closes_the_quote(self):
"""The ``b`` prefix used to be repeated before the closing quote, rendering ``b"hellob"``."""
self.assertEqual('b"hell~~o~~++p++"', self.render(b"hello", b"hellp"))
self.assertEqual('"hell~~o~~++p++"', self.render("hello", "hellp"))

def test_rendering_escapes_unprintable_bytes(self):
self.assertEqual('b"he~~\\x00~~++\\xff++o"', self.render(b"he\x00o", b"he\xffo"))
36 changes: 36 additions & 0 deletions test/test_pickle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import pickle
from io import StringIO
from unittest import TestCase

import graphtage
from graphtage.printer import Printer
from graphtage.utils import Tempfile


def build(obj) -> graphtage.TreeNode:
with Tempfile(pickle.dumps(obj)) as path:
return graphtage.FILETYPES_BY_TYPENAME["pickle"].build_tree(path)


def render_diff(from_obj, to_obj) -> str:
diff = build(from_obj).diff(build(to_obj))
stream = StringIO()
printer = Printer(out_stream=stream, ansi_color=False)
graphtage.FILETYPES_BY_TYPENAME["pickle"].get_default_formatter().print(printer, diff)
printer.flush(final=True)
return stream.getvalue().strip()


class TestPickleDiff(TestCase):
"""Covers the pickle filetype, which is the shortest route from a file to a ``bytes`` leaf node."""

def test_changed_bytes_value(self):
"""Diffing a changed ``bytes`` value used to raise ``TypeError: object of type 'int' has no len()``."""
diff = build({"payload": b"hello"}).diff(build({"payload": b"hellp"}))
self.assertEqual(1, diff.edited_cost())

def test_changed_bytes_value_renders_the_edit(self):
self.assertIn("hell~~o~~++p++", render_diff({"payload": b"hello"}, {"payload": b"hellp"}))

def test_unchanged_bytes_value(self):
self.assertEqual(0, build({"payload": b"hello"}).diff(build({"payload": b"hello"})).edited_cost())