buildman: Return an error if there are maintainer warnings
[platform/kernel/u-boot.git] / tools / buildman / boards.py
1 # SPDX-License-Identifier: GPL-2.0+
2 # Copyright (c) 2012 The Chromium OS Authors.
3 # Author: Simon Glass <sjg@chromium.org>
4 # Author: Masahiro Yamada <yamada.m@jp.panasonic.com>
5
6 """Maintains a list of boards and allows them to be selected"""
7
8 from collections import OrderedDict
9 import errno
10 import fnmatch
11 import glob
12 import multiprocessing
13 import os
14 import re
15 import sys
16 import tempfile
17 import time
18
19 from buildman import board
20 from buildman import kconfiglib
21
22
23 ### constant variables ###
24 OUTPUT_FILE = 'boards.cfg'
25 CONFIG_DIR = 'configs'
26 SLEEP_TIME = 0.03
27 COMMENT_BLOCK = f'''#
28 # List of boards
29 #   Automatically generated by {__file__}: don't edit
30 #
31 # Status, Arch, CPU, SoC, Vendor, Board, Target, Config, Maintainers
32
33 '''
34
35
36 def try_remove(fname):
37     """Remove a file ignoring 'No such file or directory' error.
38
39     Args:
40         fname (str): Filename to remove
41
42     Raises:
43         OSError: output file exists but could not be removed
44     """
45     try:
46         os.remove(fname)
47     except OSError as exception:
48         # Ignore 'No such file or directory' error
49         if exception.errno != errno.ENOENT:
50             raise
51
52
53 def output_is_new(output):
54     """Check if the output file is up to date.
55
56     Looks at defconfig and Kconfig files to make sure none is newer than the
57     output file. Also ensures that the boards.cfg does not mention any removed
58     boards.
59
60     Args:
61         output (str): Filename to check
62
63     Returns:
64         True if the given output file exists and is newer than any of
65         *_defconfig, MAINTAINERS and Kconfig*.  False otherwise.
66
67     Raises:
68         OSError: output file exists but could not be opened
69     """
70     # pylint: disable=too-many-branches
71     try:
72         ctime = os.path.getctime(output)
73     except OSError as exception:
74         if exception.errno == errno.ENOENT:
75             # return False on 'No such file or directory' error
76             return False
77         raise
78
79     for (dirpath, _, filenames) in os.walk(CONFIG_DIR):
80         for filename in fnmatch.filter(filenames, '*_defconfig'):
81             if fnmatch.fnmatch(filename, '.*'):
82                 continue
83             filepath = os.path.join(dirpath, filename)
84             if ctime < os.path.getctime(filepath):
85                 return False
86
87     for (dirpath, _, filenames) in os.walk('.'):
88         for filename in filenames:
89             if (fnmatch.fnmatch(filename, '*~') or
90                 not fnmatch.fnmatch(filename, 'Kconfig*') and
91                 not filename == 'MAINTAINERS'):
92                 continue
93             filepath = os.path.join(dirpath, filename)
94             if ctime < os.path.getctime(filepath):
95                 return False
96
97     # Detect a board that has been removed since the current board database
98     # was generated
99     with open(output, encoding="utf-8") as inf:
100         for line in inf:
101             if 'Options,' in line:
102                 return False
103             if line[0] == '#' or line == '\n':
104                 continue
105             defconfig = line.split()[6] + '_defconfig'
106             if not os.path.exists(os.path.join(CONFIG_DIR, defconfig)):
107                 return False
108
109     return True
110
111
112 class Expr:
113     """A single regular expression for matching boards to build"""
114
115     def __init__(self, expr):
116         """Set up a new Expr object.
117
118         Args:
119             expr (str): String cotaining regular expression to store
120         """
121         self._expr = expr
122         self._re = re.compile(expr)
123
124     def matches(self, props):
125         """Check if any of the properties match the regular expression.
126
127         Args:
128            props (list of str): List of properties to check
129         Returns:
130            True if any of the properties match the regular expression
131         """
132         for prop in props:
133             if self._re.match(prop):
134                 return True
135         return False
136
137     def __str__(self):
138         return self._expr
139
140 class Term:
141     """A list of expressions each of which must match with properties.
142
143     This provides a list of 'AND' expressions, meaning that each must
144     match the board properties for that board to be built.
145     """
146     def __init__(self):
147         self._expr_list = []
148         self._board_count = 0
149
150     def add_expr(self, expr):
151         """Add an Expr object to the list to check.
152
153         Args:
154             expr (Expr): New Expr object to add to the list of those that must
155                   match for a board to be built.
156         """
157         self._expr_list.append(Expr(expr))
158
159     def __str__(self):
160         """Return some sort of useful string describing the term"""
161         return '&'.join([str(expr) for expr in self._expr_list])
162
163     def matches(self, props):
164         """Check if any of the properties match this term
165
166         Each of the expressions in the term is checked. All must match.
167
168         Args:
169            props (list of str): List of properties to check
170         Returns:
171            True if all of the expressions in the Term match, else False
172         """
173         for expr in self._expr_list:
174             if not expr.matches(props):
175                 return False
176         return True
177
178
179 class KconfigScanner:
180
181     """Kconfig scanner."""
182
183     ### constant variable only used in this class ###
184     _SYMBOL_TABLE = {
185         'arch' : 'SYS_ARCH',
186         'cpu' : 'SYS_CPU',
187         'soc' : 'SYS_SOC',
188         'vendor' : 'SYS_VENDOR',
189         'board' : 'SYS_BOARD',
190         'config' : 'SYS_CONFIG_NAME',
191         # 'target' is added later
192     }
193
194     def __init__(self):
195         """Scan all the Kconfig files and create a Kconfig object."""
196         # Define environment variables referenced from Kconfig
197         os.environ['srctree'] = os.getcwd()
198         os.environ['UBOOTVERSION'] = 'dummy'
199         os.environ['KCONFIG_OBJDIR'] = ''
200         self._tmpfile = None
201         self._conf = kconfiglib.Kconfig(warn=False)
202
203     def __del__(self):
204         """Delete a leftover temporary file before exit.
205
206         The scan() method of this class creates a temporay file and deletes
207         it on success.  If scan() method throws an exception on the way,
208         the temporary file might be left over.  In that case, it should be
209         deleted in this destructor.
210         """
211         if self._tmpfile:
212             try_remove(self._tmpfile)
213
214     def scan(self, defconfig):
215         """Load a defconfig file to obtain board parameters.
216
217         Args:
218             defconfig (str): path to the defconfig file to be processed
219
220         Returns:
221             A dictionary of board parameters.  It has a form of:
222             {
223                 'arch': <arch_name>,
224                 'cpu': <cpu_name>,
225                 'soc': <soc_name>,
226                 'vendor': <vendor_name>,
227                 'board': <board_name>,
228                 'target': <target_name>,
229                 'config': <config_header_name>,
230             }
231         """
232         # strip special prefixes and save it in a temporary file
233         outfd, self._tmpfile = tempfile.mkstemp()
234         with os.fdopen(outfd, 'w') as outf:
235             with open(defconfig, encoding='utf-8') as inf:
236                 for line in inf:
237                     colon = line.find(':CONFIG_')
238                     if colon == -1:
239                         outf.write(line)
240                     else:
241                         outf.write(line[colon + 1:])
242
243         self._conf.load_config(self._tmpfile)
244         try_remove(self._tmpfile)
245         self._tmpfile = None
246
247         params = {}
248
249         # Get the value of CONFIG_SYS_ARCH, CONFIG_SYS_CPU, ... etc.
250         # Set '-' if the value is empty.
251         for key, symbol in list(self._SYMBOL_TABLE.items()):
252             value = self._conf.syms.get(symbol).str_value
253             if value:
254                 params[key] = value
255             else:
256                 params[key] = '-'
257
258         defconfig = os.path.basename(defconfig)
259         params['target'], match, rear = defconfig.partition('_defconfig')
260         assert match and not rear, f'{defconfig} : invalid defconfig'
261
262         # fix-up for aarch64
263         if params['arch'] == 'arm' and params['cpu'] == 'armv8':
264             params['arch'] = 'aarch64'
265
266         return params
267
268
269 class MaintainersDatabase:
270
271     """The database of board status and maintainers.
272
273     Properties:
274         database: dict:
275             key: Board-target name (e.g. 'snow')
276             value: tuple:
277                 str: Board status (e.g. 'Active')
278                 str: List of maintainers, separated by :
279         warnings (list of str): List of warnings due to missing status, etc.
280     """
281
282     def __init__(self):
283         """Create an empty database."""
284         self.database = {}
285         self.warnings = []
286
287     def get_status(self, target):
288         """Return the status of the given board.
289
290         The board status is generally either 'Active' or 'Orphan'.
291         Display a warning message and return '-' if status information
292         is not found.
293
294         Args:
295             target (str): Build-target name
296
297         Returns:
298             str: 'Active', 'Orphan' or '-'.
299         """
300         if not target in self.database:
301             self.warnings.append(f"WARNING: no status info for '{target}'")
302             return '-'
303
304         tmp = self.database[target][0]
305         if tmp.startswith('Maintained'):
306             return 'Active'
307         if tmp.startswith('Supported'):
308             return 'Active'
309         if tmp.startswith('Orphan'):
310             return 'Orphan'
311         self.warnings.append(f"WARNING: {tmp}: unknown status for '{target}'")
312         return '-'
313
314     def get_maintainers(self, target):
315         """Return the maintainers of the given board.
316
317         Args:
318             target (str): Build-target name
319
320         Returns:
321             str: Maintainers of the board.  If the board has two or more
322             maintainers, they are separated with colons.
323         """
324         if not target in self.database:
325             self.warnings.append(f"WARNING: no maintainers for '{target}'")
326             return ''
327
328         return ':'.join(self.database[target][1])
329
330     def parse_file(self, fname):
331         """Parse a MAINTAINERS file.
332
333         Parse a MAINTAINERS file and accumulate board status and maintainers
334         information in the self.database dict.
335
336         Args:
337             fname (str): MAINTAINERS file to be parsed
338         """
339         targets = []
340         maintainers = []
341         status = '-'
342         with open(fname, encoding="utf-8") as inf:
343             for line in inf:
344                 # Check also commented maintainers
345                 if line[:3] == '#M:':
346                     line = line[1:]
347                 tag, rest = line[:2], line[2:].strip()
348                 if tag == 'M:':
349                     maintainers.append(rest)
350                 elif tag == 'F:':
351                     # expand wildcard and filter by 'configs/*_defconfig'
352                     for item in glob.glob(rest):
353                         front, match, rear = item.partition('configs/')
354                         if not front and match:
355                             front, match, rear = rear.rpartition('_defconfig')
356                             if match and not rear:
357                                 targets.append(front)
358                 elif tag == 'S:':
359                     status = rest
360                 elif line == '\n':
361                     for target in targets:
362                         self.database[target] = (status, maintainers)
363                     targets = []
364                     maintainers = []
365                     status = '-'
366         if targets:
367             for target in targets:
368                 self.database[target] = (status, maintainers)
369
370
371 class Boards:
372     """Manage a list of boards."""
373     def __init__(self):
374         # Use a simple list here, sinc OrderedDict requires Python 2.7
375         self._boards = []
376
377     def add_board(self, brd):
378         """Add a new board to the list.
379
380         The board's target member must not already exist in the board list.
381
382         Args:
383             brd (Board): board to add
384         """
385         self._boards.append(brd)
386
387     def read_boards(self, fname):
388         """Read a list of boards from a board file.
389
390         Create a Board object for each and add it to our _boards list.
391
392         Args:
393             fname (str): Filename of boards.cfg file
394         """
395         with open(fname, 'r', encoding='utf-8') as inf:
396             for line in inf:
397                 if line[0] == '#':
398                     continue
399                 fields = line.split()
400                 if not fields:
401                     continue
402                 for upto, field in enumerate(fields):
403                     if field == '-':
404                         fields[upto] = ''
405                 while len(fields) < 8:
406                     fields.append('')
407                 if len(fields) > 8:
408                     fields = fields[:8]
409
410                 brd = board.Board(*fields)
411                 self.add_board(brd)
412
413
414     def get_list(self):
415         """Return a list of available boards.
416
417         Returns:
418             List of Board objects
419         """
420         return self._boards
421
422     def get_dict(self):
423         """Build a dictionary containing all the boards.
424
425         Returns:
426             Dictionary:
427                 key is board.target
428                 value is board
429         """
430         board_dict = OrderedDict()
431         for brd in self._boards:
432             board_dict[brd.target] = brd
433         return board_dict
434
435     def get_selected_dict(self):
436         """Return a dictionary containing the selected boards
437
438         Returns:
439             List of Board objects that are marked selected
440         """
441         board_dict = OrderedDict()
442         for brd in self._boards:
443             if brd.build_it:
444                 board_dict[brd.target] = brd
445         return board_dict
446
447     def get_selected(self):
448         """Return a list of selected boards
449
450         Returns:
451             List of Board objects that are marked selected
452         """
453         return [brd for brd in self._boards if brd.build_it]
454
455     def get_selected_names(self):
456         """Return a list of selected boards
457
458         Returns:
459             List of board names that are marked selected
460         """
461         return [brd.target for brd in self._boards if brd.build_it]
462
463     @classmethod
464     def _build_terms(cls, args):
465         """Convert command line arguments to a list of terms.
466
467         This deals with parsing of the arguments. It handles the '&'
468         operator, which joins several expressions into a single Term.
469
470         For example:
471             ['arm & freescale sandbox', 'tegra']
472
473         will produce 3 Terms containing expressions as follows:
474             arm, freescale
475             sandbox
476             tegra
477
478         The first Term has two expressions, both of which must match for
479         a board to be selected.
480
481         Args:
482             args (list of str): List of command line arguments
483
484         Returns:
485             list of Term: A list of Term objects
486         """
487         syms = []
488         for arg in args:
489             for word in arg.split():
490                 sym_build = []
491                 for term in word.split('&'):
492                     if term:
493                         sym_build.append(term)
494                     sym_build.append('&')
495                 syms += sym_build[:-1]
496         terms = []
497         term = None
498         oper = None
499         for sym in syms:
500             if sym == '&':
501                 oper = sym
502             elif oper:
503                 term.add_expr(sym)
504                 oper = None
505             else:
506                 if term:
507                     terms.append(term)
508                 term = Term()
509                 term.add_expr(sym)
510         if term:
511             terms.append(term)
512         return terms
513
514     def select_boards(self, args, exclude=None, brds=None):
515         """Mark boards selected based on args
516
517         Normally either boards (an explicit list of boards) or args (a list of
518         terms to match against) is used. It is possible to specify both, in
519         which case they are additive.
520
521         If brds and args are both empty, all boards are selected.
522
523         Args:
524             args (list of str): List of strings specifying boards to include,
525                 either named, or by their target, architecture, cpu, vendor or
526                 soc. If empty, all boards are selected.
527             exclude (list of str): List of boards to exclude, regardless of
528                 'args', or None for none
529             brds (list of Board): List of boards to build, or None/[] for all
530
531         Returns:
532             Tuple
533                 Dictionary which holds the list of boards which were selected
534                     due to each argument, arranged by argument.
535                 List of errors found
536         """
537         def _check_board(brd):
538             """Check whether to include or exclude a board
539
540             Checks the various terms and decide whether to build it or not (the
541             'build_it' variable).
542
543             If it is built, add the board to the result[term] list so we know
544             which term caused it to be built. Add it to result['all'] also.
545
546             Keep a list of boards we found in 'found', so we can report boards
547             which appear in self._boards but not in brds.
548
549             Args:
550                 brd (Board): Board to check
551             """
552             matching_term = None
553             build_it = False
554             if terms:
555                 for term in terms:
556                     if term.matches(brd.props):
557                         matching_term = str(term)
558                         build_it = True
559                         break
560             elif brds:
561                 if brd.target in brds:
562                     build_it = True
563                     found.append(brd.target)
564             else:
565                 build_it = True
566
567             # Check that it is not specifically excluded
568             for expr in exclude_list:
569                 if expr.matches(brd.props):
570                     build_it = False
571                     break
572
573             if build_it:
574                 brd.build_it = True
575                 if matching_term:
576                     result[matching_term].append(brd.target)
577                 result['all'].append(brd.target)
578
579         result = OrderedDict()
580         warnings = []
581         terms = self._build_terms(args)
582
583         result['all'] = []
584         for term in terms:
585             result[str(term)] = []
586
587         exclude_list = []
588         if exclude:
589             for expr in exclude:
590                 exclude_list.append(Expr(expr))
591
592         found = []
593         for brd in self._boards:
594             _check_board(brd)
595
596         if brds:
597             remaining = set(brds) - set(found)
598             if remaining:
599                 warnings.append(f"Boards not found: {', '.join(remaining)}\n")
600
601         return result, warnings
602
603     @classmethod
604     def scan_defconfigs_for_multiprocess(cls, queue, defconfigs):
605         """Scan defconfig files and queue their board parameters
606
607         This function is intended to be passed to multiprocessing.Process()
608         constructor.
609
610         Args:
611             queue (multiprocessing.Queue): The resulting board parameters are
612                 written into this.
613             defconfigs (sequence of str): A sequence of defconfig files to be
614                 scanned.
615         """
616         kconf_scanner = KconfigScanner()
617         for defconfig in defconfigs:
618             queue.put(kconf_scanner.scan(defconfig))
619
620     @classmethod
621     def read_queues(cls, queues, params_list):
622         """Read the queues and append the data to the paramers list"""
623         for que in queues:
624             while not que.empty():
625                 params_list.append(que.get())
626
627     def scan_defconfigs(self, jobs=1):
628         """Collect board parameters for all defconfig files.
629
630         This function invokes multiple processes for faster processing.
631
632         Args:
633             jobs (int): The number of jobs to run simultaneously
634         """
635         all_defconfigs = []
636         for (dirpath, _, filenames) in os.walk(CONFIG_DIR):
637             for filename in fnmatch.filter(filenames, '*_defconfig'):
638                 if fnmatch.fnmatch(filename, '.*'):
639                     continue
640                 all_defconfigs.append(os.path.join(dirpath, filename))
641
642         total_boards = len(all_defconfigs)
643         processes = []
644         queues = []
645         for i in range(jobs):
646             defconfigs = all_defconfigs[total_boards * i // jobs :
647                                         total_boards * (i + 1) // jobs]
648             que = multiprocessing.Queue(maxsize=-1)
649             proc = multiprocessing.Process(
650                 target=self.scan_defconfigs_for_multiprocess,
651                 args=(que, defconfigs))
652             proc.start()
653             processes.append(proc)
654             queues.append(que)
655
656         # The resulting data should be accumulated to this list
657         params_list = []
658
659         # Data in the queues should be retrieved preriodically.
660         # Otherwise, the queues would become full and subprocesses would get stuck.
661         while any(p.is_alive() for p in processes):
662             self.read_queues(queues, params_list)
663             # sleep for a while until the queues are filled
664             time.sleep(SLEEP_TIME)
665
666         # Joining subprocesses just in case
667         # (All subprocesses should already have been finished)
668         for proc in processes:
669             proc.join()
670
671         # retrieve leftover data
672         self.read_queues(queues, params_list)
673
674         return params_list
675
676     @classmethod
677     def insert_maintainers_info(cls, params_list):
678         """Add Status and Maintainers information to the board parameters list.
679
680         Args:
681             params_list (list of dict): A list of the board parameters
682
683         Returns:
684             list of str: List of warnings collected due to missing status, etc.
685         """
686         database = MaintainersDatabase()
687         for (dirpath, _, filenames) in os.walk('.'):
688             if 'MAINTAINERS' in filenames:
689                 database.parse_file(os.path.join(dirpath, 'MAINTAINERS'))
690
691         for i, params in enumerate(params_list):
692             target = params['target']
693             params['status'] = database.get_status(target)
694             params['maintainers'] = database.get_maintainers(target)
695             params_list[i] = params
696         return database.warnings
697
698     @classmethod
699     def format_and_output(cls, params_list, output):
700         """Write board parameters into a file.
701
702         Columnate the board parameters, sort lines alphabetically,
703         and then write them to a file.
704
705         Args:
706             params_list (list of dict): The list of board parameters
707             output (str): The path to the output file
708         """
709         fields = ('status', 'arch', 'cpu', 'soc', 'vendor', 'board', 'target',
710                   'config', 'maintainers')
711
712         # First, decide the width of each column
713         max_length = {f: 0 for f in fields}
714         for params in params_list:
715             for field in fields:
716                 max_length[field] = max(max_length[field], len(params[field]))
717
718         output_lines = []
719         for params in params_list:
720             line = ''
721             for field in fields:
722                 # insert two spaces between fields like column -t would
723                 line += '  ' + params[field].ljust(max_length[field])
724             output_lines.append(line.strip())
725
726         # ignore case when sorting
727         output_lines.sort(key=str.lower)
728
729         with open(output, 'w', encoding="utf-8") as outf:
730             outf.write(COMMENT_BLOCK + '\n'.join(output_lines) + '\n')
731
732     def ensure_board_list(self, output, jobs=1, force=False, quiet=False):
733         """Generate a board database file if needed.
734
735         Args:
736             output (str): The name of the output file
737             jobs (int): The number of jobs to run simultaneously
738             force (bool): Force to generate the output even if it is new
739             quiet (bool): True to avoid printing a message if nothing needs doing
740
741         Returns:
742             bool: True if all is well, False if there were warnings
743         """
744         if not force and output_is_new(output):
745             if not quiet:
746                 print(f'{output} is up to date. Nothing to do.')
747             return True
748         params_list = self.scan_defconfigs(jobs)
749         warnings = self.insert_maintainers_info(params_list)
750         for warn in warnings:
751             print(warn, file=sys.stderr)
752         self.format_and_output(params_list, output)
753         return not warnings