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