1import os 2import re 3import subprocess 4import sys 5from io import open 6 7import click 8 9from .constants import GENERATORS 10from .errors import FatalError 11 12 13def executable_exists(args): 14 try: 15 subprocess.check_output(args) 16 return True 17 18 except Exception: 19 return False 20 21 22def realpath(path): 23 """ 24 Return the cannonical path with normalized case. 25 26 It is useful on Windows to comparision paths in case-insensitive manner. 27 On Unix and Mac OS X it works as `os.path.realpath()` only. 28 """ 29 return os.path.normcase(os.path.realpath(path)) 30 31 32def _idf_version_from_cmake(): 33 version_path = os.path.join(os.environ['IDF_PATH'], 'tools/cmake/version.cmake') 34 regex = re.compile(r'^\s*set\s*\(\s*IDF_VERSION_([A-Z]{5})\s+(\d+)') 35 ver = {} 36 try: 37 with open(version_path) as f: 38 for line in f: 39 m = regex.match(line) 40 41 if m: 42 ver[m.group(1)] = m.group(2) 43 44 return 'v%s.%s.%s' % (ver['MAJOR'], ver['MINOR'], ver['PATCH']) 45 except (KeyError, OSError): 46 sys.stderr.write('WARNING: Cannot find ESP-IDF version in version.cmake\n') 47 return None 48 49 50def idf_version(): 51 """Print version of ESP-IDF""" 52 53 # Try to get version from git: 54 try: 55 version = subprocess.check_output([ 56 'git', 57 '--git-dir=%s' % os.path.join(os.environ['IDF_PATH'], '.git'), 58 '--work-tree=%s' % os.environ['IDF_PATH'], 'describe', '--tags', '--dirty' 59 ]).decode('utf-8', 'ignore').strip() 60 except (subprocess.CalledProcessError, UnicodeError): 61 # if failed, then try to parse cmake.version file 62 sys.stderr.write('WARNING: Git version unavailable, reading from source\n') 63 version = _idf_version_from_cmake() 64 65 return version 66 67 68def run_tool(tool_name, args, cwd, env=dict()): 69 def quote_arg(arg): 70 " Quote 'arg' if necessary " 71 if ' ' in arg and not (arg.startswith('"') or arg.startswith("'")): 72 return "'" + arg + "'" 73 return arg 74 75 args = [str(arg) for arg in args] 76 display_args = ' '.join(quote_arg(arg) for arg in args) 77 print('Running %s in directory %s' % (tool_name, quote_arg(cwd))) 78 print('Executing "%s"...' % str(display_args)) 79 80 env_copy = dict(os.environ) 81 env_copy.update(env) 82 83 if sys.version_info[0] < 3: 84 # The subprocess lib cannot accept environment variables as "unicode". Convert to str. 85 # This encoding step is required only in Python 2. 86 for (key, val) in env_copy.items(): 87 if not isinstance(val, str): 88 env_copy[key] = val.encode(sys.getfilesystemencoding() or 'utf-8') 89 90 try: 91 # Note: we explicitly pass in os.environ here, as we may have set IDF_PATH there during startup 92 subprocess.check_call(args, env=env_copy, cwd=cwd) 93 except subprocess.CalledProcessError as e: 94 raise FatalError('%s failed with exit code %d' % (tool_name, e.returncode)) 95 96 97def run_target(target_name, args, env=dict()): 98 generator_cmd = GENERATORS[args.generator]['command'] 99 100 if args.verbose: 101 generator_cmd += [GENERATORS[args.generator]['verbose_flag']] 102 103 run_tool(generator_cmd[0], generator_cmd + [target_name], args.build_dir, env) 104 105 106def _strip_quotes(value, regexp=re.compile(r"^\"(.*)\"$|^'(.*)'$|^(.*)$")): 107 """ 108 Strip quotes like CMake does during parsing cache entries 109 """ 110 111 return [x for x in regexp.match(value).groups() if x is not None][0].rstrip() 112 113 114def _parse_cmakecache(path): 115 """ 116 Parse the CMakeCache file at 'path'. 117 118 Returns a dict of name:value. 119 120 CMakeCache entries also each have a "type", but this is currently ignored. 121 """ 122 result = {} 123 with open(path, encoding='utf-8') as f: 124 for line in f: 125 # cmake cache lines look like: CMAKE_CXX_FLAGS_DEBUG:STRING=-g 126 # groups are name, type, value 127 m = re.match(r'^([^#/:=]+):([^:=]+)=(.*)\n$', line) 128 if m: 129 result[m.group(1)] = m.group(3) 130 return result 131 132 133def _new_cmakecache_entries(cache_path, new_cache_entries): 134 if not os.path.exists(cache_path): 135 return True 136 137 if new_cache_entries: 138 current_cache = _parse_cmakecache(cache_path) 139 140 for entry in new_cache_entries: 141 key, value = entry.split('=', 1) 142 current_value = current_cache.get(key, None) 143 if current_value is None or _strip_quotes(value) != current_value: 144 return True 145 146 return False 147 148 149def _detect_cmake_generator(prog_name): 150 """ 151 Find the default cmake generator, if none was specified. Raises an exception if no valid generator is found. 152 """ 153 for (generator_name, generator) in GENERATORS.items(): 154 if executable_exists(generator['version']): 155 return generator_name 156 raise FatalError("To use %s, either the 'ninja' or 'GNU make' build tool must be available in the PATH" % prog_name) 157 158 159def ensure_build_directory(args, prog_name, always_run_cmake=False): 160 """Check the build directory exists and that cmake has been run there. 161 162 If this isn't the case, create the build directory (if necessary) and 163 do an initial cmake run to configure it. 164 165 This function will also check args.generator parameter. If the parameter is incompatible with 166 the build directory, an error is raised. If the parameter is None, this function will set it to 167 an auto-detected default generator or to the value already configured in the build directory. 168 """ 169 project_dir = args.project_dir 170 # Verify the project directory 171 if not os.path.isdir(project_dir): 172 if not os.path.exists(project_dir): 173 raise FatalError('Project directory %s does not exist' % project_dir) 174 else: 175 raise FatalError('%s must be a project directory' % project_dir) 176 if not os.path.exists(os.path.join(project_dir, 'CMakeLists.txt')): 177 raise FatalError('CMakeLists.txt not found in project directory %s' % project_dir) 178 179 # Verify/create the build directory 180 build_dir = args.build_dir 181 if not os.path.isdir(build_dir): 182 os.makedirs(build_dir) 183 184 # Parse CMakeCache, if it exists 185 cache_path = os.path.join(build_dir, 'CMakeCache.txt') 186 cache = _parse_cmakecache(cache_path) if os.path.exists(cache_path) else {} 187 188 # Validate or set IDF_TARGET 189 _guess_or_check_idf_target(args, prog_name, cache) 190 191 args.define_cache_entry.append('CCACHE_ENABLE=%d' % args.ccache) 192 193 if always_run_cmake or _new_cmakecache_entries(cache_path, args.define_cache_entry): 194 if args.generator is None: 195 args.generator = _detect_cmake_generator(prog_name) 196 try: 197 cmake_args = [ 198 'cmake', 199 '-G', 200 args.generator, 201 '-DPYTHON_DEPS_CHECKED=1', 202 '-DESP_PLATFORM=1', 203 ] 204 if args.cmake_warn_uninitialized: 205 cmake_args += ['--warn-uninitialized'] 206 207 if args.define_cache_entry: 208 cmake_args += ['-D' + d for d in args.define_cache_entry] 209 cmake_args += [project_dir] 210 211 run_tool('cmake', cmake_args, cwd=args.build_dir) 212 except Exception: 213 # don't allow partially valid CMakeCache.txt files, 214 # to keep the "should I run cmake?" logic simple 215 if os.path.exists(cache_path): 216 os.remove(cache_path) 217 raise 218 219 # need to update cache so subsequent access in this method would reflect the result of the previous cmake run 220 cache = _parse_cmakecache(cache_path) if os.path.exists(cache_path) else {} 221 222 try: 223 generator = cache['CMAKE_GENERATOR'] 224 except KeyError: 225 generator = _detect_cmake_generator(prog_name) 226 if args.generator is None: 227 args.generator = (generator) # reuse the previously configured generator, if none was given 228 if generator != args.generator: 229 raise FatalError("Build is configured for generator '%s' not '%s'. Run '%s fullclean' to start again." % 230 (generator, args.generator, prog_name)) 231 232 try: 233 home_dir = cache['CMAKE_HOME_DIRECTORY'] 234 if realpath(home_dir) != realpath(project_dir): 235 raise FatalError( 236 "Build directory '%s' configured for project '%s' not '%s'. Run '%s fullclean' to start again." % 237 (build_dir, realpath(home_dir), realpath(project_dir), prog_name)) 238 except KeyError: 239 pass # if cmake failed part way, CMAKE_HOME_DIRECTORY may not be set yet 240 241 242def merge_action_lists(*action_lists): 243 merged_actions = { 244 'global_options': [], 245 'actions': {}, 246 'global_action_callbacks': [], 247 } 248 for action_list in action_lists: 249 merged_actions['global_options'].extend(action_list.get('global_options', [])) 250 merged_actions['actions'].update(action_list.get('actions', {})) 251 merged_actions['global_action_callbacks'].extend(action_list.get('global_action_callbacks', [])) 252 return merged_actions 253 254 255def get_sdkconfig_value(sdkconfig_file, key): 256 """ 257 Return the value of given key from sdkconfig_file. 258 If sdkconfig_file does not exist or the option is not present, returns None. 259 """ 260 assert key.startswith('CONFIG_') 261 if not os.path.exists(sdkconfig_file): 262 return None 263 # keep track of the last seen value for the given key 264 value = None 265 # if the value is quoted, this excludes the quotes from the value 266 pattern = re.compile(r"^{}=\"?([^\"]*)\"?$".format(key)) 267 with open(sdkconfig_file, 'r') as f: 268 for line in f: 269 match = re.match(pattern, line) 270 if match: 271 value = match.group(1) 272 return value 273 274 275def is_target_supported(project_path, supported_targets): 276 """ 277 Returns True if the active target is supported, or False otherwise. 278 """ 279 return get_sdkconfig_value(os.path.join(project_path, 'sdkconfig'), 'CONFIG_IDF_TARGET') in supported_targets 280 281 282def _guess_or_check_idf_target(args, prog_name, cache): 283 """ 284 If CMakeCache.txt doesn't exist, and IDF_TARGET is not set in the environment, guess the value from 285 sdkconfig or sdkconfig.defaults, and pass it to CMake in IDF_TARGET variable. 286 287 Otherwise, cross-check the three settings (sdkconfig, CMakeCache, environment) and if there is 288 mismatch, fail with instructions on how to fix this. 289 """ 290 # Default locations of sdkconfig files. 291 # FIXME: they may be overridden in the project or by a CMake variable (IDF-1369). 292 sdkconfig_path = os.path.join(args.project_dir, 'sdkconfig') 293 sdkconfig_defaults_path = os.path.join(args.project_dir, 'sdkconfig.defaults') 294 295 # These are used to guess the target from sdkconfig, or set the default target by sdkconfig.defaults. 296 idf_target_from_sdkconfig = get_sdkconfig_value(sdkconfig_path, 'CONFIG_IDF_TARGET') 297 idf_target_from_sdkconfig_defaults = get_sdkconfig_value(sdkconfig_defaults_path, 'CONFIG_IDF_TARGET') 298 idf_target_from_env = os.environ.get('IDF_TARGET') 299 idf_target_from_cache = cache.get('IDF_TARGET') 300 301 if not cache and not idf_target_from_env: 302 # CMakeCache.txt does not exist yet, and IDF_TARGET is not set in the environment. 303 guessed_target = idf_target_from_sdkconfig or idf_target_from_sdkconfig_defaults 304 if guessed_target: 305 if args.verbose: 306 print("IDF_TARGET is not set, guessed '%s' from sdkconfig" % (guessed_target)) 307 args.define_cache_entry.append('IDF_TARGET=' + guessed_target) 308 309 elif idf_target_from_env: 310 # Let's check that IDF_TARGET values are consistent 311 if idf_target_from_sdkconfig and idf_target_from_sdkconfig != idf_target_from_env: 312 raise FatalError("Project sdkconfig was generated for target '{t_conf}', but environment variable IDF_TARGET " 313 "is set to '{t_env}'. Run '{prog} set-target {t_env}' to generate new sdkconfig file for target {t_env}." 314 .format(t_conf=idf_target_from_sdkconfig, t_env=idf_target_from_env, prog=prog_name)) 315 316 if idf_target_from_cache and idf_target_from_cache != idf_target_from_env: 317 raise FatalError("Target settings are not consistent: '{t_env}' in the environment, '{t_cache}' in CMakeCache.txt. " 318 "Run '{prog} fullclean' to start again." 319 .format(t_env=idf_target_from_env, t_cache=idf_target_from_cache, prog=prog_name)) 320 321 elif idf_target_from_cache and idf_target_from_sdkconfig and idf_target_from_cache != idf_target_from_sdkconfig: 322 # This shouldn't happen, unless the user manually edits CMakeCache.txt or sdkconfig, but let's check anyway. 323 raise FatalError("Project sdkconfig was generated for target '{t_conf}', but CMakeCache.txt contains '{t_cache}'. " 324 "To keep the setting in sdkconfig ({t_conf}) and re-generate CMakeCache.txt, run '{prog} fullclean'. " 325 "To re-generate sdkconfig for '{t_cache}' target, run '{prog} set-target {t_cache}'." 326 .format(t_conf=idf_target_from_sdkconfig, t_cache=idf_target_from_cache, prog=prog_name)) 327 328 329class TargetChoice(click.Choice): 330 """ 331 A version of click.Choice with two special features: 332 - ignores hyphens 333 - not case sensitive 334 """ 335 def __init__(self, choices): 336 super(TargetChoice, self).__init__(choices, case_sensitive=False) 337 338 def convert(self, value, param, ctx): 339 def normalize(str): 340 return str.lower().replace('-', '') 341 342 saved_token_normalize_func = ctx.token_normalize_func 343 ctx.token_normalize_func = normalize 344 345 try: 346 return super(TargetChoice, self).convert(value, param, ctx) 347 finally: 348 ctx.token_normalize_func = saved_token_normalize_func 349