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