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
if line.startswith('#'):
307
if line.startswith('==='):
309
elif line.startswith('--- '):
310
if len(saved_lines) > 0:
313
saved_lines.append(line)
314
if len(saved_lines) > 0:
318
def iter_lines_handle_nl(iter_lines):
320
Iterates through lines, ensuring that lines that originally had no
321
terminating \n are produced without one. This transformation may be
322
applied at any point up until hunk line parsing, and is safe to apply
326
for line in iter_lines:
328
assert last_line.endswith('\n')
329
last_line = last_line[:-1]
331
if last_line is not None:
334
if last_line is not None:
338
def parse_patches(iter_lines):
339
iter_lines = iter_lines_handle_nl(iter_lines)
340
return [parse_patch(f.__iter__()) for f in iter_file_patch(iter_lines)]
343
def difference_index(atext, btext):
344
"""Find the indext of the first character that differs betweeen two texts
346
:param atext: The first text
348
:param btext: The second text
350
:return: The index, or None if there are no differences within the range
351
:rtype: int or NoneType
354
if len(btext) < length:
356
for i in range(length):
357
if atext[i] != btext[i]:
361
class PatchConflict(Exception):
362
def __init__(self, line_no, orig_line, patch_line):
363
orig = orig_line.rstrip('\n')
364
patch = str(patch_line).rstrip('\n')
365
msg = 'Text contents mismatch at line %d. Original has "%s",'\
366
' but patch says it should be "%s"' % (line_no, orig, patch)
367
Exception.__init__(self, msg)
370
def iter_patched(orig_lines, patch_lines):
371
"""Iterate through a series of lines with a patch applied.
372
This handles a single file, and does exact, not fuzzy patching.
374
if orig_lines is not None:
375
orig_lines = orig_lines.__iter__()
377
patch_lines = iter_lines_handle_nl(patch_lines.__iter__())
378
get_patch_names(patch_lines)
380
for hunk in iter_hunks(patch_lines):
381
while line_no < hunk.orig_pos:
382
orig_line = orig_lines.next()
385
for hunk_line in hunk.lines:
386
seen_patch.append(str(hunk_line))
387
if isinstance(hunk_line, InsertLine):
388
yield hunk_line.contents
389
elif isinstance(hunk_line, (ContextLine, RemoveLine)):
390
orig_line = orig_lines.next()
391
if orig_line != hunk_line.contents:
392
raise PatchConflict(line_no, orig_line, "".join(seen_patch))
393
if isinstance(hunk_line, ContextLine):
396
assert isinstance(hunk_line, RemoveLine)
401
class PatchesTester(unittest.TestCase):
402
def datafile(self, filename):
403
data_path = os.path.join(os.path.dirname(__file__), "testdata",
405
return file(data_path, "rb")
407
def testValidPatchHeader(self):
408
"""Parse a valid patch header"""
409
lines = "--- orig/commands.py\n+++ mod/dommands.py\n".split('\n')
410
(orig, mod) = get_patch_names(lines.__iter__())
411
assert(orig == "orig/commands.py")
412
assert(mod == "mod/dommands.py")
414
def testInvalidPatchHeader(self):
415
"""Parse an invalid patch header"""
416
lines = "-- orig/commands.py\n+++ mod/dommands.py".split('\n')
417
self.assertRaises(MalformedPatchHeader, get_patch_names,
420
def testValidHunkHeader(self):
421
"""Parse a valid hunk header"""
422
header = "@@ -34,11 +50,6 @@\n"
423
hunk = hunk_from_header(header);
424
assert (hunk.orig_pos == 34)
425
assert (hunk.orig_range == 11)
426
assert (hunk.mod_pos == 50)
427
assert (hunk.mod_range == 6)
428
assert (str(hunk) == header)
430
def testValidHunkHeader2(self):
431
"""Parse a tricky, valid hunk header"""
432
header = "@@ -1 +0,0 @@\n"
433
hunk = hunk_from_header(header);
434
assert (hunk.orig_pos == 1)
435
assert (hunk.orig_range == 1)
436
assert (hunk.mod_pos == 0)
437
assert (hunk.mod_range == 0)
438
assert (str(hunk) == header)
440
def makeMalformed(self, header):
441
self.assertRaises(MalformedHunkHeader, hunk_from_header, header)
443
def testInvalidHeader(self):
444
"""Parse an invalid hunk header"""
445
self.makeMalformed(" -34,11 +50,6 \n")
446
self.makeMalformed("@@ +50,6 -34,11 @@\n")
447
self.makeMalformed("@@ -34,11 +50,6 @@")
448
self.makeMalformed("@@ -34.5,11 +50,6 @@\n")
449
self.makeMalformed("@@-34,11 +50,6@@\n")
450
self.makeMalformed("@@ 34,11 50,6 @@\n")
451
self.makeMalformed("@@ -34,11 @@\n")
452
self.makeMalformed("@@ -34,11 +50,6.5 @@\n")
453
self.makeMalformed("@@ -34,11 +50,-6 @@\n")
455
def lineThing(self,text, type):
456
line = parse_line(text)
457
assert(isinstance(line, type))
458
assert(str(line)==text)
460
def makeMalformedLine(self, text):
461
self.assertRaises(MalformedLine, parse_line, text)
463
def testValidLine(self):
464
"""Parse a valid hunk line"""
465
self.lineThing(" hello\n", ContextLine)
466
self.lineThing("+hello\n", InsertLine)
467
self.lineThing("-hello\n", RemoveLine)
469
def testMalformedLine(self):
470
"""Parse invalid valid hunk lines"""
471
self.makeMalformedLine("hello\n")
473
def compare_parsed(self, patchtext):
474
lines = patchtext.splitlines(True)
475
patch = parse_patch(lines.__iter__())
477
i = difference_index(patchtext, pstr)
479
print "%i: \"%s\" != \"%s\"" % (i, patchtext[i], pstr[i])
480
self.assertEqual (patchtext, str(patch))
483
"""Test parsing a whole patch"""
484
patchtext = """--- orig/commands.py
486
@@ -1337,7 +1337,8 @@
488
def set_title(self, command=None):
490
- version = self.tree.tree_version.nonarch
491
+ version = pylon.alias_or_version(self.tree.tree_version, self.tree,
494
version = "[no version]"
496
@@ -1983,7 +1984,11 @@
498
if len(new_merges) > 0:
499
if cmdutil.prompt("Log for merge"):
500
- mergestuff = cmdutil.log_for_merge(tree, comp_version)
501
+ if cmdutil.prompt("changelog for merge"):
502
+ mergestuff = "Patches applied:\\n"
503
+ mergestuff += pylon.changelog_for_merge(new_merges)
505
+ mergestuff = cmdutil.log_for_merge(tree, comp_version)
506
log.description += mergestuff
510
self.compare_parsed(patchtext)
513
"""Handle patches missing half the position, range tuple"""
515
"""--- orig/__init__.py
518
__docformat__ = "restructuredtext en"
519
+__doc__ = An alternate Arch commandline interface
521
self.compare_parsed(patchtext)
525
def testLineLookup(self):
527
"""Make sure we can accurately look up mod line from orig"""
528
patch = parse_patch(self.datafile("diff"))
529
orig = list(self.datafile("orig"))
530
mod = list(self.datafile("mod"))
532
for i in range(len(orig)):
533
mod_pos = patch.pos_in_mod(i)
535
removals.append(orig[i])
537
assert(mod[mod_pos]==orig[i])
538
rem_iter = removals.__iter__()
539
for hunk in patch.hunks:
540
for line in hunk.lines:
541
if isinstance(line, RemoveLine):
542
next = rem_iter.next()
543
if line.contents != next:
544
sys.stdout.write(" orig:%spatch:%s" % (next,
546
assert(line.contents == next)
547
self.assertRaises(StopIteration, rem_iter.next)
549
def testFirstLineRenumber(self):
550
"""Make sure we handle lines at the beginning of the hunk"""
551
patch = parse_patch(self.datafile("insert_top.patch"))
552
assert (patch.pos_in_mod(0)==1)
555
patchesTestSuite = unittest.makeSuite(PatchesTester,'test')
556
runner = unittest.TextTestRunner(verbosity=0)
557
return runner.run(patchesTestSuite)
560
if __name__ == "__main__":
562
# arch-tag: d1541a25-eac5-4de9-a476-08a7cecd5683