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

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

See merge request espressif/esp-idf!49020
This commit is contained in:
Jiang Jiang Jian
2026-06-30 14:01:39 +08:00
18 changed files with 412 additions and 145 deletions

View File

@@ -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

View File

@@ -157,9 +157,15 @@ def _safe_relpath(path: str, start: str | None = None) -> str:
def init_cli(verbose_output: list | None = None) -> Any:
# Click is imported here to run it after check_environment()
import click
# rich-click is imported here to run it after check_environment()
import rich_click as click
from click.core import ParameterSource
from click.shell_completion import CompletionItem
from rich_click import Context
from rich_click import RichHelpConfiguration
from rich_click import RichHelpFormatter
from rich_click.rich_click import MAX_WIDTH
from rich_click.rich_context import RichContext
class Deprecation:
"""Construct deprecation notice for help messages"""
@@ -211,7 +217,7 @@ def init_cli(verbose_output: list | None = None) -> Any:
text = text or ''
return ('Deprecated! ' + text) if self.deprecated else text
def check_deprecation(ctx: click.core.Context) -> None:
def check_deprecation(ctx: Context) -> None:
"""Prints deprecation warnings for arguments in given context"""
for option in ctx.command.params:
# Skip non-Option parameters
@@ -228,7 +234,7 @@ def init_cli(verbose_output: list | None = None) -> Any:
if hasattr(ctx, 'get_parameter_source'):
source = ctx.get_parameter_source(option.name)
# Skip if option was not explicitly provided by user (only warn when actually used)
if source not in (click.core.ParameterSource.COMMANDLINE, click.core.ParameterSource.ENVIRONMENT):
if source not in (ParameterSource.COMMANDLINE, ParameterSource.ENVIRONMENT):
continue
else:
# Fallback: check if value differs from default
@@ -262,15 +268,13 @@ def init_cli(verbose_output: list | None = None) -> Any:
self.action_args = action_args
self.aliases = aliases
def __call__(
self, context: click.core.Context, global_args: PropertyDict, action_args: dict | None = None
) -> None:
def __call__(self, context: Context, global_args: PropertyDict, action_args: dict | None = None) -> None:
if action_args is None:
action_args = self.action_args
self.callback(self.name, context, global_args, **action_args)
class Action(click.Command):
class Action(click.RichCommand):
callback: Callable
def __init__(
@@ -331,7 +335,7 @@ def init_cli(verbose_output: list | None = None) -> Any:
self.callback: Callable = wrapped_callback
def invoke(self, ctx: click.core.Context) -> click.core.Context:
def invoke(self, ctx: Context) -> Context:
if self.deprecated:
deprecation = Deprecation(self.deprecated)
message = deprecation.full_message(f'Command "{self.name}"')
@@ -347,7 +351,27 @@ def init_cli(verbose_output: list | None = None) -> Any:
check_deprecation(ctx)
return super().invoke(ctx)
class Argument(click.Argument):
def format_options(self, ctx: Context, formatter: RichHelpFormatter) -> None:
"""
default_panels_first=True causes the
renderer to drop `post_default_panels` for options on non-Group
commands, which is exactly where the subcommand "Options" panel
lives -- `idf.py <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.RichArgument):
"""
Positional argument
@@ -390,7 +414,7 @@ def init_cli(verbose_output: list | None = None) -> Any:
def __str__(self) -> str:
return self._scope
class Option(click.Option):
class Option(click.RichOption):
"""Option that knows whether it should be global"""
def __init__(
@@ -428,14 +452,80 @@ def init_cli(verbose_output: list | None = None) -> Any:
if self.scope.is_global:
self.help += ' This option can be used at most once either globally, or for one subcommand.'
def get_help_record(self, ctx: click.core.Context) -> Any:
def get_help_record(self, ctx: Context) -> Any:
# Backport "hidden" parameter to click 5.0
if self.hidden:
return None
return super().get_help_record(ctx)
class CLI(click.Group):
def _emit_cmake_custom_targets_help_panel(formatter: RichHelpFormatter, targets: list[tuple[str, str]]) -> None:
"""Render CMake phony targets as an extra rich-click-looking panel.
They are not Click commands/options, so the ordinary help machinery does not list them.
"""
import rich.box
from rich.box import Box
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
# Rich-click adds these boxes on top of rich.box; recreate them locally with the
# same eight-line layout so the public rich API is enough.
# Otherwise rich-click internal modules would be needed (which can change over time).
_RICH_CLICK_EXTRA_BOXES: dict[str, Box] = {
'BLANK': Box(' \n' * 8),
'HORIZONTALS_TOP': Box(' ── \n' + ' \n' * 7),
'HORIZONTALS_DOUBLE_TOP': Box(' ══ \n' + ' \n' * 7),
}
def _resolve_panel_box(raw: Any) -> Box:
if isinstance(raw, Box):
return raw
if isinstance(raw, str):
if raw in _RICH_CLICK_EXTRA_BOXES:
return _RICH_CLICK_EXTRA_BOXES[raw]
box = getattr(rich.box, raw, None)
if isinstance(box, Box):
return box
return rich.box.ROUNDED
cfg = formatter.config
panel_box = _resolve_panel_box(cfg.style_commands_panel_box)
t_styles = {
'show_lines': cfg.style_commands_table_show_lines,
'leading': cfg.style_commands_table_leading,
'box': None,
'border_style': cfg.style_commands_table_border_style,
'row_styles': cfg.style_commands_table_row_styles,
'pad_edge': cfg.style_commands_table_pad_edge,
'padding': cfg.style_commands_table_padding,
'expand': cfg.style_commands_table_expand,
}
table = Table(show_header=False, highlight=False, **t_styles)
ratio = cfg.style_commands_table_column_width_ratio
r0, r1 = (None, None) if ratio is None else ratio
table.add_column(style=cfg.style_command, no_wrap=True, ratio=r0)
table.add_column(no_wrap=False, ratio=r1)
for name, desc in targets:
desc_cell = Text(desc, style=cfg.style_helptext) if desc else Text()
table.add_row(Text(name, style=cfg.style_command), desc_cell)
title = Text(
_help_custom_targets.CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE,
style=cfg.style_commands_panel_title_style,
)
panel = Panel(
table,
border_style=cfg.style_commands_panel_border,
title_align=cfg.align_commands_panel,
box=panel_box,
padding=cfg.style_commands_panel_padding,
style=cfg.style_commands_panel_style,
title=title,
)
formatter.console.print(panel, highlight=False)
class CLI(click.RichGroup):
"""Action list contains all actions with options available for CLI"""
def __init__(
@@ -443,13 +533,26 @@ def init_cli(verbose_output: list | None = None) -> Any:
all_actions: dict | None = None,
verbose_output: list | None = None,
cli_help: str | None = None,
command_groups: dict[str, list[dict[str, Any]]] | None = None,
) -> None:
rich_help_config_kwargs: dict[str, Any] = {
'max_width': MAX_WIDTH,
'command_groups': command_groups if command_groups is not None else {},
}
# ``default_panels_first`` was added in rich-click 1.9.6; on older
# versions passing it raises TypeError.
if hasattr(RichHelpConfiguration, 'default_panels_first'):
rich_help_config_kwargs['default_panels_first'] = True
super().__init__(
PROG,
chain=True,
invoke_without_command=True,
result_callback=self.execute_tasks,
no_args_is_help=True,
context_settings={'max_content_width': 140},
context_settings={
'help_option_names': ['-h', '--help'],
'rich_help_config': RichHelpConfiguration(**rich_help_config_kwargs),
},
help=cli_help,
)
self._actions = {}
@@ -489,6 +592,7 @@ def init_cli(verbose_output: list | None = None) -> Any:
options = []
self._actions[name] = Action(name=name, **action)
self.commands[name] = self._actions[name]
for alias in [name] + action.get('aliases', []):
self.commands_with_aliases[alias] = name
@@ -514,10 +618,10 @@ def init_cli(verbose_output: list | None = None) -> Any:
self._actions[name].params.append(option)
def list_commands(self, ctx: click.core.Context) -> list:
def list_commands(self, ctx: Context) -> list:
return sorted(filter(lambda name: not self._actions[name].hidden, self._actions))
def get_command(self, ctx: click.core.Context, name: str) -> Action | None:
def get_command(self, ctx: Context, name: str) -> Action | None:
if name in self.commands_with_aliases:
return self._actions.get(self.commands_with_aliases.get(name))
@@ -528,7 +632,7 @@ def init_cli(verbose_output: list | None = None) -> Any:
return Action(name=name, callback=callback.unwrapped_callback)
return None
def shell_complete(self, ctx: click.core.Context, incomplete: str) -> list[CompletionItem]:
def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]:
# Enable @-argument completion in bash only if @ is not present in
# COMP_WORDBREAKS. When @ is included, the @-argument is not considered
# part of the completion word, causing @-argument completion to function
@@ -847,13 +951,13 @@ def init_cli(verbose_output: list | None = None) -> Any:
return [(n, '') for n in sorted(found, key=str.lower)]
def format_help(self, ctx: click.core.Context, formatter: click.HelpFormatter) -> None:
"""Override to append custom CMake targets section."""
def format_help(self, ctx: RichContext, formatter: RichHelpFormatter) -> None:
"""Append CMake custom targets using the same Rich console as the rest of rich-click help."""
super().format_help(ctx, formatter)
targets = self._get_custom_targets()
if targets:
with formatter.section('CMake Custom Targets'):
formatter.write_dl(list(targets))
if not targets:
return
_emit_cmake_custom_targets_help_panel(formatter, targets)
def load_cli_extension_from_dir(ext_dir: str) -> Any | None:
"""Load extension 'idf_ext.py' from directory and return the action_extensions function"""
@@ -928,15 +1032,14 @@ def init_cli(verbose_output: list | None = None) -> Any:
build_dir: str = args.build_dir
return os.path.abspath(build_dir)
def _extract_relevant_path(path: str) -> str:
"""
Returns part of the path starting from 'components' or 'managed_components'.
If neither is found, returns the full path.
"""
for keyword in ('components', 'managed_components'):
# arg path is loaded from project_description.json, where paths are always defined with '/'
if keyword in path.split('/'):
return keyword + path.split(keyword, 1)[1]
def _path_relative_to_project(path: str, project_dir: str) -> str:
"""If ``path`` is under ``project_dir``, return its path relative to the project; else ``path`` unchanged."""
path_abs = os.path.abspath(os.path.normpath(path))
project_abs = os.path.abspath(os.path.normpath(project_dir))
parent_prefix = project_abs.rstrip(os.sep) + os.sep
if path_abs == project_abs or path_abs.startswith(parent_prefix):
return _safe_relpath(path_abs, project_abs)
return path
# Mutable dict used as a cache keyed by lock path
@@ -1008,6 +1111,20 @@ def init_cli(verbose_output: list | None = None) -> Any:
return lock_key in _get_trusted_names_from_lock(lock_path)
return False
def _build_rich_help_command_groups(
external_panels: list[tuple[str, list[str]]],
) -> dict[str, list[dict[str, Any]]]:
"""Build ``command_groups`` for rich-click's ``RichHelpConfiguration``.
``external_panels`` is a list of ``(title, command_names)`` from ``idf_ext.py`` extension
modules and from Python entry-point extensions. Those panels appear on
the root ``idf.py --help`` after the default Commands section.
"""
panels: list[dict[str, Any]] = []
for title, cmds in external_panels:
if cmds:
panels.append({'name': title, 'commands': cmds})
return {PROG: panels} if panels else {}
# That's a tiny parser that parse project-dir even before constructing
# fully featured click parser to be sure that extensions are loaded from the right place
@click.command(
@@ -1107,31 +1224,44 @@ def init_cli(verbose_output: list | None = None) -> Any:
else:
print_warning(
f'WARNING: Not loading component extension from untrusted source '
f'"{_extract_relevant_path(comp_dir)}". '
f'"{_path_relative_to_project(comp_dir, project_dir)}". '
'Only extensions from trusted sources are loaded. Run '
'"idf.py docs -sp api-guides/tools/idf-py.html#extending-idf-py" '
'for the list of trusted sources. Set IDF_EXTENSION_ALLOW_UNTRUSTED=1 to load all.'
)
# Load extensions from directories that participate in the build (components and project)
external_help_panels: list[tuple[str, list[str]]] = []
for ext_dir in component_idf_ext_dirs + [project_dir]:
extension_func = load_cli_extension_from_dir(ext_dir)
if extension_func:
try:
all_actions = merge_action_lists(all_actions, custom_actions=extension_func(all_actions, project_dir))
custom_actions = extension_func(all_actions, project_dir)
all_actions = merge_action_lists(all_actions, custom_actions=custom_actions)
except Exception as e:
print_warning(f'WARNING: Cannot load directory extension from "{ext_dir}": {e}')
else:
panel_cmds = sorted(n for n in custom_actions.get('actions') or {} if n != 'fallback')
if panel_cmds:
panel_title = (
'Project' if ext_dir == project_dir else _path_relative_to_project(ext_dir, project_dir)
)
external_help_panels.append((panel_title, panel_cmds))
if ext_dir != project_dir:
print(f'INFO: Loaded component extension from "{_extract_relevant_path(ext_dir)}"')
print(f'INFO: Loaded component extension from "{_path_relative_to_project(ext_dir, project_dir)}"')
# Load extensions from Python entry points
entry_point_extensions = load_cli_extensions_from_entry_points()
for name, extension_func in entry_point_extensions:
for ep_name, extension_func in entry_point_extensions:
try:
all_actions = merge_action_lists(all_actions, custom_actions=extension_func(all_actions, project_dir))
custom_actions = extension_func(all_actions, project_dir)
all_actions = merge_action_lists(all_actions, custom_actions=custom_actions)
except Exception as e:
print_warning(f'WARNING: Cannot load entry point extension "{name}": {e}')
print_warning(f'WARNING: Cannot load entry point extension "{ep_name}": {e}')
else:
panel_cmds = sorted(n for n in (custom_actions.get('actions') or {}) if n != 'fallback')
if panel_cmds:
external_help_panels.append((ep_name, panel_cmds))
cli_help = (
'ESP-IDF CLI build management tool. '
@@ -1139,7 +1269,13 @@ def init_cli(verbose_output: list | None = None) -> Any:
f'Selected target: {get_target(project_dir)}'
)
return CLI(cli_help=cli_help, verbose_output=verbose_output, all_actions=all_actions)
help_command_groups = _build_rich_help_command_groups(external_help_panels)
return CLI(
cli_help=cli_help,
verbose_output=verbose_output,
all_actions=all_actions,
command_groups=help_command_groups,
)
def main(argv: list[Any] | None = None) -> None:

View File

@@ -15,8 +15,8 @@ from urllib.request import Request
from urllib.request import urlopen
from webbrowser import open_new_tab
import click
from click.core import Context
import rich_click as click
from rich_click import Context
from idf_py_actions.constants import GENERATORS
from idf_py_actions.constants import PREVIEW_TARGETS
@@ -167,7 +167,7 @@ def action_extensions(base_actions: dict, project_path: str) -> Any:
os.environ.pop('ESP_IDF_KCONFIG_MIN_LABELS', None)
build_target(target_name, ctx, args)
def refresh_config(action: str, ctx: click.core.Context, args: PropertyDict, policy: str) -> None:
def refresh_config(action: str, ctx: Context, args: PropertyDict, policy: str) -> None:
ensure_build_directory(args, ctx.info_name)
run_target('refresh-config', args=args, env={'KCONFIG_DEFAULTS_POLICY': policy}, interactive=True)

View File

@@ -8,7 +8,7 @@ from collections.abc import Callable
from shutil import copyfile
from shutil import copytree
import click
from rich_click import Context
from idf_py_actions.tools import PropertyDict
@@ -106,7 +106,7 @@ def create_component(target_path: str, name: str) -> None:
def action_extensions(base_actions: dict, project_path: str) -> dict:
def create_new(action: str, ctx: click.core.Context, global_args: PropertyDict, **action_args: str) -> dict:
def create_new(action: str, ctx: Context, global_args: PropertyDict, **action_args: str) -> dict:
target_path = action_args.get('path') or os.path.join(project_path, action_args['name'])
is_empty_and_create(target_path, action)

View File

@@ -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

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
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.',
}
],
},

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
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': {

