1#!/usr/bin/env python3
2#
3# Copyright (c) 2022, Nordic Semiconductor ASA
4#
5# SPDX-License-Identifier: Apache-2.0
6
7'''Internal snippets tool.
8This is part of the build system's support for snippets.
9It is not meant for use outside of the build system.
10
11Output CMake variables:
12
13- SNIPPET_NAMES: CMake list of discovered snippet names
14- SNIPPET_FOUND_{snippet}: one per discovered snippet
15'''
16
17from collections import defaultdict, UserDict
18from dataclasses import dataclass, field
19from pathlib import Path, PurePosixPath
20from typing import Dict, Iterable, List, Set
21import argparse
22import logging
23import os
24import pykwalify.core
25import pykwalify.errors
26import re
27import sys
28import textwrap
29import yaml
30import platform
31
32# Marker type for an 'append:' configuration. Maps variables
33# to the list of values to append to them.
34Appends = Dict[str, List[str]]
35
36def _new_append():
37    return defaultdict(list)
38
39def _new_board2appends():
40    return defaultdict(_new_append)
41
42@dataclass
43class Snippet:
44    '''Class for keeping track of all the settings discovered for an
45    individual snippet.'''
46
47    name: str
48    appends: Appends = field(default_factory=_new_append)
49    board2appends: Dict[str, Appends] = field(default_factory=_new_board2appends)
50
51    def process_data(self, pathobj: Path, snippet_data: dict):
52        '''Process the data in a snippet.yml file, after it is loaded into a
53        python object and validated by pykwalify.'''
54        def append_value(variable, value):
55            if variable in ('EXTRA_DTC_OVERLAY_FILE', 'EXTRA_CONF_FILE'):
56                path = pathobj.parent / value
57                if not path.is_file():
58                    _err(f'snippet file {pathobj}: {variable}: file not found: {path}')
59                return f'"{path}"'
60            if variable in ('DTS_EXTRA_CPPFLAGS'):
61                return f'"{value}"'
62            _err(f'unknown append variable: {variable}')
63
64        for variable, value in snippet_data.get('append', {}).items():
65            self.appends[variable].append(append_value(variable, value))
66        for board, settings in snippet_data.get('boards', {}).items():
67            if board.startswith('/') and not board.endswith('/'):
68                _err(f"snippet file {pathobj}: board {board} starts with '/', so "
69                     "it must end with '/' to use a regular expression")
70            for variable, value in settings.get('append', {}).items():
71                self.board2appends[board][variable].append(
72                    append_value(variable, value))
73
74class Snippets(UserDict):
75    '''Type for all the information we have discovered about all snippets.
76    As a dict, this maps a snippet's name onto the Snippet object.
77    Any additional global attributes about all snippets go here as
78    instance attributes.'''
79
80    def __init__(self, requested: Iterable[str] = None):
81        super().__init__()
82        self.paths: Set[Path] = set()
83        self.requested: List[str] = list(requested or [])
84
85class SnippetsError(Exception):
86    '''Class for signalling expected errors'''
87
88    def __init__(self, msg):
89        self.msg = msg
90
91class SnippetToCMakePrinter:
92    '''Helper class for printing a Snippets's semantics to a .cmake
93    include file for use by snippets.cmake.'''
94
95    def __init__(self, snippets: Snippets, out_file):
96        self.snippets = snippets
97        self.out_file = out_file
98        self.section = '#' * 79
99
100    def print_cmake(self):
101        '''Print to the output file provided to the constructor.'''
102        # TODO: add source file info
103        snippets = self.snippets
104        snippet_names = sorted(snippets.keys())
105
106        if platform.system() == "Windows":
107            # Change to linux-style paths for windows to avoid cmake escape character code issues
108            snippets.paths = set(map(lambda x: str(PurePosixPath(x)), snippets.paths))
109
110            for this_snippet in snippets:
111                for snippet_append in (snippets[this_snippet].appends):
112                    snippets[this_snippet].appends[snippet_append] = \
113                        set(map(lambda x: str(x.replace("\\", "/")), \
114                            snippets[this_snippet].appends[snippet_append]))
115
116        snippet_path_list = " ".join(
117            sorted(f'"{path}"' for path in snippets.paths))
118
119        self.print('''\
120# WARNING. THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!
121#
122# This file contains build system settings derived from your snippets.
123# Its contents are an implementation detail that should not be used outside
124# of Zephyr's snippets CMake module.
125#
126# See the Snippets guide in the Zephyr documentation for more information.
127''')
128
129        self.print(f'''\
130{self.section}
131# Global information about all snippets.
132
133# The name of every snippet that was discovered.
134set(SNIPPET_NAMES {' '.join(f'"{name}"' for name in snippet_names)})
135# The paths to all the snippet.yml files. One snippet
136# can have multiple snippet.yml files.
137set(SNIPPET_PATHS {snippet_path_list})
138
139# Create variable scope for snippets build variables
140zephyr_create_scope(snippets)
141''')
142
143        for snippet_name in snippets.requested:
144            self.print_cmake_for(snippets[snippet_name])
145            self.print()
146
147    def print_cmake_for(self, snippet: Snippet):
148        self.print(f'''\
149{self.section}
150# Snippet '{snippet.name}'
151
152# Common variable appends.''')
153        self.print_appends(snippet.appends, 0)
154        for board, appends in snippet.board2appends.items():
155            self.print_appends_for_board(board, appends)
156
157    def print_appends_for_board(self, board: str, appends: Appends):
158        if board.startswith('/'):
159            board_re = board[1:-1]
160            self.print(f'''\
161# Appends for board regular expression '{board_re}'
162if("${{BOARD}}" MATCHES "^{board_re}$")''')
163        else:
164            self.print(f'''\
165# Appends for board '{board}'
166if("${{BOARD}}" STREQUAL "{board}")''')
167        self.print_appends(appends, 1)
168        self.print('endif()')
169
170    def print_appends(self, appends: Appends, indent: int):
171        space = '  ' * indent
172        for name, values in appends.items():
173            for value in values:
174                self.print(f'{space}zephyr_set({name} {value} SCOPE snippets APPEND)')
175
176    def print(self, *args, **kwargs):
177        kwargs['file'] = self.out_file
178        print(*args, **kwargs)
179
180# Name of the file containing the pykwalify schema for snippet.yml
181# files.
182SCHEMA_PATH = str(Path(__file__).parent / 'schemas' / 'snippet-schema.yml')
183with open(SCHEMA_PATH, 'r') as f:
184    SNIPPET_SCHEMA = yaml.safe_load(f.read())
185
186# The name of the file which contains metadata about the snippets
187# being defined in a directory.
188SNIPPET_YML = 'snippet.yml'
189
190# Regular expression for validating snippet names. Snippet names must
191# begin with an alphanumeric character, and may contain alphanumeric
192# characters or underscores. This is intentionally very restrictive to
193# keep things consistent and easy to type and remember. We can relax
194# this a bit later if needed.
195SNIPPET_NAME_RE = re.compile('[A-Za-z0-9][A-Za-z0-9_-]*')
196
197# Logger for this module.
198LOG = logging.getLogger('snippets')
199
200def _err(msg):
201    raise SnippetsError(f'error: {msg}')
202
203def parse_args():
204    parser = argparse.ArgumentParser(description='snippets helper',
205                                     allow_abbrev=False)
206    parser.add_argument('--snippet-root', default=[], action='append', type=Path,
207                        help='''a SNIPPET_ROOT element; may be given
208                        multiple times''')
209    parser.add_argument('--snippet', dest='snippets', default=[], action='append',
210                        help='''a SNIPPET element; may be given
211                        multiple times''')
212    parser.add_argument('--cmake-out', type=Path,
213                        help='''file to write cmake output to; include()
214                        this file after calling this script''')
215    return parser.parse_args()
216
217def setup_logging():
218    # Silence validation errors from pykwalify, which are logged at
219    # logging.ERROR level. We want to handle those ourselves as
220    # needed.
221    logging.getLogger('pykwalify').setLevel(logging.CRITICAL)
222    logging.basicConfig(level=logging.INFO,
223                        format='  %(name)s: %(message)s')
224
225def process_snippets(args: argparse.Namespace) -> Snippets:
226    '''Process snippet.yml files under each *snippet_root*
227    by recursive search. Return a Snippets object describing
228    the results of the search.
229    '''
230    # This will contain information about all the snippets
231    # we discover in each snippet_root element.
232    snippets = Snippets(requested=args.snippets)
233
234    # Process each path in snippet_root in order, adjusting
235    # snippets as needed for each one.
236    for root in args.snippet_root:
237        process_snippets_in(root, snippets)
238
239    return snippets
240
241def find_snippets_in_roots(requested_snippets, snippet_roots) -> Snippets:
242    '''Process snippet.yml files under each *snippet_root*
243    by recursive search. Return a Snippets object describing
244    the results of the search.
245    '''
246    # This will contain information about all the snippets
247    # we discover in each snippet_root element.
248    snippets = Snippets(requested=requested_snippets)
249
250    # Process each path in snippet_root in order, adjusting
251    # snippets as needed for each one.
252    for root in snippet_roots:
253        process_snippets_in(root, snippets)
254
255    return snippets
256
257def process_snippets_in(root_dir: Path, snippets: Snippets) -> None:
258    '''Process snippet.yml files in *root_dir*,
259    updating *snippets* as needed.'''
260
261    if not root_dir.is_dir():
262        LOG.warning(f'SNIPPET_ROOT {root_dir} '
263                    'is not a directory; ignoring it')
264        return
265
266    snippets_dir = root_dir / 'snippets'
267    if not snippets_dir.is_dir():
268        return
269
270    for dirpath, _, filenames in os.walk(snippets_dir):
271        if SNIPPET_YML not in filenames:
272            continue
273
274        snippet_yml = Path(dirpath) / SNIPPET_YML
275        snippet_data = load_snippet_yml(snippet_yml)
276        name = snippet_data['name']
277        if name not in snippets:
278            snippets[name] = Snippet(name=name)
279        snippets[name].process_data(snippet_yml, snippet_data)
280        snippets.paths.add(snippet_yml)
281
282def load_snippet_yml(snippet_yml: Path) -> dict:
283    '''Load a snippet.yml file *snippet_yml*, validate the contents
284    against the schema, and do other basic checks. Return the dict
285    of the resulting YAML data.'''
286
287    with open(snippet_yml, 'r') as f:
288        try:
289            snippet_data = yaml.safe_load(f.read())
290        except yaml.scanner.ScannerError:
291            _err(f'snippets file {snippet_yml} is invalid YAML')
292
293    def pykwalify_err(e):
294        return f'''\
295invalid {SNIPPET_YML} file: {snippet_yml}
296{textwrap.indent(e.msg, '  ')}
297'''
298
299    try:
300        pykwalify.core.Core(source_data=snippet_data,
301                            schema_data=SNIPPET_SCHEMA).validate()
302    except pykwalify.errors.PyKwalifyException as e:
303        _err(pykwalify_err(e))
304
305    name = snippet_data['name']
306    if not SNIPPET_NAME_RE.fullmatch(name):
307        _err(f"snippet file {snippet_yml}: invalid snippet name '{name}'; "
308             'snippet names must begin with a letter '
309             'or number, and may only contain letters, numbers, '
310             'dashes (-), and underscores (_)')
311
312    return snippet_data
313
314def check_for_errors(snippets: Snippets) -> None:
315    unknown_snippets = sorted(snippet for snippet in snippets.requested
316                              if snippet not in snippets)
317    if unknown_snippets:
318        all_snippets = '\n    '.join(sorted(snippets))
319        _err(f'''\
320snippets not found: {', '.join(unknown_snippets)}
321  Please choose from among the following snippets:
322    {all_snippets}''')
323
324def write_cmake_out(snippets: Snippets, cmake_out: Path) -> None:
325    '''Write a cmake include file to *cmake_out* which
326    reflects the information in *snippets*.
327
328    The contents of this file should be considered an implementation
329    detail and are not meant to be used outside of snippets.cmake.'''
330    if not cmake_out.parent.exists():
331        cmake_out.parent.mkdir()
332    with open(cmake_out, 'w', encoding="utf-8") as f:
333        SnippetToCMakePrinter(snippets, f).print_cmake()
334
335def main():
336    args = parse_args()
337    setup_logging()
338    try:
339        snippets = process_snippets(args)
340        check_for_errors(snippets)
341    except SnippetsError as e:
342        LOG.critical(e.msg)
343        sys.exit(1)
344    write_cmake_out(snippets, args.cmake_out)
345
346if __name__ == "__main__":
347    main()
348