Skip to content

Stop printing a replacement twice inside a list - #170

Merged
ESultanik merged 1 commit into
masterfrom
152-with-edits-not-forwarded
Sep 9, 2026
Merged

ESultanik merged 1 commit into
masterfrom
152-with-edits-not-forwarded

Conversation

@ESultanik

@ESultanik ESultanik commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #152

Root cause

GraphtageFormatter.print accepts a with_edits flag, but with_edits is not part of the
print_<NodeType>(printer, node) calling convention that every formatter implements, so the flag stops at the
formatter boundary (graphtage/tree.py, the formatter(printer, node) dispatch).

That matters because formatters routinely hand a node back to the protocol. JSONListFormatter has no
print_MappingNode, so a dict nested in a list falls through to its print_SequenceNode, which calls
self.parent.print(printer, node) to reach JSONDictFormatter. The re-entrant call defaults to with_edits=True,
finds the node's Replace still attached with a non-zero cost, and prints the whole edit a second time:

sequences.py  print_SequenceNode -> edit_print -> tree.py -> Replace.print
  edits.py    formatter.print(printer, self.from_node, False)
    tree.py   formatter(printer, node)          <-- with_edits=False is lost here
      json.py self.parent.print(printer, node)  <-- restarts with with_edits=True
        tree.py -> Replace.print AGAIN

A replaced dict value was never affected: it reaches JSONDictFormatter.print_MappingNode through
print_KeyValuePairNode, which calls super().print_SequenceNode instead of re-entering .print().

The fix

GraphtageFormatter.print now records the node while that node's own formatter is on the stack, 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 dispatch moved into a small
_print_node helper so the record is released in a finally.

I chose the re-entrancy guard over threading with_edits through the dispatch. Threading it would require adding a
with_edits parameter to every print_* method in the package: the delegating methods are written as
def print_SequenceNode(self, *args, **kwargs) and forward **kwargs straight into implementations such as
SequenceFormatter.print_SequenceNode(self, printer, node) and JSONListFormatter.print_ListNode, which would raise
TypeError on the extra keyword. Signature inspection at the dispatch site would guess wrong for exactly those
forwarding methods. The guard is central to tree.py, changes no formatter signature, and leaves
graphtage/json.py untouched 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.rst documents this protocol and is updated to match.

Formats affected

Reproduced before the fix and correct after it:

Format Before After
JSON [1,{"a": 1} -> [2,3] -> [2,3]] [1,{"a": 1} -> [2,3]]
JSON5 duplicated one replacement
YAML - a: 1 -> \n - 2\n - 3 -> \n - 2\n - 3 - a: 1 -> \n - 2\n - 3
TOML [1,a = 1\n\n -> [2,3] -> [2,3]] [1,a = 1\n\n -> [2,3]]
INI 1,a = 1\n -> 2,3 -> 2,3 1,a = 1\n -> 2,3

plist and XML never delegate across container types (PLISTSequenceFormatter defines both print_ListNode and
print_MultiSetNode; graphtage/xml.py has no self.parent.print call), so they were already correct and stay
correct.

Insert and Remove did not duplicate, because neither attaches itself to EditedTreeNode.edit; only Replace
and Match leave a non-zero-cost edit on the node that the re-entrant call could find.

Validation

New regression test test/test_issue_152.py:

  • the exact print_diff([1, {"a": 1}], [1, [2, 3]]) reproducer from the issue,
  • the full JSON rendering of the same diff,
  • a sweep over json, json5, yaml, toml, and ini asserting exactly one replacement arrow,
  • the control case from the issue, a replaced dict value, over the formats that render one.

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]]' and 1 != 2 arrows for each of the five formats; with
the 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 master and is unchanged by this PR.

Local CI:

  • ruff check graphtage test docs bindist — all checks passed
  • pytest — 144 passed, 9 subtests passed
  • pytest 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 succeeded
  • uv lock --check — fails identically on unmodified origin/master in this environment because of a global
    exclude-newer setting; neither pyproject.toml nor uv.lock is touched by this PR

Also 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

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
@ESultanik
ESultanik merged commit fb63347 into master Sep 9, 2026
12 checks passed
@ESultanik
ESultanik deleted the 152-with-edits-not-forwarded branch September 9, 2026 14:46
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.

Replace inside a list prints the replacement twice

1 participant