xref: /openbmc/openbmc/poky/meta/lib/patchtest/tests/base.py (revision 8460358c3d24c71d9d38fd126c745854a6301564)
1# Base class to be used by all test cases defined in the suite
2#
3# Copyright (C) 2016 Intel Corporation
4#
5# SPDX-License-Identifier: GPL-2.0-only
6
7import unittest
8import logging
9import json
10import unidiff
11from patchtest_parser import PatchtestParser
12import mailbox
13import patchtest_patterns
14import collections
15import sys
16import os
17import re
18
19logger = logging.getLogger("patchtest")
20debug = logger.debug
21info = logger.info
22warn = logger.warn
23error = logger.error
24
25Commit = collections.namedtuple(
26    "Commit", ["author", "subject", "commit_message", "shortlog", "payload"]
27)
28
29Commit = collections.namedtuple('Commit', ['author', 'subject', 'commit_message', 'shortlog', 'payload'])
30
31class PatchtestOEError(Exception):
32    """Exception for handling patchtest-oe errors"""
33    def __init__(self, message, exitcode=1):
34        super().__init__(message)
35        self.exitcode = exitcode
36
37class Base(unittest.TestCase):
38    # if unit test fails, fail message will throw at least the following JSON: {"id": <testid>}
39
40    @staticmethod
41    def msg_to_commit(msg):
42        payload = msg.get_payload()
43        return Commit(subject=msg['subject'].replace('\n', ' ').replace('  ', ' '),
44                      author=msg.get('From'),
45                      shortlog=Base.shortlog(msg['subject']),
46                      commit_message=Base.commit_message(payload),
47                      payload=payload)
48
49    @staticmethod
50    def commit_message(payload):
51        commit_message = payload.__str__()
52        match = patchtest_patterns.endcommit_messages_regex.search(payload)
53        if match:
54            commit_message = payload[:match.start()]
55        return commit_message
56
57    @staticmethod
58    def shortlog(shlog):
59        # remove possible prefix (between brackets) before colon
60        start = shlog.find(']', 0, shlog.find(':'))
61        # remove also newlines and spaces at both sides
62        return shlog[start + 1:].replace('\n', '').strip()
63
64    @classmethod
65    def setUpClass(cls):
66
67        # General objects: mailbox.mbox and patchset
68        cls.mbox = mailbox.mbox(PatchtestParser.repo.patch.path)
69
70        # Patch may be malformed, so try parsing it
71        cls.unidiff_parse_error = ''
72        cls.patchset = None
73        try:
74            cls.patchset = unidiff.PatchSet.from_filename(
75                PatchtestParser.repo.patch.path, encoding="UTF-8"
76            )
77        except unidiff.UnidiffParseError as upe:
78            cls.patchset = []
79            cls.unidiff_parse_error = str(upe)
80
81        # Easy to iterate list of commits
82        cls.commits = []
83        for msg in cls.mbox:
84            if msg['subject'] and msg.get_payload():
85                cls.commits.append(Base.msg_to_commit(msg))
86
87        cls.setUpClassLocal()
88
89    @classmethod
90    def tearDownClass(cls):
91        cls.tearDownClassLocal()
92
93    @classmethod
94    def setUpClassLocal(cls):
95        pass
96
97    @classmethod
98    def tearDownClassLocal(cls):
99        pass
100
101    def fail(self, issue, fix=None, commit=None, data=None):
102        """ Convert to a JSON string failure data"""
103        value = {'id': self.id(),
104                 'issue': issue}
105
106        if fix:
107            value['fix'] = fix
108        if commit:
109            value['commit'] = {'subject': commit.subject,
110                               'shortlog': commit.shortlog}
111
112        # extend return value with other useful info
113        if data:
114            value['data'] = data
115
116        return super(Base, self).fail(json.dumps(value))
117
118    def skip(self, issue, data=None):
119        """ Convert the skip string to JSON"""
120        value = {'id': self.id(),
121                 'issue': issue}
122
123        # extend return value with other useful info
124        if data:
125            value['data'] = data
126
127        return super(Base, self).skipTest(json.dumps(value))
128
129    def shortid(self):
130        return self.id().split('.')[-1]
131
132    def __str__(self):
133        return json.dumps({'id': self.id()})
134
135class Metadata(Base):
136    @classmethod
137    def setUpClassLocal(cls):
138        cls.tinfoil = cls.setup_tinfoil()
139
140        # get info about added/modified/remove recipes
141        cls.added, cls.modified, cls.removed = cls.get_metadata_stats(cls.patchset)
142
143    @classmethod
144    def tearDownClassLocal(cls):
145        cls.tinfoil.shutdown()
146
147    @classmethod
148    def setup_tinfoil(cls, config_only=False):
149        """Initialize tinfoil api from bitbake"""
150
151        # import relevant libraries
152        try:
153            scripts_path = os.path.join(PatchtestParser.repodir, "scripts", "lib")
154            if scripts_path not in sys.path:
155                sys.path.insert(0, scripts_path)
156            import scriptpath
157            scriptpath.add_bitbake_lib_path()
158            import bb.tinfoil
159        except ImportError:
160            raise PatchtestOEError('Could not import tinfoil module')
161
162        orig_cwd = os.path.abspath(os.curdir)
163
164        # Load tinfoil
165        tinfoil = None
166        try:
167            builddir = os.environ.get('BUILDDIR')
168            if not builddir:
169                logger.warn('Bitbake environment not loaded?')
170                return tinfoil
171            os.chdir(builddir)
172            tinfoil = bb.tinfoil.Tinfoil()
173            tinfoil.prepare(config_only=config_only)
174        except bb.tinfoil.TinfoilUIException as te:
175            if tinfoil:
176                tinfoil.shutdown()
177            raise PatchtestOEError('Could not prepare properly tinfoil (TinfoilUIException)')
178        except Exception as e:
179            if tinfoil:
180                tinfoil.shutdown()
181            raise e
182        finally:
183            os.chdir(orig_cwd)
184
185        return tinfoil
186
187    @classmethod
188    def get_metadata_stats(cls, patchset):
189        """Get lists of added, modified and removed metadata files"""
190
191        def find_pn(data, path):
192            """Find the PN from data"""
193            pn = None
194            pn_native = None
195            for _path, _pn in data:
196                if path in _path:
197                    if 'native' in _pn:
198                        # store the native PN but look for the non-native one first
199                        pn_native = _pn
200                    else:
201                        pn = _pn
202                        break
203            else:
204                # sent the native PN if found previously
205                if pn_native:
206                    return pn_native
207
208                # on renames (usually upgrades), we need to check (FILE) base names
209                # because the unidiff library does not provided the new filename, just the modified one
210                # and tinfoil datastore, once the patch is merged, will contain the new filename
211                path_basename = path.split('_')[0]
212                for _path, _pn in data:
213                    _path_basename = _path.split('_')[0]
214                    if path_basename == _path_basename:
215                        pn = _pn
216            return pn
217
218        if not cls.tinfoil:
219            cls.tinfoil = cls.setup_tinfoil()
220
221        added_paths, modified_paths, removed_paths = [], [], []
222        added, modified, removed = [], [], []
223
224        # get metadata filename additions, modification and removals
225        for patch in patchset:
226            if patch.path.endswith('.bb') or patch.path.endswith('.bbappend') or patch.path.endswith('.inc'):
227                if patch.is_added_file:
228                    added_paths.append(
229                        os.path.join(
230                            os.path.abspath(PatchtestParser.repodir), patch.path
231                        )
232                    )
233                elif patch.is_modified_file:
234                    modified_paths.append(
235                        os.path.join(
236                            os.path.abspath(PatchtestParser.repodir), patch.path
237                        )
238                    )
239                elif patch.is_removed_file:
240                    removed_paths.append(
241                        os.path.join(
242                            os.path.abspath(PatchtestParser.repodir), patch.path
243                        )
244                    )
245
246        data = cls.tinfoil.cooker.recipecaches[''].pkg_fn.items()
247
248        added = [find_pn(data,path) for path in added_paths]
249        modified = [find_pn(data,path) for path in modified_paths]
250        removed = [find_pn(data,path) for path in removed_paths]
251
252        return [a for a in added if a], [m for m in modified if m], [r for r in removed if r]
253