]> granicus.if.org Git - esp-idf/blob - tools/idf.py
1e234215f39ff1a12c309102fce3f681ff266ab8
[esp-idf] / tools / idf.py
1 #!/usr/bin/env python
2 #
3 # 'idf.py' is a top-level config/build command line tool for ESP-IDF
4 #
5 # You don't have to use idf.py, you can use cmake directly
6 # (or use cmake in an IDE)
7 #
8 #
9 #
10 # Copyright 2019 Espressif Systems (Shanghai) PTE LTD
11 #
12 # Licensed under the Apache License, Version 2.0 (the "License");
13 # you may not use this file except in compliance with the License.
14 # You may obtain a copy of the License at
15 #
16 #     http://www.apache.org/licenses/LICENSE-2.0
17 #
18 # Unless required by applicable law or agreed to in writing, software
19 # distributed under the License is distributed on an "AS IS" BASIS,
20 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21 # See the License for the specific language governing permissions and
22 # limitations under the License.
23 #
24
25 # WARNING: we don't check for Python build-time dependencies until
26 # check_environment() function below. If possible, avoid importing
27 # any external libraries here - put in external script, or import in
28 # their specific function instead.
29 import codecs
30 import json
31 import locale
32 import multiprocessing
33 import os
34 import os.path
35 import re
36 import shutil
37 import subprocess
38 import sys
39
40
41 class FatalError(RuntimeError):
42     """
43     Wrapper class for runtime errors that aren't caused by bugs in idf.py or the build proces.s
44     """
45
46     pass
47
48
49 # Use this Python interpreter for any subprocesses we launch
50 PYTHON = sys.executable
51
52 # note: os.environ changes don't automatically propagate to child processes,
53 # you have to pass env=os.environ explicitly anywhere that we create a process
54 os.environ["PYTHON"] = sys.executable
55
56 # Name of the program, normally 'idf.py'.
57 # Can be overridden from idf.bat using IDF_PY_PROGRAM_NAME
58 PROG = os.getenv("IDF_PY_PROGRAM_NAME", sys.argv[0])
59
60 # Make flavors, across the various kinds of Windows environments & POSIX...
61 if "MSYSTEM" in os.environ:  # MSYS
62     MAKE_CMD = "make"
63     MAKE_GENERATOR = "MSYS Makefiles"
64 elif os.name == "nt":  # other Windows
65     MAKE_CMD = "mingw32-make"
66     MAKE_GENERATOR = "MinGW Makefiles"
67 else:
68     MAKE_CMD = "make"
69     MAKE_GENERATOR = "Unix Makefiles"
70
71 GENERATORS = [
72     # ('generator name', 'build command line', 'version command line', 'verbose flag')
73     ("Ninja", ["ninja"], ["ninja", "--version"], "-v"),
74     (
75         MAKE_GENERATOR,
76         [MAKE_CMD, "-j", str(multiprocessing.cpu_count() + 2)],
77         [MAKE_CMD, "--version"],
78         "VERBOSE=1",
79     ),
80 ]
81 GENERATOR_CMDS = dict((a[0], a[1]) for a in GENERATORS)
82 GENERATOR_VERBOSE = dict((a[0], a[3]) for a in GENERATORS)
83
84
85 def _run_tool(tool_name, args, cwd):
86     def quote_arg(arg):
87         " Quote 'arg' if necessary "
88         if " " in arg and not (arg.startswith('"') or arg.startswith("'")):
89             return "'" + arg + "'"
90         return arg
91
92     display_args = " ".join(quote_arg(arg) for arg in args)
93     print("Running %s in directory %s" % (tool_name, quote_arg(cwd)))
94     print('Executing "%s"...' % str(display_args))
95     try:
96         # Note: we explicitly pass in os.environ here, as we may have set IDF_PATH there during startup
97         subprocess.check_call(args, env=os.environ, cwd=cwd)
98     except subprocess.CalledProcessError as e:
99         raise FatalError("%s failed with exit code %d" % (tool_name, e.returncode))
100
101
102 def _realpath(path):
103     """
104     Return the cannonical path with normalized case.
105
106     It is useful on Windows to comparision paths in case-insensitive manner.
107     On Unix and Mac OS X it works as `os.path.realpath()` only.
108     """
109     return os.path.normcase(os.path.realpath(path))
110
111
112 def check_environment():
113     """
114     Verify the environment contains the top-level tools we need to operate
115
116     (cmake will check a lot of other things)
117     """
118     if not executable_exists(["cmake", "--version"]):
119         raise FatalError("'cmake' must be available on the PATH to use %s" % PROG)
120     # find the directory idf.py is in, then the parent directory of this, and assume this is IDF_PATH
121     detected_idf_path = _realpath(os.path.join(os.path.dirname(__file__), ".."))
122     if "IDF_PATH" in os.environ:
123         set_idf_path = _realpath(os.environ["IDF_PATH"])
124         if set_idf_path != detected_idf_path:
125             print(
126                 "WARNING: IDF_PATH environment variable is set to %s but %s path indicates IDF directory %s. "
127                 "Using the environment variable directory, but results may be unexpected..."
128                 % (set_idf_path, PROG, detected_idf_path)
129             )
130     else:
131         print("Setting IDF_PATH environment variable: %s" % detected_idf_path)
132         os.environ["IDF_PATH"] = detected_idf_path
133
134     # check Python dependencies
135     print("Checking Python dependencies...")
136     try:
137         subprocess.check_call(
138             [
139                 os.environ["PYTHON"],
140                 os.path.join(
141                     os.environ["IDF_PATH"], "tools", "check_python_dependencies.py"
142                 ),
143             ],
144             env=os.environ,
145         )
146     except subprocess.CalledProcessError:
147         raise SystemExit(1)
148
149
150 def executable_exists(args):
151     try:
152         subprocess.check_output(args)
153         return True
154     except Exception:
155         return False
156
157
158 def detect_cmake_generator():
159     """
160     Find the default cmake generator, if none was specified. Raises an exception if no valid generator is found.
161     """
162     for (generator, _, version_check, _) in GENERATORS:
163         if executable_exists(version_check):
164             return generator
165     raise FatalError(
166         "To use %s, either the 'ninja' or 'GNU make' build tool must be available in the PATH"
167         % PROG
168     )
169
170
171 def _strip_quotes(value, regexp=re.compile(r"^\"(.*)\"$|^'(.*)'$|^(.*)$")):
172     """
173     Strip quotes like CMake does during parsing cache entries
174     """
175
176     return [x for x in regexp.match(value).groups() if x is not None][0].rstrip()
177
178
179 def _new_cmakecache_entries(cache_path, new_cache_entries):
180     if not os.path.exists(cache_path):
181         return True
182
183     current_cache = parse_cmakecache(cache_path)
184
185     if new_cache_entries:
186         current_cache = parse_cmakecache(cache_path)
187
188         for entry in new_cache_entries:
189             key, value = entry.split("=", 1)
190             current_value = current_cache.get(key, None)
191             if current_value is None or _strip_quotes(value) != current_value:
192                 return True
193
194     return False
195
196
197 def _ensure_build_directory(args, always_run_cmake=False):
198     """Check the build directory exists and that cmake has been run there.
199
200     If this isn't the case, create the build directory (if necessary) and
201     do an initial cmake run to configure it.
202
203     This function will also check args.generator parameter. If the parameter is incompatible with
204     the build directory, an error is raised. If the parameter is None, this function will set it to
205     an auto-detected default generator or to the value already configured in the build directory.
206     """
207     project_dir = args.project_dir
208     # Verify the project directory
209     if not os.path.isdir(project_dir):
210         if not os.path.exists(project_dir):
211             raise FatalError("Project directory %s does not exist" % project_dir)
212         else:
213             raise FatalError("%s must be a project directory" % project_dir)
214     if not os.path.exists(os.path.join(project_dir, "CMakeLists.txt")):
215         raise FatalError(
216             "CMakeLists.txt not found in project directory %s" % project_dir
217         )
218
219     # Verify/create the build directory
220     build_dir = args.build_dir
221     if not os.path.isdir(build_dir):
222         os.makedirs(build_dir)
223     cache_path = os.path.join(build_dir, "CMakeCache.txt")
224
225     if always_run_cmake or _new_cmakecache_entries(cache_path, args.define_cache_entry):
226         if args.generator is None:
227             args.generator = detect_cmake_generator()
228         try:
229             cmake_args = [
230                 "cmake",
231                 "-G",
232                 args.generator,
233                 "-DPYTHON_DEPS_CHECKED=1",
234                 "-DESP_PLATFORM=1",
235             ]
236             if not args.no_warnings:
237                 cmake_args += ["--warn-uninitialized"]
238             if args.ccache:
239                 cmake_args += ["-DCCACHE_ENABLE=1"]
240             if args.define_cache_entry:
241                 cmake_args += ["-D" + d for d in args.define_cache_entry]
242             cmake_args += [project_dir]
243
244             _run_tool("cmake", cmake_args, cwd=args.build_dir)
245         except Exception:
246             # don't allow partially valid CMakeCache.txt files,
247             # to keep the "should I run cmake?" logic simple
248             if os.path.exists(cache_path):
249                 os.remove(cache_path)
250             raise
251
252     # Learn some things from the CMakeCache.txt file in the build directory
253     cache = parse_cmakecache(cache_path)
254     try:
255         generator = cache["CMAKE_GENERATOR"]
256     except KeyError:
257         generator = detect_cmake_generator()
258     if args.generator is None:
259         args.generator = (
260             generator
261         )  # reuse the previously configured generator, if none was given
262     if generator != args.generator:
263         raise FatalError(
264             "Build is configured for generator '%s' not '%s'. Run '%s fullclean' to start again."
265             % (generator, args.generator, PROG)
266         )
267
268     try:
269         home_dir = cache["CMAKE_HOME_DIRECTORY"]
270         if _realpath(home_dir) != _realpath(project_dir):
271             raise FatalError(
272                 "Build directory '%s' configured for project '%s' not '%s'. Run '%s fullclean' to start again."
273                 % (build_dir, _realpath(home_dir), _realpath(project_dir), PROG)
274             )
275     except KeyError:
276         pass  # if cmake failed part way, CMAKE_HOME_DIRECTORY may not be set yet
277
278
279 def parse_cmakecache(path):
280     """
281     Parse the CMakeCache file at 'path'.
282
283     Returns a dict of name:value.
284
285     CMakeCache entries also each have a "type", but this is currently ignored.
286     """
287     result = {}
288     with open(path) as f:
289         for line in f:
290             # cmake cache lines look like: CMAKE_CXX_FLAGS_DEBUG:STRING=-g
291             # groups are name, type, value
292             m = re.match(r"^([^#/:=]+):([^:=]+)=(.*)\n$", line)
293             if m:
294                 result[m.group(1)] = m.group(3)
295     return result
296
297
298 def build_target(target_name, ctx, args):
299     """
300     Execute the target build system to build target 'target_name'
301
302     Calls _ensure_build_directory() which will run cmake to generate a build
303     directory (with the specified generator) as needed.
304     """
305     _ensure_build_directory(args)
306     generator_cmd = GENERATOR_CMDS[args.generator]
307
308     if args.ccache:
309         # Setting CCACHE_BASEDIR & CCACHE_NO_HASHDIR ensures that project paths aren't stored in the ccache entries
310         # (this means ccache hits can be shared between different projects. It may mean that some debug information
311         # will point to files in another project, if these files are perfect duplicates of each other.)
312         #
313         # It would be nicer to set these from cmake, but there's no cross-platform way to set build-time environment
314         # os.environ["CCACHE_BASEDIR"] = args.build_dir
315         # os.environ["CCACHE_NO_HASHDIR"] = "1"
316         pass
317     if args.verbose:
318         generator_cmd += [GENERATOR_VERBOSE[args.generator]]
319
320     _run_tool(generator_cmd[0], generator_cmd + [target_name], args.build_dir)
321
322
323 def _get_esptool_args(args):
324     esptool_path = os.path.join(
325         os.environ["IDF_PATH"], "components/esptool_py/esptool/esptool.py"
326     )
327     if args.port is None:
328         args.port = get_default_serial_port()
329     result = [PYTHON, esptool_path]
330     result += ["-p", args.port]
331     result += ["-b", str(args.baud)]
332
333     with open(os.path.join(args.build_dir, "flasher_args.json")) as f:
334         flasher_args = json.load(f)
335
336     extra_esptool_args = flasher_args["extra_esptool_args"]
337     result += ["--after", extra_esptool_args["after"]]
338     return result
339
340
341 def flash(action, ctx, args):
342     """
343     Run esptool to flash the entire project, from an argfile generated by the build system
344     """
345     flasher_args_path = {  # action -> name of flasher args file generated by build system
346         "bootloader-flash": "flash_bootloader_args",
347         "partition_table-flash": "flash_partition_table_args",
348         "app-flash": "flash_app_args",
349         "flash": "flash_project_args",
350         "encrypted-app-flash": "flash_encrypted_app_args",
351         "encrypted-flash": "flash_encrypted_project_args",
352     }[
353         action
354     ]
355     esptool_args = _get_esptool_args(args)
356     esptool_args += ["write_flash", "@" + flasher_args_path]
357     _run_tool("esptool.py", esptool_args, args.build_dir)
358
359
360 def erase_flash(action, ctx, args):
361     esptool_args = _get_esptool_args(args)
362     esptool_args += ["erase_flash"]
363     _run_tool("esptool.py", esptool_args, args.build_dir)
364
365
366 def monitor(action, ctx, args, print_filter):
367     """
368     Run idf_monitor.py to watch build output
369     """
370     if args.port is None:
371         args.port = get_default_serial_port()
372     desc_path = os.path.join(args.build_dir, "project_description.json")
373     if not os.path.exists(desc_path):
374         _ensure_build_directory(args)
375     with open(desc_path, "r") as f:
376         project_desc = json.load(f)
377
378     elf_file = os.path.join(args.build_dir, project_desc["app_elf"])
379     if not os.path.exists(elf_file):
380         raise FatalError(
381             "ELF file '%s' not found. You need to build & flash the project before running 'monitor', "
382             "and the binary on the device must match the one in the build directory exactly. "
383             "Try '%s flash monitor'." % (elf_file, PROG)
384         )
385     idf_monitor = os.path.join(os.environ["IDF_PATH"], "tools/idf_monitor.py")
386     monitor_args = [PYTHON, idf_monitor]
387     if args.port is not None:
388         monitor_args += ["-p", args.port]
389     monitor_args += ["-b", project_desc["monitor_baud"]]
390     if print_filter is not None:
391         monitor_args += ["--print_filter", print_filter]
392     monitor_args += [elf_file]
393
394     idf_py = [PYTHON] + get_commandline_options(ctx)  # commands to re-run idf.py
395     monitor_args += ["-m", " ".join("'%s'" % a for a in idf_py)]
396
397     if "MSYSTEM" in os.environ:
398         monitor_args = ["winpty"] + monitor_args
399     _run_tool("idf_monitor", monitor_args, args.project_dir)
400
401
402 def clean(action, ctx, args):
403     if not os.path.isdir(args.build_dir):
404         print("Build directory '%s' not found. Nothing to clean." % args.build_dir)
405         return
406     build_target("clean", ctx, args)
407
408
409 def reconfigure(action, ctx, args):
410     _ensure_build_directory(args, True)
411
412
413 def _delete_windows_symlinks(directory):
414     """
415     It deletes symlinks recursively on Windows. It is useful for Python 2 which doesn't detect symlinks on Windows.
416     """
417     deleted_paths = []
418     if os.name == "nt":
419         import ctypes
420
421         for root, dirnames, _filenames in os.walk(directory):
422             for d in dirnames:
423                 full_path = os.path.join(root, d)
424                 try:
425                     full_path = full_path.decode("utf-8")
426                 except Exception:
427                     pass
428                 if ctypes.windll.kernel32.GetFileAttributesW(full_path) & 0x0400:
429                     os.rmdir(full_path)
430                     deleted_paths.append(full_path)
431     return deleted_paths
432
433
434 def fullclean(action, ctx, args):
435     build_dir = args.build_dir
436     if not os.path.isdir(build_dir):
437         print("Build directory '%s' not found. Nothing to clean." % build_dir)
438         return
439     if len(os.listdir(build_dir)) == 0:
440         print("Build directory '%s' is empty. Nothing to clean." % build_dir)
441         return
442
443     if not os.path.exists(os.path.join(build_dir, "CMakeCache.txt")):
444         raise FatalError(
445             "Directory '%s' doesn't seem to be a CMake build directory. Refusing to automatically "
446             "delete files in this directory. Delete the directory manually to 'clean' it."
447             % build_dir
448         )
449     red_flags = ["CMakeLists.txt", ".git", ".svn"]
450     for red in red_flags:
451         red = os.path.join(build_dir, red)
452         if os.path.exists(red):
453             raise FatalError(
454                 "Refusing to automatically delete files in directory containing '%s'. Delete files manually if you're sure."
455                 % red
456             )
457     # OK, delete everything in the build directory...
458     # Note: Python 2.7 doesn't detect symlinks on Windows (it is supported form 3.2). Tools promising to not
459     # follow symlinks will actually follow them. Deleting the build directory with symlinks deletes also items
460     # outside of this directory.
461     deleted_symlinks = _delete_windows_symlinks(build_dir)
462     if args.verbose and len(deleted_symlinks) > 1:
463         print(
464             "The following symlinks were identified and removed:\n%s"
465             % "\n".join(deleted_symlinks)
466         )
467     for f in os.listdir(
468         build_dir
469     ):  # TODO: once we are Python 3 only, this can be os.scandir()
470         f = os.path.join(build_dir, f)
471         if args.verbose:
472             print("Removing: %s" % f)
473         if os.path.isdir(f):
474             shutil.rmtree(f)
475         else:
476             os.remove(f)
477
478
479 def _safe_relpath(path, start=None):
480     """ Return a relative path, same as os.path.relpath, but only if this is possible.
481
482     It is not possible on Windows, if the start directory and the path are on different drives.
483     """
484     try:
485         return os.path.relpath(path, os.curdir if start is None else start)
486     except ValueError:
487         return os.path.abspath(path)
488
489
490 def get_commandline_options(ctx):
491     """ Return all the command line options up to first action """
492     # This approach ignores argument parsing done Click
493     result = []
494
495     for arg in sys.argv:
496         if arg in ctx.command.commands_with_aliases:
497             break
498
499         result.append(arg)
500
501     return result
502
503
504 def get_default_serial_port():
505     """ Return a default serial port. esptool can do this (smarter), but it can create
506     inconsistencies where esptool.py uses one port and idf_monitor uses another.
507
508     Same logic as esptool.py search order, reverse sort by name and choose the first port.
509     """
510     # Import is done here in order to move it after the check_environment() ensured that pyserial has been installed
511     import serial.tools.list_ports
512
513     ports = list(reversed(sorted(p.device for p in serial.tools.list_ports.comports())))
514     try:
515         print(
516             "Choosing default port %s (use '-p PORT' option to set a specific serial port)"
517             % ports[0].encode("ascii", "ignore")
518         )
519         return ports[0]
520     except IndexError:
521         raise RuntimeError(
522             "No serial ports found. Connect a device, or use '-p PORT' option to set a specific port."
523         )
524
525
526 class PropertyDict(dict):
527     def __init__(self, *args, **kwargs):
528         super(PropertyDict, self).__init__(*args, **kwargs)
529         self.__dict__ = self
530
531
532 def init_cli():
533     # Click is imported here to run it after check_environment()
534     import click
535
536     class Task(object):
537         def __init__(
538             self, callback, name, aliases, dependencies, order_dependencies, action_args
539         ):
540             self.callback = callback
541             self.name = name
542             self.dependencies = dependencies
543             self.order_dependencies = order_dependencies
544             self.action_args = action_args
545             self.aliases = aliases
546
547         def run(self, context, global_args, action_args=None):
548             if action_args is None:
549                 action_args = self.action_args
550
551             self.callback(self.name, context, global_args, **action_args)
552
553     class Action(click.Command):
554         def __init__(
555             self,
556             name=None,
557             aliases=None,
558             dependencies=None,
559             order_dependencies=None,
560             **kwargs
561         ):
562             super(Action, self).__init__(name, **kwargs)
563
564             self.name = self.name or self.callback.__name__
565
566             if aliases is None:
567                 aliases = []
568             self.aliases = aliases
569
570             self.help = self.help or self.callback.__doc__
571             if self.help is None:
572                 self.help = ""
573
574             if dependencies is None:
575                 dependencies = []
576
577             if order_dependencies is None:
578                 order_dependencies = []
579
580             # Show first line of help if short help is missing
581             self.short_help = self.short_help or self.help.split("\n")[0]
582
583             # Add aliases to help string
584             if aliases:
585                 aliases_help = "Aliases: %s." % ", ".join(aliases)
586
587                 self.help = "\n".join([self.help, aliases_help])
588                 self.short_help = " ".join([aliases_help, self.short_help])
589
590             if self.callback is not None:
591                 callback = self.callback
592
593                 def wrapped_callback(**action_args):
594                     return Task(
595                         callback=callback,
596                         name=self.name,
597                         dependencies=dependencies,
598                         order_dependencies=order_dependencies,
599                         action_args=action_args,
600                         aliases=self.aliases,
601                     )
602
603                 self.callback = wrapped_callback
604
605     class Argument(click.Argument):
606         """Positional argument"""
607
608         def __init__(self, **kwargs):
609             names = kwargs.pop("names")
610             super(Argument, self).__init__(names, **kwargs)
611
612     class Scope(object):
613         """
614             Scope for sub-command option.
615             possible values:
616             - default - only available on defined level (global/action)
617             - global - When defined for action, also available as global
618             - shared - Opposite to 'global': when defined in global scope, also available for all actions
619         """
620
621         SCOPES = ("default", "global", "shared")
622
623         def __init__(self, scope=None):
624             if scope is None:
625                 self._scope = "default"
626             elif isinstance(scope, str) and scope in self.SCOPES:
627                 self._scope = scope
628             elif isinstance(scope, Scope):
629                 self._scope = str(scope)
630             else:
631                 raise FatalError("Unknown scope for option: %s" % scope)
632
633         @property
634         def is_global(self):
635             return self._scope == "global"
636
637         @property
638         def is_shared(self):
639             return self._scope == "shared"
640
641         def __str__(self):
642             return self._scope
643
644     class Option(click.Option):
645         """Option that knows whether it should be global"""
646
647         def __init__(self, scope=None, **kwargs):
648             kwargs["param_decls"] = kwargs.pop("names")
649             super(Option, self).__init__(**kwargs)
650
651             self.scope = Scope(scope)
652
653             if self.scope.is_global:
654                 self.help += " This option can be used at most once either globally, or for one subcommand."
655
656     class CLI(click.MultiCommand):
657         """Action list contains all actions with options available for CLI"""
658
659         def __init__(self, action_lists=None, help=None):
660             super(CLI, self).__init__(
661                 chain=True,
662                 invoke_without_command=True,
663                 result_callback=self.execute_tasks,
664                 context_settings={"max_content_width": 140},
665                 help=help,
666             )
667             self._actions = {}
668             self.global_action_callbacks = []
669             self.commands_with_aliases = {}
670
671             if action_lists is None:
672                 action_lists = []
673
674             shared_options = []
675
676             for action_list in action_lists:
677                 # Global options
678                 for option_args in action_list.get("global_options", []):
679                     option = Option(**option_args)
680                     self.params.append(option)
681
682                     if option.scope.is_shared:
683                         shared_options.append(option)
684
685             for action_list in action_lists:
686                 # Global options validators
687                 self.global_action_callbacks.extend(
688                     action_list.get("global_action_callbacks", [])
689                 )
690
691             for action_list in action_lists:
692                 # Actions
693                 for name, action in action_list.get("actions", {}).items():
694                     arguments = action.pop("arguments", [])
695                     options = action.pop("options", [])
696
697                     if arguments is None:
698                         arguments = []
699
700                     if options is None:
701                         options = []
702
703                     self._actions[name] = Action(name=name, **action)
704                     for alias in [name] + action.get("aliases", []):
705                         self.commands_with_aliases[alias] = name
706
707                     for argument_args in arguments:
708                         self._actions[name].params.append(Argument(**argument_args))
709
710                     # Add all shared options
711                     for option in shared_options:
712                         self._actions[name].params.append(option)
713
714                     for option_args in options:
715                         option = Option(**option_args)
716
717                         if option.scope.is_shared:
718                             raise FatalError(
719                                 '"%s" is defined for action "%s". '
720                                 ' "shared" options can be declared only on global level' % (option.name, name)
721                             )
722
723                         # Promote options to global if see for the first time
724                         if option.scope.is_global and option.name not in [o.name for o in self.params]:
725                             self.params.append(option)
726
727                         self._actions[name].params.append(option)
728
729         def list_commands(self, ctx):
730             return sorted(self._actions)
731
732         def get_command(self, ctx, name):
733             return self._actions.get(self.commands_with_aliases.get(name))
734
735         def _print_closing_message(self, args, actions):
736             # print a closing message of some kind
737             #
738             if "flash" in str(actions):
739                 print("Done")
740                 return
741
742             # Otherwise, if we built any binaries print a message about
743             # how to flash them
744             def print_flashing_message(title, key):
745                 print("\n%s build complete. To flash, run this command:" % title)
746
747                 with open(os.path.join(args.build_dir, "flasher_args.json")) as f:
748                     flasher_args = json.load(f)
749
750                 def flasher_path(f):
751                     return _safe_relpath(os.path.join(args.build_dir, f))
752
753                 if key != "project":  # flashing a single item
754                     cmd = ""
755                     if (
756                         key == "bootloader"
757                     ):  # bootloader needs --flash-mode, etc to be passed in
758                         cmd = " ".join(flasher_args["write_flash_args"]) + " "
759
760                     cmd += flasher_args[key]["offset"] + " "
761                     cmd += flasher_path(flasher_args[key]["file"])
762                 else:  # flashing the whole project
763                     cmd = " ".join(flasher_args["write_flash_args"]) + " "
764                     flash_items = sorted(
765                         (
766                             (o, f)
767                             for (o, f) in flasher_args["flash_files"].items()
768                             if len(o) > 0
769                         ),
770                         key=lambda x: int(x[0], 0),
771                     )
772                     for o, f in flash_items:
773                         cmd += o + " " + flasher_path(f) + " "
774
775                 print(
776                     "%s -p %s -b %s --after %s write_flash %s"
777                     % (
778                         _safe_relpath(
779                             "%s/components/esptool_py/esptool/esptool.py"
780                             % os.environ["IDF_PATH"]
781                         ),
782                         args.port or "(PORT)",
783                         args.baud,
784                         flasher_args["extra_esptool_args"]["after"],
785                         cmd.strip(),
786                     )
787                 )
788                 print(
789                     "or run 'idf.py -p %s %s'"
790                     % (
791                         args.port or "(PORT)",
792                         key + "-flash" if key != "project" else "flash",
793                     )
794                 )
795
796             if "all" in actions or "build" in actions:
797                 print_flashing_message("Project", "project")
798             else:
799                 if "app" in actions:
800                     print_flashing_message("App", "app")
801                 if "partition_table" in actions:
802                     print_flashing_message("Partition Table", "partition_table")
803                 if "bootloader" in actions:
804                     print_flashing_message("Bootloader", "bootloader")
805
806         def execute_tasks(self, tasks, **kwargs):
807             ctx = click.get_current_context()
808             global_args = PropertyDict(ctx.params)
809
810             # Set propagated global options
811             for task in tasks:
812                 for key in list(task.action_args):
813                     option = next((o for o in ctx.command.params if o.name == key), None)
814                     if option and (option.scope.is_global or option.scope.is_shared):
815                         local_value = task.action_args.pop(key)
816                         global_value = global_args[key]
817                         default = () if option.multiple else option.default
818
819                         if global_value != default and local_value != default and global_value != local_value:
820                             raise FatalError(
821                                 'Option "%s" provided for "%s" is already defined to a different value. '
822                                 "This option can appear at most once in the command line." % (key, task.name)
823                             )
824                         if local_value != default:
825                             global_args[key] = local_value
826
827             # Validate global arguments
828             for action_callback in ctx.command.global_action_callbacks:
829                 action_callback(ctx, global_args, tasks)
830
831             # very simple dependency management
832             completed_tasks = set()
833
834             if not tasks:
835                 print(ctx.get_help())
836                 ctx.exit()
837
838             while tasks:
839                 task = tasks[0]
840                 tasks_dict = dict([(t.name, t) for t in tasks])
841
842                 name_with_aliases = task.name
843                 if task.aliases:
844                     name_with_aliases += " (aliases: %s)" % ", ".join(task.aliases)
845
846                 ready_to_run = True
847                 for dep in task.dependencies:
848                     if dep not in completed_tasks:
849                         print(
850                             'Adding %s\'s dependency "%s" to list of actions'
851                             % (task.name, dep)
852                         )
853                         dep_task = ctx.invoke(ctx.command.get_command(ctx, dep))
854
855                         # Remove global options from dependent tasks
856                         for key in list(dep_task.action_args):
857                             option = next((o for o in ctx.command.params if o.name == key), None)
858                             if option and (option.scope.is_global or option.scope.is_shared):
859                                 dep_task.action_args.pop(key)
860
861                         tasks.insert(0, dep_task)
862                         ready_to_run = False
863
864                 for dep in task.order_dependencies:
865                     if dep in tasks_dict.keys() and dep not in completed_tasks:
866                         tasks.insert(0, tasks.pop(tasks.index(tasks_dict[dep])))
867                         ready_to_run = False
868
869                 if ready_to_run:
870                     tasks.pop(0)
871
872                     if task.name in completed_tasks:
873                         print(
874                             "Skipping action that is already done: %s"
875                             % name_with_aliases
876                         )
877                     else:
878                         print("Executing action: %s" % name_with_aliases)
879                         task.run(ctx, global_args, task.action_args)
880
881                     completed_tasks.add(task.name)
882
883             self._print_closing_message(global_args, completed_tasks)
884
885         @staticmethod
886         def merge_action_lists(*action_lists):
887             merged_actions = {
888                 "global_options": [],
889                 "actions": {},
890                 "global_action_callbacks": [],
891             }
892             for action_list in action_lists:
893                 merged_actions["global_options"].extend(
894                     action_list.get("global_options", [])
895                 )
896                 merged_actions["actions"].update(action_list.get("actions", {}))
897                 merged_actions["global_action_callbacks"].extend(
898                     action_list.get("global_action_callbacks", [])
899                 )
900             return merged_actions
901
902     # That's a tiny parser that parse project-dir even before constructing
903     # fully featured click parser to be sure that extensions are loaded from the right place
904     @click.command(
905         add_help_option=False,
906         context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
907     )
908     @click.option("-C", "--project-dir", default=os.getcwd())
909     def parse_project_dir(project_dir):
910         return _realpath(project_dir)
911
912     project_dir = parse_project_dir(standalone_mode=False)
913
914     # Load base idf commands
915     def validate_root_options(ctx, args, tasks):
916         args.project_dir = _realpath(args.project_dir)
917         if args.build_dir is not None and args.project_dir == _realpath(args.build_dir):
918             raise FatalError(
919                 "Setting the build directory to the project directory is not supported. Suggest dropping "
920                 "--build-dir option, the default is a 'build' subdirectory inside the project directory."
921             )
922         if args.build_dir is None:
923             args.build_dir = os.path.join(args.project_dir, "build")
924         args.build_dir = _realpath(args.build_dir)
925
926     # Possible keys for action dict are: global_options, actions and global_action_callbacks
927     global_options = [
928         {
929             "names": ["-D", "--define-cache-entry"],
930             "help": "Create a cmake cache entry.",
931             "scope": "global",
932             "multiple": True,
933         }
934     ]
935
936     root_options = {
937         "global_options": [
938             {
939                 "names": ["-C", "--project-dir"],
940                 "help": "Project directory.",
941                 "type": click.Path(),
942                 "default": os.getcwd(),
943             },
944             {
945                 "names": ["-B", "--build-dir"],
946                 "help": "Build directory.",
947                 "type": click.Path(),
948                 "default": None,
949             },
950             {
951                 "names": ["-n", "--no-warnings"],
952                 "help": "Disable Cmake warnings.",
953                 "is_flag": True,
954                 "default": False,
955             },
956             {
957                 "names": ["-v", "--verbose"],
958                 "help": "Verbose build output.",
959                 "is_flag": True,
960                 "default": False,
961             },
962             {
963                 "names": ["--ccache"],
964                 "help": "Use ccache in build",
965                 "is_flag": True,
966                 "default": False,
967             },
968             {
969                 # This is unused/ignored argument, as ccache use was originally opt-out.
970                 # Use of ccache has been made opt-in using --cache arg.
971                 "names": ["--no-ccache"],
972                 "default": True,
973                 "is_flag": True,
974                 "hidden": True,
975             },
976             {
977                 "names": ["-G", "--generator"],
978                 "help": "CMake generator.",
979                 "type": click.Choice(GENERATOR_CMDS.keys()),
980             },
981         ],
982         "global_action_callbacks": [validate_root_options],
983     }
984
985     build_actions = {
986         "actions": {
987             "all": {
988                 "aliases": ["build"],
989                 "callback": build_target,
990                 "short_help": "Build the project.",
991                 "help": "Build the project. This can involve multiple steps:\n\n"
992                 + "1. Create the build directory if needed. The sub-directory 'build' is used to hold build output, "
993                 + "although this can be changed with the -B option.\n\n"
994                 + "2. Run CMake as necessary to configure the project and generate build files for the main build tool.\n\n"
995                 + "3. Run the main build tool (Ninja or GNU Make). By default, the build tool is automatically detected "
996                 + "but it can be explicitly set by passing the -G option to idf.py.\n\n",
997                 "options": global_options,
998                 "order_dependencies": [
999                     "reconfigure",
1000                     "menuconfig",
1001                     "clean",
1002                     "fullclean",
1003                 ],
1004             },
1005             "menuconfig": {
1006                 "callback": build_target,
1007                 "help": 'Run "menuconfig" project configuration tool.',
1008                 "options": global_options,
1009             },
1010             "confserver": {
1011                 "callback": build_target,
1012                 "help": "Run JSON configuration server.",
1013                 "options": global_options,
1014             },
1015             "size": {
1016                 "callback": build_target,
1017                 "help": "Print basic size information about the app.",
1018                 "options": global_options,
1019                 "dependencies": ["app"],
1020             },
1021             "size-components": {
1022                 "callback": build_target,
1023                 "help": "Print per-component size information.",
1024                 "options": global_options,
1025                 "dependencies": ["app"],
1026             },
1027             "size-files": {
1028                 "callback": build_target,
1029                 "help": "Print per-source-file size information.",
1030                 "options": global_options,
1031                 "dependencies": ["app"],
1032             },
1033             "bootloader": {
1034                 "callback": build_target,
1035                 "help": "Build only bootloader.",
1036                 "options": global_options,
1037             },
1038             "app": {
1039                 "callback": build_target,
1040                 "help": "Build only the app.",
1041                 "order_dependencies": ["clean", "fullclean", "reconfigure"],
1042                 "options": global_options,
1043             },
1044             "efuse_common_table": {
1045                 "callback": build_target,
1046                 "help": "Genereate C-source for IDF's eFuse fields.",
1047                 "order_dependencies": ["reconfigure"],
1048                 "options": global_options,
1049             },
1050             "efuse_custom_table": {
1051                 "callback": build_target,
1052                 "help": "Genereate C-source for user's eFuse fields.",
1053                 "order_dependencies": ["reconfigure"],
1054                 "options": global_options,
1055             },
1056             "show_efuse_table": {
1057                 "callback": build_target,
1058                 "help": "Print eFuse table.",
1059                 "order_dependencies": ["reconfigure"],
1060                 "options": global_options,
1061             },
1062             "partition_table": {
1063                 "callback": build_target,
1064                 "help": "Build only partition table.",
1065                 "order_dependencies": ["reconfigure"],
1066                 "options": global_options,
1067             },
1068             "erase_otadata": {
1069                 "callback": build_target,
1070                 "help": "Erase otadata partition.",
1071                 "options": global_options,
1072             },
1073             "read_otadata": {
1074                 "callback": build_target,
1075                 "help": "Read otadata partition.",
1076                 "options": global_options,
1077             },
1078         }
1079     }
1080
1081     clean_actions = {
1082         "actions": {
1083             "reconfigure": {
1084                 "callback": reconfigure,
1085                 "short_help": "Re-run CMake.",
1086                 "help": "Re-run CMake even if it doesn't seem to need re-running. This isn't necessary during normal usage, "
1087                 + "but can be useful after adding/removing files from the source tree, or when modifying CMake cache variables. "
1088                 + "For example, \"idf.py -DNAME='VALUE' reconfigure\" "
1089                 + 'can be used to set variable "NAME" in CMake cache to value "VALUE".',
1090                 "options": global_options,
1091                 "order_dependencies": ["menuconfig"],
1092             },
1093             "clean": {
1094                 "callback": clean,
1095                 "short_help": "Delete build output files from the build directory.",
1096                 "help": "Delete build output files from the build directory , forcing a 'full rebuild' the next time "
1097                 + "the project is built. Cleaning doesn't delete CMake configuration output and some other files",
1098                 "order_dependencies": ["fullclean"],
1099             },
1100             "fullclean": {
1101                 "callback": fullclean,
1102                 "short_help": "Delete the entire build directory contents.",
1103                 "help": "Delete the entire build directory contents. This includes all CMake configuration output."
1104                 + "The next time the project is built, CMake will configure it from scratch. "
1105                 + "Note that this option recursively deletes all files in the build directory, so use with care."
1106                 + "Project configuration is not deleted.",
1107             },
1108         }
1109     }
1110
1111     baud_rate = {
1112         "names": ["-b", "--baud"],
1113         "help": "Baud rate.",
1114         "scope": "global",
1115         "envvar": "ESPBAUD",
1116         "default": 460800,
1117     }
1118
1119     port = {
1120         "names": ["-p", "--port"],
1121         "help": "Serial port.",
1122         "scope": "global",
1123         "envvar": "ESPPORT",
1124         "default": None,
1125     }
1126
1127     serial_actions = {
1128         "actions": {
1129             "flash": {
1130                 "callback": flash,
1131                 "help": "Flash the project.",
1132                 "options": global_options + [baud_rate, port],
1133                 "dependencies": ["all"],
1134                 "order_dependencies": ["erase_flash"],
1135             },
1136             "erase_flash": {
1137                 "callback": erase_flash,
1138                 "help": "Erase entire flash chip.",
1139                 "options": [baud_rate, port],
1140             },
1141             "monitor": {
1142                 "callback": monitor,
1143                 "help": "Display serial output.",
1144                 "options": [
1145                     port,
1146                     {
1147                         "names": ["--print-filter", "--print_filter"],
1148                         "help": (
1149                             "Filter monitor output.\n"
1150                             "Restrictions on what to print can be specified as a series of <tag>:<log_level> items "
1151                             "where <tag> is the tag string and <log_level> is a character from the set "
1152                             "{N, E, W, I, D, V, *} referring to a level. "
1153                             'For example, "tag1:W" matches and prints only the outputs written with '
1154                             'ESP_LOGW("tag1", ...) or at lower verbosity level, i.e. ESP_LOGE("tag1", ...). '
1155                             'Not specifying a <log_level> or using "*" defaults to Verbose level.\n'
1156                             'Please see the IDF Monitor section of the ESP-IDF documentation '
1157                             'for a more detailed description and further examples.'),
1158                         "default": None,
1159                     },
1160                 ],
1161                 "order_dependencies": [
1162                     "flash",
1163                     "partition_table-flash",
1164                     "bootloader-flash",
1165                     "app-flash",
1166                 ],
1167             },
1168             "partition_table-flash": {
1169                 "callback": flash,
1170                 "help": "Flash partition table only.",
1171                 "options": [baud_rate, port],
1172                 "dependencies": ["partition_table"],
1173                 "order_dependencies": ["erase_flash"],
1174             },
1175             "bootloader-flash": {
1176                 "callback": flash,
1177                 "help": "Flash bootloader only.",
1178                 "options": [baud_rate, port],
1179                 "dependencies": ["bootloader"],
1180                 "order_dependencies": ["erase_flash"],
1181             },
1182             "app-flash": {
1183                 "callback": flash,
1184                 "help": "Flash the app only.",
1185                 "options": [baud_rate, port],
1186                 "dependencies": ["app"],
1187                 "order_dependencies": ["erase_flash"],
1188             },
1189             "encrypted-app-flash": {
1190                 "callback": flash,
1191                 "help": "Flash the encrypted app only.",
1192                 "dependencies": ["app"],
1193                 "order_dependencies": ["erase_flash"],
1194             },
1195             "encrypted-flash": {
1196                 "callback": flash,
1197                 "help": "Flash the encrypted project.",
1198                 "dependencies": ["all"],
1199                 "order_dependencies": ["erase_flash"],
1200             },
1201         },
1202     }
1203
1204     base_actions = CLI.merge_action_lists(
1205         root_options, build_actions, clean_actions, serial_actions
1206     )
1207     all_actions = [base_actions]
1208
1209     # Load extensions
1210     if os.path.exists(os.path.join(project_dir, "idf_ext.py")):
1211         sys.path.append(project_dir)
1212         try:
1213             from idf_ext import action_extensions
1214         except ImportError:
1215             print("Error importing extension file idf_ext.py. Skipping.")
1216             print(
1217                 "Please make sure that it contains implementation (even if it's empty) of add_action_extensions"
1218             )
1219
1220     # Add actions extensions
1221     try:
1222         all_actions.append(action_extensions(base_actions, project_dir))
1223     except NameError:
1224         pass
1225
1226     return CLI(help="ESP-IDF build management", action_lists=all_actions)
1227
1228
1229 def main():
1230     check_environment()
1231     cli = init_cli()
1232     cli(prog_name=PROG)
1233
1234
1235 def _valid_unicode_config():
1236     # Python 2 is always good
1237     if sys.version_info[0] == 2:
1238         return True
1239
1240     # With python 3 unicode environment is required
1241     try:
1242         return codecs.lookup(locale.getpreferredencoding()).name != "ascii"
1243     except Exception:
1244         return False
1245
1246
1247 def _find_usable_locale():
1248     try:
1249         locales = subprocess.Popen(
1250             ["locale", "-a"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
1251         ).communicate()[0]
1252     except OSError:
1253         locales = ""
1254     if isinstance(locales, bytes):
1255         locales = locales.decode("ascii", "replace")
1256
1257     usable_locales = []
1258     for line in locales.splitlines():
1259         locale = line.strip()
1260         locale_name = locale.lower().replace("-", "")
1261
1262         # C.UTF-8 is the best option, if supported
1263         if locale_name == "c.utf8":
1264             return locale
1265
1266         if locale_name.endswith(".utf8"):
1267             # Make a preference of english locales
1268             if locale.startswith("en_"):
1269                 usable_locales.insert(0, locale)
1270             else:
1271                 usable_locales.append(locale)
1272
1273     if not usable_locales:
1274         raise FatalError(
1275             "Support for Unicode filenames is required, but no suitable UTF-8 locale was found on your system."
1276             " Please refer to the manual for your operating system for details on locale reconfiguration."
1277         )
1278
1279     return usable_locales[0]
1280
1281
1282 if __name__ == "__main__":
1283     try:
1284         # On MSYS2 we need to run idf.py with "winpty" in order to be able to cancel the subprocesses properly on
1285         # keyboard interrupt (CTRL+C).
1286         # Using an own global variable for indicating that we are running with "winpty" seems to be the most suitable
1287         # option as os.environment['_'] contains "winpty" only when it is run manually from console.
1288         WINPTY_VAR = "WINPTY"
1289         WINPTY_EXE = "winpty"
1290         if ("MSYSTEM" in os.environ) and (
1291             not os.environ.get("_", "").endswith(WINPTY_EXE) and WINPTY_VAR not in os.environ
1292         ):
1293             os.environ[WINPTY_VAR] = "1"  # the value is of no interest to us
1294             # idf.py calls itself with "winpty" and WINPTY global variable set
1295             ret = subprocess.call(
1296                 [WINPTY_EXE, sys.executable] + sys.argv, env=os.environ
1297             )
1298             if ret:
1299                 raise SystemExit(ret)
1300
1301         elif os.name == "posix" and not _valid_unicode_config():
1302             # Trying to find best utf-8 locale available on the system and restart python with it
1303             best_locale = _find_usable_locale()
1304
1305             print(
1306                 "Your environment is not configured to handle unicode filenames outside of ASCII range."
1307                 " Environment variable LC_ALL is temporary set to %s for unicode support."
1308                 % best_locale
1309             )
1310
1311             os.environ["LC_ALL"] = best_locale
1312             ret = subprocess.call([sys.executable] + sys.argv, env=os.environ)
1313             if ret:
1314                 raise SystemExit(ret)
1315
1316         else:
1317             main()
1318
1319     except FatalError as e:
1320         print(e)
1321         sys.exit(2)