/brz/remove-bazaar

To get this branch, use:
bzr branch http://gegoxaren.bato24.eu/bzr/brz/remove-bazaar

« back to all changes in this revision

Viewing changes to breezy/patches.py

[merge] robertc's integration, updated tests to check for retcode=3

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright (C) 2005-2010 Aaron Bentley, Canonical Ltd
2
 
# <aaron.bentley@utoronto.ca>
3
 
#
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.
8
 
#
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.
13
 
#
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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
 
 
18
 
from __future__ import absolute_import
19
 
 
20
 
from .errors import (
21
 
    BzrError,
22
 
    )
23
 
 
24
 
import os
25
 
import re
26
 
 
27
 
 
28
 
binary_files_re = b'Binary files (.*) and (.*) differ\n'
29
 
 
30
 
 
31
 
class PatchSyntax(BzrError):
32
 
    """Base class for patch syntax errors."""
33
 
 
34
 
 
35
 
class BinaryFiles(BzrError):
36
 
 
37
 
    _fmt = 'Binary files section encountered.'
38
 
 
39
 
    def __init__(self, orig_name, mod_name):
40
 
        self.orig_name = orig_name
41
 
        self.mod_name = mod_name
42
 
 
43
 
 
44
 
class MalformedPatchHeader(PatchSyntax):
45
 
 
46
 
    _fmt = "Malformed patch header.  %(desc)s\n%(line)r"
47
 
 
48
 
    def __init__(self, desc, line):
49
 
        self.desc = desc
50
 
        self.line = line
51
 
 
52
 
 
53
 
class MalformedLine(PatchSyntax):
54
 
 
55
 
    _fmt = "Malformed line.  %(desc)s\n%(line)r"
56
 
 
57
 
    def __init__(self, desc, line):
58
 
        self.desc = desc
59
 
        self.line = line
60
 
 
61
 
 
62
 
class PatchConflict(BzrError):
63
 
 
64
 
    _fmt = ('Text contents mismatch at line %(line_no)d.  Original has '
65
 
            '"%(orig_line)s", but patch says it should be "%(patch_line)s"')
66
 
 
67
 
    def __init__(self, line_no, orig_line, patch_line):
68
 
        self.line_no = line_no
69
 
        self.orig_line = orig_line.rstrip('\n')
70
 
        self.patch_line = patch_line.rstrip('\n')
71
 
 
72
 
 
73
 
class MalformedHunkHeader(PatchSyntax):
74
 
 
75
 
    _fmt = "Malformed hunk header.  %(desc)s\n%(line)r"
76
 
 
77
 
    def __init__(self, desc, line):
78
 
        self.desc = desc
79
 
        self.line = line
80
 
 
81
 
 
82
 
def get_patch_names(iter_lines):
83
 
    line = next(iter_lines)
84
 
    try:
85
 
        match = re.match(binary_files_re, line)
86
 
        if match is not None:
87
 
            raise BinaryFiles(match.group(1), match.group(2))
88
 
        if not line.startswith(b"--- "):
89
 
            raise MalformedPatchHeader("No orig name", line)
90
 
        else:
91
 
            orig_name = line[4:].rstrip(b"\n")
92
 
    except StopIteration:
93
 
        raise MalformedPatchHeader("No orig line", "")
94
 
    try:
95
 
        line = next(iter_lines)
96
 
        if not line.startswith(b"+++ "):
97
 
            raise PatchSyntax("No mod name")
98
 
        else:
99
 
            mod_name = line[4:].rstrip(b"\n")
100
 
    except StopIteration:
101
 
        raise MalformedPatchHeader("No mod line", "")
102
 
    return (orig_name, mod_name)
103
 
 
104
 
 
105
 
def parse_range(textrange):
106
 
    """Parse a patch range, handling the "1" special-case
107
 
 
108
 
    :param textrange: The text to parse
109
 
    :type textrange: str
110
 
    :return: the position and range, as a tuple
111
 
    :rtype: (int, int)
112
 
    """
113
 
    tmp = textrange.split(b',')
114
 
    if len(tmp) == 1:
115
 
        pos = tmp[0]
116
 
        range = b"1"
117
 
    else:
