From 007e0139d7e466b41388945b84c1864a2501c1c2 Mon Sep 17 00:00:00 2001 From: Marek Fiala Date: Wed, 22 Apr 2026 15:45:46 +0200 Subject: [PATCH 1/4] feat(tools): Replaced click with rich_click --- tools/bt/ble_log_console/console.py | 2 +- tools/idf.py | 37 ++++++++------- tools/idf_py_actions/core_ext.py | 4 +- tools/idf_py_actions/create_ext.py | 9 ++-- tools/idf_py_actions/debug_ext.py | 4 +- tools/idf_py_actions/dfu_ext.py | 25 +++++----- tools/idf_py_actions/diag_ext.py | 45 ++++++++---------- tools/idf_py_actions/errors.py | 4 +- tools/idf_py_actions/mcp_ext.py | 2 +- tools/idf_py_actions/qemu_ext.py | 2 +- tools/idf_py_actions/serial_ext.py | 58 ++++++++++++------------ tools/idf_py_actions/tools.py | 2 +- tools/idf_py_actions/uf2_ext.py | 12 +++-- tools/requirements/requirements.core.txt | 1 + tools/test_idf_py/test_idf_py.py | 22 +++++++++ 15 files changed, 126 insertions(+), 103 deletions(-) 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 075ebfb8b58..beb9a368be7 100755 --- a/tools/idf.py +++ b/tools/idf.py @@ -156,9 +156,12 @@ 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.shell_completion import CompletionItem + from rich_click import Context + from rich_click import RichHelpConfiguration + from rich_click.rich_click import MAX_WIDTH class Deprecation: """Construct deprecation notice for help messages""" @@ -210,7 +213,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: default = () if option.multiple else option.default @@ -240,15 +243,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__( @@ -309,7 +310,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}"') @@ -325,7 +326,7 @@ def init_cli(verbose_output: list | None = None) -> Any: check_deprecation(ctx) return super().invoke(ctx) - class Argument(click.Argument): + class Argument(click.RichArgument): """ Positional argument @@ -368,7 +369,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__( @@ -406,14 +407,14 @@ 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): + class CLI(click.RichGroup): """Action list contains all actions with options available for CLI""" def __init__( @@ -427,7 +428,10 @@ def init_cli(verbose_output: list | None = None) -> Any: 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(max_width=MAX_WIDTH), + }, help=cli_help, ) self._actions = {} @@ -467,6 +471,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 @@ -492,10 +497,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)) @@ -506,7 +511,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 diff --git a/tools/idf_py_actions/core_ext.py b/tools/idf_py_actions/core_ext.py index 82c12aa9cde..b763393c6fd 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 diff --git a/tools/idf_py_actions/create_ext.py b/tools/idf_py_actions/create_ext.py index ef53d2d11f7..6286b0d7db8 100644 --- a/tools/idf_py_actions/create_ext.py +++ b/tools/idf_py_actions/create_ext.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022-2025 Espressif Systems (Shanghai) CO LTD +# SPDX-FileCopyrightText: 2022-2026 Espressif Systems (Shanghai) CO LTD # SPDX-License-Identifier: Apache-2.0 import os import re @@ -6,9 +6,8 @@ import stat import sys from shutil import copyfile from shutil import copytree -from typing import Dict -import click +from rich_click import Context from idf_py_actions.tools import PropertyDict @@ -100,8 +99,8 @@ def create_component(target_path: str, name: str) -> None: replace_in_file(os.path.join(target_path, 'CMakeLists.txt'), 'main', name) -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 action_extensions(base_actions: dict, project_path: 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/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 ccba6a7f64d..90c44880bd7 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, force: bool, extra_args: str, @@ -240,13 +242,13 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: } 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: @@ -254,7 +256,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. @@ -267,7 +269,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 @@ -315,7 +317,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, @@ -341,7 +343,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'] @@ -357,7 +359,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, @@ -383,7 +385,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'] @@ -394,7 +396,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'] @@ -415,7 +417,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'] @@ -427,7 +429,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, @@ -456,7 +458,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'] @@ -470,7 +472,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, @@ -488,7 +490,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'] @@ -501,7 +503,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] @@ -518,7 +520,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) @@ -527,7 +529,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) @@ -542,9 +544,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) @@ -553,7 +553,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) @@ -564,7 +564,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, @@ -579,7 +579,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 2ab73dc937c..e2a6ea85794 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 f89d81c8e10..f2d4518fcc1 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_idf_py/test_idf_py.py b/tools/test_idf_py/test_idf_py.py index 83998bfed70..f48d890821e 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.join(current_dir, '..', 'idf_py_actions') 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): From b6872d32a0f8b9675a6156d5886fe96eca358901 Mon Sep 17 00:00:00 2001 From: Marek Fiala Date: Wed, 29 Apr 2026 15:10:50 +0200 Subject: [PATCH 2/4] feat(tools): Visibly grouped custom extension actions in `idf.py --help` --- tools/idf.py | 89 +++++++++++++++---- tools/test_build_system/test_idf_extension.py | 85 +++++++++++++++++- 2 files changed, 155 insertions(+), 19 deletions(-) diff --git a/tools/idf.py b/tools/idf.py index beb9a368be7..d82815ee9e9 100755 --- a/tools/idf.py +++ b/tools/idf.py @@ -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 --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: diff --git a/tools/test_build_system/test_idf_extension.py b/tools/test_build_system/test_idf_extension.py index b378d4d0eb9..58c7310e22c 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 @@ -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 ----------- From ab20f29305cf401664f644675fc4620058b258be Mon Sep 17 00:00:00 2001 From: Marek Fiala Date: Wed, 27 May 2026 15:33:14 +0200 Subject: [PATCH 3/4] fix(tools): Patch rich-click version < 1.9.0 --- tools/idf.py | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/tools/idf.py b/tools/idf.py index d82815ee9e9..341ad82f512 100755 --- a/tools/idf.py +++ b/tools/idf.py @@ -161,9 +161,44 @@ 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 + # ``RichHelpFormatter`` was promoted to the top-level namespace in + # rich-click 1.9; the submodule path is stable across 1.8.x/1.9.x. For + # positional/option base classes we deliberately use plain ``click.Argument`` + # / ``click.Option`` rather than the rich-click 1.9 ``RichArgument`` / + # ``RichOption`` subclasses: those subclasses are empty wrappers on 1.9.x + # (the only observable difference is the ``isinstance(obj, RichArgument)`` + # gate that auto-populates an Arguments panel when ``obj.help is not None`` + # -- idf.py never declares ``help=`` on a positional argument), and the + # symbols don't exist on 1.8.x at all. Using the click base classes keeps + # one code path for both rich-click lines. + from rich_click.rich_help_formatter import RichHelpFormatter + + # click 8.2 made ``Parameter.make_metavar(ctx)`` mandatory. rich-click + # versions before 1.8.6 still call ``param.make_metavar()`` with no ctx + # (see ``rich_click/rich_help_rendering.py``), which crashes with + # ``TypeError`` on click >= 8.2. The crash hits *every* parameter rich-click + # iterates -- including click's built-in ``--help`` option, which is a + # plain ``click.Option`` instance not under our control. So patch the + # ``Parameter.make_metavar`` method itself to accept ctx as optional, + # fishing the running context from ``click.get_current_context`` when + # rich-click forgets to pass it. The patch is a no-op on click < 8.2 + # (signature already takes only ``self``) and on click >= 8.2 it simply + # bridges the old rich-click call site. + if click.Parameter.make_metavar.__code__.co_argcount >= 2: + _orig_make_metavar = click.Parameter.make_metavar + + def _make_metavar_with_optional_ctx(self: 'click.Parameter', ctx: 'Context | None' = None) -> str: + if ctx is None: + try: + ctx = click.get_current_context() + except RuntimeError: + ctx = click.Context(click.Command(self.name or '_')) + return _orig_make_metavar(self, ctx) # type: ignore[no-any-return] + + click.Parameter.make_metavar = _make_metavar_with_optional_ctx # type: ignore[method-assign] + class Deprecation: """Construct deprecation notice for help messages""" @@ -343,7 +378,7 @@ def init_cli(verbose_output: list | None = None) -> Any: finally: formatter.config.default_panels_first = prev_default_first - class Argument(click.RichArgument): + class Argument(click.Argument): """ Positional argument @@ -386,7 +421,7 @@ def init_cli(verbose_output: list | None = None) -> Any: def __str__(self) -> str: return self._scope - class Option(click.RichOption): + class Option(click.Option): """Option that knows whether it should be global""" def __init__( From e5322213a95b4c73999fcc23a65ab7f8ad2758bb Mon Sep 17 00:00:00 2001 From: Marek Fiala Date: Tue, 26 May 2026 17:25:30 +0200 Subject: [PATCH 4/4] fix(tools): Patch rich-click version < 1.9.5 --- tools/idf.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tools/idf.py b/tools/idf.py index 341ad82f512..e7e884f4786 100755 --- a/tools/idf.py +++ b/tools/idf.py @@ -371,6 +371,10 @@ def init_cli(verbose_output: list | None = None) -> Any: 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 @@ -476,6 +480,14 @@ def init_cli(verbose_output: list | None = None) -> Any: 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, @@ -484,11 +496,7 @@ def init_cli(verbose_output: list | None = None) -> Any: no_args_is_help=True, context_settings={ 'help_option_names': ['-h', '--help'], - 'rich_help_config': RichHelpConfiguration( - max_width=MAX_WIDTH, - command_groups=command_groups if command_groups is not None else {}, - default_panels_first=True, - ), + 'rich_help_config': RichHelpConfiguration(**rich_help_config_kwargs), }, help=cli_help, )