Skip to content

Commit

Permalink
Parsing of diff files, and a command line tool
Browse files Browse the repository at this point in the history
  • Loading branch information
regebro committed Feb 25, 2019
1 parent 1a2b218 commit b92948d
Show file tree
Hide file tree
Showing 9 changed files with 290 additions and 15 deletions.
2 changes: 1 addition & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Changes
2.3 (unreleased)
----------------

- Nothing changed yet.
- Added a simple ``xmlpatch`` command and API.


2.2 (2018-10-12)
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
test_suite='tests',
entry_points={
'console_scripts': [
'xmldiff = xmldiff.main:run',
'xmldiff = xmldiff.main:diff_command',
'xmlpatch = xmldiff.main:patch_command',
],
},
)
4 changes: 4 additions & 0 deletions tests/test_data/insert-node.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[update-text, /body/div[1], "\n "]
[insert, /body/div[1], p, 0]
[update-text, /body/div/p[1], "Simple text"]
[update-text-after, /body/div/p[1], "\n "]
2 changes: 1 addition & 1 deletion tests/test_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ def test_insert_node(self):

def test_rename_attr(self):
action = actions.RenameAttrib('/document/node', 'attr', 'bottr')
expected = '[move-attribute, /document/node, attr, bottr]'
expected = '[rename-attribute, /document/node, attr, bottr]'
self._format_test(action, expected)

def test_move_node(self):
Expand Down
24 changes: 19 additions & 5 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def test_api_diff_files_with_formatter(self):

class MainCLITests(unittest.TestCase):

def call_run(self, args):
def call_run(self, args, command=main.diff_command):
output = six.StringIO()
errors = six.StringIO()

Expand All @@ -84,18 +84,18 @@ def call_run(self, args):
sys.stdout = output
sys.stderr = errors

main.run(args)
command(args)
finally:
sys.stdout = stdout
sys.stderr = stderr

return output.getvalue(), errors.getvalue()

def test_cli_no_args(self):
def test_diff_cli_no_args(self):
with self.assertRaises(SystemExit):
stdout, stderr = self.call_run([])

def test_cli_simple(self):
def test_diff_cli_simple(self):
curdir = os.path.dirname(__file__)
filepath = os.path.join(curdir, 'test_data')
file1 = os.path.join(filepath, 'insert-node.left.html')
Expand All @@ -106,7 +106,7 @@ def test_cli_simple(self):
# This should default to the diff formatter:
self.assertEqual(output[0], '[')

def test_cli_args(self):
def test_diff_cli_args(self):
curdir = os.path.dirname(__file__)
filepath = os.path.join(curdir, 'test_data')
file1 = os.path.join(filepath, 'insert-node.left.html')
Expand Down Expand Up @@ -155,3 +155,17 @@ def test_cli_args(self):
# Or none
output, errors = self.call_run([file1, file2, '--unique-attributes'])
self.assertEqual(len(output.splitlines()), 3)

def test_patch_cli_simple(self):
curdir = os.path.dirname(__file__)
filepath = os.path.join(curdir, 'test_data')
patchfile = os.path.join(filepath, 'insert-node.diff')
xmlfile = os.path.join(filepath, 'insert-node.left.html')

output, errors = self.call_run([patchfile, xmlfile],
command=main.patch_command)

expectedfile = os.path.join(filepath, 'insert-node.right.html')
with open(expectedfile, 'rt') as f:
expected = f.read()
self.assertEqual(output, expected)
131 changes: 129 additions & 2 deletions tests/test_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import unittest

