1
# Copyright (C) 2004, 2005 Aaron Bentley
2
# <aaron.bentley@utoronto.ca>
4
# This program is free software; you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License as published by
6
# the Free Software Foundation; either version 2 of the License, or
7
# (at your option) any later version.
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU General Public License for more details.
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
18
class PatchSyntax(Exception):
19
def __init__(self, msg):
20
Exception.__init__(self, msg)
23
class MalformedPatchHeader(PatchSyntax):
24
def __init__(self, desc, line):
27
msg = "Malformed patch header. %s\n%r" % (self.desc, self.line)
28
PatchSyntax.__init__(self, msg)
30
class MalformedHunkHeader(PatchSyntax):
31
def __init__(self, desc, line):
34
msg = "Malformed hunk header. %s\n%r" % (self.desc, self.line)
35
PatchSyntax.__init__(self, msg)
37
class MalformedLine(PatchSyntax):
38
def __init__(self, desc, line):
41
msg = "Malformed line. %s\n%s" % (self.desc, self.line)
42
PatchSyntax.__init__(self, msg)
44
def get_patch_names(iter_lines):
46
line = iter_lines.next()
47
if not line.startswith("--- "):
48
raise MalformedPatchHeader("No orig name", line)
50
orig_name = line[4:].rstrip("\n")
52
raise MalformedPatchHeader("No orig line", "")
54
line = iter_lines.next()
55
if not line.startswith("+++ "):
56
raise PatchSyntax("No mod name")
58
mod_name = line[4:].rstrip("\n")
60
raise MalformedPatchHeader("No mod line", "")
61
return (orig_name, mod_name)
63
def parse_range(textrange):
64
"""Parse a patch range, handling the "1" special-case
66
:param textrange: The text to parse
68
:return: the position and range, as a tuple
71
tmp = textrange.split(',')
82
def hunk_from_header(line):
83
if not line.startswith("@@") or not line.endswith("@@\n") \
85
raise MalformedHunkHeader("Does not start and end with @@.", line)
87
(orig, mod) = line[3:-4].split(" ")
89
raise MalformedHunkHeader(str(e), line)
90
if not orig.startswith('-') or not mod.startswith('+'):
91
raise MalformedHunkHeader("Positions don't start with + or -.", line)
93
(orig_pos, orig_range) = parse_range(orig[1:])
94
(mod_pos, mod_range) = parse_range(mod[1:])
96
raise MalformedHunkHeader(str(e), line)
97
if mod_range < 0 or orig_range < 0:
98
raise MalformedHunkHeader("Hunk range is negative", line)
99
return Hunk(orig_pos, orig_range, mod_pos, mod_range)
103
def __init__(self, contents):
104
self.contents = contents
106
def get_str(self, leadchar):
107
if self.contents == "\n" and leadchar == " " and False:
109
if not self.contents.endswith('\n'):
110
terminator = '\n' + NO_NL
113
return leadchar + self.contents + terminator
116
class ContextLine(HunkLine):
117
def __init__(self, contents):
118
HunkLine.__init__(self, contents)
121
return self.get_str(" ")
124
class InsertLine(HunkLine):
125
def __init__(self, contents):
126
HunkLine.__init__(self, contents)
129
return self.get_str("+")
132
class RemoveLine(HunkLine):
133
def __init__(self, contents):
134
HunkLine.__init__(self, contents)
137
return self.get_str("-")
139
NO_NL = '\\ No newline at end of file\n'
140
__pychecker__="no-returnvalues"
142
def parse_line(line):
143
if line.startswith("\n"):
144
return ContextLine(line)
145
elif line.startswith(" "):
146
return ContextLine(line[1:])
147
elif line.startswith("+"):
148
return InsertLine(line[1:])
149
elif line.startswith("-"):
150
return RemoveLine(line[1:])
154
raise MalformedLine("Unknown line type", line)
159
def __init__(self, orig_pos, orig_range, mod_pos, mod_range):
160
self.orig_pos = orig_pos
161
self.orig_range = orig_range
162
self.mod_pos = mod_pos
163
self.mod_range = mod_range
166
def get_header(self):
167
return "@@ -%s +%s @@\n" % (self.range_str(self.orig_pos,
169
self.range_str(self.mod_pos,
172
def range_str(self, pos, range):
173
"""Return a file range, special-casing for 1-line files.
175
:param pos: The position in the file
177
:range: The range in the file
179
:return: a string in the format 1,4 except when range == pos == 1
184
return "%i,%i" % (pos, range)
187
lines = [self.get_header()]
188
for line in self.lines:
189
lines.append(str(line))
190
return "".join(lines)
192
def shift_to_mod(self, pos):
193
if pos < self.orig_pos-1:
195
elif pos > self.orig_pos+self.orig_range:
196
return self.mod_range - self.orig_range
198
return self.shift_to_mod_lines(pos)
200
def shift_to_mod_lines(self, pos):
201
assert (pos >= self.orig_pos-1 and pos <= self.orig_pos+self.orig_range)
202
position = self.orig_pos-1
204
for line in self.lines:
205
if isinstance(line, InsertLine):
207
elif isinstance(line, RemoveLine):
212
elif isinstance(line, ContextLine):
218
def iter_hunks(iter_lines):
220
for line in iter_lines:
228
hunk = hunk_from_header(line)
231
while orig_size < hunk.orig_range or mod_size < hunk.mod_range:
232
hunk_line = parse_line(iter_lines.next())
233
hunk.lines.append(hunk_line)
234
if isinstance(hunk_line, (RemoveLine, ContextLine)):
236
if isinstance(hunk_line, (InsertLine, ContextLine)):
242
def __init__(self, oldname, newname):
243
self.oldname = oldname
244
self.newname = newname
248
ret = self.get_header()
249
ret += "".join([str(h) for h in self.hunks])
252
def get_header(self):
253
return "--- %s\n+++ %s\n" % (self.oldname, self.newname)
256
"""Return a string of patch statistics"""
259
for hunk in self.hunks:
260
for line in hunk.lines:
261
if isinstance(line, InsertLine):
263
elif isinstance(line, RemoveLine):
265
return "%i inserts, %i removes in %i hunks" % \
266
(inserts, removes, len(self.hunks))
268
def pos_in_mod(self, position):
270
for hunk in self.hunks:
271
shift = hunk.shift_to_mod(position)
277
def iter_inserted(self):
278
"""Iteraties through inserted lines
280
:return: Pair of line number, line
281
:rtype: iterator of (int, InsertLine)
283
for hunk in self.hunks:
284
pos = hunk.mod_pos - 1;
285
for line in hunk.lines:
286
if isinstance(line, InsertLine):
289
if isinstance(line, ContextLine):
292
def parse_patch(iter_lines):
293
(orig_name, mod_name) = get_patch_names(iter_lines)
294
patch = Patch(orig_name, mod_name)
295
for hunk in iter_hunks(iter_lines):
296
patch.hunks.append(hunk)
300
def iter_file_patch(iter_lines):
302
for line in iter_lines:
303
if line.startswith('*** '):
305
elif line.startswith('--- '):
306
if len(saved_lines) > 0:
309
saved_lines.append(line)
310
if len(saved_lines) > 0:
314
def iter_lines_handle_nl(iter_lines):
316
Iterates through lines, ensuring that lines that originally had no
317
terminating \n are produced without one. This transformation may be
318
applied at any point up until hunk line parsing, and is safe to apply
322
for line in iter_lines:
324
assert last_line.endswith('\n')
325
last_line = last_line[:-1]
327
if last_line is not None:
330
if last_line is not None:
334
def parse_patches(iter_lines):
335
iter_lines = iter_lines_handle_nl(iter_lines)
336
return [parse_patch(f.__iter__()) for f in iter_file_patch(iter_lines)]
339
def difference_index(atext, btext):
340
"""Find the indext of the first character that differs betweeen two texts
342
:param atext: The first text
344
:param btext: The second text
346
:return: The index, or None if there are no differences within the range
347
:rtype: int or NoneType
350
if len(btext) < length:
352
for i in range(length):
353
if atext[i] != btext[i]:
357
class PatchConflict(Exception):
358
def __init__(self, line_no, orig_line, patch_line):
359
orig = orig_line.rstrip('\n')
360
patch = str(patch_line).rstrip('\n')
361
msg = 'Text contents mismatch at line %d. Original has "%s",'\
362
' but patch says it should be "%s"' % (line_no, orig, patch)
363
Exception.__init__(self, msg)
366
def iter_patched(orig_lines, patch_lines):
367
"""Iterate through a series of lines with a patch applied.
368
This handles a single file, and does exact, not fuzzy patching.
370
if orig_lines is not None:
371
orig_lines = orig_lines.__iter__()
373
patch_lines = iter_lines_handle_nl(patch_lines.__iter__())
374
get_patch_names(patch_lines)
376
for hunk in iter_hunks(patch_lines):
377
while line_no < hunk.orig_pos:
378
orig_line = orig_lines.next()
381
for hunk_line in hunk.lines:
382
seen_patch.append(str(hunk_line))
383
if isinstance(hunk_line, InsertLine):
384
yield hunk_line.contents
385
elif isinstance(hunk_line, (ContextLine, RemoveLine)):
386
orig_line = orig_lines.next()
387
if orig_line != hunk_line.contents:
388
raise PatchConflict(line_no, orig_line, "".join(seen_patch))
389
if isinstance(hunk_line, ContextLine):
392
assert isinstance(hunk_line, RemoveLine)
397
class PatchesTester(unittest.TestCase):
398
def datafile(self, filename):
399
data_path = os.path.join(os.path.dirname(__file__), "testdata",
401
return file(data_path, "rb")
403
def testValidPatchHeader(self):
404
"""Parse a valid patch header"""
405
lines = "--- orig/commands.py\n+++ mod/dommands.py\n".split('\n')
406
(orig, mod) = get_patch_names(lines.__iter__())
407
assert(orig == "orig/commands.py")
408
assert(mod == "mod/dommands.py")
410
def testInvalidPatchHeader(self):
411
"""Parse an invalid patch header"""
412
lines = "-- orig/commands.py\n+++ mod/dommands.py".split('\n')
413
self.assertRaises(MalformedPatchHeader, get_patch_names,
416
def testValidHunkHeader(self):
417
"""Parse a valid hunk header"""
418
header = "@@ -34,11 +50,6 @@\n"
419
hunk = hunk_from_header(header);
420
assert (hunk.orig_pos == 34)
421
assert (hunk.orig_range == 11)
422
assert (hunk.mod_pos == 50)
423
assert (hunk.mod_range == 6)
424
assert (str(hunk) == header)
426
def testValidHunkHeader2(self):
427
"""Parse a tricky, valid hunk header"""
428
header = "@@ -1 +0,0 @@\n"
429
hunk = hunk_from_header(header);
430
assert (hunk.orig_pos == 1)
431
assert (hunk.orig_range == 1)
432
assert (hunk.mod_pos == 0)
433
assert (hunk.mod_range == 0)
434
assert (str(hunk) == header)
436
def makeMalformed(self, header):
437
self.assertRaises(MalformedHunkHeader, hunk_from_header, header)
439
def testInvalidHeader(self):
440
"""Parse an invalid hunk header"""
441
self.makeMalformed(" -34,11 +50,6 \n")
442
self.makeMalformed("@@ +50,6 -34,11 @@\n")
443
self.makeMalformed("@@ -34,11 +50,6 @@")
444
self.makeMalformed("@@ -34.5,11 +50,6 @@\n")
445
self.makeMalformed("@@-34,11 +50,6@@\n")
446
self.makeMalformed("@@ 34,11 50,6 @@\n")
447
self.makeMalformed("@@ -34,11 @@\n")
448
self.makeMalformed("@@ -34,11 +50,6.5 @@\n")
449
self.makeMalformed("@@ -34,11 +50,-6 @@\n")
451
def lineThing(self,text, type):
452
line = parse_line(text)
453
assert(isinstance(line, type))
454
assert(str(line)==text)
456
def makeMalformedLine(self, text):
457
self.assertRaises(MalformedLine, parse_line, text)
459
def testValidLine(self):
460
"""Parse a valid hunk line"""
461
self.lineThing(" hello\n", ContextLine)
462
self.lineThing("+hello\n", InsertLine)
463
self.lineThing("-hello\n", RemoveLine)
465
def testMalformedLine(self):
466
"""Parse invalid valid hunk lines"""
467
self.makeMalformedLine("hello\n")
469
def compare_parsed(self, patchtext):
470
lines = patchtext.splitlines(True)
471
patch = parse_patch(lines.__iter__())
473
i = difference_index(patchtext, pstr)
475
print "%i: \"%s\" != \"%s\"" % (i, patchtext[i], pstr[i])
476
self.assertEqual (patchtext, str(patch))
479
"""Test parsing a whole patch"""
480
patchtext = """--- orig/commands.py
482
@@ -1337,7 +1337,8 @@
484
def set_title(self, command=None):
486
- version = self.tree.tree_version.nonarch
487
+ version = pylon.alias_or_version(self.tree.tree_version, self.tree,
490
version = "[no version]"
492
@@ -1983,7 +1984,11 @@
494
if len(new_merges) > 0:
495
if cmdutil.prompt("Log for merge"):
496
- mergestuff = cmdutil.log_for_merge(tree, comp_version)
497
+ if cmdutil.prompt("changelog for merge"):
498
+ mergestuff = "Patches applied:\\n"
499
+ mergestuff += pylon.changelog_for_merge(new_merges)
501
+ mergestuff = cmdutil.log_for_merge(tree, comp_version)
502
log.description += mergestuff
506
self.compare_parsed(patchtext)
509
"""Handle patches missing half the position, range tuple"""
511
"""--- orig/__init__.py
514
__docformat__ = "restructuredtext en"
515
+__doc__ = An alternate Arch commandline interface
517
self.compare_parsed(patchtext)
521
def testLineLookup(self):
523
"""Make sure we can accurately look up mod line from orig"""
524
patch = parse_patch(self.datafile("diff"))
525
orig = list(self.datafile("orig"))
526
mod = list(self.datafile("mod"))
528
for i in range(len(orig)):
529
mod_pos = patch.pos_in_mod(i)
531
removals.append(orig[i])
533
assert(mod[mod_pos]==orig[i])
534
rem_iter = removals.__iter__()
535
for hunk in patch.hunks:
536
for line in hunk.lines:
537
if isinstance(line, RemoveLine):
538
next = rem_iter.next()
539
if line.contents != next:
540
sys.stdout.write(" orig:%spatch:%s" % (next,
542
assert(line.contents == next)
543
self.assertRaises(StopIteration, rem_iter.next)
545
def testFirstLineRenumber(self):
546
"""Make sure we handle lines at the beginning of the hunk"""
547
patch = parse_patch(self.datafile("insert_top.patch"))
548
assert (patch.pos_in_mod(0)==1)
551
patchesTestSuite = unittest.makeSuite(PatchesTester,'test')
552
runner = unittest.TextTestRunner(verbosity=0)
553
return runner.run(patchesTestSuite)
556
if __name__ == "__main__":
558
# arch-tag: d1541a25-eac5-4de9-a476-08a7cecd5683