From bcfec6c5526090964499275ba14f82807930d1ef Mon Sep 17 00:00:00 2001 From: Evan Sultanik Date: Wed, 9 Sep 2026 10:21:39 -0400 Subject: [PATCH] Stop joined JSON containers from indenting nested sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SequenceFormatter.print_SequenceNode` always wraps its items in `self.items_indent(printer)`, and the default implementation always calls `printer.indent()`. `Printer.write` materializes indentation lazily, on the first write after a newline, so a container that never emits a newline still raises the indent counter for everything nested inside it. `--join-dict-items` suppresses the newlines between key/value pairs, so the dict's extra level of indentation was invisible at the dict level but still counted. A list nested inside that dict added its own level and did emit newlines, so its items came out eight spaces deep and its closing bracket four, as though the collapsed dict were still spread over several lines. `--join-lists` had the same latent problem for anything nested inside a joined list. Both JSON sub-formatters now return the printer unchanged from `items_indent` when their join option is set, so indentation tracks the depth that actually breaks lines. `Printer` is itself a context manager, and several other formatters already return one this way. Joined items were also run together with a bare comma, giving `[1,2,3]` and `{"bar": "baz","foo": …}`. Both formatters now emit a single space in place of the newline they suppress, matching the spacing that the same documents get when they are pretty-printed. The README recorded the old, wrong `-jd` and `-j` output; those blocks are regenerated from the real command. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa --- README.md | 14 ++++---- graphtage/json.py | 54 ++++++++++++++++++++++++++-- test/test_json.py | 92 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 test/test_json.py diff --git a/README.md b/README.md index e195fa8..2e6e3fd 100644 --- a/README.md +++ b/README.md @@ -101,20 +101,20 @@ Use the `--join-lists` or `-jl` option to suppress linebreaks after list items: ```json { "bar": "baz", - "foo": [1,2,3] + "foo": [1, 2, 3] } ``` Likewise, use the `--join-dict-items` or `-jd` option to suppress linebreaks after key/value pairs in a dict: ```json -{"bar": "baz","foo": [ - 1, - 2, - 3 - ]} +{"bar": "baz", "foo": [ + 1, + 2, + 3 +]} ``` Use `--condensed` or `-j` to apply both of these options: ```json -{"bar": "baz","foo": [1,2,3]} +{"bar": "baz", "foo": [1, 2, 3]} ``` The `--only-edits` or `-e` option will print out a list of edits rather than applying them to the input file in place. diff --git a/graphtage/json.py b/graphtage/json.py index 80c2724..c5bd639 100644 --- a/graphtage/json.py +++ b/graphtage/json.py @@ -109,9 +109,34 @@ def __init__(self): """ super().__init__('[', ']', ',') + def _joined(self, printer: Printer) -> bool: + return bool(getattr(printer, 'join_lists', False)) + def item_newline(self, printer: Printer, is_first: bool = False, is_last: bool = False): - if not hasattr(printer, 'join_lists') or not printer.join_lists: + """Separates two list items. + + A joined list keeps all of its items on one line, separated by a single space so that the result reads as + ``[1, 2, 3]`` rather than ``[1,2,3]``. + + """ + if not self._joined(printer): printer.newline() + elif not is_first and not is_last: + printer.write(' ') + + def items_indent(self, printer: Printer) -> Printer: + """Returns the printer context in which the list items are printed. + + A joined list emits no newlines of its own, so indenting its items would have no visible effect on the list + itself while still adding a level of indentation to any nested sequence that *does* break across lines. + + Returns: + Printer: :obj:`printer` itself if the list is joined, otherwise ``printer.indent()``. + + """ + if self._joined(printer): + return printer + return printer.indent() def print_ListNode(self, *args, **kwargs): """Prints a :class:`graphtage.ListNode`. @@ -146,9 +171,34 @@ class JSONDictFormatter(SequenceFormatter): def __init__(self): super().__init__('{', '}', ',') + def _joined(self, printer: Printer) -> bool: + return bool(getattr(printer, 'join_dict_items', False)) + def item_newline(self, printer: Printer, is_first: bool = False, is_last: bool = False): - if not hasattr(printer, 'join_dict_items') or not printer.join_dict_items: + """Separates two dict items. + + A joined dict keeps all of its items on one line, separated by a single space so that the result reads as + ``{"a": 1, "b": 2}`` rather than ``{"a": 1,"b": 2}``. + + """ + if not self._joined(printer): printer.newline() + elif not is_first and not is_last: + printer.write(' ') + + def items_indent(self, printer: Printer) -> Printer: + """Returns the printer context in which the dict items are printed. + + A joined dict emits no newlines of its own, so indenting its items would have no visible effect on the dict + itself while still adding a level of indentation to any nested sequence that *does* break across lines. + + Returns: + Printer: :obj:`printer` itself if the dict is joined, otherwise ``printer.indent()``. + + """ + if self._joined(printer): + return printer + return printer.indent() def print_MultiSetNode(self, *args, **kwargs): """Prints a :class:`graphtage.MultiSetNode`. diff --git a/test/test_json.py b/test/test_json.py new file mode 100644 index 0000000..ef82f12 --- /dev/null +++ b/test/test_json.py @@ -0,0 +1,92 @@ +from io import StringIO +from unittest import TestCase + +import graphtage +from graphtage.printer import Printer +from graphtage.utils import Tempfile + +NESTED = b'{"foo": [1, 2, 3], "bar": "baz"}' +DEEP = b'{"x": [{"y": [1, 2]}, 3]}' + + +def build(content: bytes) -> graphtage.TreeNode: + with Tempfile(content) as path: + return graphtage.FILETYPES_BY_TYPENAME["json"].build_tree(path) + + +def render(from_content: bytes, to_content: bytes | None = None, *, + join_lists: bool = False, join_dict_items: bool = False) -> str: + """Renders the diff of two JSON documents with the given join options. + + Args: + from_content: The JSON source of the "from" document. + to_content: The JSON source of the "to" document, or :const:`None` to diff ``from_content`` against itself. + join_lists: Whether to enable the ``--join-lists`` option. + join_dict_items: Whether to enable the ``--join-dict-items`` option. + + Returns: + str: The rendered diff. + + """ + if to_content is None: + to_content = from_content + stream = StringIO() + printer = Printer( + out_stream=stream, + ansi_color=False, + quiet=True, + options={"join_lists": join_lists, "join_dict_items": join_dict_items}, + ) + diff = build(from_content).diff(build(to_content)) + graphtage.FILETYPES_BY_TYPENAME["json"].get_default_formatter().print(printer, diff) + printer.flush(final=True) + return stream.getvalue() + + +class TestJSONJoinOptions(TestCase): + """Covers ``--join-lists`` and ``--join-dict-items``, which previously had no test coverage at all.""" + + def test_default_output_is_pretty_printed(self): + self.assertEqual( + '{\n "bar": "baz",\n "foo": [\n 1,\n 2,\n 3\n ]\n}', + render(NESTED) + ) + + def test_join_lists_keeps_the_dict_pretty_printed(self): + self.assertEqual('{\n "bar": "baz",\n "foo": [1, 2, 3]\n}', render(NESTED, join_lists=True)) + + def test_join_dict_items_does_not_indent_a_nested_list(self): + """A joined dict used to leave an unused indent on the printer, which the nested list then doubled. + + The list items came out eight spaces deep and the closing bracket four, as though the collapsed dict were + still occupying a level of indentation. + + """ + self.assertEqual('{"bar": "baz", "foo": [\n 1,\n 2,\n 3\n]}', render(NESTED, join_dict_items=True)) + + def test_condensed_joins_both_lists_and_dicts(self): + self.assertEqual( + '{"bar": "baz", "foo": [1, 2, 3]}', + render(NESTED, join_lists=True, join_dict_items=True) + ) + + def test_join_dict_items_indents_by_the_depth_that_breaks_lines(self): + """Only the sequences that actually emit newlines contribute a level of indentation.""" + self.assertEqual( + '{"x": [\n {"y": [\n 1,\n 2\n ]},\n 3\n]}', + render(DEEP, join_dict_items=True) + ) + + def test_join_lists_indents_by_the_depth_that_breaks_lines(self): + self.assertEqual('{\n "x": [{\n "y": [1, 2]\n }, 3]\n}', render(DEEP, join_lists=True)) + + def test_joined_items_are_separated_by_a_space(self): + """Joined output used to run the items together as ``[1,2,3]`` and ``{"bar": "baz","foo": …}``.""" + self.assertIn('[1, 2, 3]', render(NESTED, join_lists=True)) + self.assertIn('"baz", "foo"', render(NESTED, join_dict_items=True)) + + def test_joined_output_separates_inserted_and_removed_items(self): + self.assertEqual( + '{"a": [1, ~~2~~, 3], ++"b": 4++}', + render(b'{"a": [1, 2, 3]}', b'{"a": [1, 3], "b": 4}', join_lists=True, join_dict_items=True) + )