Merge branch 'feat/replace_click_witch_rich_click_v6.0' into 'release/v6.0'

feat(tools): Replaced click with rich_click (v6.0)

See merge request espressif/esp-idf!49021
This commit is contained in:
Roland Dobai
2026-06-29 07:37:36 +02:00
16 changed files with 321 additions and 119 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ Usage:
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import click import rich_click as click
from src.app import BLELogApp from src.app import BLELogApp
from src.backend.models import format_bytes from src.backend.models import format_bytes
from src.backend.uart_transport import validate_uart_port from src.backend.uart_transport import validate_uart_port
+133 -30
View File
@@ -156,9 +156,48 @@ def _safe_relpath(path: str, start: str | None = None) -> str:
def init_cli(verbose_output: list | None = None) -> Any: def init_cli(verbose_output: list | None = None) -> Any:
# Click is imported here to run it after check_environment() # rich-click is imported here to run it after check_environment()
import click import rich_click as click
from click.shell_completion import CompletionItem from click.shell_completion import CompletionItem
from rich_click import Context
from rich_click import RichHelpConfiguration
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: class Deprecation:
"""Construct deprecation notice for help messages""" """Construct deprecation notice for help messages"""
@@ -210,7 +249,7 @@ def init_cli(verbose_output: list | None = None) -> Any:
text = text or '' text = text or ''
return ('Deprecated! ' + text) if self.deprecated else text 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""" """Prints deprecation warnings for arguments in given context"""
for option in ctx.command.params: for option in ctx.command.params:
default = () if option.multiple else option.default default = () if option.multiple else option.default
@@ -240,15 +279,13 @@ def init_cli(verbose_output: list | None = None) -> Any:
self.action_args = action_args self.action_args = action_args
self.aliases = aliases self.aliases = aliases
def __call__( def __call__(self, context: Context, global_args: PropertyDict, action_args: dict | None = None) -> None:
self, context: click.core.Context, global_args: PropertyDict, action_args: dict | None = None
) -> None:
if action_args is None: if action_args is None:
action_args = self.action_args action_args = self.action_args
self.callback(self.name, context, global_args, **action_args) self.callback(self.name, context, global_args, **action_args)
class Action(click.Command): class Action(click.RichCommand):
callback: Callable callback: Callable
def __init__( def __init__(
@@ -309,7 +346,7 @@ def init_cli(verbose_output: list | None = None) -> Any:
self.callback: Callable = wrapped_callback self.callback: Callable = wrapped_callback
def invoke(self, ctx: click.core.Context) -> click.core.Context: def invoke(self, ctx: Context) -> Context:
if self.deprecated: if self.deprecated:
deprecation = Deprecation(self.deprecated) deprecation = Deprecation(self.deprecated)
message = deprecation.full_message(f'Command "{self.name}"') message = deprecation.full_message(f'Command "{self.name}"')
@@ -325,6 +362,26 @@ def init_cli(verbose_output: list | None = None) -> Any:
check_deprecation(ctx) check_deprecation(ctx)
return super().invoke(ctx) return super().invoke(ctx)
def format_options(self, ctx: Context, formatter: RichHelpFormatter) -> None:
"""
default_panels_first=True causes the
renderer to drop `post_default_panels` for options on non-Group
commands, which is exactly where the subcommand "Options" panel
lives -- `idf.py <subcmd> --help` would otherwise show only
Usage + description. Temporarily flip the flag to False while
rendering options.
"""
# 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.Argument): class Argument(click.Argument):
""" """
Positional argument Positional argument
@@ -406,14 +463,14 @@ def init_cli(verbose_output: list | None = None) -> Any:
if self.scope.is_global: if self.scope.is_global:
self.help += ' This option can be used at most once either globally, or for one subcommand.' 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 # Backport "hidden" parameter to click 5.0
if self.hidden: if self.hidden:
return None return None
return super().get_help_record(ctx) return super().get_help_record(ctx)
class CLI(click.Group): class CLI(click.RichGroup):
"""Action list contains all actions with options available for CLI""" """Action list contains all actions with options available for CLI"""
def __init__( def __init__(
@@ -421,13 +478,26 @@ def init_cli(verbose_output: list | None = None) -> Any:
all_actions: dict | None = None, all_actions: dict | None = None,
verbose_output: list | None = None, verbose_output: list | None = None,
cli_help: str | None = None, cli_help: str | None = None,
command_groups: dict[str, list[dict[str, Any]]] | None = 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__( super().__init__(
PROG,
chain=True, chain=True,
invoke_without_command=True, invoke_without_command=True,
result_callback=self.execute_tasks, result_callback=self.execute_tasks,
no_args_is_help=True, 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, help=cli_help,
) )
self._actions = {} self._actions = {}
@@ -467,6 +537,7 @@ def init_cli(verbose_output: list | None = None) -> Any:
options = [] options = []
self._actions[name] = Action(name=name, **action) self._actions[name] = Action(name=name, **action)
self.commands[name] = self._actions[name]
for alias in [name] + action.get('aliases', []): for alias in [name] + action.get('aliases', []):
self.commands_with_aliases[alias] = name self.commands_with_aliases[alias] = name
@@ -492,10 +563,10 @@ def init_cli(verbose_output: list | None = None) -> Any:
self._actions[name].params.append(option) 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)) 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: if name in self.commands_with_aliases:
return self._actions.get(self.commands_with_aliases.get(name)) return self._actions.get(self.commands_with_aliases.get(name))
@@ -506,7 +577,7 @@ def init_cli(verbose_output: list | None = None) -> Any:
return Action(name=name, callback=callback.unwrapped_callback) return Action(name=name, callback=callback.unwrapped_callback)
return None 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 # Enable @-argument completion in bash only if @ is not present in
# COMP_WORDBREAKS. When @ is included, the @-argument is not considered # COMP_WORDBREAKS. When @ is included, the @-argument is not considered
# part of the completion word, causing @-argument completion to function # part of the completion word, causing @-argument completion to function
@@ -799,15 +870,14 @@ def init_cli(verbose_output: list | None = None) -> Any:
build_dir: str = args.build_dir build_dir: str = args.build_dir
return os.path.abspath(build_dir) return os.path.abspath(build_dir)
def _extract_relevant_path(path: str) -> str: 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."""
Returns part of the path starting from 'components' or 'managed_components'. path_abs = os.path.abspath(os.path.normpath(path))
If neither is found, returns the full path. project_abs = os.path.abspath(os.path.normpath(project_dir))
""" parent_prefix = project_abs.rstrip(os.sep) + os.sep
for keyword in ('components', 'managed_components'): if path_abs == project_abs or path_abs.startswith(parent_prefix):
# arg path is loaded from project_description.json, where paths are always defined with '/' return _safe_relpath(path_abs, project_abs)
if keyword in path.split('/'):
return keyword + path.split(keyword, 1)[1]
return path return path
# Mutable dict used as a cache keyed by lock path # Mutable dict used as a cache keyed by lock path
@@ -879,6 +949,20 @@ def init_cli(verbose_output: list | None = None) -> Any:
return lock_key in _get_trusted_names_from_lock(lock_path) return lock_key in _get_trusted_names_from_lock(lock_path)
return False 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 # 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 # fully featured click parser to be sure that extensions are loaded from the right place
@click.command( @click.command(
@@ -978,31 +1062,44 @@ def init_cli(verbose_output: list | None = None) -> Any:
else: else:
print_warning( print_warning(
f'WARNING: Not loading component extension from untrusted source ' 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 ' 'Only extensions from trusted sources are loaded. Run '
'"idf.py docs -sp api-guides/tools/idf-py.html#extending-idf-py" ' '"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.' '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) # 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]: for ext_dir in component_idf_ext_dirs + [project_dir]:
extension_func = load_cli_extension_from_dir(ext_dir) extension_func = load_cli_extension_from_dir(ext_dir)
if extension_func: if extension_func:
try: 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: except Exception as e:
print_warning(f'WARNING: Cannot load directory extension from "{ext_dir}": {e}') print_warning(f'WARNING: Cannot load directory extension from "{ext_dir}": {e}')
else: 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: 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 # Load extensions from Python entry points
entry_point_extensions = load_cli_extensions_from_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: 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: 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 = ( cli_help = (
'ESP-IDF CLI build management tool. ' 'ESP-IDF CLI build management tool. '
@@ -1010,7 +1107,13 @@ def init_cli(verbose_output: list | None = None) -> Any:
f'Selected target: {get_target(project_dir)}' 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: def main(argv: list[Any] | None = None) -> None:
+2 -2
View File
@@ -15,8 +15,8 @@ from urllib.request import Request
from urllib.request import urlopen from urllib.request import urlopen
from webbrowser import open_new_tab from webbrowser import open_new_tab
import click import rich_click as click
from click.core import Context from rich_click import Context
from idf_py_actions.constants import GENERATORS from idf_py_actions.constants import GENERATORS
from idf_py_actions.constants import PREVIEW_TARGETS from idf_py_actions.constants import PREVIEW_TARGETS
+4 -5
View File
@@ -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 # SPDX-License-Identifier: Apache-2.0
import os import os
import re import re
@@ -6,9 +6,8 @@ import stat
import sys import sys
from shutil import copyfile from shutil import copyfile
from shutil import copytree from shutil import copytree
from typing import Dict
import click from rich_click import Context
from idf_py_actions.tools import PropertyDict 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) replace_in_file(os.path.join(target_path, 'CMakeLists.txt'), 'main', name)
def action_extensions(base_actions: Dict, project_path: str) -> Dict: 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']) target_path = action_args.get('path') or os.path.join(project_path, action_args['name'])
is_empty_and_create(target_path, action) is_empty_and_create(target_path, action)
+2 -2
View File
@@ -11,9 +11,9 @@ import time
from threading import Thread from threading import Thread
from typing import Any from typing import Any
from click import INT
from click.core import Context
from esp_coredump import CoreDump 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.errors import FatalError
from idf_py_actions.serial_ext import BAUD_RATE from idf_py_actions.serial_ext import BAUD_RATE
+13 -12
View File
@@ -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 # 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.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 ensure_build_directory
from idf_py_actions.tools import is_target_supported from idf_py_actions.tools import is_target_supported
from idf_py_actions.tools import PropertyDict
from idf_py_actions.tools import run_target 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'] SUPPORTED_TARGETS = ['esp32s2', 'esp32s3', 'esp32p4']
def dfu_target(target_name: str, ctx: Context, args: PropertyDict, part_size: str) -> None: 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}) run_target(target_name, args, {'ESP_DFU_PATH': path})
except FatalError: except FatalError:
# Cannot capture the error from dfu-util here so the best advise is: # 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 ' print(
'ESP-IDF documentation for solving common dfu-util issues.') '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 raise
dfu_actions = { dfu_actions = {
@@ -43,8 +44,8 @@ def action_extensions(base_actions: Dict, project_path: str) -> Dict:
{ {
'names': ['--part-size'], 'names': ['--part-size'],
'help': 'Large files are split up into smaller partitions in order to avoid timeout during ' '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 ' 'erasing flash. This option allows to overwrite the default partition size of '
'mkdfu.py.' 'mkdfu.py.',
} }
], ],
}, },
@@ -62,8 +63,8 @@ def action_extensions(base_actions: Dict, project_path: str) -> Dict:
'names': ['--path'], 'names': ['--path'],
'default': '', 'default': '',
'help': 'Specify path to DFU device. The default empty path works if there is just one ' '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 ' 'ESP device with the same product identifier. See the device list for paths '
'of available devices.' 'of available devices.',
} }
], ],
}, },
+19 -26
View File
@@ -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 # SPDX-License-Identifier: Apache-2.0
import sys import sys
import uuid import uuid
from typing import Any 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 PropertyDict
from idf_py_actions.tools import RunTool from idf_py_actions.tools import RunTool
@@ -16,20 +13,20 @@ from idf_py_actions.tools import yellow_print
def diag( def diag(
action: str, action: str,
ctx: click.core.Context, ctx: Context,
args: PropertyDict, args: PropertyDict,
debug: bool, debug: bool,
log_prefix: bool, log_prefix: bool,
force: bool, force: bool,
no_color: bool, no_color: bool,
zip_directory: Optional[str], zip_directory: str | None,
list_recipes: bool, list_recipes: bool,
check_recipes: bool, check_recipes: bool,
cmdl_recipes: Tuple, cmdl_recipes: tuple,
cmdl_tags: Tuple, cmdl_tags: tuple,
purge_file: Optional[str], purge_file: str | None,
append: bool, append: bool,
output: Optional[str], output: str | None,
) -> None: ) -> None:
diag_args: list = [sys.executable, '-m', 'esp_idf_diag'] diag_args: list = [sys.executable, '-m', 'esp_idf_diag']
@@ -106,12 +103,10 @@ def diag(
diag_args += ['--port', args.port] diag_args += ['--port', args.port]
else: else:
yellow_print( yellow_print(
( 'The target serial port is not specified, so '
'The target serial port is not specified, so ' 'autodetection will be used. To set it manually, use '
'autodetection will be used. To set it manually, use ' 'the "--port" option. Example: "idf.py --port '
'the "--port" option. Example: "idf.py --port ' '/dev/ttyUSB0 diag".'
'/dev/ttyUSB0 diag".'
)
) )
try: try:
@@ -121,18 +116,16 @@ def diag(
if command == 'create': if command == 'create':
yellow_print( yellow_print(
( f'Please make sure to thoroughly check it for any sensitive '
f'Please make sure to thoroughly check it for any sensitive ' f'information before sharing and remove files you do not want '
f'information before sharing and remove files you do not want ' f'to share. Kindly include any additional files you find '
f'to share. Kindly include any additional files you find ' f'relevant that were not automatically added. Please archive '
f'relevant that were not automatically added. Please archive ' f'the contents of the final report directory using the command:\n'
f'the contents of the final report directory using the command:\n' f'"idf.py diag --zip {output}".'
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 { return {
'actions': { 'actions': {
'diag': { 'diag': {
+2 -2
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 Espressif Systems (Shanghai) CO LTD # SPDX-FileCopyrightText: 2022 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
from click.core import Context from rich_click import Context
class FatalError(RuntimeError): 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. 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) super(RuntimeError, self).__init__(message)
# if context is defined, check for the cleanup tasks # if context is defined, check for the cleanup tasks
if ctx is not None and 'cleanup' in ctx.meta: if ctx is not None and 'cleanup' in ctx.meta:
+1 -1
View File
@@ -8,7 +8,7 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any 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.errors import FatalError
from idf_py_actions.tools import PropertyDict from idf_py_actions.tools import PropertyDict
+1 -1
View File
@@ -14,7 +14,7 @@ import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from click.core import Context from rich_click import Context
try: try:
from idf_py_actions.tools import PropertyDict from idf_py_actions.tools import PropertyDict
+29 -29
View File
@@ -8,7 +8,9 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any 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.errors import FatalError
from idf_py_actions.global_options import global_options 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 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') desc_path = os.path.join(args.build_dir, 'project_description.json')
if not os.path.exists(desc_path): if not os.path.exists(desc_path):
ensure_build_directory(args, ctx.info_name) ensure_build_directory(args, ctx.info_name)
@@ -83,7 +85,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
result += ['--no-stub'] result += ['--no-stub']
return result 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""" """Return all the command line options up to first action"""
# This approach ignores argument parsing done Click # This approach ignores argument parsing done Click
result = [] result = []
@@ -98,7 +100,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
def monitor( def monitor(
action: str, action: str,
ctx: click.core.Context, ctx: Context,
args: PropertyDict, args: PropertyDict,
print_filter: str, print_filter: str,
monitor_baud: 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 the global baud rate if it has been changed by the command line.
# Use project_desc['monitor_baud'] as the last option. # 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'] baud = args.baud if global_baud_defined else project_desc['monitor_baud']
monitor_args += ['-b', baud] monitor_args += ['-b', baud]
@@ -205,7 +207,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
def flash( def flash(
action: str, action: str,
ctx: click.core.Context, ctx: Context,
args: PropertyDict, args: PropertyDict,
force: bool, force: bool,
extra_args: str, 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) 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) ensure_build_directory(args, ctx.info_name)
esptool_args = _get_esptool_args(args) esptool_args = _get_esptool_args(args)
esptool_args += ['erase-flash'] esptool_args += ['erase-flash']
RunTool('esptool', esptool_args, args.build_dir, hints=not args.no_hints, interactive=True)() 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]) encryption = any([task.name in ('encrypted-flash', 'encrypted-app-flash') for task in tasks])
if encryption: if encryption:
for task in tasks: for task in tasks:
@@ -254,7 +256,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
task.action_args['encrypted'] = True task.action_args['encrypted'] = True
break 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'. Execute the target build system to build target 'target_name'.
Additionally set global variables for baud and port. 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( def merge_bin(
action: str, action: str,
ctx: click.core.Context, ctx: Context,
args: PropertyDict, args: PropertyDict,
output: str, output: str,
format: str, # noqa: A002 format: str, # noqa: A002
@@ -315,7 +317,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
def secure_decrypt_flash_data( def secure_decrypt_flash_data(
action: str, action: str,
ctx: click.core.Context, ctx: Context,
args: PropertyDict, args: PropertyDict,
aes_xts: bool, aes_xts: bool,
keyfile: str, 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)() RunTool('espsecure', decrypt_flash_data_args, args.build_dir)()
def secure_digest_secure_bootloader( 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: ) -> None:
ensure_build_directory(args, ctx.info_name) ensure_build_directory(args, ctx.info_name)
digest_secure_bootloader_args = [PYTHON, '-m', 'espsecure', 'digest-secure-bootloader'] 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( def secure_encrypt_flash_data(
action: str, action: str,
ctx: click.core.Context, ctx: Context,
args: PropertyDict, args: PropertyDict,
aes_xts: bool, aes_xts: bool,
keyfile: str, 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)() RunTool('espsecure', encrypt_flash_data_args, args.build_dir)()
def secure_generate_flash_encryption_key( 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: ) -> None:
ensure_build_directory(args, ctx.info_name) ensure_build_directory(args, ctx.info_name)
generate_flash_encryption_key_args = [PYTHON, '-m', 'espsecure', 'generate-flash-encryption-key'] 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)() RunTool('espsecure', generate_flash_encryption_key_args, args.project_dir)()
def secure_generate_signing_key( 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: ) -> None:
ensure_build_directory(args, ctx.info_name) ensure_build_directory(args, ctx.info_name)
generate_signing_key_args = [PYTHON, '-m', 'espsecure', 'generate-signing-key'] 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)() RunTool('espsecure', generate_signing_key_args, args.project_dir)()
def secure_generate_key_digest( 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: ) -> None:
ensure_build_directory(args, ctx.info_name) ensure_build_directory(args, ctx.info_name)
generate_key_digest_args = [PYTHON, '-m', 'espsecure', 'digest-sbv2-public-key'] 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( def secure_sign_data(
action: str, action: str,
ctx: click.core.Context, ctx: Context,
args: PropertyDict, args: PropertyDict,
version: str, version: str,
keyfile: 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)() RunTool('espsecure', sign_data_args, args.build_dir)()
def secure_verify_signature( 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: ) -> None:
ensure_build_directory(args, ctx.info_name) ensure_build_directory(args, ctx.info_name)
verify_signature_args = [PYTHON, '-m', 'espsecure', 'verify-signature'] 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( def secure_generate_nvs_partition_key(
action: str, action: str,
ctx: click.core.Context, ctx: Context,
args: PropertyDict, args: PropertyDict,
encryption_scheme: str, encryption_scheme: str,
keyfile: 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)() RunTool('espsecure', generate_nvs_partition_key_args, args.project_dir)()
def secure_encrypt_nvs_partition( 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: ) -> None:
ensure_build_directory(args, ctx.info_name) ensure_build_directory(args, ctx.info_name)
encrypt_nvs_partition_args = [PYTHON, '-m', 'esp_idf_nvs_partition_gen', 'encrypt'] 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']] encrypt_nvs_partition_args += [extra_args['partition_size']]
RunTool('espsecure', encrypt_nvs_partition_args, args.project_dir)() 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 = [] efuse_args = []
if args.port: if args.port:
efuse_args += ['-p', 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'] efuse_args += ['--do-not-confirm']
return efuse_args 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) ensure_build_directory(args, ctx.info_name)
burn_efuse_args = [PYTHON, '-m', 'espefuse'] burn_efuse_args = [PYTHON, '-m', 'espefuse']
burn_efuse_args += _parse_efuse_args(ctx, args, extra_args) 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']) burn_efuse_args += list(extra_args['efuse_positional_args'])
RunTool('espefuse', burn_efuse_args, args.build_dir)() 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) ensure_build_directory(args, ctx.info_name)
burn_key_args = [PYTHON, '-m', 'espefuse'] burn_key_args = [PYTHON, '-m', 'espefuse']
burn_key_args += _parse_efuse_args(ctx, args, extra_args) 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'] burn_key_args += extra_args['efuse_positional_args']
RunTool('espefuse', burn_key_args, args.project_dir, build_dir=args.build_dir)() RunTool('espefuse', burn_key_args, args.project_dir, build_dir=args.build_dir)()
def efuse_dump( def efuse_dump(action: str, ctx: Context, args: PropertyDict, file_name: str, **extra_args: dict) -> None:
action: str, ctx: click.core.Context, args: PropertyDict, file_name: str, **extra_args: dict
) -> None:
ensure_build_directory(args, ctx.info_name) ensure_build_directory(args, ctx.info_name)
dump_args = [PYTHON, '-m', 'espefuse'] dump_args = [PYTHON, '-m', 'espefuse']
dump_args += _parse_efuse_args(ctx, args, extra_args) 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] dump_args += ['--file-name', file_name]
RunTool('espefuse', dump_args, args.build_dir)() 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) ensure_build_directory(args, ctx.info_name)
read_protect_args = [PYTHON, '-m', 'espefuse'] read_protect_args = [PYTHON, '-m', 'espefuse']
read_protect_args += _parse_efuse_args(ctx, args, extra_args) 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( def efuse_summary(
action: str, action: str,
ctx: click.core.Context, ctx: Context,
args: PropertyDict, args: PropertyDict,
format: str, # noqa: A002 format: str, # noqa: A002
**extra_args: dict, **extra_args: dict,
@@ -579,7 +579,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
summary_args += [str(extra_args['efuse_name'])] summary_args += [str(extra_args['efuse_name'])]
RunTool('espefuse', summary_args, args.build_dir)() 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) ensure_build_directory(args, ctx.info_name)
write_protect_args = [PYTHON, '-m', 'espefuse'] write_protect_args = [PYTHON, '-m', 'espefuse']
write_protect_args += _parse_efuse_args(ctx, args, extra_args) write_protect_args += _parse_efuse_args(ctx, args, extra_args)
+1 -1
View File
@@ -16,7 +16,7 @@ from typing import Any
from typing import TextIO from typing import TextIO
from typing import cast from typing import cast
import click import rich_click as click
import yaml import yaml
from esp_idf_monitor import get_ansi_converter from esp_idf_monitor import get_ansi_converter
+7 -5
View File
@@ -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 # SPDX-License-Identifier: Apache-2.0
from typing import Dict, List
from click.core import Context from rich_click import Context
from idf_py_actions.tools import PropertyDict, ensure_build_directory, run_target
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: def uf2_target(target_name: str, ctx: Context, args: PropertyDict, md5_disable: bool) -> None:
ensure_build_directory(args, ctx.info_name) ensure_build_directory(args, ctx.info_name)
extra = list() extra = list()
+1
View File
@@ -7,6 +7,7 @@
setuptools setuptools
packaging packaging
click click
rich-click
pyserial pyserial
cryptography cryptography
pyparsing pyparsing
+83 -2
View File
@@ -18,6 +18,7 @@ from test_build_system_helpers import EnvDict
from test_build_system_helpers import IdfPyFunc from test_build_system_helpers import IdfPyFunc
from test_build_system_helpers import find_python from test_build_system_helpers import find_python
from test_build_system_helpers import replace_in_file 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 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') idf_py('reconfigure')
ret = idf_py('--help') ret = idf_py('--help')
assert 'test-component-action' in ret.stdout 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') ret = idf_py('test-component-action')
assert 'Test extension action executed - component extension' in ret.stdout 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: 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 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 ----------- # ----------- 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 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 ----------- # ----------- Regression test: idf.py recursion via idf_version clause -----------
+22
View File
@@ -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') 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): class TestWithoutExtensions(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):