blob: d725f08891e0af8fa27e4e4cc86f0be768c19df3 [file] [log] [blame]
Marti Bolivar06c9bf42023-01-07 15:27:07 -08001#!/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
Jamie McCraec5fbcc42023-03-27 10:16:26 +010019from pathlib import Path, PurePosixPath
Marti Bolivar06c9bf42023-01-07 15:27:07 -080020from 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
Jamie McCraec5fbcc42023-03-27 10:16:26 +010030import platform
Marti Bolivar06c9bf42023-01-07 15:27:07 -080031
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
Jamie McCrae0711f422024-05-24 07:46:33 +010051 def process_data(self, pathobj: Path, snippet_data: dict, sysbuild: bool):
Marti Bolivar06c9bf42023-01-07 15:27:07 -080052 '''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 McCrae0711f422024-05-24 07:46:33 +010055 if variable in ('SB_EXTRA_CONF_FILE', 'EXTRA_DTC_OVERLAY_FILE', 'EXTRA_CONF_FILE'):
Marti Bolivar06c9bf42023-01-07 15:27:07 -080056 path = pathobj.parent / value
57 if not path.is_file():
58 _err(f'snippet file {pathobj}: {variable}: file not found: {path}')
Jamie McCraeb680a6e2024-03-08 11:41:38 +000059 return f'"{path.as_posix()}"'
Jordan Yatesfe498ad2023-07-08 20:28:37 +100060 if variable in ('DTS_EXTRA_CPPFLAGS'):
61 return f'"{value}"'
Marti Bolivar06c9bf42023-01-07 15:27:07 -080062 _err(f'unknown append variable: {variable}')
63
64 for variable, value in snippet_data.get('append', {}).items():
Jamie McCrae0711f422024-05-24 07:46:33 +010065 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 Bolivar06c9bf42023-01-07 15:27:07 -080068 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 McCrae0711f422024-05-24 07:46:33 +010073 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 Bolivar06c9bf42023-01-07 15:27:07 -080077
78class 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 Ioannidis4f5cb1b2023-04-22 00:28:09 +090087 self.requested: List[str] = list(requested or [])
Marti Bolivar06c9bf42023-01-07 15:27:07 -080088
89class SnippetsError(Exception):
90 '''Class for signalling expected errors'''
91
92 def __init__(self, msg):
93 self.msg = msg
94
95class 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 McCraec5fbcc42023-03-27 10:16:26 +0100109
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 Bolivar06c9bf42023-01-07 15:27:07 -0800120 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.
138set(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.
141set(SNIPPET_PATHS {snippet_path_list})
Torsten Rasmussenba48dd82023-05-08 14:01:34 +0200142
143# Create variable scope for snippets build variables
144zephyr_create_scope(snippets)
Marti Bolivar06c9bf42023-01-07 15:27:07 -0800145''')
146
Stephanos Ioannidis4f5cb1b2023-04-22 00:28:09 +0900147 for snippet_name in snippets.requested:
Marti Bolivar06c9bf42023-01-07 15:27:07 -0800148 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 Rasmussen732c5042024-03-19 15:26:59 +0100166if("${{BOARD}}${{BOARD_QUALIFIERS}}" MATCHES "^{board_re}$")''')
Marti Bolivar06c9bf42023-01-07 15:27:07 -0800167 else:
168 self.print(f'''\
169# Appends for board '{board}'
Torsten Rasmussen732c5042024-03-19 15:26:59 +0100170if("${{BOARD}}${{BOARD_QUALIFIERS}}" STREQUAL "{board}")''')
Marti Bolivar06c9bf42023-01-07 15:27:07 -0800171 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 Rasmussenba48dd82023-05-08 14:01:34 +0200178 self.print(f'{space}zephyr_set({name} {value} SCOPE snippets APPEND)')
Marti Bolivar06c9bf42023-01-07 15:27:07 -0800179
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.
186SCHEMA_PATH = str(Path(__file__).parent / 'schemas' / 'snippet-schema.yml')
187with 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.
192SNIPPET_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.
199SNIPPET_NAME_RE = re.compile('[A-Za-z0-9][A-Za-z0-9_-]*')
200
201# Logger for this module.
202LOG = logging.getLogger('snippets')
203
204def _err(msg):
205 raise SnippetsError(f'error: {msg}')
206
207def 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 McCrae0711f422024-05-24 07:46:33 +0100219 parser.add_argument('--sysbuild', action="store_true",
220 help='''set if this is running as sysbuild''')
Marti Bolivar06c9bf42023-01-07 15:27:07 -0800221 return parser.parse_args()
222
223def 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
231def 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 McCrae0711f422024-05-24 07:46:33 +0100243 process_snippets_in(root, snippets, args.sysbuild)
Marti Bolivar06c9bf42023-01-07 15:27:07 -0800244
245 return snippets
246
Jamie McCraebc97d8f2023-08-03 10:56:41 +0100247def 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 McCrae0711f422024-05-24 07:46:33 +0100259 process_snippets_in(root, snippets, False)
Jamie McCraebc97d8f2023-08-03 10:56:41 +0100260
261 return snippets
262
Jamie McCrae0711f422024-05-24 07:46:33 +0100263def process_snippets_in(root_dir: Path, snippets: Snippets, sysbuild: bool) -> None:
Marti Bolivar06c9bf42023-01-07 15:27:07 -0800264 '''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 McCrae0711f422024-05-24 07:46:33 +0100285 snippets[name].process_data(snippet_yml, snippet_data, sysbuild)
Marti Bolivar06c9bf42023-01-07 15:27:07 -0800286 snippets.paths.add(snippet_yml)
287
288def 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'''\
301invalid {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
320def 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'''\
326snippets not found: {', '.join(unknown_snippets)}
327 Please choose from among the following snippets:
328 {all_snippets}''')
329
330def 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 Cufifde1a232023-06-02 14:27:53 +0200338 with open(cmake_out, 'w', encoding="utf-8") as f:
Marti Bolivar06c9bf42023-01-07 15:27:07 -0800339 SnippetToCMakePrinter(snippets, f).print_cmake()
340
341def 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
352if __name__ == "__main__":
353 main()