Graphtage is a semantic diff/merge utility for tree-like structured data formats (JSON, JSON5, XML, HTML, YAML, TOML, INI, CSV, plist, Python pickle). It works as both a command-line tool and Python library.
Key capabilities:
- Semantic understanding of tree structures (recognizes key vs value changes)
- Cross-format diffing (e.g., JSON vs YAML with output in any format)
- Extensible architecture for custom node types and file formats
- HTML output support for visual diffs
TreeNode: Protocol for tree node implementations - all nodes must implement thisEdit: Protocol for edit operations with cost boundsGraphtageFormatter: Base formatter for printing nodes and edits
LeafNode: Terminal nodes (strings, numbers, booleans, null)ListNode: Ordered sequencesDictNode: Key-value mappingsKeyValuePairNode: Individual dict entries
Match: No change neededReplace: Substitute one value for anotherInsert: Add new elementRemove: Delete elementCompoundEdit: Multiple edits grouped together
- matching.py: Bipartite matching for optimal node correspondences
- levenshtein.py: String edit distance with Unicode combining marks
- search.py: Iterative tightening search for edit cost optimization
- bounds.py: Cost range calculations (Range class)
- fibonacci.py: Fibonacci search for optimization
Each format implements its own TreeNode subclasses and parser:
- json.py, yaml.py, xml.py, csv.py, toml.py, ini.py, plist.py, pickle.py
# Install with dev dependencies
pip install -e .[dev]
# Or just the package
pip install graphtagepytest # All tests
pytest test/test_graphtage.py # Specific module
pytest -q # Quiet output# Ruff is configured in pyproject.toml
ruff check graphtage test
ruff check --fix graphtage test
# CI currently uses flake8
flake8 graphtage test --select=E9,F63,F7,F82cd docs && make html
# Output in docs/_build/html/- Create
graphtage/newformat.py. - Define a
Filetypesubclass with a zero-argument__init__.FiletypeWatcherinstantiates it at class-definition time, so it must implementbuild_tree,build_tree_handling_errors, andget_default_formatter. Registration intoFILETYPES_BY_TYPENAMEandFILETYPES_BY_MIMEis automatic, and that is what generates the--from-*,--to-*, and--formatCLI flags. - Implement
build_tree(path: str, options: Optional[BuildOptions] = None) -> TreeNode. Reusinggraphtage.json.build_treeon a plain Python object gets you the wholeTreeNodecontract for free. - Add the module to the
from . import ...line ingraphtage/__init__.py. Nothing registers without it, anddocs/build_api.pydiscovers API pages from this import. - Add the extension to
register_mimetypes()ingraphtage/__main__.py.mimetypesdoes not know most of these, and format detection is extension-based, so without this every diff fails with "Could not determine the filetype". - Add a
test_<typename>_formattingmethod totest/test_formatting.py, ortest_formatter_coveragefails. Note that@filetype_testonly round-trips unedited trees, so it cannot catch a formatter that mishandles edits — add a separate diff-level test for insertions and removals. - Give the formatter
print_UnorderedListNode = print_ListNode, ortest_unordered_list_renders_like_a_listfails. A formatter that cannot resolve a node type bounces toself.parent.print(...)and recurses forever. - Mark helper formatters
is_partial = Trueso they stay out of the globalFORMATTERSlist, where they could change how unrelated formats resolve node types. - Update the format lists in
README.md,docs/index.rst,CITATION.cff,pyproject.toml, the--helpdescription ingraphtage/__main__.py, and this file. - Add a dependency to
pyproject.tomlonly if the parser is third-party, and regenerateuv.lock.
Route printing through SequenceFormatter.print_SequenceNode, which is where insert and remove edits are applied;
iterating a node's children directly silently drops them. Only one formatter may define print_<NodeType> for a
given type, so distinguish nesting levels in the key/value formatter rather than by node type (see
graphtage/yaml.py and graphtage/ini.py).
Python 3.10 is the minimum supported version. Check requires-python in pyproject.toml and the CI matrix in
.github/workflows/pythonpackage.yml before using a feature from a newer release; a runtime-evaluated annotation
that the floor does not support fails at import, which takes down the whole package.
- Edit costs are computed lazily via
bounds()method - Use
has_non_zero_cost()to check if an edit represents a change initial_boundsstores the first computed bounds for optimization
The printing system is extensible:
- Check for specialized formatter for the edit type
- Fall back to edit's
print()method - Fall back to node's
print()method
- Line length: 120 characters (configured in ruff)
- Python version: 3.10+ compatibility required; CI covers 3.10 through 3.14
- Type hints: Use typing_extensions for Protocol support
- Docstrings: Google style for public APIs
- Tests: Mirror package structure in test/ directory
# Basic diff
graphtage original.json modified.json
# Cross-format diff
graphtage file.json file.yaml --format yaml
# Condensed output
graphtage -j original.json modified.json
# Show only edits
graphtage -e original.json modified.json
# HTML output
graphtage --html original.json modified.json > diff.html- Test files are in
test/directory - Use
test_*.pynaming convention - Tests are organized by module (test_matching.py tests matching.py)
- Performance tests in timing.py (not run by default)