mirror of
https://github.com/espressif/esp-idf.git
synced 2026-10-01 18:50:34 +03:00
feat(tools): idf.py --help custom CMake targets rich-click style
This commit is contained in:
+72
-5
@@ -165,6 +165,7 @@ def init_cli(verbose_output: list | None = None) -> Any:
|
||||
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"""
|
||||
@@ -467,6 +468,72 @@ def init_cli(verbose_output: list | None = None) -> Any:
|
||||
|
||||
return super().get_help_record(ctx)
|
||||
|
||||
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"""
|
||||
|
||||
@@ -892,13 +959,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"""
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user