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