1#!/usr/bin/env python3
2
3# Copyright (c) 2020 Nordic Semiconductor ASA
4# SPDX-License-Identifier: Apache-2.0
5
6import argparse
7import difflib
8import itertools
9import sys
10from collections import Counter, defaultdict
11from dataclasses import dataclass, field
12from pathlib import Path
13
14import list_hardware
15import pykwalify.core
16import yaml
17from list_hardware import unique_paths
18
19try:
20    from yaml import CSafeLoader as SafeLoader
21except ImportError:
22    from yaml import SafeLoader
23
24BOARD_SCHEMA_PATH = str(Path(__file__).parent / 'schemas' / 'board-schema.yml')
25with open(BOARD_SCHEMA_PATH) as f:
26    board_schema = yaml.load(f.read(), Loader=SafeLoader)
27
28BOARD_VALIDATOR = pykwalify.core.Core(schema_data=board_schema, source_data={})
29
30BOARD_YML = 'board.yml'
31
32#
33# This is shared code between the build system's 'boards' target
34# and the 'west boards' extension command. If you change it, make
35# sure to test both ways it can be used.
36#
37# (It's done this way to keep west optional, making it possible to run
38# 'ninja boards' in a build directory without west installed.)
39#
40
41
42@dataclass
43class Revision:
44    name: str
45    variants: list[str] = field(default_factory=list)
46
47    @staticmethod
48    def from_dict(revision):
49        revisions = []
50        for r in revision.get('revisions', []):
51            revisions.append(Revision.from_dict(r))
52        return Revision(revision['name'], revisions)
53
54
55@dataclass
56class Variant:
57    name: str
58    variants: list[str] = field(default_factory=list)
59
60    @staticmethod
61    def from_dict(variant):
62        variants = []
63        for v in variant.get('variants', []):
64            variants.append(Variant.from_dict(v))
65        return Variant(variant['name'], variants)
66
67
68@dataclass
69class Cpucluster:
70    name: str
71    variants: list[str] = field(default_factory=list)
72
73
74@dataclass
75class Soc:
76    name: str
77    cpuclusters: list[str] = field(default_factory=list)
78    variants: list[str] = field(default_factory=list)
79
80    @staticmethod
81    def from_soc(soc, variants):
82        if soc is None:
83            return None
84        if soc.cpuclusters:
85            cpus = []
86            for c in soc.cpuclusters:
87                cpus.append(Cpucluster(c,
88                            [Variant.from_dict(v) for v in variants if c == v['cpucluster']]
89                ))
90            return Soc(soc.name, cpuclusters=cpus)
91        return Soc(soc.name, variants=[Variant.from_dict(v) for v in variants])
92
93
94@dataclass(frozen=True)
95class Board:
96    name: str
97    # HWMv1 only supports a single Path, and requires Board dataclass to be hashable.
98    directories: Path | list[Path]
99    hwm: str
100    full_name: str = None
101    arch: str = None
102    vendor: str = None
103    revision_format: str = None
104    revision_default: str = None
105    revision_exact: bool = False
106    revisions: list[str] = field(default_factory=list, compare=False)
107    socs: list[Soc] = field(default_factory=list, compare=False)
108    variants: list[str] = field(default_factory=list, compare=False)
109
110    @property
111    def dir(self):
112        # Get the main board directory.
113        if isinstance(self.directories, Path):
114            return self.directories
115        return self.directories[0]
116
117    def from_qualifier(self, qualifiers):
118        qualifiers_list = qualifiers.split('/')
119
120        node = Soc(None)
121        n = len(qualifiers_list)
122        if n > 0:
123            soc_qualifier = qualifiers_list.pop(0)
124            for s in self.socs:
125                if s.name == soc_qualifier:
126                    node = s
127                    break
128
129        if n > 1 and node.cpuclusters:
130            cpu_qualifier = qualifiers_list.pop(0)
131            for c in node.cpuclusters:
132                if c.name == cpu_qualifier:
133                    node = c
134                    break
135            else:
136                node = Variant(None)
137
138        for q in qualifiers_list:
139            for v in node.variants:
140                if v.name == q:
141                    node = v
142                    break
143            else:
144                node = Variant(None)
145
146        if node in (Soc(None), Variant(None)):
147            sys.exit(f'ERROR: qualifiers {qualifiers} not found when extending board {self.name}')
148
149        return node
150
151
152def board_key(board):
153    return board.name
154
155
156def find_arch2boards(args):
157    arch2board_set = find_arch2board_set(args)
158    return {arch: sorted(arch2board_set[arch], key=board_key)
159            for arch in arch2board_set}
160
161
162def find_boards(args):
163    return sorted(itertools.chain(*find_arch2board_set(args).values()),
164                  key=board_key)
165
166
167def find_arch2board_set(args):
168    arches = sorted(find_arches(args))
169    ret = defaultdict(set)
170
171    for root in unique_paths(args.board_roots):
172        for arch, boards in find_arch2board_set_in(root, arches, args.board_dir).items():
173            if args.board is not None:
174                ret[arch] |= {b for b in boards if b.name == args.board}
175            else:
176                ret[arch] |= boards
177
178    return ret
179
180
181def find_arches(args):
182    arch_set = set()
183
184    for root in unique_paths(args.arch_roots):
185        arch_set |= find_arches_in(root)
186
187    return arch_set
188
189
190def find_arches_in(root):
191    ret = set()
192    arch = root / 'arch'
193    common = arch / 'common'
194
195    if not arch.is_dir():
196        return ret
197
198    for maybe_arch in arch.iterdir():
199        if not maybe_arch.is_dir() or maybe_arch == common:
200            continue
201        ret.add(maybe_arch.name)
202
203    return ret
204
205
206def find_arch2board_set_in(root, arches, board_dir):
207    ret = defaultdict(set)
208    boards = root / 'boards'
209
210    for arch in arches:
211        if not (boards / arch).is_dir():
212            continue
213        for maybe_board in (boards / arch).iterdir():
214            if not maybe_board.is_dir():
215                continue
216            if board_dir and maybe_board not in board_dir:
217                continue
218            for maybe_defconfig in maybe_board.iterdir():
219                file_name = maybe_defconfig.name
220                if file_name.endswith('_defconfig') and not (maybe_board / BOARD_YML).is_file():
221                    board_name = file_name[:-len('_defconfig')]
222                    ret[arch].add(Board(board_name, maybe_board, 'v1', arch=arch))
223
224    return ret
225
226
227def load_v2_boards(board_name, board_yml, systems):
228    boards = {}
229    board_extensions = []
230    if board_yml.is_file():
231        with board_yml.open('r', encoding='utf-8') as f:
232            b = yaml.load(f.read(), Loader=SafeLoader)
233
234        try:
235            BOARD_VALIDATOR.source = b
236            BOARD_VALIDATOR.validate()
237        except pykwalify.errors.SchemaError as e:
238            sys.exit(f'ERROR: Malformed "build" section in file: {board_yml.as_posix()}\n{e}')
239
240        mutual_exclusive = {'board', 'boards'}
241        if len(mutual_exclusive - b.keys()) < 1:
242            sys.exit(f'ERROR: Malformed content in file: {board_yml.as_posix()}\n'
243                     f'{mutual_exclusive} are mutual exclusive at this level.')
244
245        board_array = b.get('boards', [b.get('board', None)])
246        for board in board_array:
247            mutual_exclusive = {'name', 'extend'}
248            if len(mutual_exclusive - board.keys()) < 1:
249                sys.exit(f'ERROR: Malformed "board" section in file: {board_yml.as_posix()}\n'
250                         f'{mutual_exclusive} are mutual exclusive at this level.')
251
252            # This is a extending an existing board, place in array to allow later processing.
253            if 'extend' in board:
254                board.update({'dir': board_yml.parent})
255                board_extensions.append(board)
256                continue
257
258            # Create board
259            if board_name is not None and board['name'] != board_name:
260                # Not the board we're looking for, ignore.
261                continue
262
263            board_revision = board.get('revision')
264            if board_revision is not None and board_revision.get('format') != 'custom':
265                if board_revision.get('default') is None:
266                    sys.exit(f'ERROR: Malformed "board" section in file: {board_yml.as_posix()}\n'
267                             "Cannot find required key 'default'. Path: '/board/revision.'")
268                if board_revision.get('revisions') is None:
269                    sys.exit(f'ERROR: Malformed "board" section in file: {board_yml.as_posix()}\n'
270                             "Cannot find required key 'revisions'. Path: '/board/revision.'")
271
272            mutual_exclusive = {'socs', 'variants'}
273            if len(mutual_exclusive - board.keys()) < 1:
274                sys.exit(f'ERROR: Malformed "board" section in file: {board_yml.as_posix()}\n'
275                         f'{mutual_exclusive} are mutual exclusive at this level.')
276            socs = [Soc.from_soc(systems.get_soc(s['name']), s.get('variants', []))
277                    for s in board.get('socs', {})]
278
279            boards[board['name']] = Board(
280                name=board['name'],
281                directories=[board_yml.parent],
282                vendor=board.get('vendor'),
283                full_name=board.get('full_name'),
284                revision_format=board.get('revision', {}).get('format'),
285                revision_default=board.get('revision', {}).get('default'),
286                revision_exact=board.get('revision', {}).get('exact', False),
287                revisions=[Revision.from_dict(v) for v in
288                           board.get('revision', {}).get('revisions', [])],
289                socs=socs,
290                variants=[Variant.from_dict(v) for v in board.get('variants', [])],
291                hwm='v2',
292            )
293            board_qualifiers = board_v2_qualifiers(boards[board['name']])
294            duplicates = [q for q, n in Counter(board_qualifiers).items() if n > 1]
295            if duplicates:
296                sys.exit(f'ERROR: Duplicated board qualifiers detected {duplicates} for board: '
297                         f'{board["name"]}.\nPlease check content of: {board_yml.as_posix()}\n')
298    return boards, board_extensions
299
300
301def extend_v2_boards(boards, board_extensions):
302    for e in board_extensions:
303        board = boards.get(e['extend'])
304        if board is None:
305            continue
306        board.directories.append(e['dir'])
307
308        for v in e.get('variants', []):
309            node = board.from_qualifier(v['qualifier'])
310            if str(v['qualifier'] + '/' + v['name']) in board_v2_qualifiers(board):
311                board_yml = e['dir'] / BOARD_YML
312                sys.exit(f'ERROR: Variant: {v["name"]}, defined multiple times for board: '
313                         f'{board.name}.\nLast defined in {board_yml}')
314            node.variants.append(Variant.from_dict(v))
315
316
317# Note that this does not share the args.board functionality of find_v2_boards
318def find_v2_board_dirs(args):
319    dirs = []
320    board_files = []
321    for root in unique_paths(args.board_roots):
322        board_files.extend((root / 'boards').rglob(BOARD_YML))
323
324    dirs = [board_yml.parent for board_yml in board_files if board_yml.is_file()]
325    return dirs
326
327
328def find_v2_boards(args):
329    root_args = argparse.Namespace(**{'soc_roots': args.soc_roots})
330    systems = list_hardware.find_v2_systems(root_args)
331
332    boards = {}
333    board_extensions = []
334    board_files = []
335    if args.board_dir:
336        board_files = [d / BOARD_YML for d in args.board_dir]
337    else:
338        for root in unique_paths(args.board_roots):
339            board_files.extend((root / 'boards').rglob(BOARD_YML))
340
341    for board_yml in board_files:
342        b, e = load_v2_boards(args.board, board_yml, systems)
343        conflict_boards = set(boards.keys()).intersection(b.keys())
344        if conflict_boards:
345            sys.exit(f'ERROR: Board(s): {conflict_boards}, defined multiple times.\n'
346                     f'Last defined in {board_yml}')
347        boards.update(b)
348        board_extensions.extend(e)
349
350    extend_v2_boards(boards, board_extensions)
351    return boards
352
353
354def parse_args():
355    parser = argparse.ArgumentParser(allow_abbrev=False)
356    add_args(parser)
357    add_args_formatting(parser)
358    return parser.parse_args()
359
360
361def add_args(parser):
362    # Remember to update west-completion.bash if you add or remove
363    # flags
364    parser.add_argument("--arch-root", dest='arch_roots', default=[],
365                        type=Path, action='append',
366                        help='add an architecture root, may be given more than once')
367    parser.add_argument("--board-root", dest='board_roots', default=[],
368                        type=Path, action='append',
369                        help='add a board root, may be given more than once')
370    parser.add_argument("--soc-root", dest='soc_roots', default=[],
371                        type=Path, action='append',
372                        help='add a soc root, may be given more than once')
373    parser.add_argument("--board", dest='board', default=None,
374                        help='lookup the specific board, fail if not found')
375    parser.add_argument("--board-dir", default=[], type=Path, action='append',
376                        help='only look for boards at the specific location')
377    parser.add_argument("--fuzzy-match", default=None,
378                        help='lookup boards similar to the given board name')
379
380
381def add_args_formatting(parser):
382    parser.add_argument("--cmakeformat", default=None,
383                        help='''CMake Format string to use to list each board''')
384
385
386def variant_v2_qualifiers(variant, qualifiers = None):
387    qualifiers_list = [variant.name] if qualifiers is None else [qualifiers + '/' + variant.name]
388    for v in variant.variants:
389        qualifiers_list.extend(variant_v2_qualifiers(v, qualifiers_list[0]))
390    return qualifiers_list
391
392
393def board_v2_qualifiers(board):
394    qualifiers_list = []
395
396    for s in board.socs:
397        if s.cpuclusters:
398            for c in s.cpuclusters:
399                id_str = s.name + '/' + c.name
400                qualifiers_list.append(id_str)
401                for v in c.variants:
402                    qualifiers_list.extend(variant_v2_qualifiers(v, id_str))
403        else:
404            qualifiers_list.append(s.name)
405            for v in s.variants:
406                qualifiers_list.extend(variant_v2_qualifiers(v, s.name))
407
408    for v in board.variants:
409        qualifiers_list.extend(variant_v2_qualifiers(v))
410    return qualifiers_list
411
412
413def board_v2_qualifiers_csv(board):
414    # Return in csv (comma separated value) format
415    return ",".join(board_v2_qualifiers(board))
416
417
418def dump_v2_boards(args):
419    boards = find_v2_boards(args)
420    if args.fuzzy_match is not None:
421        close_boards = difflib.get_close_matches(args.fuzzy_match, boards.keys())
422        boards = {b: boards[b] for b in close_boards}
423
424    for b in boards.values():
425        qualifiers_list = board_v2_qualifiers(b)
426        if args.cmakeformat is not None:
427            def notfound(x):
428                return x or 'NOTFOUND'
429            info = args.cmakeformat.format(
430                NAME='NAME;' + b.name,
431                DIR='DIR;' + ';'.join(
432                    [str(x.as_posix()) for x in b.directories]),
433                VENDOR='VENDOR;' + notfound(b.vendor),
434                HWM='HWM;' + b.hwm,
435                REVISION_DEFAULT='REVISION_DEFAULT;' + notfound(b.revision_default),
436                REVISION_FORMAT='REVISION_FORMAT;' + notfound(b.revision_format),
437                REVISION_EXACT='REVISION_EXACT;' + str(b.revision_exact),
438                REVISIONS='REVISIONS;' + ';'.join(
439                          [x.name for x in b.revisions]),
440                SOCS='SOCS;' + ';'.join([s.name for s in b.socs]),
441                QUALIFIERS='QUALIFIERS;' + ';'.join(qualifiers_list)
442            )
443            print(info)
444        else:
445            print(f'{b.name}')
446
447
448def dump_boards(args):
449    arch2boards = find_arch2boards(args)
450    for arch, boards in arch2boards.items():
451        if args.fuzzy_match is not None:
452            close_boards = difflib.get_close_matches(args.fuzzy_match, [b.name for b in boards])
453            if not close_boards:
454                continue
455            boards = [b for b in boards if b.name in close_boards]
456        if args.cmakeformat is None:
457            print(f'{arch}:')
458        for board in boards:
459            if args.cmakeformat is not None:
460                info = args.cmakeformat.format(
461                    NAME='NAME;' + board.name,
462                    DIR='DIR;' + str(board.dir.as_posix()),
463                    HWM='HWM;' + board.hwm,
464                    VENDOR='VENDOR;NOTFOUND',
465                    REVISION_DEFAULT='REVISION_DEFAULT;NOTFOUND',
466                    REVISION_FORMAT='REVISION_FORMAT;NOTFOUND',
467                    REVISION_EXACT='REVISION_EXACT;NOTFOUND',
468                    REVISIONS='REVISIONS;NOTFOUND',
469                    VARIANT_DEFAULT='VARIANT_DEFAULT;NOTFOUND',
470                    SOCS='SOCS;',
471                    QUALIFIERS='QUALIFIERS;'
472                )
473                print(info)
474            else:
475                print(f'  {board.name}')
476
477
478if __name__ == '__main__':
479    args = parse_args()
480    dump_boards(args)
481    dump_v2_boards(args)
482