from lxml import etree
from xmldiff.main import diff_trees
from xmldiff.patch import Patcher
from xmldiff.formatting import DiffFormatter, WS_NONE
from xmldiff.main import diff_trees, diff_texts, patch_text, patch_file
from xmldiff.patch import Patcher, DiffParser
from xmldiff.actions import (UpdateTextIn, InsertNode, MoveNode,
DeleteNode, UpdateAttrib, InsertAttrib,
RenameAttrib, DeleteAttrib, UpdateTextAfter,
Expand Down Expand Up @@ -93,3 +94,129 @@ def test_diff_patch(self):
# with that, so the trees are not EXACTLY the same, the trailing
# top level comment differs, but that's OK.
compare_elements(result.getroot(), right.getroot())


TEST_DIFF = '''[delete, node]
[insert, target, tag, 0]
[rename, node, tag]
[move, node, target, 0]
[update-text, node, "text"]
[update-text-after, node, "text"]
[update-attribute, node, name, "value"]
[delete-attribute, node, name]
[insert-attribute, node, name, "value"]
[rename-attribute, node, oldname, newname]
[insert-comment, target, 0, "text"]
'''


class ParserTests(unittest.TestCase):

def test_make_action(self):
parser = DiffParser()

self.assertEqual(
parser.make_action('[delete, node]'),
DeleteNode('node')
)

self.assertEqual(
parser.make_action('[insert, target, tag, 0]'),
InsertNode('target', 'tag', 0)
)

self.assertEqual(
parser.make_action('[rename, node, tag]'),
RenameNode('node', 'tag')
)

self.assertEqual(
parser.make_action('[move, node, target, 0]'),
MoveNode('node', 'target', 0)
)

self.assertEqual(
parser.make_action('[update-text, node, "text"]'),
UpdateTextIn('node', 'text')
)

self.assertEqual(
parser.make_action('[update-text-after, node, "text"]'),
UpdateTextAfter('node', 'text')
)

self.assertEqual(
parser.make_action('[update-attribute, node, name, "value"]'),
UpdateAttrib('node', 'name', 'value')
)

self.assertEqual(
parser.make_action('[delete-attribute, node, name]'),
DeleteAttrib('node', 'name')
)

self.assertEqual(
parser.make_action('[insert-attribute, node, name, "value"]'),
InsertAttrib('node', 'name', 'value')
)

self.assertEqual(
parser.make_action('[rename-attribute, node, oldname, newname]'),
RenameAttrib('node', 'oldname', 'newname')
)

self.assertEqual(
parser.make_action('[insert-comment, target, 0, "text"]'),
InsertComment('target', 0, 'text')
)

def test_parse(self):
parser = DiffParser()
actions = list(parser.parse(TEST_DIFF))
self.assertEqual(len(actions), len(TEST_DIFF.splitlines()))

def test_parse_broken(self):
# Testing incorrect patch files
parser = DiffParser()

# Empty file, nothing happens
actions = list(parser.parse(''))
self.assertEqual(actions, [])

# Not a diff raises error
with self.assertRaises(ValueError):
actions = list(parser.parse('Not a diff'))

# It should handle lines that have been broken, say in an email
actions = list(parser.parse('[insert-comment, target,\n 0, "text"]'))
self.assertEqual(actions, [InsertComment('target', 0, 'text')])

# It should not handle broken files
with self.assertRaises(ValueError):
actions = list(parser.parse('[insert-comment, target,\n'))

def test_diff_patch(self):
here = os.path.split(__file__)[0]
lfile = os.path.join(here, 'test_data', 'all_actions.left.xml')
rfile = os.path.join(here, 'test_data', 'all_actions.right.xml')
with open(lfile) as f:
left = f.read()
with open(rfile) as f:
right = f.read()

diff = diff_texts(left, right,
formatter=DiffFormatter(normalize=WS_NONE))
result = patch_text(diff, left)
compare_elements(etree.fromstring(result), etree.fromstring(right))

def test_patch_stream(self):
here = os.path.join(os.path.split(__file__)[0], 'test_data')
xmlfile = os.path.join(here, 'insert-node.left.html')
patchfile = os.path.join(here, 'insert-node.diff')
result = patch_file(patchfile, xmlfile)

expectedfile = os.path.join(here, 'insert-node.right.html')
with open(expectedfile, 'rt') as f:
expected = f.read()
# lxml.etree.parse() will strip ending whitespace
self.assertEqual(result, expected.rstrip())
3 changes: 2 additions & 1 deletion xmldiff/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,8 @@ def _handle_InsertNode(self, action):
return u"insert", action.target, action.tag, str(action.position)

def _handle_RenameAttrib(self, action):
return (u"move-attribute", action.node, action.oldname, action.newname)
return (u"rename-attribute", action.node, action.oldname,
action.newname)

def _handle_MoveNode(self, action):
return u"move", action.node, action.target, str(action.position)
Expand Down
63 changes: 59 additions & 4 deletions xmldiff/main.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""All major API points and command-line tools"""
import pkg_resources
import six

from argparse import ArgumentParser, FileType
from lxml import etree
from xmldiff import diff, formatting
from xmldiff import diff, formatting, patch

__version__ = pkg_resources.require("xmldiff")[0].version

Expand Down Expand Up @@ -50,7 +51,7 @@ def diff_files(left, right, diff_options=None, formatter=None):
diff_options=diff_options, formatter=formatter)


def make_parser():
def make_diff_parser():
parser = ArgumentParser(description='Create a diff for two XML files.',
add_help=False)
parser.add_argument('file1', type=FileType('r'),
Expand Down Expand Up @@ -84,8 +85,8 @@ def make_parser():
return parser


def run(args=None):
parser = make_parser()
def diff_command(args=None):
parser = make_diff_parser()
args = parser.parse_args(args=args)

if args.keep_whitespace:
Expand All @@ -109,3 +110,57 @@ def run(args=None):
result = diff_files(args.file1, args.file2, diff_options=diff_options,
formatter=formatter)
print(result)


def patch_tree(actions, tree):
"""Takes an lxml root element or element tree, and a list of actions"""
patcher = patch.Patcher()
return patcher.patch(actions, tree)


def patch_text(actions, tree):
"""Takes a string with XML and a string with actions"""
tree = etree.fromstring(tree)
actions = patch.DiffParser().parse(actions)
tree = patch_tree(actions, tree)
return etree.tounicode(tree)


def patch_file(actions, tree):
"""Takes two filenames or streams, one with XML the other a diff"""
tree = etree.parse(tree)

if isinstance(actions, six.string_types):
# It's a string, so it's a filename
with open(actions) as f:
actions = f.read()
else:
# We assume it's a stream
actions = actions.read()

actions = patch.DiffParser().parse(actions)
tree = patch_tree(actions, tree)
return etree.tounicode(tree)


def make_patch_parser():
parser = ArgumentParser(description='Patch an XML file with an xmldiff',
add_help=False)
parser.add_argument('patchfile', type=FileType('r'),
help='An xmldiff diff file.')
parser.add_argument('xmlfile', type=FileType('r'),
help='An unpatched XML file.')
parser.add_argument('-h', '--help', action='help',
help='Show this help message and exit.')
parser.add_argument('-v', '--version', action='version',
help='Display version and exit.',
version='xmldiff %s' % __version__)
return parser


def patch_command(args=None):
parser = make_patch_parser()
args = parser.parse_args(args=args)

result = patch_file(args.patchfile, args.xmlfile)
print(result)
Loading

0 comments on commit b92948d

Please sign in to comment.