8832e40cd5d0b941f5e709cb4e0260b5e5156f8b
[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     """
280
281     def __init__(self):
282         """Create an empty database."""
283         self.database = {}
284
285     def get_status(self, target):
286         """Return the status of the given board.
287
288         The board status is generally either 'Active' or 'Orphan'.
289         Display a warning message and return '-' if status information
290         is not found.
291
292         Args:
293             target (str): Build-target name
294
295         Returns:
296             str: 'Active', 'Orphan' or '-'.
297         """
298         if not target in self.database:
299             print(f"WARNING: no status info for '{target}'", file=sys.stderr)
300             return '-'
301
302         tmp = self.database[target][0]
303         if tmp.startswith('Maintained'):
304             return 'Active'
305         if tmp.startswith('Supported'):
306             return 'Active'
307         if tmp.startswith('Orphan'):
308             return 'Orphan'
309         print(f"WARNING: {tmp}: unknown status for '{target}'", file=sys.stderr)
310         return '-'
311
312     def get_maintainers(self, target):
313         """Return the maintainers of the given board.
314
315         Args:
316             target (str): Build-target name
317
318         Returns:
319             str: Maintainers of the board.  If the board has two or more
320             maintainers, they are separated with colons.
321         """
322         if not target in self.database:
323             print(f"WARNING: no maintainers for '{target}'", file=sys.stderr)
324             return ''
325
326         return ':'.join(self.database[target][1])
327
328     def parse_file(self, fname):
329         """Parse a MAINTAINERS file.
330
331         Parse a MAINTAINERS file and accumulate board status and maintainers
332         information in the self.database dict.
333
334         Args:
335             fname (str): MAINTAINERS file to be parsed
336         """
337         targets = []
338         maintainers = []
339         status = '-'
340         with open(fname, encoding="utf-8") as inf:
341             for line in inf:
342                 # Check also commented maintainers
343                 if line[:3] == '#M:':
344                     line = line[1:]
345                 tag, rest = line[:2], line[2:].strip()
346                 if tag == 'M:':
347                     maintainers.append(rest)
348                 elif tag == 'F:':
349                     # expand wildcard and filter by 'configs/*_defconfig'
350                     for item in glob.glob(rest):
351                         front, match, rear = item.partition('configs/')
352                         if not front and match:
353                             front, match, rear = rear.rpartition('_defconfig')
354                             if match and not rear:
355                                 targets.append(front)
356                 elif tag == 'S:':
357                     status = rest
358                 elif line == '\n':
359                     for target in targets:
360                         self.database[target] = (status, maintainers)
361                     targets = []
362                     maintainers = []
363                     status = '-'
364         if targets:
365             for target in targets:
366                 self.database[target] = (status, maintainers)
367
368
369 class Boards:
370     """Manage a list of boards."""
371     def __init__(self):
372         # Use a simple list here, sinc OrderedDict requires Python 2.7
373         self._boards = []
374
375     def add_board(self, brd):
376         """Add a new board to the list.
377
378         The board's target member must not already exist in the board list.
379
380         Args:
381             brd (Board): board to add
382         """
383         self._boards.append(brd)
384
385     def read_boards(self, fname):
386         """Read a list of boards from a board file.
387
388         Create a Board object for each and add it to our _boards list.
389
390         Args:
391             fname (str): Filename of boards.cfg file
392         """
393         with open(fname, 'r', encoding='utf-8') as inf:
394             for line in inf:
395                 if line[0] == '#':
396                     continue
397                 fields = line.split()
398                 if not fields:
399                     continue
400                 for upto, field in enumerate(fields):
401                     if field == '-':
402                         fields[upto] = ''
403                 while len(fields) < 8:
404                     fields.append('')
405                 if len(fields) > 8:
406                     fields = fields[:8]
407
408                 brd = board.Board(*fields)
409                 self.add_board(brd)
410
411
412     def get_list(self):
413         """Return a list of available boards.
414
415         Returns:
416             List of Board objects
417         """
418         return self._boards
419
420     def get_dict(self):
421         """Build a dictionary containing all the boards.
422
423         Returns:
424             Dictionary:
425                 key is board.target
426                 value is board
427         """
428         board_dict = OrderedDict()
429         for brd in self._boards:
430             board_dict[brd.target] = brd
431         return board_dict
432
433     def get_selected_dict(self):
434         """Return a dictionary containing the selected boards
435
436         Returns:
437             List of Board objects that are marked selected
438         """
439         board_dict = OrderedDict()
440         for brd in self._boards:
441             if brd.build_it:
442                 board_dict[brd.target] = brd
443         return board_dict
444
445     def get_selected(self):
446         """Return a list of selected boards
447
448         Returns:
449             List of Board objects that are marked selected
450         """
451         return [brd for brd in self._boards if brd.build_it]
452
453     def get_selected_names(self):
454         """Return a list of selected boards
455
456         Returns:
457             List of board names that are marked selected
458         """
459         return [brd.target for brd in self._boards if brd.build_it]
460
461     @classmethod
462     def _build_terms(cls, args):
463         """Convert command line arguments to a list of terms.
464
465         This deals with parsing of the arguments. It handles the '&'
466         operator, which joins several expressions into a single Term.
467
468         For example:
469             ['arm & freescale sandbox', 'tegra']
470
471         will produce 3 Terms containing expressions as follows:
472             arm, freescale
473             sandbox
474             tegra
475
476         The first Term has two expressions, both of which must match for
477         a board to be selected.
478
479         Args:
480             args (list of str): List of command line arguments
481
482         Returns:
483             list of Term: A list of Term objects
484         """
485         syms = []
486         for arg in args:
487             for word in arg.split():
488                 sym_build = []
489                 for term in word.split('&'):
490                     if term:
491                         sym_build.append(term)
492                     sym_build.append('&')
493                 syms += sym_build[:-1]
494         terms = []
495         term = None
496         oper = None
497         for sym in syms:
498             if sym == '&':
499                 oper = sym
500             elif oper:
501                 term.add_expr(sym)
502                 oper = None
503             else:
504                 if term:
505                     terms.append(term)
506                 term = Term()
507                 term.add_expr(sym)
508         if term:
509             terms.append(term)
510         return terms
511
512     def select_boards(self, args, exclude=None, brds=None):
513         """Mark boards selected based on args
514
515         Normally either boards (an explicit list of boards) or args (a list of
516         terms to match against) is used. It is possible to specify both, in
517         which case they are additive.
518
519         If brds and args are both empty, all boards are selected.
520
521         Args:
522             args (list of str): List of strings specifying boards to include,
523                 either named, or by their target, architecture, cpu, vendor or
524                 soc. If empty, all boards are selected.
525             exclude (list of str): List of boards to exclude, regardless of
526                 'args', or None for none
527             brds (list of Board): List of boards to build, or None/[] for all
528
529         Returns:
530             Tuple
531                 Dictionary which holds the list of boards which were selected
532                     due to each argument, arranged by argument.
533                 List of errors found
534         """
535         def _check_board(brd):
536             """Check whether to include or exclude a board
537
538             Checks the various terms and decide whether to build it or not (the
539             'build_it' variable).
540
541             If it is built, add the board to the result[term] list so we know
542             which term caused it to be built. Add it to result['all'] also.
543
544             Keep a list of boards we found in 'found', so we can report boards
545             which appear in self._boards but not in brds.
546
547             Args:
548                 brd (Board): Board to check
549             """
550             matching_term = None
551             build_it = False
552             if terms:
553                 for term in terms:
554                     if term.matches(brd.props):
555                         matching_term = str(term)
556                         build_it = True
557                         break
558             elif brds:
559                 if brd.target in brds:
560                     build_it = True
561                     found.append(brd.target)
562             else:
563                 build_it = True
564
565             # Check that it is not specifically excluded
566             for expr in exclude_list:
567                 if expr.matches(brd.props):
568                     build_it = False
569                     break
570
571             if build_it:
572                 brd.build_it = True
573                 if matching_term:
574                     result[matching_term].append(brd.target)
575                 result['all'].append(brd.target)
576
577         result = OrderedDict()
578         warnings = []
579         terms = self._build_terms(args)
580
581         result['all'] = []
582         for term in terms:
583             result[str(term)] = []
584
585         exclude_list = []
586         if exclude:
587             for expr in exclude:
588                 exclude_list.append(Expr(expr))
589
590         found = []
591         for brd in self._boards:
592             _check_board(brd)
593
594         if brds:
595             remaining = set(brds) - set(found)
596             if remaining:
597                 warnings.append(f"Boards not found: {', '.join(remaining)}\n")
598
599         return result, warnings
600
601     @classmethod
602     def scan_defconfigs_for_multiprocess(cls, queue, defconfigs):
603         """Scan defconfig files and queue their board parameters
604
605         This function is intended to be passed to multiprocessing.Process()
606         constructor.
607
608         Args:
609             queue (multiprocessing.Queue): The resulting board parameters are
610                 written into this.
611             defconfigs (sequence of str): A sequence of defconfig files to be
612                 scanned.
613         """
614         kconf_scanner = KconfigScanner()
615         for defconfig in defconfigs:
616             queue.put(kconf_scanner.scan(defconfig))
617
618     @classmethod
619     def read_queues(cls, queues, params_list):
620         """Read the queues and append the data to the paramers list"""
621         for que in queues:
622             while not que.empty():
623                 params_list.append(que.get())
624
625     def scan_defconfigs(self, jobs=1):
626         """Collect board parameters for all defconfig files.
627
628         This function invokes multiple processes for faster processing.
629
630         Args:
631             jobs (int): The number of jobs to run simultaneously
632         """
633         all_defconfigs = []
634         for (dirpath, _, filenames) in os.walk(CONFIG_DIR):
635             for filename in fnmatch.filter(filenames, '*_defconfig'):
636                 if fnmatch.fnmatch(filename, '.*'):
637                     continue
638                 all_defconfigs.append(os.path.join(dirpath, filename))
639
640         total_boards = len(all_defconfigs)
641         processes = []
642         queues = []
643         for i in range(jobs):
644             defconfigs = all_defconfigs[total_boards * i // jobs :
645                                         total_boards * (i + 1) // jobs]
646             que = multiprocessing.Queue(maxsize=-1)
647             proc = multiprocessing.Process(
648                 target=self.scan_defconfigs_for_multiprocess,
649                 args=(que, defconfigs))
650             proc.start()
651             processes.append(proc)
652             queues.append(que)
653
654         # The resulting data should be accumulated to this list
655         params_list = []
656
657         # Data in the queues should be retrieved preriodically.
658         # Otherwise, the queues would become full and subprocesses would get stuck.
659         while any(p.is_alive() for p in processes):
660             self.read_queues(queues, params_list)
661             # sleep for a while until the queues are filled
662             time.sleep(SLEEP_TIME)
663
664         # Joining subprocesses just in case
665         # (All subprocesses should already have been finished)
666         for proc in processes:
667             proc.join()
668
669         # retrieve leftover data
670         self.read_queues(queues, params_list)
671
672         return params_list
673
674     @classmethod
675     def insert_maintainers_info(cls, params_list):
676         """Add Status and Maintainers information to the board parameters list.
677
678         Args:
679             params_list (list of dict): A list of the board parameters
680         """
681         database = MaintainersDatabase()
682         for (dirpath, _, filenames) in os.walk('.'):
683             if 'MAINTAINERS' in filenames:
684                 database.parse_file(os.path.join(dirpath, 'MAINTAINERS'))
685
686         for i, params in enumerate(params_list):
687             target = params['target']
688             params['status'] = database.get_status(target)
689             params['maintainers'] = database.get_maintainers(target)
690             params_list[i] = params
691
692     @classmethod
693     def format_and_output(cls, params_list, output):
694         """Write board parameters into a file.
695
696         Columnate the board parameters, sort lines alphabetically,
697         and then write them to a file.
698
699         Args:
700             params_list (list of dict): The list of board parameters
701             output (str): The path to the output file
702         """
703         fields = ('status', 'arch', 'cpu', 'soc', 'vendor', 'board', 'target',
704                   'config', 'maintainers')
705
706         # First, decide the width of each column
707         max_length = {f: 0 for f in fields}
708         for params in params_list:
709             for field in fields:
710                 max_length[field] = max(max_length[field], len(params[field]))
711
712         output_lines = []
713         for params in params_list:
714             line = ''
715             for field in fields:
716                 # insert two spaces between fields like column -t would
717                 line += '  ' + params[field].ljust(max_length[field])
718             output_lines.append(line.strip())
719
720         # ignore case when sorting
721         output_lines.sort(key=str.lower)
722
723         with open(output, 'w', encoding="utf-8") as outf:
724             outf.write(COMMENT_BLOCK + '\n'.join(output_lines) + '\n')
725
726     def ensure_board_list(self, output, jobs=1, force=False, quiet=False):
727         """Generate a board database file if needed.
728
729         Args:
730             output (str): The name of the output file
731             jobs (int): The number of jobs to run simultaneously
732             force (bool): Force to generate the output even if it is new
733             quiet (bool): True to avoid printing a message if nothing needs doing
734         """
735         if not force and output_is_new(output):
736             if not quiet:
737                 print(f'{output} is up to date. Nothing to do.')
738             return
739         params_list = self.scan_defconfigs(jobs)
740         self.insert_maintainers_info(params_list)
741         self.format_and_output(params_list, output)