feat(tools): Visibly grouped custom extension actions in idf.py --help

This commit is contained in:
Marek Fiala
2026-04-29 15:10:50 +02:00
parent 007e0139d7
commit b6872d32a0
2 changed files with 155 additions and 19 deletions

View File

@@ -161,6 +161,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:
@@ -326,6 +327,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 <subcmd> --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
@@ -422,15 +439,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,
)
@@ -804,15 +827,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
@@ -884,6 +906,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(
@@ -983,31 +1019,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. '
@@ -1015,7 +1064,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:

View File

@@ -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
@@ -185,10 +186,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:
@@ -239,6 +241,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 -----------
@@ -370,6 +413,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 -----------