1import fnmatch
2import os
3import shutil
4import subprocess
5import sys
6
7import click
8from idf_py_actions.constants import GENERATORS, PREVIEW_TARGETS, SUPPORTED_TARGETS
9from idf_py_actions.errors import FatalError
10from idf_py_actions.global_options import global_options
11from idf_py_actions.tools import (TargetChoice, ensure_build_directory, idf_version, merge_action_lists, realpath,
12                                  run_target)
13
14
15def action_extensions(base_actions, project_path):
16    def build_target(target_name, ctx, args):
17        """
18        Execute the target build system to build target 'target_name'
19
20        Calls ensure_build_directory() which will run cmake to generate a build
21        directory (with the specified generator) as needed.
22        """
23        ensure_build_directory(args, ctx.info_name)
24        run_target(target_name, args)
25
26    def list_build_system_targets(target_name, ctx, args):
27        """Shows list of targets known to build sytem (make/ninja)"""
28        build_target('help', ctx, args)
29
30    def menuconfig(target_name, ctx, args, style):
31        """
32        Menuconfig target is build_target extended with the style argument for setting the value for the environment
33        variable.
34        """
35        if sys.version_info[0] < 3:
36            # The subprocess lib cannot accept environment variables as "unicode".
37            # This encoding step is required only in Python 2.
38            style = style.encode(sys.getfilesystemencoding() or 'utf-8')
39        os.environ['MENUCONFIG_STYLE'] = style
40        build_target(target_name, ctx, args)
41
42    def fallback_target(target_name, ctx, args):
43        """
44        Execute targets that are not explicitly known to idf.py
45        """
46        ensure_build_directory(args, ctx.info_name)
47
48        try:
49            subprocess.check_output(GENERATORS[args.generator]['dry_run'] + [target_name], cwd=args.build_dir)
50
51        except Exception:
52            raise FatalError(
53                'command "%s" is not known to idf.py and is not a %s target' % (target_name, args.generator))
54
55        run_target(target_name, args)
56
57    def verbose_callback(ctx, param, value):
58        if not value or ctx.resilient_parsing:
59            return
60
61        for line in ctx.command.verbose_output:
62            print(line)
63
64        return value
65
66    def clean(action, ctx, args):
67        if not os.path.isdir(args.build_dir):
68            print("Build directory '%s' not found. Nothing to clean." % args.build_dir)
69            return
70        build_target('clean', ctx, args)
71
72    def _delete_windows_symlinks(directory):
73        """
74        It deletes symlinks recursively on Windows. It is useful for Python 2 which doesn't detect symlinks on Windows.
75        """
76        deleted_paths = []
77        if os.name == 'nt':
78            import ctypes
79
80            for root, dirnames, _filenames in os.walk(directory):
81                for d in dirnames:
82                    full_path = os.path.join(root, d)
83                    try:
84                        full_path = full_path.decode('utf-8')
85                    except Exception:
86                        pass
87                    if ctypes.windll.kernel32.GetFileAttributesW(full_path) & 0x0400:
88                        os.rmdir(full_path)
89                        deleted_paths.append(full_path)
90        return deleted_paths
91
92    def fullclean(action, ctx, args):
93        build_dir = args.build_dir
94        if not os.path.isdir(build_dir):
95            print("Build directory '%s' not found. Nothing to clean." % build_dir)
96            return
97        if len(os.listdir(build_dir)) == 0:
98            print("Build directory '%s' is empty. Nothing to clean." % build_dir)
99            return
100
101        if not os.path.exists(os.path.join(build_dir, 'CMakeCache.txt')):
102            raise FatalError(
103                "Directory '%s' doesn't seem to be a CMake build directory. Refusing to automatically "
104                "delete files in this directory. Delete the directory manually to 'clean' it." % build_dir)
105        red_flags = ['CMakeLists.txt', '.git', '.svn']
106        for red in red_flags:
107            red = os.path.join(build_dir, red)
108            if os.path.exists(red):
109                raise FatalError(
110                    "Refusing to automatically delete files in directory containing '%s'. Delete files manually if you're sure."
111                    % red)
112        # OK, delete everything in the build directory...
113        # Note: Python 2.7 doesn't detect symlinks on Windows (it is supported form 3.2). Tools promising to not
114        # follow symlinks will actually follow them. Deleting the build directory with symlinks deletes also items
115        # outside of this directory.
116        deleted_symlinks = _delete_windows_symlinks(build_dir)
117        if args.verbose and len(deleted_symlinks) > 1:
118            print('The following symlinks were identified and removed:\n%s' % '\n'.join(deleted_symlinks))
119        for f in os.listdir(build_dir):  # TODO: once we are Python 3 only, this can be os.scandir()
120            f = os.path.join(build_dir, f)
121            if args.verbose:
122                print('Removing: %s' % f)
123            if os.path.isdir(f):
124                shutil.rmtree(f)
125            else:
126                os.remove(f)
127
128    def python_clean(action, ctx, args):
129        for root, dirnames, filenames in os.walk(os.environ['IDF_PATH']):
130            for d in dirnames:
131                if d == '__pycache__':
132                    dir_to_delete = os.path.join(root, d)
133                    if args.verbose:
134                        print('Removing: %s' % dir_to_delete)
135                    shutil.rmtree(dir_to_delete)
136            for filename in fnmatch.filter(filenames, '*.py[co]'):
137                file_to_delete = os.path.join(root, filename)
138                if args.verbose:
139                    print('Removing: %s' % file_to_delete)
140                os.remove(file_to_delete)
141
142    def set_target(action, ctx, args, idf_target):
143        if (not args['preview'] and idf_target in PREVIEW_TARGETS):
144            raise FatalError(
145                "%s is still in preview. You have to append '--preview' option after idf.py to use any preview feature."
146                % idf_target)
147        args.define_cache_entry.append('IDF_TARGET=' + idf_target)
148        sdkconfig_path = os.path.join(args.project_dir, 'sdkconfig')
149        sdkconfig_old = sdkconfig_path + '.old'
150        if os.path.exists(sdkconfig_old):
151            os.remove(sdkconfig_old)
152        if os.path.exists(sdkconfig_path):
153            os.rename(sdkconfig_path, sdkconfig_old)
154        print('Set Target to: %s, new sdkconfig created. Existing sdkconfig renamed to sdkconfig.old.' % idf_target)
155        ensure_build_directory(args, ctx.info_name, True)
156
157    def reconfigure(action, ctx, args):
158        ensure_build_directory(args, ctx.info_name, True)
159
160    def validate_root_options(ctx, args, tasks):
161        args.project_dir = realpath(args.project_dir)
162        if args.build_dir is not None and args.project_dir == realpath(args.build_dir):
163            raise FatalError(
164                'Setting the build directory to the project directory is not supported. Suggest dropping '
165                "--build-dir option, the default is a 'build' subdirectory inside the project directory.")
166        if args.build_dir is None:
167            args.build_dir = os.path.join(args.project_dir, 'build')
168        args.build_dir = realpath(args.build_dir)
169
170    def idf_version_callback(ctx, param, value):
171        if not value or ctx.resilient_parsing:
172            return
173
174        version = idf_version()
175
176        if not version:
177            raise FatalError('ESP-IDF version cannot be determined')
178
179        print('ESP-IDF %s' % version)
180        sys.exit(0)
181
182    def list_targets_callback(ctx, param, value):
183        if not value or ctx.resilient_parsing:
184            return
185
186        for target in SUPPORTED_TARGETS:
187            print(target)
188
189        if 'preview' in ctx.params:
190            for target in PREVIEW_TARGETS:
191                print(target)
192
193        sys.exit(0)
194
195    root_options = {
196        'global_options': [
197            {
198                'names': ['--version'],
199                'help': 'Show IDF version and exit.',
200                'is_flag': True,
201                'expose_value': False,
202                'callback': idf_version_callback,
203            },
204            {
205                'names': ['--list-targets'],
206                'help': 'Print list of supported targets and exit.',
207                'is_flag': True,
208                'expose_value': False,
209                'callback': list_targets_callback,
210            },
211            {
212                'names': ['-C', '--project-dir'],
213                'scope': 'shared',
214                'help': 'Project directory.',
215                'type': click.Path(),
216                'default': os.getcwd(),
217            },
218            {
219                'names': ['-B', '--build-dir'],
220                'help': 'Build directory.',
221                'type': click.Path(),
222                'default': None,
223            },
224            {
225                'names': ['-w/-n', '--cmake-warn-uninitialized/--no-warnings'],
226                'help': ('Enable CMake uninitialized variable warnings for CMake files inside the project directory. '
227                         "(--no-warnings is now the default, and doesn't need to be specified.)"),
228                'envvar': 'IDF_CMAKE_WARN_UNINITIALIZED',
229                'is_flag': True,
230                'default': False,
231            },
232            {
233                'names': ['-v', '--verbose'],
234                'help': 'Verbose build output.',
235                'is_flag': True,
236                'is_eager': True,
237                'default': False,
238                'callback': verbose_callback,
239            },
240            {
241                'names': ['--preview'],
242                'help': 'Enable IDF features that are still in preview.',
243                'is_flag': True,
244                'default': False,
245            },
246            {
247                'names': ['--ccache/--no-ccache'],
248                'help': 'Use ccache in build. Disabled by default.',
249                'is_flag': True,
250                'envvar': 'IDF_CCACHE_ENABLE',
251                'default': False,
252            },
253            {
254                'names': ['-G', '--generator'],
255                'help': 'CMake generator.',
256                'type': click.Choice(GENERATORS.keys()),
257            },
258            {
259                'names': ['--dry-run'],
260                'help': "Only process arguments, but don't execute actions.",
261                'is_flag': True,
262                'hidden': True,
263                'default': False,
264            },
265        ],
266        'global_action_callbacks': [validate_root_options],
267    }
268
269    build_actions = {
270        'actions': {
271            'all': {
272                'aliases': ['build'],
273                'callback': build_target,
274                'short_help': 'Build the project.',
275                'help': (
276                    'Build the project. This can involve multiple steps:\n\n'
277                    '1. Create the build directory if needed. '
278                    "The sub-directory 'build' is used to hold build output, "
279                    'although this can be changed with the -B option.\n\n'
280                    '2. Run CMake as necessary to configure the project '
281                    'and generate build files for the main build tool.\n\n'
282                    '3. Run the main build tool (Ninja or GNU Make). '
283                    'By default, the build tool is automatically detected '
284                    'but it can be explicitly set by passing the -G option to idf.py.\n\n'),
285                'options': global_options,
286                'order_dependencies': [
287                    'reconfigure',
288                    'menuconfig',
289                    'clean',
290                    'fullclean',
291                ],
292            },
293            'menuconfig': {
294                'callback': menuconfig,
295                'help': 'Run "menuconfig" project configuration tool.',
296                'options': global_options + [
297                    {
298                        'names': ['--style', '--color-scheme', 'style'],
299                        'help': (
300                            'Menuconfig style.\n'
301                            'The built-in styles include:\n\n'
302                            '- default - a yellowish theme,\n\n'
303                            '- monochrome -  a black and white theme, or\n\n'
304                            '- aquatic - a blue theme.\n\n'
305                            'It is possible to customize these themes further'
306                            ' as it is described in the Color schemes section of the kconfiglib documentation.\n'
307                            'The default value is \"aquatic\".'),
308                        'envvar': 'MENUCONFIG_STYLE',
309                        'default': 'aquatic',
310                    }
311                ],
312            },
313            'confserver': {
314                'callback': build_target,
315                'help': 'Run JSON configuration server.',
316                'options': global_options,
317            },
318            'size': {
319                'callback': build_target,
320                'help': 'Print basic size information about the app.',
321                'options': global_options,
322                'dependencies': ['app'],
323            },
324            'size-components': {
325                'callback': build_target,
326                'help': 'Print per-component size information.',
327                'options': global_options,
328                'dependencies': ['app'],
329            },
330            'size-files': {
331                'callback': build_target,
332                'help': 'Print per-source-file size information.',
333                'options': global_options,
334                'dependencies': ['app'],
335            },
336            'bootloader': {
337                'callback': build_target,
338                'help': 'Build only bootloader.',
339                'options': global_options,
340            },
341            'app': {
342                'callback': build_target,
343                'help': 'Build only the app.',
344                'order_dependencies': ['clean', 'fullclean', 'reconfigure'],
345                'options': global_options,
346            },
347            'efuse_common_table': {
348                'callback': build_target,
349                'help': "Generate C-source for IDF's eFuse fields.",
350                'order_dependencies': ['reconfigure'],
351                'options': global_options,
352            },
353            'efuse_custom_table': {
354                'callback': build_target,
355                'help': "Generate C-source for user's eFuse fields.",
356                'order_dependencies': ['reconfigure'],
357                'options': global_options,
358            },
359            'show_efuse_table': {
360                'callback': build_target,
361                'help': 'Print eFuse table.',
362                'order_dependencies': ['reconfigure'],
363                'options': global_options,
364            },
365            'partition_table': {
366                'callback': build_target,
367                'help': 'Build only partition table.',
368                'order_dependencies': ['reconfigure'],
369                'options': global_options,
370            },
371            'erase_otadata': {
372                'callback': build_target,
373                'help': 'Erase otadata partition.',
374                'options': global_options,
375            },
376            'read_otadata': {
377                'callback': build_target,
378                'help': 'Read otadata partition.',
379                'options': global_options,
380            },
381            'build-system-targets': {
382                'callback': list_build_system_targets,
383                'help': 'Print list of build system targets.',
384            },
385            'fallback': {
386                'callback': fallback_target,
387                'help': 'Handle for targets not known for idf.py.',
388                'hidden': True,
389            }
390        }
391    }
392
393    clean_actions = {
394        'actions': {
395            'reconfigure': {
396                'callback': reconfigure,
397                'short_help': 'Re-run CMake.',
398                'help': (
399                    "Re-run CMake even if it doesn't seem to need re-running. "
400                    "This isn't necessary during normal usage, "
401                    'but can be useful after adding/removing files from the source tree, '
402                    'or when modifying CMake cache variables. '
403                    "For example, \"idf.py -DNAME='VALUE' reconfigure\" "
404                    'can be used to set variable "NAME" in CMake cache to value "VALUE".'),
405                'options': global_options,
406                'order_dependencies': ['menuconfig', 'fullclean'],
407            },
408            'set-target': {
409                'callback': set_target,
410                'short_help': 'Set the chip target to build.',
411                'help': (
412                    'Set the chip target to build. This will remove the '
413                    'existing sdkconfig file and corresponding CMakeCache and '
414                    'create new ones according to the new target.\nFor example, '
415                    "\"idf.py set-target esp32\" will select esp32 as the new chip "
416                    'target.'),
417                'arguments': [
418                    {
419                        'names': ['idf-target'],
420                        'nargs': 1,
421                        'type': TargetChoice(SUPPORTED_TARGETS + PREVIEW_TARGETS),
422                    },
423                ],
424                'dependencies': ['fullclean'],
425            },
426            'clean': {
427                'callback': clean,
428                'short_help': 'Delete build output files from the build directory.',
429                'help': (
430                    'Delete build output files from the build directory, '
431                    "forcing a 'full rebuild' the next time "
432                    "the project is built. Cleaning doesn't delete "
433                    'CMake configuration output and some other files'),
434                'order_dependencies': ['fullclean'],
435            },
436            'fullclean': {
437                'callback': fullclean,
438                'short_help': 'Delete the entire build directory contents.',
439                'help': (
440                    'Delete the entire build directory contents. '
441                    'This includes all CMake configuration output.'
442                    'The next time the project is built, '
443                    'CMake will configure it from scratch. '
444                    'Note that this option recursively deletes all files '
445                    'in the build directory, so use with care.'
446                    'Project configuration is not deleted.')
447            },
448            'python-clean': {
449                'callback': python_clean,
450                'short_help': 'Delete generated Python byte code from the IDF directory',
451                'help': (
452                    'Delete generated Python byte code from the IDF directory '
453                    'which may cause issues when switching between IDF and Python versions. '
454                    'It is advised to run this target after switching versions.')
455            },
456        }
457    }
458
459    return merge_action_lists(root_options, build_actions, clean_actions)
460