|
14 | 14 | # their specific function instead. |
15 | 15 | import codecs |
16 | 16 | import glob |
| 17 | +import importlib.metadata |
| 18 | +import importlib.util |
17 | 19 | import json |
18 | 20 | import locale |
19 | 21 | import os.path |
@@ -246,6 +248,8 @@ def __call__( |
246 | 248 | self.callback(self.name, context, global_args, **action_args) |
247 | 249 |
|
248 | 250 | class Action(click.Command): |
| 251 | + callback: Callable |
| 252 | + |
249 | 253 | def __init__( |
250 | 254 | self, |
251 | 255 | name: str | None = None, |
@@ -721,6 +725,90 @@ def execute_tasks(self, tasks: list, **kwargs: str) -> OrderedDict: |
721 | 725 |
|
722 | 726 | return tasks_to_run |
723 | 727 |
|
| 728 | + def load_cli_extension_from_dir(ext_dir: str) -> Any | None: |
| 729 | + """Load extension 'idf_ext.py' from directory and return the action_extensions function""" |
| 730 | + ext_file = os.path.join(ext_dir, 'idf_ext.py') |
| 731 | + if not os.path.exists(ext_file): |
| 732 | + return None |
| 733 | + |
| 734 | + try: |
| 735 | + module_name = f'idf_ext_{os.path.basename(ext_dir)}' |
| 736 | + spec = importlib.util.spec_from_file_location(module_name, ext_file) |
| 737 | + if spec is None or spec.loader is None: |
| 738 | + raise ImportError('Failed to load python module') |
| 739 | + ext_module = importlib.util.module_from_spec(spec) |
| 740 | + sys.modules[module_name] = ext_module |
| 741 | + spec.loader.exec_module(ext_module) |
| 742 | + |
| 743 | + if hasattr(ext_module, 'action_extensions'): |
| 744 | + return ext_module.action_extensions |
| 745 | + else: |
| 746 | + print_warning(f"Warning: Extension {ext_file} has no attribute 'action_extensions'") |
| 747 | + |
| 748 | + except (ImportError, SyntaxError) as e: |
| 749 | + print_warning(f'Warning: Failed to import extension {ext_file}: {e}') |
| 750 | + |
| 751 | + return None |
| 752 | + |
| 753 | + def load_cli_extensions_from_entry_points() -> list[tuple[str, Any]]: |
| 754 | + """Load extensions from Python entry points""" |
| 755 | + extensions: list[tuple[str, Any]] = [] |
| 756 | + eps = importlib.metadata.entry_points(group='idf_extension') |
| 757 | + |
| 758 | + # declarative value is the path-like identifier of entry point defined in the components config file |
| 759 | + # having same declarative value for multiple entry points results in loading only one of them (undeterministic) |
| 760 | + eps_declarative_values: list[str] = [] |
| 761 | + for ep in eps: |
| 762 | + if ep.value in eps_declarative_values: |
| 763 | + conflicting_names = [e.name for e in eps if e.value == ep.value] |
| 764 | + print_warning( |
| 765 | + f"Warning: Entry point's declarative value [extension_file_name:method_name] " |
| 766 | + f'name collision detected for - {ep.value}. The same {ep.value} is used by ' |
| 767 | + f'{conflicting_names} entry points. To ensure successful loading, please use' |
| 768 | + ' a different extension file name or method name for the entry point.' |
| 769 | + ) |
| 770 | + # Remove any already loaded extensions with conflicting names |
| 771 | + extensions[:] = [ext for ext in extensions if ext[0] not in conflicting_names] |
| 772 | + continue |
| 773 | + |
| 774 | + if ep.value == 'idf_ext:action_extensions': |
| 775 | + print_warning( |
| 776 | + f'Entry point "{ep.name}" has declarative value "{ep.value}". For external components, ' |
| 777 | + 'it is recommended to use name like <<COMPONENT_NAME>>_ext:action_extensions, ' |
| 778 | + "so it does not interfere with the project's idf_ext.py file." |
| 779 | + ) |
| 780 | + |
| 781 | + eps_declarative_values.append(ep.value) |
| 782 | + try: |
| 783 | + extension_func = ep.load() |
| 784 | + extensions.append((ep.name, extension_func)) |
| 785 | + except Exception as e: |
| 786 | + print_warning(f'Warning: Failed to load entry point extension "{ep.name}": {e}') |
| 787 | + |
| 788 | + return extensions |
| 789 | + |
| 790 | + def resolve_build_dir() -> str: |
| 791 | + """Resolve build directory from command line arguments |
| 792 | + return build path if explicitly set, otherwise default build path""" |
| 793 | + import argparse |
| 794 | + |
| 795 | + parser = argparse.ArgumentParser(add_help=False) |
| 796 | + parser.add_argument('-B', '--build-dir', default=os.path.join(project_dir, 'build')) |
| 797 | + args, _ = parser.parse_known_args() |
| 798 | + build_dir: str = args.build_dir |
| 799 | + return os.path.abspath(build_dir) |
| 800 | + |
| 801 | + def _extract_relevant_path(path: str) -> str: |
| 802 | + """ |
| 803 | + Returns part of the path starting from 'components' or 'managed_components'. |
| 804 | + If neither is found, returns the full path. |
| 805 | + """ |
| 806 | + for keyword in ('components', 'managed_components'): |
| 807 | + # arg path is loaded from project_description.json, where paths are always defined with '/' |
| 808 | + if keyword in path.split('/'): |
| 809 | + return keyword + path.split(keyword, 1)[1] |
| 810 | + return path |
| 811 | + |
724 | 812 | # That's a tiny parser that parse project-dir even before constructing |
725 | 813 | # fully featured click parser to be sure that extensions are loaded from the right place |
726 | 814 | @click.command( |
@@ -774,21 +862,40 @@ def parse_project_dir(project_dir: str) -> Any: |
774 | 862 | except AttributeError: |
775 | 863 | print_warning(f'WARNING: Cannot load idf.py extension "{name}"') |
776 | 864 |
|
777 | | - # Load extensions from project dir |
778 | | - if os.path.exists(os.path.join(project_dir, 'idf_ext.py')): |
779 | | - sys.path.append(project_dir) |
| 865 | + component_idf_ext_dirs = [] |
| 866 | + # Get component directories with idf extensions that participate in the build |
| 867 | + build_dir_path = resolve_build_dir() |
| 868 | + project_description_json_file = os.path.join(build_dir_path, 'project_description.json') |
| 869 | + if os.path.exists(project_description_json_file): |
780 | 870 | try: |
781 | | - from idf_ext import action_extensions |
782 | | - except ImportError: |
783 | | - print_warning('Error importing extension file idf_ext.py. Skipping.') |
784 | | - print_warning( |
785 | | - "Please make sure that it contains implementation (even if it's empty) of add_action_extensions" |
786 | | - ) |
| 871 | + with open(project_description_json_file, encoding='utf-8') as f: |
| 872 | + project_desc = json.load(f) |
| 873 | + all_component_info = project_desc.get('build_component_info', {}) |
| 874 | + for _, comp_info in all_component_info.items(): |
| 875 | + comp_dir = comp_info.get('dir') |
| 876 | + if comp_dir and os.path.isdir(comp_dir) and os.path.exists(os.path.join(comp_dir, 'idf_ext.py')): |
| 877 | + component_idf_ext_dirs.append(comp_dir) |
| 878 | + except (OSError, json.JSONDecodeError) as e: |
| 879 | + print_warning(f'Warning: Failed to read component info from project_description.json: {e}') |
| 880 | + # Load extensions from directories that participate in the build (components and project) |
| 881 | + for ext_dir in component_idf_ext_dirs + [project_dir]: |
| 882 | + extension_func = load_cli_extension_from_dir(ext_dir) |
| 883 | + if extension_func: |
| 884 | + try: |
| 885 | + all_actions = merge_action_lists(all_actions, custom_actions=extension_func(all_actions, project_dir)) |
| 886 | + except Exception as e: |
| 887 | + print_warning(f'WARNING: Cannot load directory extension from "{ext_dir}": {e}') |
| 888 | + else: |
| 889 | + if ext_dir != project_dir: |
| 890 | + print(f'INFO: Loaded component extension from "{_extract_relevant_path(ext_dir)}"') |
787 | 891 |
|
| 892 | + # Load extensions from Python entry points |
| 893 | + entry_point_extensions = load_cli_extensions_from_entry_points() |
| 894 | + for name, extension_func in entry_point_extensions: |
788 | 895 | try: |
789 | | - all_actions = merge_action_lists(all_actions, action_extensions(all_actions, project_dir)) |
790 | | - except NameError: |
791 | | - pass |
| 896 | + all_actions = merge_action_lists(all_actions, custom_actions=extension_func(all_actions, project_dir)) |
| 897 | + except Exception as e: |
| 898 | + print_warning(f'WARNING: Cannot load entry point extension "{name}": {e}') |
792 | 899 |
|
793 | 900 | cli_help = ( |
794 | 901 | 'ESP-IDF CLI build management tool. ' |
|
0 commit comments