1# Copyright (c) 2018 Open Source Foundries Limited. 2# 3# SPDX-License-Identifier: Apache-2.0 4'''Common definitions for building Zephyr applications with CMake. 5 6This provides some default settings and convenience wrappers for 7building Zephyr applications needed by multiple commands. 8 9See build.py for the build command itself. 10''' 11 12from collections import OrderedDict 13import os.path 14import re 15import subprocess 16import shutil 17import sys 18 19import packaging.version 20from west import log 21from west.util import quote_sh_list 22 23DEFAULT_CACHE = 'CMakeCache.txt' 24 25DEFAULT_CMAKE_GENERATOR = 'Ninja' 26'''Name of the default CMake generator.''' 27 28 29def run_cmake(args, cwd=None, capture_output=False, dry_run=False): 30 '''Run cmake to (re)generate a build system, a script, etc. 31 32 :param args: arguments to pass to CMake 33 :param cwd: directory to run CMake in, cwd is default 34 :param capture_output: if True, the output is returned instead of being 35 displayed (None is returned by default, or if 36 dry_run is also True) 37 :param dry_run: don't actually execute the command, just print what 38 would have been run 39 40 If capture_output is set to True, returns the output of the command instead 41 of displaying it on stdout/stderr..''' 42 cmake = shutil.which('cmake') 43 if cmake is None and not dry_run: 44 log.die('CMake is not installed or cannot be found; cannot build.') 45 _ensure_min_version(cmake, dry_run) 46 47 cmd = [cmake] + args 48 49 kwargs = dict() 50 if capture_output: 51 kwargs['stdout'] = subprocess.PIPE 52 # CMake sends the output of message() to stderr unless it's STATUS 53 kwargs['stderr'] = subprocess.STDOUT 54 if cwd: 55 kwargs['cwd'] = cwd 56 57 if dry_run: 58 in_cwd = ' (in {})'.format(cwd) if cwd else '' 59 log.inf('Dry run{}:'.format(in_cwd), quote_sh_list(cmd)) 60 return None 61 62 log.dbg('Running CMake:', quote_sh_list(cmd), level=log.VERBOSE_NORMAL) 63 p = subprocess.Popen(cmd, **kwargs) 64 out, _ = p.communicate() 65 if p.returncode == 0: 66 if out: 67 return out.decode(sys.getdefaultencoding()).splitlines() 68 else: 69 return None 70 else: 71 # A real error occurred, raise an exception 72 raise subprocess.CalledProcessError(p.returncode, p.args) 73 74 75def run_build(build_directory, **kwargs): 76 '''Run cmake in build tool mode. 77 78 :param build_directory: runs "cmake --build build_directory" 79 :param extra_args: optional kwarg. List of additional CMake arguments; 80 these come after "--build <build_directory>" 81 on the command line. 82 83 Any additional keyword arguments are passed as-is to run_cmake(). 84 ''' 85 extra_args = kwargs.pop('extra_args', []) 86 return run_cmake(['--build', build_directory] + extra_args, **kwargs) 87 88 89def make_c_identifier(string): 90 '''Make a C identifier from a string in the same way CMake does. 91 ''' 92 # The behavior of CMake's string(MAKE_C_IDENTIFIER ...) is not 93 # precisely documented. This behavior matches the test case 94 # that introduced the function: 95 # 96 # https://gitlab.kitware.com/cmake/cmake/commit/0ab50aea4c4d7099b339fb38b4459d0debbdbd85 97 ret = [] 98 99 alpha_under = re.compile('[A-Za-z_]') 100 alpha_num_under = re.compile('[A-Za-z0-9_]') 101 102 if not alpha_under.match(string): 103 ret.append('_') 104 for c in string: 105 if alpha_num_under.match(c): 106 ret.append(c) 107 else: 108 ret.append('_') 109 110 return ''.join(ret) 111 112 113class CMakeCacheEntry: 114 '''Represents a CMake cache entry. 115 116 This class understands the type system in a CMakeCache.txt, and 117 converts the following cache types to Python types: 118 119 Cache Type Python type 120 ---------- ------------------------------------------- 121 FILEPATH str 122 PATH str 123 STRING str OR list of str (if ';' is in the value) 124 BOOL bool 125 INTERNAL str OR list of str (if ';' is in the value) 126 STATIC str OR list of str (if ';' is in the value) 127 ---------- ------------------------------------------- 128 ''' 129 130 # Regular expression for a cache entry. 131 # 132 # CMake variable names can include escape characters, allowing a 133 # wider set of names than is easy to match with a regular 134 # expression. To be permissive here, use a non-greedy match up to 135 # the first colon (':'). This breaks if the variable name has a 136 # colon inside, but it's good enough. 137 CACHE_ENTRY = re.compile( 138 r'''(?P<name>.*?) # name 139 :(?P<type>FILEPATH|PATH|STRING|BOOL|INTERNAL|STATIC) # type 140 =(?P<value>.*) # value 141 ''', re.X) 142 143 @classmethod 144 def _to_bool(cls, val): 145 # Convert a CMake BOOL string into a Python bool. 146 # 147 # "True if the constant is 1, ON, YES, TRUE, Y, or a 148 # non-zero number. False if the constant is 0, OFF, NO, 149 # FALSE, N, IGNORE, NOTFOUND, the empty string, or ends in 150 # the suffix -NOTFOUND. Named boolean constants are 151 # case-insensitive. If the argument is not one of these 152 # constants, it is treated as a variable." 153 # 154 # https://cmake.org/cmake/help/v3.0/command/if.html 155 val = val.upper() 156 if val in ('ON', 'YES', 'TRUE', 'Y'): 157 return True 158 elif val in ('OFF', 'NO', 'FALSE', 'N', 'IGNORE', 'NOTFOUND', ''): 159 return False 160 elif val.endswith('-NOTFOUND'): 161 return False 162 else: 163 try: 164 v = int(val) 165 return v != 0 166 except ValueError as exc: 167 raise ValueError('invalid bool {}'.format(val)) from exc 168 169 @classmethod 170 def from_line(cls, line, line_no): 171 # Comments can only occur at the beginning of a line. 172 # (The value of an entry could contain a comment character). 173 if line.startswith('//') or line.startswith('#'): 174 return None 175 176 # Whitespace-only lines do not contain cache entries. 177 if not line.strip(): 178 return None 179 180 m = cls.CACHE_ENTRY.match(line) 181 if not m: 182 return None 183 184 name, type_, value = (m.group(g) for g in ('name', 'type', 'value')) 185 if type_ == 'BOOL': 186 try: 187 value = cls._to_bool(value) 188 except ValueError as exc: 189 args = exc.args + ('on line {}: {}'.format(line_no, line),) 190 raise ValueError(args) from exc 191 elif type_ in {'STRING', 'INTERNAL', 'STATIC'}: 192 # If the value is a CMake list (i.e. is a string which 193 # contains a ';'), convert to a Python list. 194 if ';' in value: 195 value = value.split(';') 196 197 return CMakeCacheEntry(name, value) 198 199 def __init__(self, name, value): 200 self.name = name 201 self.value = value 202 203 def __str__(self): 204 fmt = 'CMakeCacheEntry(name={}, value={})' 205 return fmt.format(self.name, self.value) 206 207 208class CMakeCache: 209 '''Parses and represents a CMake cache file.''' 210 211 @staticmethod 212 def from_build_dir(build_dir): 213 return CMakeCache(os.path.join(build_dir, DEFAULT_CACHE)) 214 215 def __init__(self, cache_file): 216 self.cache_file = cache_file 217 self.load(cache_file) 218 219 def load(self, cache_file): 220 entries = [] 221 with open(cache_file, 'r', encoding="utf-8") as cache: 222 for line_no, line in enumerate(cache): 223 entry = CMakeCacheEntry.from_line(line, line_no) 224 if entry: 225 entries.append(entry) 226 self._entries = OrderedDict((e.name, e) for e in entries) 227 228 def get(self, name, default=None): 229 entry = self._entries.get(name) 230 if entry is not None: 231 return entry.value 232 else: 233 return default 234 235 def get_list(self, name, default=None): 236 if default is None: 237 default = [] 238 entry = self._entries.get(name) 239 if entry is not None: 240 value = entry.value 241 if isinstance(value, list): 242 return value 243 elif isinstance(value, str): 244 return [value] if value else [] 245 else: 246 msg = 'invalid value {} type {}' 247 raise RuntimeError(msg.format(value, type(value))) 248 else: 249 return default 250 251 def __contains__(self, name): 252 return name in self._entries 253 254 def __getitem__(self, name): 255 return self._entries[name].value 256 257 def __setitem__(self, name, entry): 258 if not isinstance(entry, CMakeCacheEntry): 259 msg = 'improper type {} for value {}, expecting CMakeCacheEntry' 260 raise TypeError(msg.format(type(entry), entry)) 261 self._entries[name] = entry 262 263 def __delitem__(self, name): 264 del self._entries[name] 265 266 def __iter__(self): 267 return iter(self._entries.values()) 268 269def _ensure_min_version(cmake, dry_run): 270 cmd = [cmake, '--version'] 271 if dry_run: 272 log.inf('Dry run:', quote_sh_list(cmd)) 273 return 274 275 try: 276 version_out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) 277 except subprocess.CalledProcessError as cpe: 278 log.die('cannot get cmake version:', str(cpe)) 279 decoded = version_out.decode('utf-8') 280 lines = decoded.splitlines() 281 if not lines: 282 log.die('can\'t get cmake version: ' + 283 'unexpected "cmake --version" output:\n{}\n'. 284 format(decoded) + 285 'Please install CMake ' + _MIN_CMAKE_VERSION_STR + 286 ' or higher (https://cmake.org/download/).') 287 version = lines[0].split()[2] 288 if '-' in version: 289 # Handle semver cases like "3.19.20210206-g1e50ab6" 290 # which Kitware uses for prerelease versions. 291 version = version.split('-', 1)[0] 292 if packaging.version.parse(version) < _MIN_CMAKE_VERSION: 293 log.die('cmake version', version, 294 'is less than minimum version {};'. 295 format(_MIN_CMAKE_VERSION_STR), 296 'please update your CMake (https://cmake.org/download/).') 297 else: 298 log.dbg('cmake version', version, 'is OK; minimum version is', 299 _MIN_CMAKE_VERSION_STR) 300 301_MIN_CMAKE_VERSION_STR = '3.13.1' 302_MIN_CMAKE_VERSION = packaging.version.parse(_MIN_CMAKE_VERSION_STR) 303