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 UNINITIALIZED str OR list of str (if ';' is in the value) 128 ---------- ------------------------------------------- 129 ''' 130 131 # Regular expression for a cache entry. 132 # 133 # CMake variable names can include escape characters, allowing a 134 # wider set of names than is easy to match with a regular 135 # expression. To be permissive here, use a non-greedy match up to 136 # the first colon (':'). This breaks if the variable name has a 137 # colon inside, but it's good enough. 138 CACHE_ENTRY = re.compile( 139 r'''(?P<name>.*?) # name 140 :(?P<type>FILEPATH|PATH|STRING|BOOL|INTERNAL|STATIC|UNINITIALIZED) # type 141 =(?P<value>.*) # value 142 ''', re.X) 143 144 @classmethod 145 def _to_bool(cls, val): 146 # Convert a CMake BOOL string into a Python bool. 147 # 148 # "True if the constant is 1, ON, YES, TRUE, Y, or a 149 # non-zero number. False if the constant is 0, OFF, NO, 150 # FALSE, N, IGNORE, NOTFOUND, the empty string, or ends in 151 # the suffix -NOTFOUND. Named boolean constants are 152 # case-insensitive. If the argument is not one of these 153 # constants, it is treated as a variable." 154 # 155 # https://cmake.org/cmake/help/v3.0/command/if.html 156 val = val.upper() 157 if val in ('ON', 'YES', 'TRUE', 'Y'): 158 return True 159 elif val in ('OFF', 'NO', 'FALSE', 'N', 'IGNORE', 'NOTFOUND', ''): 160 return False 161 elif val.endswith('-NOTFOUND'): 162 return False 163 else: 164 try: 165 v = int(val) 166 return v != 0 167 except ValueError as exc: 168 raise ValueError('invalid bool {}'.format(val)) from exc 169 170 @classmethod 171 def from_line(cls, line, line_no): 172 # Comments can only occur at the beginning of a line. 173 # (The value of an entry could contain a comment character). 174 if line.startswith('//') or line.startswith('#'): 175 return None 176 177 # Whitespace-only lines do not contain cache entries. 178 if not line.strip(): 179 return None 180 181 m = cls.CACHE_ENTRY.match(line) 182 if not m: 183 return None 184 185 name, type_, value = (m.group(g) for g in ('name', 'type', 'value')) 186 if type_ == 'BOOL': 187 try: 188 value = cls._to_bool(value) 189 except ValueError as exc: 190 args = exc.args + ('on line {}: {}'.format(line_no, line),) 191 raise ValueError(args) from exc 192 elif type_ in {'STRING', 'INTERNAL', 'STATIC', 'UNINITIALIZED'}: 193 # If the value is a CMake list (i.e. is a string which 194 # contains a ';'), convert to a Python list. 195 if ';' in value: 196 value = value.split(';') 197 198 return CMakeCacheEntry(name, value) 199 200 def __init__(self, name, value): 201 self.name = name 202 self.value = value 203 204 def __str__(self): 205 fmt = 'CMakeCacheEntry(name={}, value={})' 206 return fmt.format(self.name, self.value) 207 208 209class CMakeCache: 210 '''Parses and represents a CMake cache file.''' 211 212 @staticmethod 213 def from_build_dir(build_dir): 214 return CMakeCache(os.path.join(build_dir, DEFAULT_CACHE)) 215 216 def __init__(self, cache_file): 217 self.cache_file = cache_file 218 self.load(cache_file) 219 220 def load(self, cache_file): 221 entries = [] 222 with open(cache_file, 'r', encoding="utf-8") as cache: 223 for line_no, line in enumerate(cache): 224 entry = CMakeCacheEntry.from_line(line, line_no) 225 if entry: 226 entries.append(entry) 227 self._entries = OrderedDict((e.name, e) for e in entries) 228 229 def get(self, name, default=None): 230 entry = self._entries.get(name) 231 if entry is not None: 232 return entry.value 233 else: 234 return default 235 236 def get_list(self, name, default=None): 237 if default is None: 238 default = [] 239 entry = self._entries.get(name) 240 if entry is not None: 241 value = entry.value 242 if isinstance(value, list): 243 return value 244 elif isinstance(value, str): 245 return [value] if value else [] 246 else: 247 msg = 'invalid value {} type {}' 248 raise RuntimeError(msg.format(value, type(value))) 249 else: 250 return default 251 252 def __contains__(self, name): 253 return name in self._entries 254 255 def __getitem__(self, name): 256 return self._entries[name].value 257 258 def __setitem__(self, name, entry): 259 if not isinstance(entry, CMakeCacheEntry): 260 msg = 'improper type {} for value {}, expecting CMakeCacheEntry' 261 raise TypeError(msg.format(type(entry), entry)) 262 self._entries[name] = entry 263 264 def __delitem__(self, name): 265 del self._entries[name] 266 267 def __iter__(self): 268 return iter(self._entries.values()) 269 270def _ensure_min_version(cmake, dry_run): 271 cmd = [cmake, '--version'] 272 if dry_run: 273 log.inf('Dry run:', quote_sh_list(cmd)) 274 return 275 276 try: 277 version_out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) 278 except subprocess.CalledProcessError as cpe: 279 log.die('cannot get cmake version:', str(cpe)) 280 decoded = version_out.decode('utf-8') 281 lines = decoded.splitlines() 282 if not lines: 283 log.die('can\'t get cmake version: ' + 284 'unexpected "cmake --version" output:\n{}\n'. 285 format(decoded) + 286 'Please install CMake ' + _MIN_CMAKE_VERSION_STR + 287 ' or higher (https://cmake.org/download/).') 288 version = lines[0].split()[2] 289 if '-' in version: 290 # Handle semver cases like "3.19.20210206-g1e50ab6" 291 # which Kitware uses for prerelease versions. 292 version = version.split('-', 1)[0] 293 if packaging.version.parse(version) < _MIN_CMAKE_VERSION: 294 log.die('cmake version', version, 295 'is less than minimum version {};'. 296 format(_MIN_CMAKE_VERSION_STR), 297 'please update your CMake (https://cmake.org/download/).') 298 else: 299 log.dbg('cmake version', version, 'is OK; minimum version is', 300 _MIN_CMAKE_VERSION_STR) 301 302_MIN_CMAKE_VERSION_STR = '3.13.1' 303_MIN_CMAKE_VERSION = packaging.version.parse(_MIN_CMAKE_VERSION_STR) 304