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