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