View File

@@ -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:

View File

@@ -16,6 +16,9 @@ from __future__ import annotations
import re
from collections.abc import Set
# Panel title in ``idf.py --help`` (rich-click)
CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE = 'CMake Custom Targets:'
# ``cmake --build … --target help`` / ``ninja -t targets`` lines: ``name: phony`` (last ``:`` before rule).
PHONY_BUILD_LINE_RE = re.compile(r'^(.+):\s*(\S+)\s*$')
# ``build.ninja`` lines: ``build <name>: phony``.

View File

@@ -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

View File

@@ -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

View File

@@ -8,7 +8,9 @@ import sys
from pathlib import Path
from typing import Any
import click
import rich_click as click
from click.core import ParameterSource
from rich_click import Context
from idf_py_actions.errors import FatalError
from idf_py_actions.global_options import global_options
@@ -50,7 +52,7 @@ def yellow_print(message: str, newline: str | None = '\n') -> None:
def action_extensions(base_actions: dict, project_path: str) -> dict:
def _get_project_desc(ctx: click.core.Context, args: PropertyDict) -> Any:
def _get_project_desc(ctx: Context, args: PropertyDict) -> Any:
desc_path = os.path.join(args.build_dir, 'project_description.json')
if not os.path.exists(desc_path):
ensure_build_directory(args, ctx.info_name)
@@ -83,7 +85,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
result += ['--no-stub']
return result
def _get_commandline_options(ctx: click.core.Context) -> list:
def _get_commandline_options(ctx: Context) -> list:
"""Return all the command line options up to first action"""
# This approach ignores argument parsing done Click
result = []
@@ -98,7 +100,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
def monitor(
action: str,
ctx: click.core.Context,
ctx: Context,
args: PropertyDict,
print_filter: str,
monitor_baud: str,
@@ -136,7 +138,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
# Use the global baud rate if it has been changed by the command line.
# Use project_desc['monitor_baud'] as the last option.
global_baud_defined = ctx._parameter_source['baud'] == click.core.ParameterSource.COMMANDLINE
global_baud_defined = ctx._parameter_source['baud'] == ParameterSource.COMMANDLINE
baud = args.baud if global_baud_defined else project_desc['monitor_baud']
monitor_args += ['-b', baud]
@@ -205,7 +207,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
def flash(
action: str,
ctx: click.core.Context,
ctx: Context,
args: PropertyDict,
flash_all: bool,
trust_flash_content: bool,
@@ -244,13 +246,13 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
env['IDF_TRUST_FLASH_CONTENT'] = '1'
run_target(action, args, env, force_progression=True, interactive=True)
def erase_flash(action: str, ctx: click.core.Context, args: PropertyDict) -> None:
def erase_flash(action: str, ctx: Context, args: PropertyDict) -> None:
ensure_build_directory(args, ctx.info_name)
esptool_args = _get_esptool_args(args)
esptool_args += ['erase-flash']
RunTool('esptool', esptool_args, args.build_dir, hints=not args.no_hints, interactive=True)()
def global_callback(ctx: click.core.Context, global_args: dict, tasks: PropertyDict) -> None:
def global_callback(ctx: Context, global_args: dict, tasks: PropertyDict) -> None:
encryption = any([task.name in ('encrypted-flash', 'encrypted-app-flash') for task in tasks])
if encryption:
for task in tasks:
@@ -258,7 +260,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
task.action_args['encrypted'] = True
break
def ota_targets(target_name: str, ctx: click.core.Context, args: PropertyDict) -> None:
def ota_targets(target_name: str, ctx: Context, args: PropertyDict) -> None:
"""
Execute the target build system to build target 'target_name'.
Additionally set global variables for baud and port.
@@ -271,7 +273,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
def merge_bin(
action: str,
ctx: click.core.Context,
ctx: Context,
args: PropertyDict,
output: str,
format: str, # noqa: A002
@@ -319,7 +321,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
def secure_decrypt_flash_data(
action: str,
ctx: click.core.Context,
ctx: Context,
args: PropertyDict,
aes_xts: bool,
keyfile: str,
@@ -345,7 +347,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
RunTool('espsecure', decrypt_flash_data_args, args.build_dir)()
def secure_digest_secure_bootloader(
action: str, ctx: click.core.Context, args: PropertyDict, keyfile: str, output: str, iv: str, **extra_args: str
action: str, ctx: Context, args: PropertyDict, keyfile: str, output: str, iv: str, **extra_args: str
) -> None:
ensure_build_directory(args, ctx.info_name)
digest_secure_bootloader_args = [PYTHON, '-m', 'espsecure', 'digest-secure-bootloader']
@@ -361,7 +363,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
def secure_encrypt_flash_data(
action: str,
ctx: click.core.Context,
ctx: Context,
args: PropertyDict,
aes_xts: bool,
keyfile: str,
@@ -387,7 +389,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
RunTool('espsecure', encrypt_flash_data_args, args.build_dir)()
def secure_generate_flash_encryption_key(
action: str, ctx: click.core.Context, args: PropertyDict, keylen: str, **extra_args: str
action: str, ctx: Context, args: PropertyDict, keylen: str, **extra_args: str
) -> None:
ensure_build_directory(args, ctx.info_name)
generate_flash_encryption_key_args = [PYTHON, '-m', 'espsecure', 'generate-flash-encryption-key']
@@ -398,7 +400,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
RunTool('espsecure', generate_flash_encryption_key_args, args.project_dir)()
def secure_generate_signing_key(
action: str, ctx: click.core.Context, args: PropertyDict, version: str, scheme: str, **extra_args: str
action: str, ctx: Context, args: PropertyDict, version: str, scheme: str, **extra_args: str
) -> None:
ensure_build_directory(args, ctx.info_name)
generate_signing_key_args = [PYTHON, '-m', 'espsecure', 'generate-signing-key']
@@ -419,7 +421,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
RunTool('espsecure', generate_signing_key_args, args.project_dir)()
def secure_generate_key_digest(
action: str, ctx: click.core.Context, args: PropertyDict, keyfile: str, output: str, **extra_args: str
action: str, ctx: Context, args: PropertyDict, keyfile: str, output: str, **extra_args: str
) -> None:
ensure_build_directory(args, ctx.info_name)
generate_key_digest_args = [PYTHON, '-m', 'espsecure', 'digest-sbv2-public-key']
@@ -431,7 +433,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
def secure_sign_data(
action: str,
ctx: click.core.Context,
ctx: Context,
args: PropertyDict,
version: str,
keyfile: str,
@@ -460,7 +462,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
RunTool('espsecure', sign_data_args, args.build_dir)()
def secure_verify_signature(
action: str, ctx: click.core.Context, args: PropertyDict, version: str, keyfile: str, **extra_args: str
action: str, ctx: Context, args: PropertyDict, version: str, keyfile: str, **extra_args: str
) -> None:
ensure_build_directory(args, ctx.info_name)
verify_signature_args = [PYTHON, '-m', 'espsecure', 'verify-signature']
@@ -474,7 +476,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
def secure_generate_nvs_partition_key(
action: str,
ctx: click.core.Context,
ctx: Context,
args: PropertyDict,
encryption_scheme: str,
keyfile: str,
@@ -492,7 +494,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
RunTool('espsecure', generate_nvs_partition_key_args, args.project_dir)()
def secure_encrypt_nvs_partition(
action: str, ctx: click.core.Context, args: PropertyDict, keyfile: str, **extra_args: str
action: str, ctx: Context, args: PropertyDict, keyfile: str, **extra_args: str
) -> None:
ensure_build_directory(args, ctx.info_name)
encrypt_nvs_partition_args = [PYTHON, '-m', 'esp_idf_nvs_partition_gen', 'encrypt']
@@ -505,7 +507,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
encrypt_nvs_partition_args += [extra_args['partition_size']]
RunTool('espsecure', encrypt_nvs_partition_args, args.project_dir)()
def _parse_efuse_args(ctx: click.core.Context, args: PropertyDict, extra_args: dict) -> list:
def _parse_efuse_args(ctx: Context, args: PropertyDict, extra_args: dict) -> list:
efuse_args = []
if args.port:
efuse_args += ['-p', args.port]
@@ -522,7 +524,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
efuse_args += ['--do-not-confirm']
return efuse_args
def efuse_burn(action: str, ctx: click.core.Context, args: PropertyDict, **extra_args: dict) -> None:
def efuse_burn(action: str, ctx: Context, args: PropertyDict, **extra_args: dict) -> None:
ensure_build_directory(args, ctx.info_name)
burn_efuse_args = [PYTHON, '-m', 'espefuse']
burn_efuse_args += _parse_efuse_args(ctx, args, extra_args)
@@ -531,7 +533,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
burn_efuse_args += list(extra_args['efuse_positional_args'])
RunTool('espefuse', burn_efuse_args, args.build_dir)()
def efuse_burn_key(action: str, ctx: click.core.Context, args: PropertyDict, **extra_args: str) -> None:
def efuse_burn_key(action: str, ctx: Context, args: PropertyDict, **extra_args: str) -> None:
ensure_build_directory(args, ctx.info_name)
burn_key_args = [PYTHON, '-m', 'espefuse']
burn_key_args += _parse_efuse_args(ctx, args, extra_args)
@@ -546,9 +548,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
burn_key_args += extra_args['efuse_positional_args']
RunTool('espefuse', burn_key_args, args.project_dir, build_dir=args.build_dir)()
def efuse_dump(
action: str, ctx: click.core.Context, args: PropertyDict, file_name: str, **extra_args: dict
) -> None:
def efuse_dump(action: str, ctx: Context, args: PropertyDict, file_name: str, **extra_args: dict) -> None:
ensure_build_directory(args, ctx.info_name)
dump_args = [PYTHON, '-m', 'espefuse']
dump_args += _parse_efuse_args(ctx, args, extra_args)
@@ -557,7 +557,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
dump_args += ['--file-name', file_name]
RunTool('espefuse', dump_args, args.build_dir)()
def efuse_read_protect(action: str, ctx: click.core.Context, args: PropertyDict, **extra_args: dict) -> None:
def efuse_read_protect(action: str, ctx: Context, args: PropertyDict, **extra_args: dict) -> None:
ensure_build_directory(args, ctx.info_name)
read_protect_args = [PYTHON, '-m', 'espefuse']
read_protect_args += _parse_efuse_args(ctx, args, extra_args)
@@ -568,7 +568,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
def efuse_summary(
action: str,
ctx: click.core.Context,
ctx: Context,
args: PropertyDict,
format: str, # noqa: A002
**extra_args: dict,
@@ -583,7 +583,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict:
summary_args += [str(extra_args['efuse_name'])]
RunTool('espefuse', summary_args, args.build_dir)()
def efuse_write_protect(action: str, ctx: click.core.Context, args: PropertyDict, **extra_args: dict) -> None:
def efuse_write_protect(action: str, ctx: Context, args: PropertyDict, **extra_args: dict) -> None:
ensure_build_directory(args, ctx.info_name)
write_protect_args = [PYTHON, '-m', 'espefuse']
write_protect_args += _parse_efuse_args(ctx, args, extra_args)

View File

@@ -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

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
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()

View File

@@ -7,6 +7,7 @@
setuptools
packaging
click
rich-click
pyserial
cryptography
pyparsing

View File

@@ -3,6 +3,7 @@
import json
import logging
import os
import re
import shutil
import stat
import subprocess
@@ -22,24 +23,44 @@ from test_build_system_helpers import get_snapshot
from test_build_system_helpers import replace_in_file
from test_build_system_helpers import run_idf_py
_tools_dir = str(Path(EXT_IDF_PATH) / 'tools')
if _tools_dir not in sys.path:
sys.path.insert(0, _tools_dir)
from idf_py_actions.help_custom_targets_skip import CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE # noqa: E402
def _parse_idf_py_help_cmake_custom_target_names(stdout: str) -> list[str]:
"""Return target names listed under the ``CMake Custom Targets`` heading in ``idf.py --help`` output."""
"""Parse CMake Custom Targets listed in ``idf.py --help`` in rich-click format.
Custom "plain-slim" rich-click theme is used to minimize the formatting impact.
Parsing starts from the line containing ``CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE``.
- Piped outputs may use ``| … |`` table rows instead on some architectures (Unix/Windows).
- CI may still wrap lines in CSI SGR sequences (``ESC [ … m``).
"""
lines = stdout.splitlines()
for i, line in enumerate(lines):
if line.strip() != 'CMake Custom Targets:':
continue
names: list[str] = []
for j in range(i + 1, len(lines)):
raw = lines[j]
stripped = raw.strip()
if not stripped:
continue
if not raw.startswith((' ', '\t')):
idx = next((i for i, line in enumerate(lines) if CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE in line), None)
if idx is None:
return []
_SGR_ESCAPE_RE = re.compile(r'\x1b\[[0-9;:]*m')
names: list[str] = []
for raw in lines[idx + 1 :]:
line = _SGR_ESCAPE_RE.sub('', raw)
stripped = line.strip()
if not stripped:
if names:
break
continue
if stripped.startswith('|'):
cells = [c.strip() for c in stripped.split('|') if c.strip()]
if cells:
names.append(cells[0].split(None, 1)[0])
continue
if line[:1].isspace():
names.append(stripped.split(None, 1)[0])
return names
return []
continue
break
return names
def get_subdirs_absolute_paths(path: Path) -> list[str]:
@@ -227,7 +248,7 @@ def test_fallback_to_build_system_target(idf_py: IdfPyFunc, test_app_copy: Path)
def test_idf_py_help_without_build_dir_has_no_cmake_custom_targets_section(idf_py: IdfPyFunc) -> None:
"""With no configured build directory, root help must not advertise CMake custom targets."""
ret = idf_py('--help')
assert 'CMake Custom Targets' not in ret.stdout
assert CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE not in ret.stdout
@pytest.mark.buildv2_skip(
@@ -240,7 +261,7 @@ def test_idf_py_help_after_configure_with_no_custom_targets_has_no_section(idf_p
"""After configure, if the project defines no custom targets, `idf.py --help` must not show the section."""
idf_py('reconfigure')
ret = idf_py('--help')
assert 'CMake Custom Targets' not in ret.stdout
assert CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE not in ret.stdout
@pytest.mark.buildv2_skip(
@@ -249,7 +270,9 @@ def test_idf_py_help_after_configure_with_no_custom_targets_has_no_section(idf_p
'tools/idf_py_actions/help_custom_targets_skip.py was designed around v1 hard-coded names.'
)
@pytest.mark.usefixtures('test_app_copy')
def test_idf_py_help_lists_cmake_custom_targets_after_configure(idf_py: IdfPyFunc, test_app_copy: Path) -> None:
def test_idf_py_help_lists_cmake_custom_targets_after_configure(
idf_py: IdfPyFunc, test_app_copy: Path, default_idf_env: EnvDict
) -> None:
"""After configure, project-only phony targets should appear under CMake Custom Targets in idf.py --help."""
tgt = 'idf_py_help_visible_custom_tgt'
append_to_file(
@@ -257,8 +280,9 @@ def test_idf_py_help_lists_cmake_custom_targets_after_configure(idf_py: IdfPyFun
f'add_custom_target({tgt} COMMAND ${{CMAKE_COMMAND}} -E echo "ok")\n',
)
idf_py('reconfigure')
ret = idf_py('--help')
assert 'CMake Custom Targets' in ret.stdout
env = {**default_idf_env, 'RICH_CLICK_THEME': 'plain-slim'}
ret = run_idf_py('--help', env=env)
assert CMAKE_CUSTOM_TARGETS_HELP_PANEL_TITLE in ret.stdout
assert tgt in ret.stdout
tools_dir = str(Path(EXT_IDF_PATH) / 'tools')
@@ -268,6 +292,7 @@ def test_idf_py_help_lists_cmake_custom_targets_after_configure(idf_py: IdfPyFun
from idf_py_actions.help_custom_targets_skip import help_phony_name_passes_shape_policy # noqa: E402
names = _parse_idf_py_help_cmake_custom_target_names(ret.stdout)
assert tgt in names, f'{tgt} not found in the CMake custom target printed in stdout'
for n in names:
assert help_phony_name_passes_shape_policy(n), (
f'Target {n!r} in CMake Custom Targets violates shape policy (prefix/suffix/substring/path). '
@@ -288,7 +313,9 @@ def test_idf_py_help_lists_cmake_custom_targets_after_configure(idf_py: IdfPyFun
@pytest.mark.usefixtures('test_app_copy')
def test_idf_py_help_splits_multi_output_ninja_phony_targets(idf_py: IdfPyFunc, test_app_copy: Path) -> None:
def test_idf_py_help_splits_multi_output_ninja_phony_targets(
idf_py: IdfPyFunc, test_app_copy: Path, default_idf_env: EnvDict
) -> None:
"""Multi-output Ninja `build ...: phony` lines must yield separate target names (not a single whitespace string)."""
a = 'idf_py_help_multi_out_a'
b = 'idf_py_help_multi_out_b'
@@ -297,7 +324,8 @@ def test_idf_py_help_splits_multi_output_ninja_phony_targets(idf_py: IdfPyFunc,
# Inject a multi-output phony line directly into build.ninja and verify help parsing splits it.
append_to_file(test_app_copy / 'build' / 'build.ninja', f'\nbuild {a} {b} {c}: phony\n')
ret = idf_py('--help')
env = {**default_idf_env, 'RICH_CLICK_THEME': 'plain-slim'}
ret = run_idf_py('--help', env=env)
names = _parse_idf_py_help_cmake_custom_target_names(ret.stdout)
assert a in names

View File

@@ -18,6 +18,7 @@ from test_build_system_helpers import EnvDict
from test_build_system_helpers import IdfPyFunc
from test_build_system_helpers import find_python
from test_build_system_helpers import replace_in_file
from test_build_system_helpers import run_idf_py
from conftest import should_clean_test_dir
@@ -188,10 +189,11 @@ def test_extension_from_component(idf_py: IdfPyFunc, test_app_copy: Path) -> Non
idf_py('reconfigure')
ret = idf_py('--help')
assert 'test-component-action' in ret.stdout
assert 'INFO: Loaded component extension from "components/test_component"' in ret.stdout
expected_info = f'INFO: Loaded component extension from "{os.path.join("components", "test_component")}"'
assert expected_info in ret.stdout
ret = idf_py('test-component-action')
assert 'Test extension action executed - component extension' in ret.stdout
assert 'INFO: Loaded component extension from "components/test_component"' in ret.stdout
assert expected_info in ret.stdout
def test_extension_from_component_invalid_syntax(idf_py: IdfPyFunc, test_app_copy: Path) -> None:
@@ -242,6 +244,47 @@ def test_extension_from_component_invalid_syntax(idf_py: IdfPyFunc, test_app_cop
assert 'Attribute "version" is required in custom extension.' in ret.stderr
@pytest.mark.usefixtures('test_app_copy')
def test_idf_py_help_rich_click_component_extension_panel(
idf_py: IdfPyFunc,
default_idf_env: dict[str, str],
) -> None:
"""Default Commands panel first; component extension gets its own panel after."""
idf_py('create-component', '-C', 'components', 'help_group_comp')
comp_dir = Path('components') / 'help_group_comp'
(comp_dir / 'idf_ext.py').write_text(
textwrap.dedent(
TEST_EXT_TEMPLATE.format(
suffix='help panel comp',
global_options='',
actions="""'help-group-comp-cmd': {
'callback': test_extension_action,
'help': 'Component extension command for help panel test'
}""",
)
)
)
replace_in_file(
Path('main') / 'CMakeLists.txt',
'# placeholder_inside_idf_component_register',
'\n'.join(['INCLUDE_DIRS "." ', 'REQUIRES "help_group_comp" ']),
)
idf_py('reconfigure')
env = {**default_idf_env, 'COLUMNS': '120', 'NO_COLOR': '1'}
ret = run_idf_py('--help', env=env, workdir=os.getcwd(), check=True)
idx_commands = ret.stdout.find('Commands')
assert idx_commands != -1, 'Root idf.py --help should list the default Commands group.'
idx_comp_panel = ret.stdout.find('help_group_comp', idx_commands)
assert idx_comp_panel != -1, 'Expected help group for the help_group_comp component extension.'
assert idx_comp_panel > idx_commands, 'Component extension group should appear after the default Commands group.'
# ret.stdout[i:j]: substring from index i (inclusive) to j (exclusive); ':' separates the two bounds.
default_block = ret.stdout[idx_commands:idx_comp_panel]
assert 'build' in default_block, 'Built-in `build` should still appear under the default Commands panel.'
# ----------- Test cases for entry point extension -----------
@@ -373,6 +416,44 @@ def test_extension_entrypoint_conflicting_names(
assert 'This global option conflicts with existing one' not in ret.stdout
@pytest.mark.usefixtures('test_app_copy')
def test_idf_py_help_rich_click_entrypoint_extension_panel(
idf_py: IdfPyFunc,
default_idf_env: dict[str, str],
extension_package_manager: ExtensionPackageManager,
) -> None:
"""Default Commands panel first; entry-point extension gets its own panel after."""
extension_package_manager.create_package('helpgroup')
env = {**default_idf_env, 'COLUMNS': '120', 'NO_COLOR': '1'}
ret = run_idf_py('--help', env=env, workdir=os.getcwd(), check=True)
idx_commands = ret.stdout.find('Commands')
assert idx_commands != -1, 'Root idf.py --help should list the default Commands group.'
idx_ep_panel = ret.stdout.find('test_extension_helpgroup', idx_commands)
assert idx_ep_panel != -1, 'Expected help group for the helpgroup entry-point extension.'
assert idx_ep_panel > idx_commands, 'Entry-point extension group should appear after the default Commands group.'
# ret.stdout[i:j]: substring from index i (inclusive) to j (exclusive); ':' separates the two bounds.
default_block = ret.stdout[idx_commands:idx_ep_panel]
assert 'build' in default_block, 'Built-in `build` should still appear under the default Commands panel.'
# ----------- General extension tests -----------
@pytest.mark.usefixtures('test_app_copy')
def test_idf_py_subcommand_help_shows_options(
idf_py: IdfPyFunc,
default_idf_env: dict[str, str],
) -> None:
"""Subcommand --help must list global/action options (rich-click + default_panels_first)."""
idf_py('reconfigure')
env = {**default_idf_env, 'NO_COLOR': '1', 'COLUMNS': '120'}
ret = run_idf_py('flash', '--help', env=env, workdir=os.getcwd(), check=True)
assert '--project-dir' in ret.stdout or '-C ' in ret.stdout
# ----------- Regression test: idf.py recursion via idf_version clause -----------

View File

@@ -33,6 +33,28 @@ py_actions_path = os.path.normpath(os.path.join(current_dir, '..', 'idf_py_actio
link_path = os.path.join(py_actions_path, 'test_ext')
# As idf.py uses rich-click, unite modification variables to ensure constant results on various CI terminals
_idf_py_test_env_saved: dict[str, str | None] = {}
def setUpModule() -> None:
for key in ('COLUMNS', 'LINES', 'NO_COLOR', 'FORCE_COLOR', 'PY_COLORS', 'TERM'):
_idf_py_test_env_saved[key] = os.environ.get(key)
os.environ['COLUMNS'] = '200'
os.environ['LINES'] = '40'
os.environ['NO_COLOR'] = '1'
for unset in ('FORCE_COLOR', 'PY_COLORS'):
os.environ.pop(unset, None)
def tearDownModule() -> None:
for key, previous in _idf_py_test_env_saved.items():
if previous is None:
os.environ.pop(key, None)
else:
os.environ[key] = previous
class TestWithoutExtensions(TestCase):
@classmethod
def setUpClass(cls):