diff --git a/tools/bt/ble_log_console/console.py b/tools/bt/ble_log_console/console.py index 071761c0320..d2a449d0d03 100644 --- a/tools/bt/ble_log_console/console.py +++ b/tools/bt/ble_log_console/console.py @@ -12,7 +12,7 @@ Usage: from datetime import datetime from pathlib import Path -import click +import rich_click as click from src.app import BLELogApp from src.backend.models import format_bytes from src.backend.uart_transport import validate_uart_port diff --git a/tools/idf.py b/tools/idf.py index 7e0cb033d48..33c50ab6921 100755 --- a/tools/idf.py +++ b/tools/idf.py @@ -157,9 +157,15 @@ def _safe_relpath(path: str, start: str | None = None) -> str: def init_cli(verbose_output: list | None = None) -> Any: - # Click is imported here to run it after check_environment() - import click + # rich-click is imported here to run it after check_environment() + import rich_click as click + from click.core import ParameterSource 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 + from rich_click.rich_context import RichContext class Deprecation: """Construct deprecation notice for help messages""" @@ -211,7 +217,7 @@ def init_cli(verbose_output: list | None = None) -> Any: text = text or '' return ('Deprecated! ' + text) if self.deprecated else text - def check_deprecation(ctx: click.core.Context) -> None: + def check_deprecation(ctx: Context) -> None: """Prints deprecation warnings for arguments in given context""" for option in ctx.command.params: # Skip non-Option parameters @@ -228,7 +234,7 @@ def init_cli(verbose_output: list | None = None) -> Any: if hasattr(ctx, 'get_parameter_source'): source = ctx.get_parameter_source(option.name) # Skip if option was not explicitly provided by user (only warn when actually used) - if source not in (click.core.ParameterSource.COMMANDLINE, click.core.ParameterSource.ENVIRONMENT): + if source not in (ParameterSource.COMMANDLINE, ParameterSource.ENVIRONMENT): continue else: # Fallback: check if value differs from default @@ -262,15 +268,13 @@ def init_cli(verbose_output: list | None = None) -> Any: self.action_args = action_args self.aliases = aliases - def __call__( - self, context: click.core.Context, global_args: PropertyDict, action_args: dict | None = None - ) -> None: + def __call__(self, context: Context, global_args: PropertyDict, action_args: dict | None = None) -> None: if action_args is None: action_args = self.action_args self.callback(self.name, context, global_args, **action_args) - class Action(click.Command): + class Action(click.RichCommand): callback: Callable def __init__( @@ -331,7 +335,7 @@ def init_cli(verbose_output: list | None = None) -> Any: self.callback: Callable = wrapped_callback - def invoke(self, ctx: click.core.Context) -> click.core.Context: + def invoke(self, ctx: Context) -> Context: if self.deprecated: deprecation = Deprecation(self.deprecated) message = deprecation.full_message(f'Command "{self.name}"') @@ -347,7 +351,27 @@ def init_cli(verbose_output: list | None = None) -> Any: check_deprecation(ctx) return super().invoke(ctx) - class Argument(click.Argument): + 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. + """ + # default_panels_first=True is introduced in rich-click 1.9.6 + if not hasattr(formatter.config, 'default_panels_first'): + super().format_options(ctx, formatter) + return + 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 @@ -390,7 +414,7 @@ def init_cli(verbose_output: list | None = None) -> Any: def __str__(self) -> str: return self._scope - class Option(click.Option): + class Option(click.RichOption): """Option that knows whether it should be global""" def __init__( @@ -428,14 +452,80 @@ def init_cli(verbose_output: list | None = None) -> Any: if self.scope.is_global: self.help += ' This option can be used at most once either globally, or for one subcommand.' - def get_help_record(self, ctx: click.core.Context) -> Any: + def get_help_record(self, ctx: Context) -> Any: # Backport "hidden" parameter to click 5.0 if self.hidden: return None return super().get_help_record(ctx) - class CLI(click.Group): + def _emit_cmake_custom_targets_help_panel(formatter: RichHelpFormatter, targets: list[tuple[str, str]]) -> None: + """Render CMake phony targets as an extra rich-click-looking panel. + They are not Click commands/options, so the ordinary help machinery does not list them. + """ + import rich.box + from rich.box import Box + from rich.panel import Panel + from rich.table import Table + from rich.text import Text + + # Rich-click adds these boxes on top of rich.box; recreate them locally with the + # same eight-line layout so the public rich API is enough. + # Otherwise rich-click internal modules would be needed (which can change over time). + _RICH_CLICK_EXTRA_BOXES: dict[str, Box] = { + 'BLANK': Box(' \n' * 8), + 'HORIZONTALS_TOP': Box(' ── \n' + ' \n' * 7), + 'HORIZONTALS_DOUBLE_TOP': Box(' ══ \n' + ' \n' * 7), + } + + def _resolve_panel_box(raw: Any) -> Box: + if isinstance(raw, Box): + return raw + if isinstance(raw, str): + if raw in _RICH_CLICK_EXTRA_BOXES: + return _RICH_CLICK_EXTRA_BOXES[raw] + box = getattr(rich.box, raw, None) + if isinstance(box, Box): + return box + return rich.box.ROUNDED + + cfg = formatter.config + panel_box = _resolve_panel_box(cfg.style_commands_panel_box) + t_styles = { + 'show_lines': cfg.style_commands_table_show_lines, + 'leading': cfg.style_commands_table_leading, + 'box': None, + 'border_style': cfg.style_commands_table_border_style, + 'row_styles': cfg.style_commands_table_row_styles, + 'pad_edge': cfg.style_commands_table_pad_edge, + 'padding': cfg.style_commands_table_padding, + 'expand': cfg.style_commands_table_expand, + } + table = Table(show_header=False, highlight=False, **t_styles) + ratio = cfg.style_commands_table_column_width_ratio + r0, r1 = (None, None) if ratio is None else ratio + table.add_column(style=cfg.style_command, no_wrap=True, ratio=r0) + table.add_column(no_wrap=False, ratio=r1) + for name, desc in targets: + desc_cell = Text(desc, style=cfg.style_helptext) if desc else Text() + table.add_row(Text(name, style=cfg.style_command), desc_cell) + + title = Text( + _help_custom_targets.CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE, + style=cfg.style_commands_panel_title_style, + ) + panel = Panel( + table, + border_style=cfg.style_commands_panel_border, + title_align=cfg.align_commands_panel, + box=panel_box, + padding=cfg.style_commands_panel_padding, + style=cfg.style_commands_panel_style, + title=title, + ) + formatter.console.print(panel, highlight=False) + + class CLI(click.RichGroup): """Action list contains all actions with options available for CLI""" def __init__( @@ -443,13 +533,26 @@ 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: + rich_help_config_kwargs: dict[str, Any] = { + 'max_width': MAX_WIDTH, + 'command_groups': command_groups if command_groups is not None else {}, + } + # ``default_panels_first`` was added in rich-click 1.9.6; on older + # versions passing it raises TypeError. + if hasattr(RichHelpConfiguration, 'default_panels_first'): + rich_help_config_kwargs['default_panels_first'] = True super().__init__( + PROG, chain=True, invoke_without_command=True, result_callback=self.execute_tasks, no_args_is_help=True, - context_settings={'max_content_width': 140}, + context_settings={ + 'help_option_names': ['-h', '--help'], + 'rich_help_config': RichHelpConfiguration(**rich_help_config_kwargs), + }, help=cli_help, ) self._actions = {} @@ -489,6 +592,7 @@ def init_cli(verbose_output: list | None = None) -> Any: options = [] self._actions[name] = Action(name=name, **action) + self.commands[name] = self._actions[name] for alias in [name] + action.get('aliases', []): self.commands_with_aliases[alias] = name @@ -514,10 +618,10 @@ def init_cli(verbose_output: list | None = None) -> Any: self._actions[name].params.append(option) - def list_commands(self, ctx: click.core.Context) -> list: + def list_commands(self, ctx: Context) -> list: return sorted(filter(lambda name: not self._actions[name].hidden, self._actions)) - def get_command(self, ctx: click.core.Context, name: str) -> Action | None: + def get_command(self, ctx: Context, name: str) -> Action | None: if name in self.commands_with_aliases: return self._actions.get(self.commands_with_aliases.get(name)) @@ -528,7 +632,7 @@ def init_cli(verbose_output: list | None = None) -> Any: return Action(name=name, callback=callback.unwrapped_callback) return None - def shell_complete(self, ctx: click.core.Context, incomplete: str) -> list[CompletionItem]: + def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]: # Enable @-argument completion in bash only if @ is not present in # COMP_WORDBREAKS. When @ is included, the @-argument is not considered # part of the completion word, causing @-argument completion to function @@ -847,13 +951,13 @@ def init_cli(verbose_output: list | None = None) -> Any: return [(n, '') for n in sorted(found, key=str.lower)] - def format_help(self, ctx: click.core.Context, formatter: click.HelpFormatter) -> None: - """Override to append custom CMake targets section.""" + def format_help(self, ctx: RichContext, formatter: RichHelpFormatter) -> None: + """Append CMake custom targets using the same Rich console as the rest of rich-click help.""" super().format_help(ctx, formatter) targets = self._get_custom_targets() - if targets: - with formatter.section('CMake Custom Targets'): - formatter.write_dl(list(targets)) + if not targets: + return + _emit_cmake_custom_targets_help_panel(formatter, targets) def load_cli_extension_from_dir(ext_dir: str) -> Any | None: """Load extension 'idf_ext.py' from directory and return the action_extensions function""" @@ -928,15 +1032,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 @@ -1008,6 +1111,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( @@ -1107,31 +1224,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. ' @@ -1139,7 +1269,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/idf_py_actions/core_ext.py b/tools/idf_py_actions/core_ext.py index b1d3d359bc2..9f51c334e9e 100644 --- a/tools/idf_py_actions/core_ext.py +++ b/tools/idf_py_actions/core_ext.py @@ -15,8 +15,8 @@ from urllib.request import Request from urllib.request import urlopen from webbrowser import open_new_tab -import click -from click.core import Context +import rich_click as click +from rich_click import Context from idf_py_actions.constants import GENERATORS from idf_py_actions.constants import PREVIEW_TARGETS @@ -167,7 +167,7 @@ def action_extensions(base_actions: dict, project_path: str) -> Any: os.environ.pop('ESP_IDF_KCONFIG_MIN_LABELS', None) build_target(target_name, ctx, args) - def refresh_config(action: str, ctx: click.core.Context, args: PropertyDict, policy: str) -> None: + def refresh_config(action: str, ctx: Context, args: PropertyDict, policy: str) -> None: ensure_build_directory(args, ctx.info_name) run_target('refresh-config', args=args, env={'KCONFIG_DEFAULTS_POLICY': policy}, interactive=True) diff --git a/tools/idf_py_actions/create_ext.py b/tools/idf_py_actions/create_ext.py index 2feab908f07..5cafafb5914 100644 --- a/tools/idf_py_actions/create_ext.py +++ b/tools/idf_py_actions/create_ext.py @@ -8,7 +8,7 @@ from collections.abc import Callable from shutil import copyfile from shutil import copytree -import click +from rich_click import Context from idf_py_actions.tools import PropertyDict @@ -106,7 +106,7 @@ def create_component(target_path: str, name: str) -> None: def action_extensions(base_actions: dict, project_path: str) -> dict: - def create_new(action: str, ctx: click.core.Context, global_args: PropertyDict, **action_args: str) -> dict: + def create_new(action: str, ctx: Context, global_args: PropertyDict, **action_args: str) -> dict: target_path = action_args.get('path') or os.path.join(project_path, action_args['name']) is_empty_and_create(target_path, action) diff --git a/tools/idf_py_actions/debug_ext.py b/tools/idf_py_actions/debug_ext.py index ca4aeacf7b6..5efb28dc9fe 100644 --- a/tools/idf_py_actions/debug_ext.py +++ b/tools/idf_py_actions/debug_ext.py @@ -11,9 +11,9 @@ import time from threading import Thread from typing import Any -from click import INT -from click.core import Context from esp_coredump import CoreDump +from rich_click import INT +from rich_click import Context from idf_py_actions.errors import FatalError from idf_py_actions.serial_ext import BAUD_RATE diff --git a/tools/idf_py_actions/dfu_ext.py b/tools/idf_py_actions/dfu_ext.py index 4c72f6db829..b86bb309964 100644 --- a/tools/idf_py_actions/dfu_ext.py +++ b/tools/idf_py_actions/dfu_ext.py @@ -1,17 +1,16 @@ -# SPDX-FileCopyrightText: 2022-2024 Espressif Systems (Shanghai) CO LTD +# SPDX-FileCopyrightText: 2022-2026 Espressif Systems (Shanghai) CO LTD # SPDX-License-Identifier: Apache-2.0 -from typing import Dict -from click.core import Context +from rich_click import Context + from idf_py_actions.errors import FatalError +from idf_py_actions.tools import PropertyDict from idf_py_actions.tools import ensure_build_directory from idf_py_actions.tools import is_target_supported -from idf_py_actions.tools import PropertyDict from idf_py_actions.tools import run_target -def action_extensions(base_actions: Dict, project_path: str) -> Dict: - +def action_extensions(base_actions: dict, project_path: str) -> dict: SUPPORTED_TARGETS = ['esp32s2', 'esp32s3', 'esp32p4'] def dfu_target(target_name: str, ctx: Context, args: PropertyDict, part_size: str) -> None: @@ -29,8 +28,10 @@ def action_extensions(base_actions: Dict, project_path: str) -> Dict: run_target(target_name, args, {'ESP_DFU_PATH': path}) except FatalError: # Cannot capture the error from dfu-util here so the best advise is: - print('Please have a look at the "Device Firmware Upgrade through USB" chapter in API Guides of the ' - 'ESP-IDF documentation for solving common dfu-util issues.') + print( + 'Please have a look at the "Device Firmware Upgrade through USB" chapter in API Guides of the ' + 'ESP-IDF documentation for solving common dfu-util issues.' + ) raise dfu_actions = { @@ -43,8 +44,8 @@ def action_extensions(base_actions: Dict, project_path: str) -> Dict: { 'names': ['--part-size'], 'help': 'Large files are split up into smaller partitions in order to avoid timeout during ' - 'erasing flash. This option allows to overwrite the default partition size of ' - 'mkdfu.py.' + 'erasing flash. This option allows to overwrite the default partition size of ' + 'mkdfu.py.', } ], }, @@ -62,8 +63,8 @@ def action_extensions(base_actions: Dict, project_path: str) -> Dict: 'names': ['--path'], 'default': '', 'help': 'Specify path to DFU device. The default empty path works if there is just one ' - 'ESP device with the same product identifier. See the device list for paths ' - 'of available devices.' + 'ESP device with the same product identifier. See the device list for paths ' + 'of available devices.', } ], }, diff --git a/tools/idf_py_actions/diag_ext.py b/tools/idf_py_actions/diag_ext.py index 8950d04fef7..2a8961491ca 100644 --- a/tools/idf_py_actions/diag_ext.py +++ b/tools/idf_py_actions/diag_ext.py @@ -1,13 +1,10 @@ -# SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD +# SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD # SPDX-License-Identifier: Apache-2.0 import sys import uuid from typing import Any -from typing import Dict -from typing import Optional -from typing import Tuple -import click +from rich_click import Context from idf_py_actions.tools import PropertyDict from idf_py_actions.tools import RunTool @@ -16,20 +13,20 @@ from idf_py_actions.tools import yellow_print def diag( action: str, - ctx: click.core.Context, + ctx: Context, args: PropertyDict, debug: bool, log_prefix: bool, force: bool, no_color: bool, - zip_directory: Optional[str], + zip_directory: str | None, list_recipes: bool, check_recipes: bool, - cmdl_recipes: Tuple, - cmdl_tags: Tuple, - purge_file: Optional[str], + cmdl_recipes: tuple, + cmdl_tags: tuple, + purge_file: str | None, append: bool, - output: Optional[str], + output: str | None, ) -> None: diag_args: list = [sys.executable, '-m', 'esp_idf_diag'] @@ -106,12 +103,10 @@ def diag( diag_args += ['--port', args.port] else: yellow_print( - ( - 'The target serial port is not specified, so ' - 'autodetection will be used. To set it manually, use ' - 'the "--port" option. Example: "idf.py --port ' - '/dev/ttyUSB0 diag".' - ) + 'The target serial port is not specified, so ' + 'autodetection will be used. To set it manually, use ' + 'the "--port" option. Example: "idf.py --port ' + '/dev/ttyUSB0 diag".' ) try: @@ -121,18 +116,16 @@ def diag( if command == 'create': yellow_print( - ( - f'Please make sure to thoroughly check it for any sensitive ' - f'information before sharing and remove files you do not want ' - f'to share. Kindly include any additional files you find ' - f'relevant that were not automatically added. Please archive ' - f'the contents of the final report directory using the command:\n' - f'"idf.py diag --zip {output}".' - ) + f'Please make sure to thoroughly check it for any sensitive ' + f'information before sharing and remove files you do not want ' + f'to share. Kindly include any additional files you find ' + f'relevant that were not automatically added. Please archive ' + f'the contents of the final report directory using the command:\n' + f'"idf.py diag --zip {output}".' ) -def action_extensions(base_actions: Dict, project_path: str) -> Any: +def action_extensions(base_actions: dict, project_path: str) -> Any: return { 'actions': { 'diag': { diff --git a/tools/idf_py_actions/errors.py b/tools/idf_py_actions/errors.py index 8648952c29a..a4f5f69158a 100644 --- a/tools/idf_py_actions/errors.py +++ b/tools/idf_py_actions/errors.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2022 Espressif Systems (Shanghai) CO LTD # SPDX-License-Identifier: Apache-2.0 -from click.core import Context +from rich_click import Context class FatalError(RuntimeError): @@ -8,7 +8,7 @@ class FatalError(RuntimeError): Wrapper class for runtime errors that aren't caused by bugs in idf.py or the build process. """ - def __init__(self, message: str, ctx: Context=None): + def __init__(self, message: str, ctx: Context = None): super(RuntimeError, self).__init__(message) # if context is defined, check for the cleanup tasks if ctx is not None and 'cleanup' in ctx.meta: diff --git a/tools/idf_py_actions/help_custom_targets_skip.py b/tools/idf_py_actions/help_custom_targets_skip.py index f2cef3f0c71..b0a61f59b4d 100644 --- a/tools/idf_py_actions/help_custom_targets_skip.py +++ b/tools/idf_py_actions/help_custom_targets_skip.py @@ -16,6 +16,9 @@ from __future__ import annotations import re from collections.abc import Set +# Panel title in ``idf.py --help`` (rich-click) +CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE = 'CMake Custom Targets:' + # ``cmake --build … --target help`` / ``ninja -t targets`` lines: ``name: phony`` (last ``:`` before rule). PHONY_BUILD_LINE_RE = re.compile(r'^(.+):\s*(\S+)\s*$') # ``build.ninja`` lines: ``build : phony``. diff --git a/tools/idf_py_actions/mcp_ext.py b/tools/idf_py_actions/mcp_ext.py index 51174e45550..72bd5c3c1f6 100644 --- a/tools/idf_py_actions/mcp_ext.py +++ b/tools/idf_py_actions/mcp_ext.py @@ -8,7 +8,7 @@ import sys from pathlib import Path from typing import Any -from click.core import Context +from rich_click import Context from idf_py_actions.errors import FatalError from idf_py_actions.tools import PropertyDict diff --git a/tools/idf_py_actions/qemu_ext.py b/tools/idf_py_actions/qemu_ext.py index 680d361cd59..6aa8a7a5fc8 100644 --- a/tools/idf_py_actions/qemu_ext.py +++ b/tools/idf_py_actions/qemu_ext.py @@ -14,7 +14,7 @@ import time from dataclasses import dataclass from typing import Any -from click.core import Context +from rich_click import Context try: from idf_py_actions.tools import PropertyDict diff --git a/tools/idf_py_actions/serial_ext.py b/tools/idf_py_actions/serial_ext.py index 02b962520a6..4685708b12c 100644 --- a/tools/idf_py_actions/serial_ext.py +++ b/tools/idf_py_actions/serial_ext.py @@ -8,7 +8,9 @@ import sys from pathlib import Path from typing import Any -import click +import rich_click as click +from click.core import ParameterSource +from rich_click import Context from idf_py_actions.errors import FatalError from idf_py_actions.global_options import global_options @@ -50,7 +52,7 @@ def yellow_print(message: str, newline: str | None = '\n') -> None: def action_extensions(base_actions: dict, project_path: str) -> dict: - def _get_project_desc(ctx: click.core.Context, args: PropertyDict) -> Any: + def _get_project_desc(ctx: Context, args: PropertyDict) -> Any: desc_path = os.path.join(args.build_dir, 'project_description.json') if not os.path.exists(desc_path): ensure_build_directory(args, ctx.info_name) @@ -83,7 +85,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: result += ['--no-stub'] return result - def _get_commandline_options(ctx: click.core.Context) -> list: + def _get_commandline_options(ctx: Context) -> list: """Return all the command line options up to first action""" # This approach ignores argument parsing done Click result = [] @@ -98,7 +100,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: def monitor( action: str, - ctx: click.core.Context, + ctx: Context, args: PropertyDict, print_filter: str, monitor_baud: str, @@ -136,7 +138,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: # Use the global baud rate if it has been changed by the command line. # Use project_desc['monitor_baud'] as the last option. - global_baud_defined = ctx._parameter_source['baud'] == click.core.ParameterSource.COMMANDLINE + global_baud_defined = ctx._parameter_source['baud'] == ParameterSource.COMMANDLINE baud = args.baud if global_baud_defined else project_desc['monitor_baud'] monitor_args += ['-b', baud] @@ -205,7 +207,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: def flash( action: str, - ctx: click.core.Context, + ctx: Context, args: PropertyDict, flash_all: bool, trust_flash_content: bool, @@ -244,13 +246,13 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: env['IDF_TRUST_FLASH_CONTENT'] = '1' run_target(action, args, env, force_progression=True, interactive=True) - def erase_flash(action: str, ctx: click.core.Context, args: PropertyDict) -> None: + def erase_flash(action: str, ctx: Context, args: PropertyDict) -> None: ensure_build_directory(args, ctx.info_name) esptool_args = _get_esptool_args(args) esptool_args += ['erase-flash'] RunTool('esptool', esptool_args, args.build_dir, hints=not args.no_hints, interactive=True)() - def global_callback(ctx: click.core.Context, global_args: dict, tasks: PropertyDict) -> None: + def global_callback(ctx: Context, global_args: dict, tasks: PropertyDict) -> None: encryption = any([task.name in ('encrypted-flash', 'encrypted-app-flash') for task in tasks]) if encryption: for task in tasks: @@ -258,7 +260,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: task.action_args['encrypted'] = True break - def ota_targets(target_name: str, ctx: click.core.Context, args: PropertyDict) -> None: + def ota_targets(target_name: str, ctx: Context, args: PropertyDict) -> None: """ Execute the target build system to build target 'target_name'. Additionally set global variables for baud and port. @@ -271,7 +273,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: def merge_bin( action: str, - ctx: click.core.Context, + ctx: Context, args: PropertyDict, output: str, format: str, # noqa: A002 @@ -319,7 +321,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: def secure_decrypt_flash_data( action: str, - ctx: click.core.Context, + ctx: Context, args: PropertyDict, aes_xts: bool, keyfile: str, @@ -345,7 +347,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: RunTool('espsecure', decrypt_flash_data_args, args.build_dir)() def secure_digest_secure_bootloader( - action: str, ctx: click.core.Context, args: PropertyDict, keyfile: str, output: str, iv: str, **extra_args: str + action: str, ctx: Context, args: PropertyDict, keyfile: str, output: str, iv: str, **extra_args: str ) -> None: ensure_build_directory(args, ctx.info_name) digest_secure_bootloader_args = [PYTHON, '-m', 'espsecure', 'digest-secure-bootloader'] @@ -361,7 +363,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: def secure_encrypt_flash_data( action: str, - ctx: click.core.Context, + ctx: Context, args: PropertyDict, aes_xts: bool, keyfile: str, @@ -387,7 +389,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: RunTool('espsecure', encrypt_flash_data_args, args.build_dir)() def secure_generate_flash_encryption_key( - action: str, ctx: click.core.Context, args: PropertyDict, keylen: str, **extra_args: str + action: str, ctx: Context, args: PropertyDict, keylen: str, **extra_args: str ) -> None: ensure_build_directory(args, ctx.info_name) generate_flash_encryption_key_args = [PYTHON, '-m', 'espsecure', 'generate-flash-encryption-key'] @@ -398,7 +400,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: RunTool('espsecure', generate_flash_encryption_key_args, args.project_dir)() def secure_generate_signing_key( - action: str, ctx: click.core.Context, args: PropertyDict, version: str, scheme: str, **extra_args: str + action: str, ctx: Context, args: PropertyDict, version: str, scheme: str, **extra_args: str ) -> None: ensure_build_directory(args, ctx.info_name) generate_signing_key_args = [PYTHON, '-m', 'espsecure', 'generate-signing-key'] @@ -419,7 +421,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: RunTool('espsecure', generate_signing_key_args, args.project_dir)() def secure_generate_key_digest( - action: str, ctx: click.core.Context, args: PropertyDict, keyfile: str, output: str, **extra_args: str + action: str, ctx: Context, args: PropertyDict, keyfile: str, output: str, **extra_args: str ) -> None: ensure_build_directory(args, ctx.info_name) generate_key_digest_args = [PYTHON, '-m', 'espsecure', 'digest-sbv2-public-key'] @@ -431,7 +433,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: def secure_sign_data( action: str, - ctx: click.core.Context, + ctx: Context, args: PropertyDict, version: str, keyfile: str, @@ -460,7 +462,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: RunTool('espsecure', sign_data_args, args.build_dir)() def secure_verify_signature( - action: str, ctx: click.core.Context, args: PropertyDict, version: str, keyfile: str, **extra_args: str + action: str, ctx: Context, args: PropertyDict, version: str, keyfile: str, **extra_args: str ) -> None: ensure_build_directory(args, ctx.info_name) verify_signature_args = [PYTHON, '-m', 'espsecure', 'verify-signature'] @@ -474,7 +476,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: def secure_generate_nvs_partition_key( action: str, - ctx: click.core.Context, + ctx: Context, args: PropertyDict, encryption_scheme: str, keyfile: str, @@ -492,7 +494,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: RunTool('espsecure', generate_nvs_partition_key_args, args.project_dir)() def secure_encrypt_nvs_partition( - action: str, ctx: click.core.Context, args: PropertyDict, keyfile: str, **extra_args: str + action: str, ctx: Context, args: PropertyDict, keyfile: str, **extra_args: str ) -> None: ensure_build_directory(args, ctx.info_name) encrypt_nvs_partition_args = [PYTHON, '-m', 'esp_idf_nvs_partition_gen', 'encrypt'] @@ -505,7 +507,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: encrypt_nvs_partition_args += [extra_args['partition_size']] RunTool('espsecure', encrypt_nvs_partition_args, args.project_dir)() - def _parse_efuse_args(ctx: click.core.Context, args: PropertyDict, extra_args: dict) -> list: + def _parse_efuse_args(ctx: Context, args: PropertyDict, extra_args: dict) -> list: efuse_args = [] if args.port: efuse_args += ['-p', args.port] @@ -522,7 +524,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: efuse_args += ['--do-not-confirm'] return efuse_args - def efuse_burn(action: str, ctx: click.core.Context, args: PropertyDict, **extra_args: dict) -> None: + def efuse_burn(action: str, ctx: Context, args: PropertyDict, **extra_args: dict) -> None: ensure_build_directory(args, ctx.info_name) burn_efuse_args = [PYTHON, '-m', 'espefuse'] burn_efuse_args += _parse_efuse_args(ctx, args, extra_args) @@ -531,7 +533,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: burn_efuse_args += list(extra_args['efuse_positional_args']) RunTool('espefuse', burn_efuse_args, args.build_dir)() - def efuse_burn_key(action: str, ctx: click.core.Context, args: PropertyDict, **extra_args: str) -> None: + def efuse_burn_key(action: str, ctx: Context, args: PropertyDict, **extra_args: str) -> None: ensure_build_directory(args, ctx.info_name) burn_key_args = [PYTHON, '-m', 'espefuse'] burn_key_args += _parse_efuse_args(ctx, args, extra_args) @@ -546,9 +548,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: burn_key_args += extra_args['efuse_positional_args'] RunTool('espefuse', burn_key_args, args.project_dir, build_dir=args.build_dir)() - def efuse_dump( - action: str, ctx: click.core.Context, args: PropertyDict, file_name: str, **extra_args: dict - ) -> None: + def efuse_dump(action: str, ctx: Context, args: PropertyDict, file_name: str, **extra_args: dict) -> None: ensure_build_directory(args, ctx.info_name) dump_args = [PYTHON, '-m', 'espefuse'] dump_args += _parse_efuse_args(ctx, args, extra_args) @@ -557,7 +557,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: dump_args += ['--file-name', file_name] RunTool('espefuse', dump_args, args.build_dir)() - def efuse_read_protect(action: str, ctx: click.core.Context, args: PropertyDict, **extra_args: dict) -> None: + def efuse_read_protect(action: str, ctx: Context, args: PropertyDict, **extra_args: dict) -> None: ensure_build_directory(args, ctx.info_name) read_protect_args = [PYTHON, '-m', 'espefuse'] read_protect_args += _parse_efuse_args(ctx, args, extra_args) @@ -568,7 +568,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: def efuse_summary( action: str, - ctx: click.core.Context, + ctx: Context, args: PropertyDict, format: str, # noqa: A002 **extra_args: dict, @@ -583,7 +583,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: summary_args += [str(extra_args['efuse_name'])] RunTool('espefuse', summary_args, args.build_dir)() - def efuse_write_protect(action: str, ctx: click.core.Context, args: PropertyDict, **extra_args: dict) -> None: + def efuse_write_protect(action: str, ctx: Context, args: PropertyDict, **extra_args: dict) -> None: ensure_build_directory(args, ctx.info_name) write_protect_args = [PYTHON, '-m', 'espefuse'] write_protect_args += _parse_efuse_args(ctx, args, extra_args) diff --git a/tools/idf_py_actions/tools.py b/tools/idf_py_actions/tools.py index 8ab06824cf1..dd646001a72 100644 --- a/tools/idf_py_actions/tools.py +++ b/tools/idf_py_actions/tools.py @@ -16,7 +16,7 @@ from typing import Any from typing import TextIO from typing import cast -import click +import rich_click as click import yaml from esp_idf_monitor import get_ansi_converter diff --git a/tools/idf_py_actions/uf2_ext.py b/tools/idf_py_actions/uf2_ext.py index a6b2f467003..416735718f6 100644 --- a/tools/idf_py_actions/uf2_ext.py +++ b/tools/idf_py_actions/uf2_ext.py @@ -1,12 +1,14 @@ -# SPDX-FileCopyrightText: 2022-2023 Espressif Systems (Shanghai) CO LTD +# SPDX-FileCopyrightText: 2022-2026 Espressif Systems (Shanghai) CO LTD # SPDX-License-Identifier: Apache-2.0 -from typing import Dict, List -from click.core import Context -from idf_py_actions.tools import PropertyDict, ensure_build_directory, run_target +from rich_click import Context + +from idf_py_actions.tools import PropertyDict +from idf_py_actions.tools import ensure_build_directory +from idf_py_actions.tools import run_target -def action_extensions(base_actions: Dict, project_path: List) -> Dict: +def action_extensions(base_actions: dict, project_path: list) -> dict: def uf2_target(target_name: str, ctx: Context, args: PropertyDict, md5_disable: bool) -> None: ensure_build_directory(args, ctx.info_name) extra = list() diff --git a/tools/requirements/requirements.core.txt b/tools/requirements/requirements.core.txt index aef6c3e9a7e..854a0a3a206 100644 --- a/tools/requirements/requirements.core.txt +++ b/tools/requirements/requirements.core.txt @@ -7,6 +7,7 @@ setuptools packaging click +rich-click pyserial cryptography pyparsing diff --git a/tools/test_build_system/test_common.py b/tools/test_build_system/test_common.py index a74c0a351ae..bec4328c95d 100644 --- a/tools/test_build_system/test_common.py +++ b/tools/test_build_system/test_common.py @@ -3,6 +3,7 @@ import json import logging import os +import re import shutil import stat import subprocess @@ -22,24 +23,44 @@ from test_build_system_helpers import get_snapshot from test_build_system_helpers import replace_in_file from test_build_system_helpers import run_idf_py +_tools_dir = str(Path(EXT_IDF_PATH) / 'tools') +if _tools_dir not in sys.path: + sys.path.insert(0, _tools_dir) +from idf_py_actions.help_custom_targets_skip import CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE # noqa: E402 + def _parse_idf_py_help_cmake_custom_target_names(stdout: str) -> list[str]: - """Return target names listed under the ``CMake Custom Targets`` heading in ``idf.py --help`` output.""" + """Parse CMake Custom Targets listed in ``idf.py --help`` in rich-click format. + Custom "plain-slim" rich-click theme is used to minimize the formatting impact. + Parsing starts from the line containing ``CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE``. + + - Piped outputs may use ``| … |`` table rows instead on some architectures (Unix/Windows). + - CI may still wrap lines in CSI SGR sequences (``ESC [ … m``). + """ lines = stdout.splitlines() - for i, line in enumerate(lines): - if line.strip() != 'CMake Custom Targets:': - continue - names: list[str] = [] - for j in range(i + 1, len(lines)): - raw = lines[j] - stripped = raw.strip() - if not stripped: - continue - if not raw.startswith((' ', '\t')): + idx = next((i for i, line in enumerate(lines) if CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE in line), None) + if idx is None: + return [] + + _SGR_ESCAPE_RE = re.compile(r'\x1b\[[0-9;:]*m') + names: list[str] = [] + for raw in lines[idx + 1 :]: + line = _SGR_ESCAPE_RE.sub('', raw) + stripped = line.strip() + if not stripped: + if names: break + continue + if stripped.startswith('|'): + cells = [c.strip() for c in stripped.split('|') if c.strip()] + if cells: + names.append(cells[0].split(None, 1)[0]) + continue + if line[:1].isspace(): names.append(stripped.split(None, 1)[0]) - return names - return [] + continue + break + return names def get_subdirs_absolute_paths(path: Path) -> list[str]: @@ -227,7 +248,7 @@ def test_fallback_to_build_system_target(idf_py: IdfPyFunc, test_app_copy: Path) def test_idf_py_help_without_build_dir_has_no_cmake_custom_targets_section(idf_py: IdfPyFunc) -> None: """With no configured build directory, root help must not advertise CMake custom targets.""" ret = idf_py('--help') - assert 'CMake Custom Targets' not in ret.stdout + assert CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE not in ret.stdout @pytest.mark.buildv2_skip( @@ -240,7 +261,7 @@ def test_idf_py_help_after_configure_with_no_custom_targets_has_no_section(idf_p """After configure, if the project defines no custom targets, `idf.py --help` must not show the section.""" idf_py('reconfigure') ret = idf_py('--help') - assert 'CMake Custom Targets' not in ret.stdout + assert CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE not in ret.stdout @pytest.mark.buildv2_skip( @@ -249,7 +270,9 @@ def test_idf_py_help_after_configure_with_no_custom_targets_has_no_section(idf_p 'tools/idf_py_actions/help_custom_targets_skip.py was designed around v1 hard-coded names.' ) @pytest.mark.usefixtures('test_app_copy') -def test_idf_py_help_lists_cmake_custom_targets_after_configure(idf_py: IdfPyFunc, test_app_copy: Path) -> None: +def test_idf_py_help_lists_cmake_custom_targets_after_configure( + idf_py: IdfPyFunc, test_app_copy: Path, default_idf_env: EnvDict +) -> None: """After configure, project-only phony targets should appear under CMake Custom Targets in idf.py --help.""" tgt = 'idf_py_help_visible_custom_tgt' append_to_file( @@ -257,8 +280,9 @@ def test_idf_py_help_lists_cmake_custom_targets_after_configure(idf_py: IdfPyFun f'add_custom_target({tgt} COMMAND ${{CMAKE_COMMAND}} -E echo "ok")\n', ) idf_py('reconfigure') - ret = idf_py('--help') - assert 'CMake Custom Targets' in ret.stdout + env = {**default_idf_env, 'RICH_CLICK_THEME': 'plain-slim'} + ret = run_idf_py('--help', env=env) + assert CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE in ret.stdout assert tgt in ret.stdout tools_dir = str(Path(EXT_IDF_PATH) / 'tools') @@ -268,6 +292,7 @@ def test_idf_py_help_lists_cmake_custom_targets_after_configure(idf_py: IdfPyFun from idf_py_actions.help_custom_targets_skip import help_phony_name_passes_shape_policy # noqa: E402 names = _parse_idf_py_help_cmake_custom_target_names(ret.stdout) + assert tgt in names, f'{tgt} not found in the CMake custom target printed in stdout' for n in names: assert help_phony_name_passes_shape_policy(n), ( f'Target {n!r} in CMake Custom Targets violates shape policy (prefix/suffix/substring/path). ' @@ -288,7 +313,9 @@ def test_idf_py_help_lists_cmake_custom_targets_after_configure(idf_py: IdfPyFun @pytest.mark.usefixtures('test_app_copy') -def test_idf_py_help_splits_multi_output_ninja_phony_targets(idf_py: IdfPyFunc, test_app_copy: Path) -> None: +def test_idf_py_help_splits_multi_output_ninja_phony_targets( + idf_py: IdfPyFunc, test_app_copy: Path, default_idf_env: EnvDict +) -> None: """Multi-output Ninja `build ...: phony` lines must yield separate target names (not a single whitespace string).""" a = 'idf_py_help_multi_out_a' b = 'idf_py_help_multi_out_b' @@ -297,7 +324,8 @@ def test_idf_py_help_splits_multi_output_ninja_phony_targets(idf_py: IdfPyFunc, # Inject a multi-output phony line directly into build.ninja and verify help parsing splits it. append_to_file(test_app_copy / 'build' / 'build.ninja', f'\nbuild {a} {b} {c}: phony\n') - ret = idf_py('--help') + env = {**default_idf_env, 'RICH_CLICK_THEME': 'plain-slim'} + ret = run_idf_py('--help', env=env) names = _parse_idf_py_help_cmake_custom_target_names(ret.stdout) assert a in names 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 ----------- diff --git a/tools/test_idf_py/test_idf_py.py b/tools/test_idf_py/test_idf_py.py index c1a1b064417..bd673c1a6d2 100755 --- a/tools/test_idf_py/test_idf_py.py +++ b/tools/test_idf_py/test_idf_py.py @@ -33,6 +33,28 @@ py_actions_path = os.path.normpath(os.path.join(current_dir, '..', 'idf_py_actio link_path = os.path.join(py_actions_path, 'test_ext') +# As idf.py uses rich-click, unite modification variables to ensure constant results on various CI terminals +_idf_py_test_env_saved: dict[str, str | None] = {} + + +def setUpModule() -> None: + for key in ('COLUMNS', 'LINES', 'NO_COLOR', 'FORCE_COLOR', 'PY_COLORS', 'TERM'): + _idf_py_test_env_saved[key] = os.environ.get(key) + os.environ['COLUMNS'] = '200' + os.environ['LINES'] = '40' + os.environ['NO_COLOR'] = '1' + for unset in ('FORCE_COLOR', 'PY_COLORS'): + os.environ.pop(unset, None) + + +def tearDownModule() -> None: + for key, previous in _idf_py_test_env_saved.items(): + if previous is None: + os.environ.pop(key, None) + else: + os.environ[key] = previous + + class TestWithoutExtensions(TestCase): @classmethod def setUpClass(cls):