From 18906fb748843a3143d39f133dc7029788fbb1cd Mon Sep 17 00:00:00 2001 From: Evan Sultanik Date: Wed, 9 Sep 2026 08:46:06 -0400 Subject: [PATCH 1/4] Document the three dict strategies and unordered lists The bipartite matching description covered only `--dict-strategy match`, which stopped being the default in PR #51. Describe `auto`, `match`, and `none`, and say which one is the default. Add a section on `--ignore-list-order`, which builds `UnorderedListNode` rather than `ListNode`, and give the complexity trade-off from the `BuildOptions.ignore_list_order` docstring. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa --- docs/howitworks.rst | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/howitworks.rst b/docs/howitworks.rst index f7f4e7b6..8320a316 100644 --- a/docs/howitworks.rst +++ b/docs/howitworks.rst @@ -81,6 +81,47 @@ Dicts are matched by solving the minimum weight matching problem [#]_ on the com pairs in the source dict to key/value pairs in the destination dict. This is implemented in the :mod:`graphtage.matching` module. +That graph has an edge for every pairing of a source key/value pair with a destination key/value pair, and costing all +of those edges dominates the running time on large dicts. The ``--dict-strategy`` command line option chooses how much +of the graph Graphtage builds. It sets :attr:`graphtage.BuildOptions.allow_key_edits` and +:attr:`graphtage.BuildOptions.auto_match_keys`, so the same three strategies are available to library callers: + +``auto`` + The default. Key/value pairs whose keys are equal are matched to each other before the graph is built, and only + the pairs that are left over become vertices of the bipartite graph. Key edits are still found for those remaining + pairs, so renaming ``"bar"`` to ``"zab"`` is still reported as a key edit rather than as a removal and an + insertion. + +``match`` + Every source key/value pair is costed against every destination key/value pair, including the ones whose keys are + already equal. This is the most computationally expensive strategy. It can find a cheaper overall matching than + ``auto`` when two pairs share a key but have very different values. + +``none`` + No key edits are considered at all. The dict is built as a :class:`graphtage.FixedKeyDictNode`, which only + compares two key/value pairs when their keys are equal; every other pair is inserted or removed. This is the least + computationally expensive strategy, and it is also what ``--no-key-edits``/``-k`` selects. + +.. _Unordered Lists: + +Unordered Lists +--------------- + +Lists are ordered by default, so reordering one costs a sequence of Levenshtein edits. The ``--ignore-list-order`` +command line option, or :attr:`graphtage.BuildOptions.ignore_list_order`, builds lists as +:class:`graphtage.UnorderedListNode` instead of :class:`graphtage.ListNode`. That node type is a subclass of +:class:`graphtage.MultiSetNode`, so its elements are matched as an unordered collection by the same bipartite matcher +that dicts use, and reordering costs nothing. Duplicate elements still count, so ``[1, 1, 2]`` matches ``[2, 1, 1]`` +but not ``[1, 2, 2]``. + +The trade-off runs in both directions. Two lists whose elements are all equal match immediately, however long they +are: a shuffle of 2000 integers takes about 0.01 seconds. Two lists that differ require a bipartite matching over +their symmetric difference, which grows much faster than the ordered comparison: 30 dictionaries of which none match +took about 30 seconds in one measurement, against 0.7 seconds by default. + +This applies to every format that builds its lists through :func:`graphtage.json.build_tree`, which is all of them +except the rows of a CSV file and the children of an XML element. + Footnotes --------- From 343a782505af25da43280ca2118e44face9655d0 Mon Sep 17 00:00:00 2001 From: Evan Sultanik Date: Wed, 9 Sep 2026 08:46:13 -0400 Subject: [PATCH 2/4] Correct the print condition and document quiet printers The quoted condition for choosing an edit over a node was missing the `isinstance(node_or_edit, EditedTreeNode)` guard, and the raw bounds comparison it showed has been replaced by `Edit.has_non_zero_cost()`, which tightens bounds in a loop rather than doing one cheap check. Add sections on `Printer(quiet=...)`, `NULL_PRINTER`, `StatusWriter`, and `enable_ansi_support()`: importing graphtage no longer calls `colorama.init()` or replaces `sys.stdout`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa --- docs/printing.rst | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/printing.rst b/docs/printing.rst index ef70f0b7..795e5cec 100644 --- a/docs/printing.rst +++ b/docs/printing.rst @@ -14,7 +14,12 @@ The protocol for delegating how a :class:`graphtage.TreeNode` or :class:`graphta * If ``with_edits`` *and* the node is edited and has a non-zero cost, then choose :attr:`node_or_edit.edit `:: - node_or_edit.edit is not None and node_or_edit.edit.bounds().lower_bound > 0 + isinstance(node_or_edit, EditedTreeNode) and \ + node_or_edit.edit is not None and node_or_edit.edit.has_non_zero_cost() + + :meth:`graphtage.Edit.has_non_zero_cost` is not a single comparison: it tightens the edit's bounds in a + loop until either its lower bound exceeds zero or its bounds are definitive. Deciding whether to print an + edit can therefore do an arbitrary amount of work. * Otherwise choose ``node_or_edit`` #. If the chosen object is an edit: @@ -32,3 +37,33 @@ The protocol for delegating how a :class:`graphtage.TreeNode` or :class:`graphta This is implemented in :meth:`graphtage.GraphtageFormatter.print`. See the :ref:`Formatting Protocol` for how formatters are chosen. + +Status Output +------------- + +A :class:`graphtage.printer.Printer` is also a :class:`graphtage.progress.StatusWriter`, which is what draws the +``tqdm`` progress bars that Graphtage shows while it diffs. Both the diff output and the status output share the same +printer, so the printer buffers whole lines and hands them to :func:`tqdm.write` rather than letting the two +interleave. + +Pass ``quiet=True`` to suppress the progress bars:: + + >>> from graphtage.printer import Printer + >>> quiet_printer = Printer(quiet=True) + +The command line client passes ``quiet=True`` for ``--no-status`` and ``--quiet``. To suppress the output as well as +the status, use :attr:`graphtage.printer.NULL_PRINTER`, a printer that is both quiet and writes to a stream that +discards everything. It is the default value of :attr:`graphtage.BuildOptions.printer`, which is why building a tree +from the library draws no progress bar while the command line client does. + +Enabling ANSI Color +------------------- + +Importing :mod:`graphtage` does not call :func:`colorama.init` and does not replace :attr:`sys.stdout`. Call +:func:`graphtage.printer.enable_ansi_support` from your application's entry point if you want that behavior. On a +legacy Windows console it is what makes ANSI escape sequences work, by replacing :attr:`sys.stdout` and +:attr:`sys.stderr` with wrappers that translate the sequences into Win32 console calls. + +A :class:`graphtage.printer.Printer` captures its output stream when it is constructed, so call +:func:`graphtage.printer.enable_ansi_support` before constructing any printer. A printer constructed first writes past +the wrapper and loses its color. From 42d2d0d43bf9f06a52bcaa4f4aba2152e19ddfa4 Mon Sep 17 00:00:00 2001 From: Evan Sultanik Date: Wed, 9 Sep 2026 08:46:19 -0400 Subject: [PATCH 3/4] Fix the builder examples and document data class slots Every code sample on this page failed as written: - `graphtage.dataclasses.DataClass` does not exist; the class is `DataClassNode`. - `@Build.builder(Bar)` is a typo for `@Builder.builder(Bar)`. - The last two methods were missing `self`, so `Builder.expand` and `Builder.build` raised `TypeError` when calling them. - `StringNode`, `ListNode`, and `BasicBuilder` were used but never imported. - `ListNode` stores a tuple, so its repr is `ListNode((...))`, and `StringNode`'s repr uses single quotes. Repoint the dangling `graphtage.Builder.builder`, `graphtage.Builder.expander`, and `graphtage.SequenceNode` cross references at their real modules, use `:meth:` for the two methods, make the inline-only `CustomNode` reference a literal, and correct `instanceof` to `isinstance`. Add sections on data class slots and `post_init()`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa --- docs/builders.rst | 84 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 14 deletions(-) diff --git a/docs/builders.rst b/docs/builders.rst index 25583fac..68699ad3 100644 --- a/docs/builders.rst +++ b/docs/builders.rst @@ -10,8 +10,8 @@ Graphtage provides a :class:`graphtage.builder.Builder` class for conveniently c .. code-block:: python - from graphtage import IntegerNode, TreeNode - from graphtage.builder import Builder + from graphtage import IntegerNode, ListNode, StringNode, TreeNode + from graphtage.builder import BasicBuilder, Builder class CustomBuilder(Builder): @Builder.builder(int) @@ -21,7 +21,7 @@ Graphtage provides a :class:`graphtage.builder.Builder` class for conveniently c >>> CustomBuilder().build_tree(10) IntegerNode(10) -The :func:`@Builder.builder(int) ` decorator specifies that the function is able to build a Graphtage `TreeNode` object from inputs that are :func:`instanceof` the type `int`. If there are multiple builder functions that match a given object, the function associated with the most specialized type is chosen. For example: +The :meth:`@Builder.builder(int) ` decorator specifies that the function is able to build a Graphtage `TreeNode` object from inputs that are :func:`isinstance` of the type `int`. If there are multiple builder functions that match a given object, the function associated with the most specialized type is chosen. For example: .. code-block:: python @@ -38,20 +38,20 @@ The :func:`@Builder.builder(int) ` decorator specifie def build_foo(self, node: Foo, children: list[TreeNode]): return StringNode("foo") - @Build.builder(Bar) + @Builder.builder(Bar) def build_bar(self, node: Bar, children: list[TreeNode]): return StringNode("bar") >>> CustomBuilder().build_tree(Foo()) -StringNode("foo") +StringNode('foo') >>> CustomBuilder().build_tree(Bar()) -StringNode("bar") +StringNode('bar') Expanding Children ------------------ So far we have only given examples of the production of leaf nodes, like integers and strings. -What if a node has children, like a list? We can handle this using the :func:`@Builder.expander ` decorator. Here is an example of how a list can be built: +What if a node has children, like a list? We can handle this using the :meth:`@Builder.expander ` decorator. Here is an example of how a list can be built: .. code-block:: python @@ -68,7 +68,7 @@ What if a node has children, like a list? We can handle this using the :func:`@B return ListNode(children) >>> CustomBuilder().build_tree([1, 2, 3, 4]) -ListNode([IntegerNode(1), IntegerNode(2), IntegerNode(3), IntegerNode(4)]) +ListNode((IntegerNode(1), IntegerNode(2), IntegerNode(3), IntegerNode(4))) If an expander is not defined for a type, it is assumed that the type is a leaf with no children. @@ -79,15 +79,15 @@ Graphtage has a subclassed builder :class:`graphtage.builder.BasicBuilder` that Custom Nodes ------------ -Graphtage provides abstract classes like :class:`graphtage.ContainerNode` and :class:`graphtage.SequenceNode` to aid in the implementation of custom node types. But the easiest way to define a custom node type is to extend off of :class:`graphtage.dataclasses.DataClass`. +Graphtage provides abstract classes like :class:`graphtage.ContainerNode` and :class:`graphtage.sequences.SequenceNode` to aid in the implementation of custom node types. But the easiest way to define a custom node type is to extend off of :class:`graphtage.dataclasses.DataClassNode`. .. code-block:: python from graphtage import IntegerNode, ListNode, StringNode - from graphtage.dataclasses import DataClass + from graphtage.dataclasses import DataClassNode - class CustomNode(DataClass): + class CustomNode(DataClassNode): name: StringNode value: IntegerNode attributes: ListNode @@ -95,8 +95,9 @@ Graphtage provides abstract classes like :class:`graphtage.ContainerNode` and :c This will automatically build a node type that has three children: a string, an integer, and a list. >>> CustomNode(name=StringNode("the name"), value=IntegerNode(1337), attributes=ListNode((IntegerNode(1), IntegerNode(2), IntegerNode(3)))) +CustomNode(name=StringNode('the name'), value=IntegerNode(1337), attributes=ListNode((IntegerNode(1), IntegerNode(2), IntegerNode(3)))) -Let's say you have another, non-graphtage class that corresponds to :class:`CustomNode`: +Let's say you have another, non-graphtage class that corresponds to ``CustomNode``: .. code-block:: python @@ -111,11 +112,66 @@ You can add support for building Graphtage nodes from this custom class as follo class CustomBuilder(BasicBuilder): @Builder.expander(NonGraphtageClass) - def expand_non_graphtage_class(node: NonGraphtageClass): + def expand_non_graphtage_class(self, node: NonGraphtageClass): yield node.name yield node.value yield node.attributes @Builder.builder(NonGraphtageClass) - def build_non_graphtage_class(node: NonGraphtageClass, children: list[TreeNode]) -> CustomNode: + def build_non_graphtage_class(self, node: NonGraphtageClass, children: list[TreeNode]) -> CustomNode: return CustomNode(*children) + +Data Class Slots +---------------- + +The annotations on a :class:`graphtage.dataclasses.DataClassNode` subclass become its *slots*: the children of the +node, in the order they are declared. Only annotations that name a :class:`graphtage.TreeNode` subclass directly are +turned into slots. Every other annotation is ignored, including a subscripted generic like ``list[IntegerNode]``, +which is skipped rather than rejected: + +.. code-block:: python + + class SkipsTheSecondAnnotation(DataClassNode): + name: StringNode + items: list[IntegerNode] + +>>> SkipsTheSecondAnnotation._SLOTS +('name',) + +Slot types are enforced when the node is constructed. Passing a node of the wrong type raises a :exc:`ValueError`: + +>>> CustomNode(StringNode("the name"), StringNode("1337"), ListNode(())) +Traceback (most recent call last): + ... +ValueError: Expected a node of type IntegerNode for argument CustomNode.value but instead got StringNode('1337') + +A subclass cannot redefine a slot that one of its ancestors already declares. Doing so raises a :exc:`TypeError` when +the subclass is defined, not when it is instantiated: + +>>> class Redefined(CustomNode): +... name: StringNode +Traceback (most recent call last): + ... +TypeError: Dataclass Redefined cannot redefine slot 'name' because it is already defined in its superclass CustomNode + +Initializing a Data Class Node +------------------------------ + +:meth:`DataClassNode.__init__ ` assigns the slots from its positional +and keyword arguments, so overriding it means reimplementing that assignment. Override +:meth:`graphtage.dataclasses.DataClassNode.post_init` instead. It is called once the slots have been assigned, and it +should not call ``super().post_init()``: each ancestor's implementation is called in turn, in order of the ``__mro__``. + +.. code-block:: python + + class UnquotedName(DataClassNode): + name: StringNode + + def post_init(self): + self.name.quoted = False + +.. note:: + As of Graphtage 0.3.1, ``post_init()`` is only called for the ancestors of the class being instantiated, never for + the class itself. ``UnquotedName(StringNode("x"))`` leaves ``quoted`` set to :const:`True`; the callback runs only + when a subclass of ``UnquotedName`` is instantiated. Code that must run for the class itself still has to go in + ``__init__``. From 71a5a346ceeec3511f2c920727e801c25a969e0d Mon Sep 17 00:00:00 2001 From: Evan Sultanik Date: Wed, 9 Sep 2026 08:46:25 -0400 Subject: [PATCH 4/4] Rerun the library examples and document build options PR #132 put removals before insertions in the edit tie-break order, so every output block on this page was stale. Rerun the whole session and paste the real output. The last element of the pydiff example is now a `Replace` rather than an insert and remove pair. Delete the stray `from_node.diff(to_node)` line, which used two names the session never defines, and correct `instanceof` to `isinstance`. Read the default printer through `printer.get_default_printer()` rather than the `printer.DEFAULT_PRINTER` module attribute, and use the `p` the examples already bind. Add sections on `BuildOptions`, `pydiff.diff()`, `pydiff.build_tree()`, and diffing Python source through `pydiff.ast_to_tree`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa --- docs/library.rst | 147 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 133 insertions(+), 14 deletions(-) diff --git a/docs/library.rst b/docs/library.rst index de0ce243..ae0d8de0 100644 --- a/docs/library.rst +++ b/docs/library.rst @@ -20,6 +20,70 @@ filetype has a function to convert arbitrary Python objects (comprised of standa >>> from_tree DictNode([KeyValuePairNode(key=StringNode('foo'), value=ListNode((IntegerNode(1), IntegerNode(2), IntegerNode(3), IntegerNode(4))))]) +Build Options +------------- + +Every ``build_tree`` function—:func:`graphtage.json.build_tree`, :func:`graphtage.pydiff.build_tree`, and the +:meth:`build_tree ` method of every :class:`graphtage.Filetype`—accepts an optional +:obj:`options` argument of type :class:`graphtage.BuildOptions`. It controls which edits the matching algorithms are +allowed to consider, which is the main lever you have over both the shape of the diff and how long it takes to +compute:: + + >>> from graphtage import BuildOptions, json + >>> options = BuildOptions(ignore_list_order=True) + >>> json.build_tree([1, 2, 3], options=options) + UnorderedListNode([IntegerNode(1), IntegerNode(2), IntegerNode(3)]) + +Passing no options is the same as passing ``BuildOptions()``. The options are: + +:attr:`allow_key_edits ` + Whether to consider editing keys when matching :class:`graphtage.KeyValuePairNode` objects. Defaults to + :const:`True`. With :const:`False`, dictionary entries only match when their keys are equal, and the tree is built + from :class:`graphtage.FixedKeyDictNode` rather than :class:`graphtage.DictNode`. This is the least expensive + dictionary strategy. + +:attr:`auto_match_keys ` + Whether to automatically match key/value pairs in dictionaries if they share the same key. Defaults to + :const:`True`. Key edits are still considered for the pairs that are left over. Setting this to :const:`False` + while leaving :attr:`allow_key_edits ` set costs every possible pairing of + keys, which is the most expensive dictionary strategy. + +:attr:`allow_list_edits ` + Whether to consider insert and remove edits to lists. Defaults to :const:`True`. With :const:`False`, lists are + compared element by element. + +:attr:`ignore_list_order ` + Whether to match the elements of a list as an unordered collection. Defaults to :const:`False`. See + :ref:`Unordered Lists` for the trade-off this makes. + +:attr:`check_for_cycles ` + Whether to check the input for cycles while building the tree. Defaults to :const:`True`. In-memory Python objects + can refer to themselves, and walking such an object without this check does not terminate. + +:attr:`ignore_cycles ` + What to do when a cycle is found. Defaults to :const:`False`, which raises a :exc:`ValueError`. With :const:`True`, + the back edge is replaced by a :class:`graphtage.builder.CyclicReference` leaf and the walk continues:: + + >>> from graphtage import BuildOptions + >>> from graphtage.pydiff import build_tree + >>> cyclic = [] + >>> cyclic.append(cyclic) + >>> build_tree(cyclic) + Traceback (most recent call last): + ... + ValueError: Detected a cycle in [[...]] at child [[...]] + >>> build_tree(cyclic, options=BuildOptions(ignore_cycles=True)) + ListNode((CyclicReference(),)) + +:attr:`printer ` + The printer used to report progress while building the tree. Defaults to + :attr:`graphtage.printer.NULL_PRINTER`, which prints nothing. The command line client sets this to the printer it + is using, which is why ``graphtage`` shows a progress bar and the library does not. + +Any keyword that :class:`graphtage.BuildOptions` does not recognize is set as an attribute, and any attribute that was +never set reads back as :const:`False`. That makes it possible for a :class:`graphtage.Filetype` to define its own +options, but it also means that a misspelled option name is silently accepted. + Transforming Nodes with Edits ----------------------------- @@ -30,20 +94,19 @@ To see the sequence of edits to transform this tree to another, we call :meth:`g DictNode([KeyValuePairNode(key=StringNode('bar'), value=ListNode((IntegerNode(2), IntegerNode(3), IntegerNode(4))))]) >>> for edit in from_tree.get_all_edits(to_tree): ... print(edit) - Remove(IntegerNode(1), remove_from=ListNode((IntegerNode(1), IntegerNode(2), IntegerNode(3), IntegerNode(4)))) StringEdit(from_node=StringNode('foo'), to_node=StringNode('bar')) + Remove(IntegerNode(1), remove_from=ListNode((IntegerNode(1), IntegerNode(2), IntegerNode(3), IntegerNode(4)))) Applying Edits to Nodes ----------------------- Both nodes and edits are immutable. We can perform a diff to apply edits to nodes, producing a new tree constructed of :class:`graphtage.EditedTreeNode` objects. Using some Python magic, the new tree's nodes maintain all of the same -characteristics of the source nodes—including their source node class types—but are *also* :func:`instanceof` +characteristics of the source nodes—including their source node class types—but are *also* :func:`isinstance` :class:`graphtage.EditedTreeNode`, too. Here is how to diff two nodes:: - >>> from_node.diff(to_node) >>> diff = from_tree.diff(to_tree) >>> diff EditedDictNode([EditedKeyValuePairNode(key=EditedStringNode('foo'), value=EditedListNode((EditedIntegerNode(1), EditedIntegerNode(2), EditedIntegerNode(3), EditedIntegerNode(4))))]) @@ -58,14 +121,17 @@ Formatting and Printing Results There are two components to outputting a tree or diff: a :class:`graphtage.formatter.Formatter`, which is responsible for the syntax of the output, and a :class:`graphtage.printer.Printer`, which is responsible for rendering that output -to a stream. For example, to print our diff in JSON format to the default printer (STDOUT), we would do:: +to a stream. Read the default printer with :func:`graphtage.printer.get_default_printer` rather than importing +:attr:`graphtage.printer.DEFAULT_PRINTER` by name, because :func:`graphtage.printer.set_default_printer` rebinds the +module attribute and an imported name never sees the replacement. For example, to print our diff in JSON format to the +default printer (STDOUT), we would do:: >>> from graphtage import printer - >>> with printer.DEFAULT_PRINTER as p: - ... json.JSONFormatter.DEFAULT_INSTANCE.print(printer.DEFAULT_PRINTER, diff) + >>> with printer.get_default_printer() as p: + ... json.JSONFormatter.DEFAULT_INSTANCE.print(p, diff) ... { - "++bar++~~foo~~": [ + "~~foo~~++bar++": [ ~~1~~, 2, 3, @@ -77,10 +143,10 @@ Since Graphtage's formatters are independent of the input format, thanks to the just as easily output the diff in another format, like YAML:: >>> from graphtage import yaml - >>> with printer.DEFAULT_PRINTER as p: - ... yaml.YAMLFormatter.DEFAULT_INSTANCE.print(printer.DEFAULT_PRINTER, diff) + >>> with printer.get_default_printer() as p: + ... yaml.YAMLFormatter.DEFAULT_INSTANCE.print(p, diff) ... - ++bar++~~foo~~: + ~~foo~~++bar++: - ~~1~~ - 2 - 3 @@ -93,11 +159,11 @@ When used as a library, Graphtage has the ability to diff in-memory Python objec for example, to quickly determine the difference between two Python objects that cause a differential.:: >>> from graphtage.pydiff import print_diff - >>> with printer.DEFAULT_PRINTER as p: + >>> with printer.get_default_printer() as p: ... obj1 = [1, 2, {3: "three"}, 4] ... obj2 = [1, 2, {3: 3}, "four"] ... print_diff(obj1, obj2, printer=p) - [1,2,{3: "three" -> 3},++"four"++~~4~~] + [1,2,{3: "three" -> 3},4 -> "four"] Python object diffing also works with custom classes:: @@ -105,6 +171,59 @@ Python object diffing also works with custom classes:: ... def __init__(self, bar, baz): ... self.bar = bar ... self.baz = baz - >>> with printer.DEFAULT_PRINTER as p: + >>> with printer.get_default_printer() as p: ... print_diff(Foo("bar", "baz"), Foo("bar", "bak"), printer=p) - Foo(bar="bar", baz="ba++k++~~z~~") + Foo(bar="bar", baz="ba~~z~~++k++") + +:func:`graphtage.pydiff.print_diff` only prints. To inspect a diff instead, call :func:`graphtage.pydiff.diff`, which +returns the same edited tree that :meth:`graphtage.TreeNode.diff` produces:: + + >>> from graphtage.pydiff import diff + >>> d = diff(obj1, obj2) + >>> d + EditedListNode((EditedIntegerNode(1), EditedIntegerNode(2), EditedDictNode([EditedKeyValuePairNode(key=EditedIntegerNode(3), value=EditedStringNode('three'))]), EditedIntegerNode(4))) + >>> d.edit.bounds() + Range(9, 9) + >>> [child.edit for child in d.children() if child.edit.has_non_zero_cost()] + [, Match(match_from=EditedIntegerNode(4), match_to=StringNode('four'), cost=4)] + +:func:`graphtage.pydiff.build_tree` is the underlying tree builder. It accepts any Python object, including instances +of classes that Graphtage has never seen, and represents them as a :class:`graphtage.pydiff.PyObj` node whose children +are the object's public attributes:: + + >>> from graphtage.pydiff import build_tree + >>> build_tree(Foo("bar", "baz")) + PyObj(class_name=StringNode('Foo'), attrs=PyObjAttributes([KeywordArgument(key=StringNode('bar'), value=StringNode('bar')), KeywordArgument(key=StringNode('baz'), value=StringNode('baz'))])) + +Both functions accept the same :obj:`options` argument as the filetype tree builders. + +Diffing Python Source Code +-------------------------- + +Graphtage can also diff Python source code by way of the standard library's :mod:`ast` module. +:func:`graphtage.pydiff.ast_to_tree` converts an :class:`ast.AST` into an intermediate representation built from the +node types in :mod:`graphtage.ast`: :class:`graphtage.ast.Module` for a source file, +:class:`graphtage.ast.Assignment`, :class:`graphtage.ast.Call`, :class:`graphtage.ast.Import`, +:class:`graphtage.ast.Subscript`, and :class:`graphtage.ast.KeywordArgument`. The conversion is performed by +:class:`graphtage.pydiff.ASTBuilder`, a :class:`graphtage.builder.BasicBuilder` subclass that covers a subset of +Python's syntax; a syntax node that it has no builder for raises :exc:`NotImplementedError`. + +:class:`graphtage.pydiff.PyDiffFormatter` renders the result back as Python source:: + + >>> import ast + >>> from graphtage import printer + >>> from graphtage.pydiff import PyDiffFormatter, ast_to_tree + >>> from_ast = ast_to_tree(ast.parse("from foo import bar\nx = bar(1, 2)\n")) + >>> from_ast + Module((Import(names=ListNode((PyAlias(name=StringNode('bar'), as_name=StringNode('')),)), from_name=StringNode('foo')), Assignment(targets=ListNode((StringNode('x'),)), value=Call(func=StringNode('bar'), args=CallArguments((IntegerNode(1), IntegerNode(2))), kwargs=CallKeywords([]))))) + >>> to_ast = ast_to_tree(ast.parse("from foo import bar\nx = bar(1, 2)\ny = bar(3)\n")) + >>> with printer.get_default_printer() as p: + ... PyDiffFormatter.DEFAULT_INSTANCE.print(p, from_ast.diff(to_ast)) + ... + from foo import bar + x = bar(1, 2) + ++y = bar(3)++ + +Like the other tree builders, :func:`graphtage.pydiff.ast_to_tree` accepts an :obj:`options` argument. Its list and +tuple builder deliberately ignores :attr:`graphtage.BuildOptions.ignore_list_order`, because the elements of a Python +list or tuple literal are positional.