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