diff --git a/tools/idf.py b/tools/idf.py index c7aff30db36..2cea100d23d 100755 --- a/tools/idf.py +++ b/tools/idf.py @@ -163,6 +163,7 @@ def init_cli(verbose_output: list | None = None) -> Any: from click.shell_completion import CompletionItem from rich_click import Context from rich_click import RichHelpConfiguration + from rich_click import RichHelpFormatter from rich_click.rich_click import MAX_WIDTH class Deprecation: @@ -349,6 +350,22 @@ def init_cli(verbose_output: list | None = None) -> Any: check_deprecation(ctx) return super().invoke(ctx) + def format_options(self, ctx: Context, formatter: RichHelpFormatter) -> None: + """ + default_panels_first=True causes the + renderer to drop `post_default_panels` for options on non-Group + commands, which is exactly where the subcommand "Options" panel + lives -- `idf.py --help` would otherwise show only + Usage + description. Temporarily flip the flag to False while + rendering options. + """ + prev_default_first = formatter.config.default_panels_first + try: + formatter.config.default_panels_first = False + super().format_options(ctx, formatter) + finally: + formatter.config.default_panels_first = prev_default_first + class Argument(click.RichArgument): """ Positional argument @@ -445,15 +462,21 @@ def init_cli(verbose_output: list | None = None) -> Any: all_actions: dict | None = None, verbose_output: list | None = None, cli_help: str | None = None, + command_groups: dict[str, list[dict[str, Any]]] | None = None, ) -> None: super().__init__( + PROG, chain=True, invoke_without_command=True, result_callback=self.execute_tasks, no_args_is_help=True, context_settings={ 'help_option_names': ['-h', '--help'], - 'rich_help_config': RichHelpConfiguration(max_width=MAX_WIDTH), + 'rich_help_config': RichHelpConfiguration( + max_width=MAX_WIDTH, + command_groups=command_groups if command_groups is not None else {}, + default_panels_first=True, + ), }, help=cli_help, ) @@ -934,15 +957,14 @@ def init_cli(verbose_output: list | None = None) -> Any: build_dir: str = args.build_dir return os.path.abspath(build_dir) - def _extract_relevant_path(path: str) -> str: - """ - Returns part of the path starting from 'components' or 'managed_components'. - If neither is found, returns the full path. - """ - for keyword in ('components', 'managed_components'): - # arg path is loaded from project_description.json, where paths are always defined with '/' - if keyword in path.split('/'): - return keyword + path.split(keyword, 1)[1] + def _path_relative_to_project(path: str, project_dir: str) -> str: + """If ``path`` is under ``project_dir``, return its path relative to the project; else ``path`` unchanged.""" + path_abs = os.path.abspath(os.path.normpath(path)) + project_abs = os.path.abspath(os.path.normpath(project_dir)) + parent_prefix = project_abs.rstrip(os.sep) + os.sep + if path_abs == project_abs or path_abs.startswith(parent_prefix): + return _safe_relpath(path_abs, project_abs) + return path # Mutable dict used as a cache keyed by lock path @@ -1014,6 +1036,20 @@ def init_cli(verbose_output: list | None = None) -> Any: return lock_key in _get_trusted_names_from_lock(lock_path) return False + def _build_rich_help_command_groups( + external_panels: list[tuple[str, list[str]]], + ) -> dict[str, list[dict[str, Any]]]: + """Build ``command_groups`` for rich-click's ``RichHelpConfiguration``. + ``external_panels`` is a list of ``(title, command_names)`` from ``idf_ext.py`` extension + modules and from Python entry-point extensions. Those panels appear on + the root ``idf.py --help`` after the default Commands section. + """ + panels: list[dict[str, Any]] = [] + for title, cmds in external_panels: + if cmds: + panels.append({'name': title, 'commands': cmds}) + return {PROG: panels} if panels else {} + # That's a tiny parser that parse project-dir even before constructing # fully featured click parser to be sure that extensions are loaded from the right place @click.command( @@ -1113,31 +1149,44 @@ def init_cli(verbose_output: list | None = None) -> Any: else: print_warning( f'WARNING: Not loading component extension from untrusted source ' - f'"{_extract_relevant_path(comp_dir)}". ' + f'"{_path_relative_to_project(comp_dir, project_dir)}". ' 'Only extensions from trusted sources are loaded. Run ' '"idf.py docs -sp api-guides/tools/idf-py.html#extending-idf-py" ' 'for the list of trusted sources. Set IDF_EXTENSION_ALLOW_UNTRUSTED=1 to load all.' ) # Load extensions from directories that participate in the build (components and project) + external_help_panels: list[tuple[str, list[str]]] = [] for ext_dir in component_idf_ext_dirs + [project_dir]: extension_func = load_cli_extension_from_dir(ext_dir) if extension_func: try: - all_actions = merge_action_lists(all_actions, custom_actions=extension_func(all_actions, project_dir)) + custom_actions = extension_func(all_actions, project_dir) + all_actions = merge_action_lists(all_actions, custom_actions=custom_actions) except Exception as e: print_warning(f'WARNING: Cannot load directory extension from "{ext_dir}": {e}') else: + panel_cmds = sorted(n for n in custom_actions.get('actions') or {} if n != 'fallback') + if panel_cmds: + panel_title = ( + 'Project' if ext_dir == project_dir else _path_relative_to_project(ext_dir, project_dir) + ) + external_help_panels.append((panel_title, panel_cmds)) if ext_dir != project_dir: - print(f'INFO: Loaded component extension from "{_extract_relevant_path(ext_dir)}"') + print(f'INFO: Loaded component extension from "{_path_relative_to_project(ext_dir, project_dir)}"') # Load extensions from Python entry points entry_point_extensions = load_cli_extensions_from_entry_points() - for name, extension_func in entry_point_extensions: + for ep_name, extension_func in entry_point_extensions: try: - all_actions = merge_action_lists(all_actions, custom_actions=extension_func(all_actions, project_dir)) + custom_actions = extension_func(all_actions, project_dir) + all_actions = merge_action_lists(all_actions, custom_actions=custom_actions) except Exception as e: - print_warning(f'WARNING: Cannot load entry point extension "{name}": {e}') + print_warning(f'WARNING: Cannot load entry point extension "{ep_name}": {e}') + else: + panel_cmds = sorted(n for n in (custom_actions.get('actions') or {}) if n != 'fallback') + if panel_cmds: + external_help_panels.append((ep_name, panel_cmds)) cli_help = ( 'ESP-IDF CLI build management tool. ' @@ -1145,7 +1194,13 @@ def init_cli(verbose_output: list | None = None) -> Any: f'Selected target: {get_target(project_dir)}' ) - return CLI(cli_help=cli_help, verbose_output=verbose_output, all_actions=all_actions) + help_command_groups = _build_rich_help_command_groups(external_help_panels) + return CLI( + cli_help=cli_help, + verbose_output=verbose_output, + all_actions=all_actions, + command_groups=help_command_groups, + ) def main(argv: list[Any] | None = None) -> None: diff --git a/tools/test_build_system/test_idf_extension.py b/tools/test_build_system/test_idf_extension.py index 0a8746d341b..766eae6fa65 100644 --- a/tools/test_build_system/test_idf_extension.py +++ b/tools/test_build_system/test_idf_extension.py @@ -18,6 +18,7 @@ from test_build_system_helpers import EnvDict from test_build_system_helpers import IdfPyFunc from test_build_system_helpers import find_python from test_build_system_helpers import replace_in_file +from test_build_system_helpers import run_idf_py from conftest import should_clean_test_dir @@ -188,10 +189,11 @@ def test_extension_from_component(idf_py: IdfPyFunc, test_app_copy: Path) -> Non idf_py('reconfigure') ret = idf_py('--help') assert 'test-component-action' in ret.stdout - assert 'INFO: Loaded component extension from "components/test_component"' in ret.stdout + expected_info = f'INFO: Loaded component extension from "{os.path.join("components", "test_component")}"' + assert expected_info in ret.stdout ret = idf_py('test-component-action') assert 'Test extension action executed - component extension' in ret.stdout - assert 'INFO: Loaded component extension from "components/test_component"' in ret.stdout + assert expected_info in ret.stdout def test_extension_from_component_invalid_syntax(idf_py: IdfPyFunc, test_app_copy: Path) -> None: @@ -242,6 +244,47 @@ def test_extension_from_component_invalid_syntax(idf_py: IdfPyFunc, test_app_cop assert 'Attribute "version" is required in custom extension.' in ret.stderr +@pytest.mark.usefixtures('test_app_copy') +def test_idf_py_help_rich_click_component_extension_panel( + idf_py: IdfPyFunc, + default_idf_env: dict[str, str], +) -> None: + """Default Commands panel first; component extension gets its own panel after.""" + idf_py('create-component', '-C', 'components', 'help_group_comp') + comp_dir = Path('components') / 'help_group_comp' + (comp_dir / 'idf_ext.py').write_text( + textwrap.dedent( + TEST_EXT_TEMPLATE.format( + suffix='help panel comp', + global_options='', + actions="""'help-group-comp-cmd': { + 'callback': test_extension_action, + 'help': 'Component extension command for help panel test' + }""", + ) + ) + ) + replace_in_file( + Path('main') / 'CMakeLists.txt', + '# placeholder_inside_idf_component_register', + '\n'.join(['INCLUDE_DIRS "." ', 'REQUIRES "help_group_comp" ']), + ) + idf_py('reconfigure') + + env = {**default_idf_env, 'COLUMNS': '120', 'NO_COLOR': '1'} + ret = run_idf_py('--help', env=env, workdir=os.getcwd(), check=True) + + idx_commands = ret.stdout.find('Commands') + assert idx_commands != -1, 'Root idf.py --help should list the default Commands group.' + idx_comp_panel = ret.stdout.find('help_group_comp', idx_commands) + assert idx_comp_panel != -1, 'Expected help group for the help_group_comp component extension.' + assert idx_comp_panel > idx_commands, 'Component extension group should appear after the default Commands group.' + + # ret.stdout[i:j]: substring from index i (inclusive) to j (exclusive); ':' separates the two bounds. + default_block = ret.stdout[idx_commands:idx_comp_panel] + assert 'build' in default_block, 'Built-in `build` should still appear under the default Commands panel.' + + # ----------- Test cases for entry point extension ----------- @@ -373,6 +416,44 @@ def test_extension_entrypoint_conflicting_names( assert 'This global option conflicts with existing one' not in ret.stdout +@pytest.mark.usefixtures('test_app_copy') +def test_idf_py_help_rich_click_entrypoint_extension_panel( + idf_py: IdfPyFunc, + default_idf_env: dict[str, str], + extension_package_manager: ExtensionPackageManager, +) -> None: + """Default Commands panel first; entry-point extension gets its own panel after.""" + extension_package_manager.create_package('helpgroup') + + env = {**default_idf_env, 'COLUMNS': '120', 'NO_COLOR': '1'} + ret = run_idf_py('--help', env=env, workdir=os.getcwd(), check=True) + + idx_commands = ret.stdout.find('Commands') + assert idx_commands != -1, 'Root idf.py --help should list the default Commands group.' + idx_ep_panel = ret.stdout.find('test_extension_helpgroup', idx_commands) + assert idx_ep_panel != -1, 'Expected help group for the helpgroup entry-point extension.' + assert idx_ep_panel > idx_commands, 'Entry-point extension group should appear after the default Commands group.' + + # ret.stdout[i:j]: substring from index i (inclusive) to j (exclusive); ':' separates the two bounds. + default_block = ret.stdout[idx_commands:idx_ep_panel] + assert 'build' in default_block, 'Built-in `build` should still appear under the default Commands panel.' + + +# ----------- General extension tests ----------- + + +@pytest.mark.usefixtures('test_app_copy') +def test_idf_py_subcommand_help_shows_options( + idf_py: IdfPyFunc, + default_idf_env: dict[str, str], +) -> None: + """Subcommand --help must list global/action options (rich-click + default_panels_first).""" + idf_py('reconfigure') + env = {**default_idf_env, 'NO_COLOR': '1', 'COLUMNS': '120'} + ret = run_idf_py('flash', '--help', env=env, workdir=os.getcwd(), check=True) + assert '--project-dir' in ret.stdout or '-C ' in ret.stdout + + # ----------- Regression test: idf.py recursion via idf_version clause -----------