diff --git a/graphtage/graphtage.py b/graphtage/graphtage.py index 14f3496..651aee5 100644 --- a/graphtage/graphtage.py +++ b/graphtage/graphtage.py @@ -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 @@ -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""" @@ -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: diff --git a/test/test_graphtage.py b/test/test_graphtage.py index 0c032e6..19654b4 100644 --- a/test/test_graphtage.py +++ b/test/test_graphtage.py @@ -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")) diff --git a/test/test_pickle.py b/test/test_pickle.py new file mode 100644 index 0000000..cad158b --- /dev/null +++ b/test/test_pickle.py @@ -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())