diff --git a/tools/idf.py b/tools/idf.py index 56ac95d17d..bba0590599 100755 --- a/tools/idf.py +++ b/tools/idf.py @@ -882,7 +882,7 @@ def init_cli(verbose_output: list | None = None) -> Any: extension_func = load_cli_extension_from_dir(ext_dir) if extension_func: try: - all_actions = merge_action_lists(all_actions, extension_func(all_actions, project_dir)) + all_actions = merge_action_lists(all_actions, custom_actions=extension_func(all_actions, project_dir)) except Exception as e: print_warning(f'WARNING: Cannot load directory extension from "{ext_dir}": {e}') else: @@ -893,7 +893,7 @@ def init_cli(verbose_output: list | None = None) -> Any: entry_point_extensions = load_cli_extensions_from_entry_points() for name, extension_func in entry_point_extensions: try: - all_actions = merge_action_lists(all_actions, extension_func(all_actions, project_dir)) + all_actions = merge_action_lists(all_actions, custom_actions=extension_func(all_actions, project_dir)) except Exception as e: print_warning(f'WARNING: Cannot load entry point extension "{name}": {e}') diff --git a/tools/idf_py_actions/tools.py b/tools/idf_py_actions/tools.py index 0b5f2bfce1..3a5b30a346 100644 --- a/tools/idf_py_actions/tools.py +++ b/tools/idf_py_actions/tools.py @@ -721,7 +721,40 @@ def ensure_build_directory( _set_build_context(args) -def merge_action_lists(*action_lists: dict) -> dict: +def merge_action_lists(*action_lists: dict, custom_actions: dict[str, Any] | None = None) -> dict: + """ + Merge multiple action lists into a single dictionary. + + External action lists (via custom_actions) come from outside components or + user-defined extensions: + - Any duplicate with an existing action or option will trigger a warning, + and external definitions will not override defaults. + + *action_lists: Actions that comes from official ESP-IDF development + custom_actions: Actions that comes from external extensions + """ + + def _get_all_action_identifiers(actions_dict: dict[str, Any]) -> set[str]: + """Extract all action names and their aliases as a single set.""" + return {name for name in actions_dict.keys()} | { + alias for action in actions_dict.values() for alias in action.get('aliases', []) + } + + def _check_action_conflicts(name: str, action: dict[str, Any], existing_identifiers: set[str]) -> None: + """Check if an action name or its aliases conflict with existing identifiers. + Raises UserWarning if conflicts are found. + """ + if name in existing_identifiers: + raise UserWarning(f"Action '{name}' already defined") + + aliases = action.get('aliases', []) + conflicting_aliases = set(aliases) & existing_identifiers + if conflicting_aliases: + raise UserWarning( + f"Action '{name}' has aliases {list(conflicting_aliases)} " + 'that conflict with existing actions or aliases' + ) + merged_actions: dict = { 'global_options': [], 'actions': {}, @@ -731,6 +764,33 @@ def merge_action_lists(*action_lists: dict) -> dict: merged_actions['global_options'].extend(action_list.get('global_options', [])) merged_actions['actions'].update(action_list.get('actions', {})) merged_actions['global_action_callbacks'].extend(action_list.get('global_action_callbacks', [])) + + if not custom_actions: + return merged_actions + + existing_identifiers = _get_all_action_identifiers(merged_actions['actions']) + for name, action in custom_actions.get('actions', {}).items(): + try: + _check_action_conflicts(name, action, existing_identifiers) + merged_actions['actions'][name] = action + existing_identifiers.add(name) + existing_identifiers.update(action.get('aliases', [])) + except UserWarning as e: + yellow_print(f'WARNING: {e}. External action will not be added.') + + for new_opt in custom_actions.get('global_options', []): + if any( + set(new_opt.get('names', [])) & set(existing.get('names', [])) + for existing in merged_actions['global_options'] + ): + yellow_print( + f'WARNING: Global option {new_opt["names"]} already defined. External option will not be added.' + ) + else: + merged_actions['global_options'].append(new_opt) + + merged_actions['global_action_callbacks'].extend(custom_actions.get('global_action_callbacks', [])) + return merged_actions