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