Skip to content

Fix the crash on diffing two differing bytes values - #194

Merged
ESultanik merged 2 commits into
masterfrom
fix-bytes-diff-crash
Sep 15, 2026
Merged

ESultanik merged 2 commits into
masterfrom
fix-bytes-diff-crash

Conversation

@ESultanik

Copy link
Copy Markdown
Collaborator

The bug

Diffing two differing bytes values raises TypeError:

from graphtage.graphtage import StringNode

edit = StringNode(b"hello").edits(StringNode(b"hellp"))
while edit.tighten_bounds():
    pass
# TypeError: object of type 'int' has no len()

Bounds are computed lazily, so the exception surfaces on the first tighten_bounds() call rather
than in edits().

Mechanism

StringNode.edits takes a shortcut for single-character strings:

elif len(self.object) == 1 and len(node.object) == 1:
    return Match(self, node, 1)

StringEdit prices a pair of strings by calling string_edit_distance, which builds a
character-level lattice by wrapping each element of the input in its own StringNode. Iterating
over bytes yields int byte values, not length-one bytes, so the lattice holds
StringNode(int) nodes. graphtage/levenshtein.py then calls StringNode(int).edits(StringNode(int))
for each cell, int has no __len__, and the diff aborts.

Equal values return Match(self, node, 0) before the shortcut is reached, so only differing values
crash.

User-visible impact

The pickle filetype is the reachable case. graphtage.builder.BasicBuilder.build_str is registered
for both str and bytes, and graphtage.pydiff.ASTBuilder extends it, so every leaf built from a
bytes constant is a StringNode wrapping bytes. Diffing two pickles whose binary payloads differ
ends in a traceback:

$ python -c 'import pickle; pickle.dump({"payload": b"hello"}, open("a.pickle", "wb"))'
$ python -c 'import pickle; pickle.dump({"payload": b"hellp"}, open("b.pickle", "wb"))'
$ graphtage a.pickle b.pickle
...
TypeError: object of type 'int' has no len()

The same applies to the graphtage.pydiff.diff and graphtage.pydiff.build_tree library entry
points, and to any downstream Builder subclass that reaches build_str with bytes.

The other filetypes are unaffected, because graphtage.json.build_tree calls
python_obj.decode('utf-8') on bytes and stores a str. That is why plist <data> and YAML
!!binary values do not reach this code path. See the follow-up note below.

After the fix:

$ graphtage a.pickle b.pickle
result = "payload": "hell~~o~~++p++"

Semantics

bytes values diff byte by byte, and one differing byte costs 1, matching what one differing
character costs for str. Two things in the existing code fix that model:

  • StringFormatter.write_char already accepts c: str | int and renders an int as a printable
    character or as \xNN, with the comment "we are printing a bytes object, not a str". The lattice
    node for a byte is meant to wrap an int.
  • StringNode.edits charges 1 for a mismatched pair of single-character nodes, and
    graphtage.edits.Insert and Remove charge to_insert.total_size + penalty, which is 1 per
    character for str because LeafNode.calculate_total_size returns len(str(self.object)).

So a byte node has to report a length of 1 and a total size of 1. _num_characters reports 1 for an
int and len otherwise, and StringNode.edits and the new StringNode.calculate_total_size
both use it.

The total_size override matters on its own. LeafNode.calculate_total_size returns
len(str(self.object)), which for a byte node is len(str(111)), or 2. That made removing a byte
cost 2 or 3 depending on its value, so b"" against b"abc" cost 6 where "" against "abc"
costs 3. The override also brings StringNode(b"hello").total_size to 5, from the 8 of
len("b'hello'"), which is what the same value costs as a str.

str against bytes keeps the behavior the code already implied: "hello" never equals
b"hello", each position is a substitution, and the distance is 5. The tests pin that rather than
changing it.

Rendering

StringFormatter.write_end_quote wrote the b prefix of a bytes literal a second time, so
StringNode(b"hello") rendered as b"hellob" and a diff of two bytes values ended in b" instead
of ". The second commit drops the two lines; only the prefix in write_start_quote belongs in the
output. The JSON, YAML, and pydiff formatters override both quote methods, so the stray character
reached output through StringNode.print and through any formatter that inherits the default
quoting, which includes XML, TOML, and INI.

Tests

test/test_graphtage.py gains TestBytesStringNode, covering the substitution case, equal values,
single-byte values, empty values on either side, insertion and removal cost, str against bytes,
and the two rendering cases. test/test_pickle.py is new and covers the end-to-end filetype diff.

Each test was checked against the unfixed code: reverting the edits shortcut fails the
substitution, str against bytes, rendering, and pickle tests with the original TypeError;
reverting calculate_total_size fails the empty-value and insertion tests on cost; restoring the
duplicated b fails the two rendering tests.

pytest -q passes, 202 tests. ruff check graphtage test docs bindist is clean.

Follow-up, not in this PR

graphtage.json.build_tree decodes bytes with python_obj.decode('utf-8'), so a plist <data>
element that is not valid UTF-8 fails before any diff starts:

$ python -c 'import plistlib; plistlib.dump({"payload": b"\xff\xfe\x00"}, open("a.plist", "wb"))'
$ python -c 'import plistlib; plistlib.dump({"payload": b"\xff\xfe\x01"}, open("b.plist", "wb"))'
$ graphtage a.plist b.plist
...
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte

YAML !!binary takes the same route. That is a separate defect with a different mechanism, and
fixing it means deciding whether those filetypes should build StringNode(bytes) and how each
formatter should write one. This PR makes that option viable but does not take it.

🤖 Generated with Claude Code

ESultanik and others added 2 commits September 15, 2026 14:58
StringNode.edits took a shortcut for single-character strings that called
len() on the wrapped object. The character-level lattice that
string_edit_distance builds wraps each element of the input in its own
StringNode, and iterating bytes yields int, so comparing two differing
bytes values reached StringNode(int).edits(StringNode(int)) and raised
"TypeError: object of type 'int' has no len()".

A byte node stands for one character, so _num_characters reports 1 for an
int. StringNode.calculate_total_size now uses the same count, which makes
inserting or removing a byte cost 1 rather than the 1 to 3 that
len(str(byte_value)) charged, and makes StringNode(b"hello").total_size 5
rather than the 8 of len("b'hello'").

The pickle filetype is the user-visible case: every leaf built from a
bytes constant is a StringNode wrapping bytes, so diffing two pickles
whose binary payloads differ aborted with a traceback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
StringFormatter.write_end_quote wrote the `b` prefix of a bytes literal a
second time, so a bytes node rendered as b"hello" followed by a stray b,
and a diff of two bytes values ended in b" instead of ". Only the prefix
in write_start_quote belongs in the output.

The JSON, YAML, and pydiff formatters override both quote methods, so the
stray character reached output through StringNode.print and through any
formatter that inherits the default quoting, such as XML, TOML, and INI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ESultanik
ESultanik merged commit 390feed into master Sep 15, 2026
12 checks passed
@ESultanik
ESultanik deleted the fix-bytes-diff-crash branch September 15, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant