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 sys
9from collections import Counter
10from dataclasses import dataclass, field
11from pathlib import Path
12
13import jsonschema
14import list_hardware
15import yaml
16from jsonschema.exceptions import best_match
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.yaml')
25with open(BOARD_SCHEMA_PATH) as f:
26    board_schema = yaml.load(f.read(), Loader=SafeLoader)
27
28validator_class = jsonschema.validators.validator_for(board_schema)
29validator_class.check_schema(board_schema)
30board_validator = validator_class(board_schema)
31
32BOARD_YML = 'board.yml'
33
34#
35# This is shared code between the build system's 'boards' target
36# and the 'west boards' extension command. If you change it, make
37# sure to test both ways it can be used.
38#
39# (It's done this way to keep west optional, making it possible to run
40# 'ninja boards' in a build directory without west installed.)
41#
42
43
44@dataclass
45class Revision:
46    name: str
47    variants: list[str] = field(default_factory=list)
48
49    @staticmethod
50    def from_dict(revision):
51        revisions = []
52        for r in revision.get('revisions', []):
53            revisions.append(Revision.from_dict(r))
54        return Revision(revision['name'], revisions)
55
56
57@dataclass
58class Variant:
59    name: str
60    variants: list[str] = field(default_factory=list)
61
62    @staticmethod
63    def from_dict(variant):
64        variants = []
65        for v in variant.get('variants', []):
66            variants.append(Variant.from_dict(v))
67        return Variant(variant['name'], variants)
68
69
70@dataclass
71class Cpucluster:
72    name: str
73    variants: list[str] = field(default_factory=list)
74
75
76@dataclass
77class Soc:
78    name: str
79    cpuclusters: list[str] = field(default_factory=list)
80    variants: list[str] = field(default_factory=list)
81
82    @staticmethod
83    def from_soc(soc, variants):
84        if soc is None:
85            return None
86        if soc.cpuclusters:
87            cpus = []
88            for c in soc.cpuclusters:
89                cpus.append(Cpucluster(c,
90                            [Variant.from_dict(v) for v in variants if c == v['cpucluster']]
91                ))
92            return Soc(soc.name, cpuclusters=cpus)
93        return Soc(soc.name, variants=[Variant.from_dict(v) for v in variants])
94
95
96@dataclass(frozen=True)
97class Board:
98    name: str
99    # HWMv1 only supports a single Path, and requires Board dataclass to be hashable.
100    directories: Path | list[Path]
101    hwm: str
102    full_name: str = None
103    arch: str = None
104    vendor: str = None
105    revision_format: str = None
106    revision_default: str = None
107    revision_exact: bool = False
108    revisions: list[str] = field(default_factory=list, compare=False)
109    socs: list[Soc] = field(default_factory=list, compare=False)
110    variants: list[str] = field(default_factory=list, compare=False)
111
112    @property
113    def dir(self):
114        # Get the main board directory.
115        if isinstance(self.directories, Path):
116            return self.directories
117        return self.directories[0]
118
119    def from_qualifier(self, qualifiers):
120        qualifiers_list = qualifiers.split('/')
121
122        node = Soc(None)
123        n = len(qualifiers_list)
124        if n > 0:
125            soc_qualifier = qualifiers_list.pop(0)
126            for s in self.socs:
127                if s.name == soc_qualifier:
128                    node = s
129                    break
130
131        if n > 1 and node.cpuclusters:
132            cpu_qualifier = qualifiers_list.pop(0)
133            for c in node.cpuclusters:
134                if c.name == cpu_qualifier:
135                    node = c
136                    break
137            else:
138                node = Variant(None)
139
140        for q in qualifiers_list:
141            for v in node.variants:
142                if v.name == q:
143                    node = v
144                    break
145            else:
146                node = Variant(None)
147
148        if node in (Soc(None), Variant(None)):
149            sys.exit(f'ERROR: qualifiers {qualifiers} not found when extending board {self.name}')
150
151        return node
152
153
154def load_v2_boards(board_name, board_yml, systems):
155    boards = {}
156    board_extensions = []
157    if board_yml.is_file():
158        with board_yml.open('r', encoding='utf-8') as f:
159            b = yaml.load(f.read(), Loader=SafeLoader)
160
161        errors = list(board_validator.iter_errors(b))
162        if errors:
163            sys.exit('ERROR: Malformed board YAML file: '
164                     f'{board_yml.as_posix()}\n'
165                     f'{best_match(errors).message} in {best_match(errors).json_path}')
166
167        board_array = b.get('boards', [b.get('board', None)])
168        for board in board_array:
169
170            # This is a extending an existing board, place in array to allow later processing.
171            if 'extend' in board:
172                board.update({'dir': board_yml.parent})
173                board_extensions.append(board)
174                continue
175
176            # Create board
177            if board_name is not None and board['name'] != board_name:
178                # Not the board we're looking for, ignore.
179                continue
180
181            socs = [Soc.from_soc(systems.get_soc(s['name']), s.get('variants', []))
182                    for s in board.get('socs', {})]
183
184            boards[board['name']] = Board(
185                name=board['name'],
186                directories=[board_yml.parent],
187                vendor=board.get('vendor'),
188                full_name=board.get('full_name'),
189                revision_format=board.get('revision', {}).get('format'),
190                revision_default=board.get('revision', {}).get('default'),
191                revision_exact=board.get('revision', {}).get('exact', False),
192                revisions=[Revision.from_dict(v) for v in
193                           board.get('revision', {}).get('revisions', [])],
194                socs=socs,
195                variants=[Variant.from_dict(v) for v in board.get('variants', [])],
196                hwm='v2',
197            )
198            board_qualifiers = board_v2_qualifiers(boards[board['name']])
199            duplicates = [q for q, n in Counter(board_qualifiers).items() if n > 1]
200            if duplicates:
201                sys.exit(f'ERROR: Duplicated board qualifiers detected {duplicates} for board: '
202                         f'{board["name"]}.\nPlease check content of: {board_yml.as_posix()}\n')
203    return boards, board_extensions
204
205
206def extend_v2_boards(boards, board_extensions):
207    for e in board_extensions:
208        board = boards.get(e['extend'])
209        if board is None:
210            continue
211        board.directories.append(e['dir'])
212
213        for v in e.get('variants', []):
214            node = board.from_qualifier(v['qualifier'])
215            if str(v['qualifier'] + '/' + v['name']) in board_v2_qualifiers(board):
216                board_yml = e['dir'] / BOARD_YML
217                sys.exit(f'ERROR: Variant: {v["name"]}, defined multiple times for board: '
218                         f'{board.name}.\nLast defined in {board_yml}')
219            node.variants.append(Variant.from_dict(v))
220
221
222# Note that this does not share the args.board functionality of find_v2_boards
223def find_v2_board_dirs(args):
224    dirs = []
225    board_files = []
226    for root in unique_paths(args.board_roots):
227        board_files.extend((root / 'boards').rglob(BOARD_YML))
228
229    dirs = [board_yml.parent for board_yml in board_files if board_yml.is_file()]
230    return dirs
231
232
233def find_v2_boards(args):
234    root_args = argparse.Namespace(**{'soc_roots': args.soc_roots})
235    systems = list_hardware.find_v2_systems(root_args)
236
237    boards = {}
238    board_extensions = []
239    board_files = []
240    if args.board_dir:
241        board_files = [d / BOARD_YML for d in args.board_dir]
242    else:
243        for root in unique_paths(args.board_roots):
244            board_files.extend((root / 'boards').rglob(BOARD_YML))
245
246    for board_yml in board_files:
247        b, e = load_v2_boards(args.board, board_yml, systems)
248        conflict_boards = set(boards.keys()).intersection(b.keys())
249        if conflict_boards:
250            sys.exit(f'ERROR: Board(s): {conflict_boards}, defined multiple times.\n'
251                     f'Last defined in {board_yml}')
252        boards.update(b)
253        board_extensions.extend(e)
254
255    extend_v2_boards(boards, board_extensions)
256    return boards
257
258
259def parse_args():
260    parser = argparse.ArgumentParser(allow_abbrev=False)
261    add_args(parser)
262    add_args_formatting(parser)
263    return parser.parse_args()
264
265
266def add_args(parser):
267    # Remember to update west-completion.bash if you add or remove
268    # flags
269    parser.add_argument("--arch-root", dest='arch_roots', default=[],
270                        type=Path, action='append',
271                        help='add an architecture root, may be given more than once')
272    parser.add_argument("--board-root", dest='board_roots', default=[],
273                        type=Path, action='append',
274                        help='add a board root, may be given more than once')
275    parser.add_argument("--soc-root", dest='soc_roots', default=[],
276                        type=Path, action='append',
277                        help='add a soc root, may be given more than once')
278    parser.add_argument("--board", dest='board', default=None,
279                        help='lookup the specific board, fail if not found')
280    parser.add_argument("--board-dir", default=[], type=Path, action='append',
281                        help='only look for boards at the specific location')
282    parser.add_argument("--fuzzy-match", default=None,
283                        help='lookup boards similar to the given board name')
284
285
286def add_args_formatting(parser):
287    parser.add_argument("--cmakeformat", default=None,
288                        help='''CMake Format string to use to list each board''')
289
290
291def variant_v2_qualifiers(variant, qualifiers = None):
292    qualifiers_list = [variant.name] if qualifiers is None else [qualifiers + '/' + variant.name]
293    for v in variant.variants:
294        qualifiers_list.extend(variant_v2_qualifiers(v, qualifiers_list[0]))
295    return qualifiers_list
296
297
298def board_v2_qualifiers(board):
299    qualifiers_list = []
300
301    for s in board.socs:
302        if s.cpuclusters:
303            for c in s.cpuclusters:
304                id_str = s.name + '/' + c.name
305                qualifiers_list.append(id_str)
306                for v in c.variants:
307                    qualifiers_list.extend(variant_v2_qualifiers(v, id_str))
308        else:
309            qualifiers_list.append(s.name)
310            for v in s.variants:
311                qualifiers_list.extend(variant_v2_qualifiers(v, s.name))
312
313    for v in board.variants:
314        qualifiers_list.extend(variant_v2_qualifiers(v))
315    return qualifiers_list
316
317
318def board_v2_qualifiers_csv(board):
319    # Return in csv (comma separated value) format
320    return ",".join(board_v2_qualifiers(board))
321
322
323def dump_v2_boards(args):
324    boards = find_v2_boards(args)
325    if args.fuzzy_match is not None:
326        close_boards = difflib.get_close_matches(args.fuzzy_match, boards.keys())
327        boards = {b: boards[b] for b in close_boards}
328
329    for b in boards.values():
330        qualifiers_list = board_v2_qualifiers(b)
331        if args.cmakeformat is not None:
332            def notfound(x):
333                return x or 'NOTFOUND'
334            info = args.cmakeformat.format(
335                NAME='NAME;' + b.name,
336                DIR='DIR;' + ';'.join(
337                    [str(x.as_posix()) for x in b.directories]),
338                VENDOR='VENDOR;' + notfound(b.vendor),
339                HWM='HWM;' + b.hwm,
340                REVISION_DEFAULT='REVISION_DEFAULT;' + notfound(b.revision_default),
341                REVISION_FORMAT='REVISION_FORMAT;' + notfound(b.revision_format),
342                REVISION_EXACT='REVISION_EXACT;' + str(b.revision_exact),
343                REVISIONS='REVISIONS;' + ';'.join(
344                          [x.name for x in b.revisions]),
345                SOCS='SOCS;' + ';'.join([s.name for s in b.socs]),
346                QUALIFIERS='QUALIFIERS;' + ';'.join(qualifiers_list)
347            )
348            print(info)
349        else:
350            print(f'{b.name}')
351
352
353if __name__ == '__main__':
354    args = parse_args()
355    dump_v2_boards(args)
356