# vim: set fileencoding=utf-8 : # # (C) 2011,2015,2017 Guido Günther # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, please see # """Handle Patches and Patch Series""" import collections import os import re import tempfile from gbp.errors import GbpError from gbp.git.repository import GitRepository VALID_DEP3_ENDS = re.compile(r'(?:---|\*\*\*|Index:)[ \t][^ \t]|^diff -|^---') class Patch(object): """ A patch in a L{PatchSeries} @ivar path: path to the patch @type path: string @ivar topic: the topic of the patch (the directory component) @type topic: string @ivar strip: path components to strip (think patch -p) @type strip: integer @ivar info: Information retrieved from a RFC822 style patch header @type info: C{dict} with C{str} keys and values @ivar long_desc: the long description of the patch """ patch_exts = ['diff', 'patch'] def __init__(self, path, topic=None, strip=None): self.path = path self.topic = topic self.strip = strip self.info = None self.long_desc = None def __repr__(self): repr = ">> p = Patch('debian/patches/foo.patch') >>> p._get_subject_from_filename() 'foo' >>> Patch('foo.patch')._get_subject_from_filename() 'foo' >>> Patch('debian/patches/foo.bar')._get_subject_from_filename() 'foo.bar' >>> p = Patch('debian/patches/foo') >>> p._get_subject_from_filename() 'foo' >>> Patch('0123-foo.patch')._get_subject_from_filename() 'foo' >>> Patch('0123.patch')._get_subject_from_filename() '0123' >>> Patch('0123-foo-0123.patch')._get_subject_from_filename() 'foo-0123' @return: the patch's subject @rtype: C{str} """ subject = os.path.basename(self.path) # Strip of .diff or .patch from patch name try: base, ext = subject.rsplit('.', 1) if ext in self.patch_exts: subject = base except ValueError: pass # No ext so keep subject as is return subject.lstrip('0123456789-') or subject def _get_info_field(self, key, get_val=None): """ Return the key I{key} from the info C{dict} or use val if I{key} is not a valid key. Fill self.info if not already done. @param key: key to fetch @type key: C{str} @param get_val: alternate value if key is not in info dict @type get_val: C{()->str} """ if self.info is None: self._read_info() if key in self.info: return self.info[key] else: return get_val() if get_val else None @property def subject(self): """ The patch's subject, either from the patch header or from the filename. """ return self._get_info_field('subject', self._get_subject_from_filename) @property def author(self): """The patch's author""" return self._get_info_field('author') @property def email(self): """The patch author's email address""" return self._get_info_field('email') @property def date(self): """The patch's modification time""" return self._get_info_field('date') class Dep3Patch(Patch): def _read_info(self): super(Dep3Patch, self)._read_info() self._check_dep3() def _dep3_get_value(self, lines): value = [] for line in lines: if line.startswith(' '): line = line[1:] if line == '.\n': line = line[1:] else: line = line.split(':', 1)[1].lstrip() value.append(line) return ''.join(value) def _dep3_to_info(self, headers): """ Process the ordered dict generated by check_dep3 and add the information to self.info """ def add_author(lines): if 'email' in self.info and 'author' in self.info: return 0 value = self._dep3_get_value(lines).strip() m = re.match('(.*)<([^<>]+)>', value) if m: value = m.group(1).strip() self.info['email'] = m.group(2) self.info['author'] = value return 1 def add_subject(lines, long_desc, changes): if 'subject' in self.info: return long_desc, 0 value = self._dep3_get_value(lines).lstrip() if '\n' in value: value, description = value.split('\n', 1) # prepend the continuation lines long_desc = description + long_desc self.info['subject'] = value return long_desc, changes + 1 def add_date(lines): if 'date' in self.info: return 0 self.info['date'] = self._dep3_get_value(lines).strip() return 1 changes = 0 pseudo_headers = '' long_desc = self._dep3_get_value(headers.get('long_desc', list())) for k, v in headers.items(): if k in ('author', 'from'): changes += add_author(v) elif k in ('subject', 'description'): long_desc, changes = add_subject(v, long_desc, changes) elif k in ['date']: changes += add_date(v) elif k == 'long_desc': pass elif k in ( 'content-transfer-encoding', 'content-type', 'mime-version', ): # These can appear in `git format-patch` or `gbp pq export` # output. They are not semantically significant: they're # part of the encoding of the patch as an email, rather # than real patch metadata. pass else: pseudo_headers += ''.join(v) changes += 1 if changes: self.long_desc = (pseudo_headers + ('\n' if pseudo_headers else '') + long_desc + self.long_desc) def _check_dep3(self): """ Read DEP3 patch information into a structured form """ if not os.path.exists(self.path): return # patch_header logic from quilt plus any line starting with --- # which is the dep3 stop processing and the git separation between the # header and diff stat headers = collections.OrderedDict() current = 'long_desc' with open(self.path, errors='replace') as file: for line in file: if VALID_DEP3_ENDS.search(line): break if line.startswith(' '): # continuation headers.setdefault(current, list()).append(line) elif ':' in line: current = line.split(':', 1)[0].lower() headers.setdefault(current, list()).append(line) else: # end of paragraph or not a header, read_info already left # everything else in the long_desc, nothing else to do break self._dep3_to_info(headers) class PatchSeries(list): """ A series of L{Patch}es as read from a quilt series file). """ comment_re = re.compile(r'\s+#.*$') level_re = re.compile(r'-p(?P[0-9]+)') @classmethod def read_series_file(cls, seriesfile): """Read a series file into L{Patch} objects""" patch_dir = os.path.dirname(seriesfile) if not os.path.exists(seriesfile): return [] try: s = open(seriesfile) except Exception as err: raise GbpError("Cannot open series file: %s" % err) queue = cls._read_series(s, patch_dir) s.close() return queue @classmethod def _read_series(cls, series, patch_dir): """ Read patch series >>> PatchSeries._read_series(['a/b', ... 'a -p1 # comment', ... 'a/b -p2'], '.') ... # doctest:+NORMALIZE_WHITESPACE [, , ] >>> PatchSeries._read_series(['# foo', 'a/b', '', '# bar'], '.') [] @param series: series of patches in quilt format @type series: iterable of strings @param patch_dir: path prefix to prepend to each patch path @type patch_dir: string """ queue = PatchSeries() for line in series: try: if line[0] in ['\n', '#']: continue except IndexError: continue # ignore empty lines queue.append(cls._parse_line(line, patch_dir)) return queue @staticmethod def _get_topic(line): """ Get the topic from the patch's path >>> PatchSeries._get_topic("a/b c") 'a' >>> PatchSeries._get_topic("asdf") >>> PatchSeries._get_topic("/asdf") """ topic = os.path.dirname(line) if topic in ['', '/']: topic = None return topic @classmethod def _strip_comment(cls, line): """ Strip a comment from a series file line >>> PatchSeries._strip_comment("does/not matter") 'does/not matter' >>> PatchSeries._strip_comment("remove/the # comment # text") 'remove/the' >>> PatchSeries._strip_comment("leave/level/intact -p1 # comment # text") 'leave/level/intact -p1' """ return re.sub(cls.comment_re, '', line) @classmethod def _split_strip(cls, line): """ Separate the -p option from the patch name >>> PatchSeries._split_strip("asdf -p1") ('asdf', 1) >>> PatchSeries._split_strip("a/nice/patch") ('a/nice/patch', None) >>> PatchSeries._split_strip("asdf foo") ('asdf foo', None) """ patch = line strip = None split = line.rsplit(None, 1) if len(split) > 1: m = cls.level_re.match(split[1]) if m: patch = split[0] strip = int(m.group('level')) return (patch, strip) @classmethod def _parse_line(cls, line, patch_dir): """ Parse a single line from a series file >>> PatchSeries._parse_line("a/b -p1", '/tmp/patches') >>> PatchSeries._parse_line("a/b", '.') """ line = cls._strip_comment(line.rstrip()) topic = cls._get_topic(line) (patch, split) = cls._split_strip(line) return Dep3Patch(os.path.join(patch_dir, patch), topic, split)