118
 
        (pos, range) = tmp
119
 
    pos = int(pos)
120
 
    range = int(range)
121
 
    return (pos, range)
122
 
 
123
 
 
124
 
def hunk_from_header(line):
125
 
    import re
126
 
    matches = re.match(br'\@\@ ([^@]*) \@\@( (.*))?\n', line)
127
 
    if matches is None:
128
 
        raise MalformedHunkHeader("Does not match format.", line)
129
 
    try:
130
 
        (orig, mod) = matches.group(1).split(b" ")
131
 
    except (ValueError, IndexError) as e:
132
 
        raise MalformedHunkHeader(str(e), line)
133
 
    if not orig.startswith(b'-') or not mod.startswith(b'+'):
134
 
        raise MalformedHunkHeader("Positions don't start with + or -.", line)
135
 
    try:
136
 
        (orig_pos, orig_range) = parse_range(orig[1:])
137
 
        (mod_pos, mod_range) = parse_range(mod[1:])
138
 
    except (ValueError, IndexError) as e:
139
 
        raise MalformedHunkHeader(str(e), line)
140
 
    if mod_range < 0 or orig_range < 0:
141
 
        raise MalformedHunkHeader("Hunk range is negative", line)
142
 
    tail = matches.group(3)
143
 
    return Hunk(orig_pos, orig_range, mod_pos, mod_range, tail)
144
 
 
145
 
 
146
 
class HunkLine(object):
147
 
 
148
 
    def __init__(self, contents):
149
 
        self.contents = contents
150
 
 
151
 
    def get_str(self, leadchar):
152
 
        if self.contents == b"\n" and leadchar == b" " and False:
153
 
            return b"\n"
154
 
        if not self.contents.endswith(b'\n'):
155
 
            terminator = b'\n' + NO_NL
156
 
        else:
157
 
            terminator = b''
158
 
        return leadchar + self.contents + terminator
159
 
 
160
 
    def as_bytes(self):
161
 
        raise NotImplementedError
162
 
 
163
 
 
164
 
class ContextLine(HunkLine):
165
 
 
166
 
    def __init__(self, contents):
167
 
        HunkLine.__init__(self, contents)
168
 
 
169
 
    def as_bytes(self):
170
 
        return self.get_str(b" ")
171
 
 
172
 
 
173
 
class InsertLine(HunkLine):
174
 
    def __init__(self, contents):
175
 
        HunkLine.__init__(self, contents)
176
 
 
177
 
    def as_bytes(self):
178
 
        return self.get_str(b"+")
179
 
 
180
 
 
181
 
class RemoveLine(HunkLine):
182
 
    def __init__(self, contents):
183
 
        HunkLine.__init__(self, contents)
184
 
 
185
 
    def as_bytes(self):
186
 
        return self.get_str(b"-")
187
 
 
188
 
 
189
 
NO_NL = b'\\ No newline at end of file\n'
190
 
__pychecker__ = "no-returnvalues"
191
 
 
192
 
 
193
 
def parse_line(line):
194
 
    if line.startswith(b"\n"):
195
 
        return ContextLine(line)
196
 
    elif line.startswith(b" "):
197
 
        return ContextLine(line[1:])
198
 
    elif line.startswith(b"+"):
199
 
        return InsertLine(line[1:])
200
 
    elif line.startswith(b"-"):
201
 
        return RemoveLine(line[1:])
202
 
    else:
203
 
        raise MalformedLine("Unknown line type", line)
204
 
 
205
 
 
206
 
__pychecker__ = ""
207
 
 
208
 
 
209
 
class Hunk(object):
210
 
 
211
 
    def __init__(self, orig_pos, orig_range, mod_pos, mod_range, tail=None):
212
 
        self.orig_pos = orig_pos
213
 
        self.orig_range = orig_range
214
 
        self.mod_pos = mod_pos
215
 
        self.mod_range = mod_range
216
 
        self.tail = tail
217
 
        self.lines = []
218
 
 
219
 
    def get_header(self):
220
 
        if self.tail is None:
221
 
            tail_str = b''
222
 
        else:
223
 
            tail_str = b' ' + self.tail
224
 
        return b"@@ -%s +%s @@%s\n" % (self.range_str(self.orig_pos,
225
 
                                                      self.orig_range),
226
 
                                       self.range_str(self.mod_pos,
227
 
                                                      self.mod_range),
228
 
                                       tail_str)
229
 
 
230
 
    def range_str(self, pos, range):
231
 
        """Return a file range, special-casing for 1-line files.
232
 
 
233
 
        :param pos: The position in the file
234
 
        :type pos: int
235
 
        :range: The range in the file
236
 
        :type range: int
237
 
        :return: a string in the format 1,4 except when range == pos == 1
238
 
        """
239
 
        if range == 1:
240
 
            return b"%i" % pos
241
 
        else:
242
 
            return b"%i,%i" % (pos, range)
243
 
 
244
 
    def as_bytes(self):
245
 
        lines = [self.get_header()]
246
 
        for line in self.lines:
247
 
            lines.append(line.as_bytes())
248
 
        return b"".join(lines)
249
 
 
250
 
    __bytes__ = as_bytes
251
 
 
252
 
    def shift_to_mod(self, pos):
253
 
        if pos < self.orig_pos - 1:
254
 
            return 0
255
 
        elif pos > self.orig_pos + self.orig_range:
256
 
            return self.mod_range - self.orig_range
257
 
        else:
258
 
            return self.shift_to_mod_lines(pos)
259
 
 
260
 
    def shift_to_mod_lines(self, pos):
261
 
        position = self.orig_pos - 1
262
 
        shift = 0
263
 
        for line in self.lines:
264
 
            if isinstance(line, InsertLine):
265
 
                shift += 1
266
 
            elif isinstance(line, RemoveLine):
267
 
                if position == pos:
268
 
                    return None
269
 
                shift -= 1
270
 
                position += 1
271
 
            elif isinstance(line, ContextLine):
272
 
                position += 1
273
 
            if position > pos:
274
 
                break
275
 
        return shift
276
 
 
277
 
 
278
 
def iter_hunks(iter_lines, allow_dirty=False):
279
 
    '''
280
 
    :arg iter_lines: iterable of lines to parse for hunks
281
 
    :kwarg allow_dirty: If True, when we encounter something that is not
282
 
        a hunk header when we're looking for one, assume the rest of the lines
283
 
        are not part of the patch (comments or other junk).  Default False
284
 
    '''
285
 
    hunk = None
286
 
    for line in iter_lines:
287
 
        if line == b"\n":
288
 
            if hunk is not None:
289
 
                yield hunk
290
 
                hunk = None
291
 
            continue
292
 
        if hunk is not None:
293
 
            yield hunk
294
 
        try:
295
 
            hunk = hunk_from_header(line)
296
 
        except MalformedHunkHeader:
297
 
            if allow_dirty:
298
 
                # If the line isn't a hunk header, then we've reached the end
299
 
                # of this patch and there's "junk" at the end.  Ignore the
300
 
                # rest of this patch.
301
 
                return
302
 
            raise
303
 
        orig_size = 0
304
 
        mod_size = 0
305
 
        while orig_size < hunk.orig_range or mod_size < hunk.mod_range:
306
 
            hunk_line = parse_line(next(iter_lines))
307
 
            hunk.lines.append(hunk_line)
308
 
            if isinstance(hunk_line, (RemoveLine, ContextLine)):
309
 
                orig_size += 1
310
 
            if isinstance(hunk_line, (InsertLine, ContextLine)):
311
 
                mod_size += 1
312
 
    if hunk is not None:
313
 
        yield hunk
314
 
 
315
 
 
316
 
class BinaryPatch(object):
317
 
 
318
 
    def __init__(self, oldname, newname):
319
 
        self.oldname = oldname
320
 
        self.newname = newname
321
 
 
322
 
    def as_bytes(self):
323
 
        return b'Binary files %s and %s differ\n' % (self.oldname, self.newname)
324
 
 
325
 
 
326
 
class Patch(BinaryPatch):
327
 
 
328
 
    def __init__(self, oldname, newname):
329
 
        BinaryPatch.__init__(self, oldname, newname)
330
 
        self.hunks = []
331
 
 
332
 
    def as_bytes(self):
333
 
        ret = self.get_header()
334
 
        ret += b"".join([h.as_bytes() for h in self.hunks])
335
 
        return ret
336
 
 
337
 
    def get_header(self):
338
 
        return b"--- %s\n+++ %s\n" % (self.oldname, self.newname)
339
 
 
340
 
    def stats_values(self):
341
 
        """Calculate the number of inserts and removes."""
342
 
        removes = 0
343
 
        inserts = 0
344
 
        for hunk in self.hunks:
345
 
            for line in hunk.lines:
346
 
                if isinstance(line, InsertLine):
347
 
                    inserts += 1
348
 
                elif isinstance(line, RemoveLine):
349
 
                    removes += 1
350
 
        return (inserts, removes, len(self.hunks))
351
 
 
352
 
    def stats_str(self):
353
 
        """Return a string of patch statistics"""
354
 
        return "%i inserts, %i removes in %i hunks" % \
355
 
            self.stats_values()
356
 
 
357
 
    def pos_in_mod(self, position):
358
 
        newpos = position
359
 
        for hunk in self.hunks:
360
 
            shift = hunk.shift_to_mod(position)
361
 
            if shift is None:
362
 
                return None
363
 
            newpos += shift
364
 
        return newpos
365
 
 
366
 
    def iter_inserted(self):
367
 
        """Iteraties through inserted lines
368
 
 
369
 
        :return: Pair of line number, line
370
 
        :rtype: iterator of (int, InsertLine)
371
 
        """
372
 
        for hunk in self.hunks:
373
 
            pos = hunk.mod_pos - 1
374
 
            for line in hunk.lines:
375
 
                if isinstance(line, InsertLine):
376
 
                    yield (pos, line)
377
 
                    pos += 1
378
 
                if isinstance(line, ContextLine):
379
 
                    pos += 1
380
 
 
381
 
 
382
 
def parse_patch(iter_lines, allow_dirty=False):
383
 
    '''
384
 
    :arg iter_lines: iterable of lines to parse
385
 
    :kwarg allow_dirty: If True, allow the patch to have trailing junk.
386
 
        Default False
387
 
    '''
388
 
    iter_lines = iter_lines_handle_nl(iter_lines)
389
 
    try:
390
 
        (orig_name, mod_name) = get_patch_names(iter_lines)
391
 
    except BinaryFiles as e:
392
 
        return BinaryPatch(e.orig_name, e.mod_name)
393
 
    else:
394
 
        patch = Patch(orig_name, mod_name)
395
 
        for hunk in iter_hunks(iter_lines, allow_dirty):
396
 
            patch.hunks.append(hunk)
397
 
        return patch
398
 
 
399
 
 
400
 
def iter_file_patch(iter_lines, allow_dirty=False, keep_dirty=False):
401
 
    '''
402
 
    :arg iter_lines: iterable of lines to parse for patches
403
 
    :kwarg allow_dirty: If True, allow comments and other non-patch text
404
 
        before the first patch.  Note that the algorithm here can only find
405
 
        such text before any patches have been found.  Comments after the
406
 
        first patch are stripped away in iter_hunks() if it is also passed
407
 
        allow_dirty=True.  Default False.
408
 
    '''
409
 
    # FIXME: Docstring is not quite true.  We allow certain comments no
410
 
    # matter what, If they startwith '===', '***', or '#' Someone should
411
 
    # reexamine this logic and decide if we should include those in
412
 
    # allow_dirty or restrict those to only being before the patch is found
413
 
    # (as allow_dirty does).
414
 
    regex = re.compile(binary_files_re)
415
 
    saved_lines = []
416
 
    dirty_head = []
417
 
    orig_range = 0
418
 
    beginning = True
419
 
 
420
 
    for line in iter_lines:
421
 
        if line.startswith(b'=== '):
422
 
            if len(saved_lines) > 0:
423
 
                if keep_dirty and len(dirty_head) > 0:
424
 
                    yield {'saved_lines': saved_lines,
425
 
                           'dirty_head': dirty_head}
426
 
                    dirty_head = []
427
 
                else:
428
 
                    yield saved_lines
429
 
                saved_lines = []
430
 
            dirty_head.append(line)
431
 
            continue
432
 
        if line.startswith(b'*** '):
433
 
            continue
434
 
        if line.startswith(b'#'):
435
 
            continue
436
 
        elif orig_range > 0:
437
 
            if line.startswith(b'-') or line.startswith(b' '):
438
 
                orig_range -= 1
439
 
        elif line.startswith(b'--- ') or regex.match(line):
440
 
            if allow_dirty and beginning:
441
 
                # Patches can have "junk" at the beginning
442
 
                # Stripping junk from the end of patches is handled when we
443
 
                # parse the patch
444
 
                beginning = False
445
 
            elif len(saved_lines) > 0:
446
 
                if keep_dirty and len(dirty_head) > 0:
447
 
                    yield {'saved_lines': saved_lines,
448
 
                           'dirty_head': dirty_head}
449
 
                    dirty_head = []
450
 
                else:
451
 
                    yield saved_lines
452
 
            saved_lines = []
453
 
        elif line.startswith(b'@@'):
454
 
            hunk = hunk_from_header(line)
455
 
            orig_range = hunk.orig_range
456
 
        saved_lines.append(line)
457
 
    if len(saved_lines) > 0:
458
 
        if keep_dirty and len(dirty_head) > 0:
459
 
            yield {'saved_lines': saved_lines,
460
 
                   'dirty_head': dirty_head}
461
 
        else:
462
 
            yield saved_lines
463
 
 
464
 
 
465
 
def iter_lines_handle_nl(iter_lines):
466
 
    """
467
 
    Iterates through lines, ensuring that lines that originally had no
468
 
    terminating \n are produced without one.  This transformation may be
469
 
    applied at any point up until hunk line parsing, and is safe to apply
470
 
    repeatedly.
471
 
    """
472
 
    last_line = None
473
 
    for line in iter_lines:
474
 
        if line == NO_NL:
475
 
            if not last_line.endswith(b'\n'):
476
 
                raise AssertionError()
477
 
            last_line = last_line[:-1]
478
 
            line = None
479
 
        if last_line is not None:
480
 
            yield last_line
481
 
        last_line = line
482
 
    if last_line is not None:
483
 
        yield last_line
484
 
 
485
 
 
486
 
def parse_patches(iter_lines, allow_dirty=False, keep_dirty=False):
487
 
    '''
488
 
    :arg iter_lines: iterable of lines to parse for patches
489
 
    :kwarg allow_dirty: If True, allow text that's not part of the patch at
490
 
        selected places.  This includes comments before and after a patch
491
 
        for instance.  Default False.
492
 
    :kwarg keep_dirty: If True, returns a dict of patches with dirty headers.
493
 
        Default False.
494
 
    '''
495
 
    for patch_lines in iter_file_patch(iter_lines, allow_dirty, keep_dirty):
496
 
        if 'dirty_head' in patch_lines:
497
 
            yield ({'patch': parse_patch(patch_lines['saved_lines'], allow_dirty),
498
 
                    'dirty_head': patch_lines['dirty_head']})
499
 
        else:
500
 
            yield parse_patch(patch_lines, allow_dirty)
501
 
 
502
 
 
503
 
def difference_index(atext, btext):
504
 
    """Find the indext of the first character that differs between two texts
505
 
 
506
 
    :param atext: The first text
507
 
    :type atext: str
508
 
    :param btext: The second text
509
 
    :type str: str
510
 
    :return: The index, or None if there are no differences within the range
511
 
    :rtype: int or NoneType
512
 
    """
513
 
    length = len(atext)
514
 
    if len(btext) < length:
515
 
        length = len(btext)
516
 
    for i in range(length):
517
 
        if atext[i] != btext[i]:
518
 
            return i
519
 
    return None
520
 
 
521
 
 
522
 
def iter_patched(orig_lines, patch_lines):
523
 
    """Iterate through a series of lines with a patch applied.
524
 
    This handles a single file, and does exact, not fuzzy patching.
525
 
    """
526
 
    patch_lines = iter_lines_handle_nl(iter(patch_lines))
527
 
    get_patch_names(patch_lines)
528
 
    return iter_patched_from_hunks(orig_lines, iter_hunks(patch_lines))
529
 
 
530
 
 
531
 
def iter_patched_from_hunks(orig_lines, hunks):
532
 
    """Iterate through a series of lines with a patch applied.
533
 
    This handles a single file, and does exact, not fuzzy patching.
534
 
 
535
 
    :param orig_lines: The unpatched lines.
536
 
    :param hunks: An iterable of Hunk instances.
537
 
    """
538
 
    seen_patch = []
539
 
    line_no = 1
540
 
    if orig_lines is not None:
541
 
        orig_lines = iter(orig_lines)
542
 
    for hunk in hunks:
543
 
        while line_no < hunk.orig_pos:
544
 
            orig_line = next(orig_lines)
545
 
            yield orig_line
546
 
            line_no += 1
547
 
        for hunk_line in hunk.lines:
548
 
            seen_patch.append(hunk_line.contents)
549
 
            if isinstance(hunk_line, InsertLine):
550
 
                yield hunk_line.contents
551
 
            elif isinstance(hunk_line, (ContextLine, RemoveLine)):
552
 
                orig_line = next(orig_lines)
553
 
                if orig_line != hunk_line.contents:
554
 
                    raise PatchConflict(line_no, orig_line,
555
 
                                        b''.join(seen_patch))
556
 
                if isinstance(hunk_line, ContextLine):
557
 
                    yield orig_line
558
 
                else:
559
 
                    if not isinstance(hunk_line, RemoveLine):
560
 
                        raise AssertionError(hunk_line)
561
 
                line_no += 1
562
 
    if orig_lines is not None:
563
 
        for line in orig_lines:
564
 
            yield line
565
 
 
566
 
 
567
 
def apply_patches(tt, patches, prefix=1):
568
 
    """Apply patches to a TreeTransform.
569
 
 
570
 
    :param tt: TreeTransform instance
571
 
    :param patches: List of patches
572
 
    :param prefix: Number leading path segments to strip
573
 
    """
574
 
    def strip_prefix(p):
575
 
        return '/'.join(p.split('/')[1:])
576
 
 
577
 
    from breezy.bzr.generate_ids import gen_file_id
578
 
    # TODO(jelmer): Extract and set mode
579
 
    for patch in patches:
580
 
        if patch.oldname == b'/dev/null':
581
 
            trans_id = None
582
 
            orig_contents = b''
583
 
        else:
584
 
            oldname = strip_prefix(patch.oldname.decode())
585
 
            trans_id = tt.trans_id_tree_path(oldname)
586
 
            orig_contents = tt._tree.get_file_text(oldname)
587
 
            tt.delete_contents(trans_id)
588
 
 
589
 
        if patch.newname != b'/dev/null':
590
 
            newname = strip_prefix(patch.newname.decode())
591
 
            new_contents = iter_patched_from_hunks(
592
 
                orig_contents.splitlines(True), patch.hunks)
593
 
            if trans_id is None:
594
 
                parts = os.path.split(newname)
595
 
                trans_id = tt.root
596
 
                for part in parts[1:-1]:
597
 
                    trans_id = tt.new_directory(part, trans_id)
598
 
                tt.new_file(
599
 
                    parts[-1], trans_id, new_contents,
600
 
                    file_id=gen_file_id(newname))
601
 
            else:
602
 
                tt.create_file(new_contents, trans_id)
603
 
 
604
 
 
605
 
class AppliedPatches(object):
606
 
    """Context that provides access to a tree with patches applied.
607
 
    """
608
 
 
609
 
    def __init__(self, tree, patches, prefix=1):
610
 
        self.tree = tree
611
 
        self.patches = patches
612
 
        self.prefix = prefix
613
 
 
614
 
    def __enter__(self):
615
 
        from .transform import TransformPreview
616
 
        self._tt = TransformPreview(self.tree)
617
 
        apply_patches(self._tt, self.patches, prefix=self.prefix)
618
 
        return self._tt.get_preview_tree()
619
 
 
620
 
    def __exit__(self, exc_type, exc_value, exc_tb):
621
 
        self._tt.finalize()
622
 
        return False