Stop printing a replacement twice inside a list - #170
Merged
Merged
Conversation
GraphtageFormatter.print takes a with_edits flag, but it drops the flag when
it dispatches to the resolved formatter, because with_edits is not part of the
print_<NodeType>(printer, node) calling convention. Formatters rely on being
able to hand a node back to the protocol: a list formatter that meets a nested
dict has no print_MappingNode of its own, so it calls self.parent.print(...)
to reach the format's dict formatter. That call restarted the protocol with
with_edits defaulting to True, found the node's Replace still attached, and
printed the whole edit a second time, so [1, {"a": 1}] against [1, [2, 3]]
rendered as [1,{"a": 1} -> [2,3] -> [2,3]].
Threading the flag through the dispatch would mean adding a with_edits
parameter to every print_* method in the package, since the delegating methods
forward *args and **kwargs into implementations that do not accept it. Instead,
GraphtageFormatter.print now records a node while that node's own formatter is
running and treats a re-entrant call for the same node as with_edits=False. The
decision about a node's edit is made once, by the outermost call, which is the
invariant the delegating formatters already assumed.
The suppression covers the node being printed and not its children, so edits
nested inside a delegated subtree still print. JSON, JSON5, YAML, TOML, and INI
all shared the defect; plist and XML never delegate across container types and
were unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #152
Root cause
GraphtageFormatter.printaccepts awith_editsflag, butwith_editsis not part of theprint_<NodeType>(printer, node)calling convention that every formatter implements, so the flag stops at theformatter boundary (
graphtage/tree.py, theformatter(printer, node)dispatch).That matters because formatters routinely hand a node back to the protocol.
JSONListFormatterhas noprint_MappingNode, so a dict nested in a list falls through to itsprint_SequenceNode, which callsself.parent.print(printer, node)to reachJSONDictFormatter. The re-entrant call defaults towith_edits=True,finds the node's
Replacestill attached with a non-zero cost, and prints the whole edit a second time:A replaced dict value was never affected: it reaches
JSONDictFormatter.print_MappingNodethroughprint_KeyValuePairNode, which callssuper().print_SequenceNodeinstead of re-entering.print().The fix
GraphtageFormatter.printnow records the node while that node's own formatter is on the stack, and treats are-entrant call for the same node as
with_edits=False. The decision about a node's edit is made once, by theoutermost call — which is the invariant the delegating formatters already assumed. The dispatch moved into a small
_print_nodehelper so the record is released in afinally.I chose the re-entrancy guard over threading
with_editsthrough the dispatch. Threading it would require adding awith_editsparameter to everyprint_*method in the package: the delegating methods are written asdef print_SequenceNode(self, *args, **kwargs)and forward**kwargsstraight into implementations such asSequenceFormatter.print_SequenceNode(self, printer, node)andJSONListFormatter.print_ListNode, which would raiseTypeErroron the extra keyword. Signature inspection at the dispatch site would guess wrong for exactly thoseforwarding methods. The guard is central to
tree.py, changes no formatter signature, and leavesgraphtage/json.pyuntouched for the parallel work on #158.The guard covers only the node being printed, not its children, so an edit nested inside a delegated subtree still
prints normally.
docs/printing.rstdocuments this protocol and is updated to match.Formats affected
Reproduced before the fix and correct after it:
[1,{"a": 1} -> [2,3] -> [2,3]][1,{"a": 1} -> [2,3]]- a: 1 -> \n - 2\n - 3 -> \n - 2\n - 3- a: 1 -> \n - 2\n - 3[1,a = 1\n\n -> [2,3] -> [2,3]][1,a = 1\n\n -> [2,3]]1,a = 1\n -> 2,3 -> 2,31,a = 1\n -> 2,3plist and XML never delegate across container types (
PLISTSequenceFormatterdefines bothprint_ListNodeandprint_MultiSetNode;graphtage/xml.pyhas noself.parent.printcall), so they were already correct and staycorrect.
InsertandRemovedid not duplicate, because neither attaches itself toEditedTreeNode.edit; onlyReplaceand
Matchleave a non-zero-cost edit on the node that the re-entrant call could find.Validation
New regression test
test/test_issue_152.py:print_diff([1, {"a": 1}], [1, [2, 3]])reproducer from the issue,json,json5,yaml,toml, andiniasserting exactly one replacement arrow,Verified the tests catch the bug: run against the unpatched
graphtage/tree.py, 2 tests and 5 subtests fail with'[1,{"a": 1} -> [2,3]]' != '[1,{"a": 1} -> [2,3] -> [2,3]]'and1 != 2arrows for each of the five formats; withthe fix, all pass. The dict-value control passes in both states, confirming it isolates the delegating path.
The control sweep skips TOML, which renders a replaced table value as the original table with no edit at all. That is
a separate, pre-existing defect: it behaves identically on unmodified
masterand is unchanged by this PR.Local CI:
ruff check graphtage test docs bindist— all checks passedpytest— 144 passed, 9 subtests passedpytest test/test_formatting.py— 15 passed. This is the main safety net for a change to the printing protocol:1000 fuzz round-trip iterations each for JSON, CSV, YAML, and plist, 250 for XML, and 200 each for TOML and INI,
plus the JSON5, HTML, and pickle round-trips
make -C docs html SPHINXOPTS="-W --keep-going"— build succeededuv lock --check— fails identically on unmodifiedorigin/masterin this environment because of a globalexclude-newersetting; neitherpyproject.tomlnoruv.lockis touched by this PRAlso spot-checked by hand: two sibling replacements in one list, a replacement three levels deep, a replacement
alongside a string edit, and the HTML printer — all render a single replacement.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa