mirror of
https://github.com/espressif/esp-idf.git
synced 2026-06-09 06:36:35 +03:00
feat(tools): Added listing of CMake custom targets in idf.py --help
This commit is contained in:
108
tools/idf.py
108
tools/idf.py
@@ -38,6 +38,7 @@ sys.dont_write_bytecode = True
|
||||
import python_version_checker # noqa: E402
|
||||
|
||||
try:
|
||||
import idf_py_actions.help_custom_targets_skip as _help_custom_targets
|
||||
from idf_py_actions.errors import FatalError
|
||||
from idf_py_actions.tools import PROG
|
||||
from idf_py_actions.tools import SHELL_COMPLETE_RUN
|
||||
@@ -747,6 +748,113 @@ def init_cli(verbose_output: list | None = None) -> Any:
|
||||
|
||||
return tasks_to_run
|
||||
|
||||
def _get_custom_targets(self) -> list[tuple[str, str]]:
|
||||
"""
|
||||
List likely user-facing phony targets: ``ninja -t targets`` when possible;
|
||||
else ``cmake --build … help``.
|
||||
"""
|
||||
# Note: ``project_dir`` and ``resolve_build_dir()`` are captured from the enclosing init_cli scope and
|
||||
# resolved at call-time (when formatting help), not at function definition time.
|
||||
build_dir = resolve_build_dir()
|
||||
cache = os.path.join(build_dir, 'CMakeCache.txt')
|
||||
ninja_file = os.path.join(build_dir, 'build.ninja')
|
||||
makefile = os.path.join(build_dir, 'Makefile')
|
||||
if not (os.path.isfile(cache) or os.path.isfile(ninja_file) or os.path.isfile(makefile)):
|
||||
return []
|
||||
|
||||
def configure_ready_for_cmake_help() -> bool:
|
||||
"""
|
||||
Skip ``cmake --build`` until configure has run
|
||||
and root ``CMakeLists.txt`` is not newer than the cache.
|
||||
"""
|
||||
if not os.path.isfile(cache):
|
||||
return False
|
||||
root_cml = os.path.join(project_dir, 'CMakeLists.txt')
|
||||
if not os.path.isfile(root_cml):
|
||||
return True
|
||||
try:
|
||||
return os.path.getmtime(root_cml) <= os.path.getmtime(cache)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
defined = set(self._actions.keys()) | set(self.commands_with_aliases.keys())
|
||||
found: set[str] = set()
|
||||
|
||||
def add_from_help_text(text: str) -> None:
|
||||
for raw in text.splitlines():
|
||||
line = _help_custom_targets.strip_cmake_help_listing_quotes(raw.strip())
|
||||
m = _help_custom_targets.PHONY_BUILD_LINE_RE.match(line)
|
||||
if m and m.group(2).lower() == 'phony':
|
||||
n = m.group(1).strip()
|
||||
if _help_custom_targets.should_list_custom_target(n, defined):
|
||||
found.add(n)
|
||||
elif line.startswith('...'):
|
||||
rest = line[3:].lstrip()
|
||||
if rest:
|
||||
n = rest.split()[0]
|
||||
if _help_custom_targets.should_list_custom_target(n, defined):
|
||||
found.add(n)
|
||||
|
||||
if os.path.isfile(ninja_file):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['ninja', '-t', 'targets', 'all'],
|
||||
cwd=build_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
timeout=_help_custom_targets.NINJA_TARGETS_HELP_TIMEOUT_SEC,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
add_from_help_text((r.stdout or '') + (r.stderr or ''))
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
||||
print_warning(f'Failed querying Ninja custom targets with "ninja -t targets all": {exc}')
|
||||
pass
|
||||
|
||||
# If ``ninja -t`` produced nothing we could parse (empty output, different format, etc.),
|
||||
# still read ``build.ninja`` so ``--help`` works without Ninja on PATH and without CMake.
|
||||
if not found:
|
||||
try:
|
||||
with open(ninja_file, encoding='utf-8', errors='replace') as f:
|
||||
for raw in f:
|
||||
m = _help_custom_targets.NINJA_BUILD_PHONY_RE.match(raw)
|
||||
if not m:
|
||||
continue
|
||||
outputs = m.group(1).strip()
|
||||
for n in _help_custom_targets.split_ninja_build_outputs(outputs):
|
||||
if _help_custom_targets.should_list_custom_target(n, defined):
|
||||
found.add(n)
|
||||
except OSError as exc:
|
||||
print_warning(f'Failed reading Ninja file {ninja_file} for custom targets: {exc}')
|
||||
pass
|
||||
|
||||
if not found and configure_ready_for_cmake_help():
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['cmake', '--build', build_dir, '--target', 'help'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
timeout=_help_custom_targets.CMAKE_BUILD_HELP_TIMEOUT_SEC,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
add_from_help_text((r.stdout or '') + (r.stderr or ''))
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
||||
print_warning(f'Failed querying CMake custom targets with "cmake --build --target help": {exc}')
|
||||
pass
|
||||
|
||||
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."""
|
||||
super().format_help(ctx, formatter)
|
||||
targets = self._get_custom_targets()
|
||||
if targets:
|
||||
with formatter.section('CMake Custom Targets'):
|
||||
formatter.write_dl(list(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"""
|
||||
ext_file = os.path.join(ext_dir, 'idf_ext.py')
|
||||
|
||||
112
tools/idf_py_actions/help_custom_targets_skip.py
Normal file
112
tools/idf_py_actions/help_custom_targets_skip.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Policy for which phony CMake targets appear under ``CMake Custom Targets`` in ``idf.py --help``.
|
||||
|
||||
Exact-name suppression is in ``IDF_PY_HELP_SKIP_TARGETS``; CI asserts that list plus the shared shape policy
|
||||
(``help_phony_name_passes_shape_policy``: path chars, prefixes, suffixes, substrings) against real help output in
|
||||
``tools/test_build_system/test_common.py``. Adjust those rules when new classes of internal phony targets appear.
|
||||
|
||||
Note: ``PHONY_BUILD_LINE_RE`` is intentionally permissive; callers must still enforce the two-step contract of
|
||||
matching the line then checking the extracted rule equals ``phony`` (case-insensitive).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Set
|
||||
|
||||
# ``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``.
|
||||
NINJA_BUILD_PHONY_RE = re.compile(r'^build\s+(.+?)\s*:\s*phony(?:\s|$)')
|
||||
|
||||
# ``cmake --build`` may still run generator checks; cap wall time for help-only invocations.
|
||||
CMAKE_BUILD_HELP_TIMEOUT_SEC = 15
|
||||
# ``ninja -t targets`` is a local metadata query; keep tight so ``idf.py --help`` stays responsive.
|
||||
NINJA_TARGETS_HELP_TIMEOUT_SEC = 5
|
||||
|
||||
# Targets whose names look like filesystem paths are never user-facing phony labels in this section.
|
||||
# Internal / generated prefixes from CMake, Ninja, ESP-IDF component graphs (not project-level custom commands).
|
||||
# Note: this also suppresses user-defined custom targets beginning with ``lib``.
|
||||
HELP_PHONY_NAME_PREFIXES: tuple[str, ...] = ('_', 'cmake_', 'gen_', 'lib', 'esp-idf')
|
||||
|
||||
# File-like / glue targets (``*_args``, ``.cmake``, etc.) that ``help`` lists but are not interactive commands.
|
||||
HELP_PHONY_NAME_SUFFIXES: tuple[str, ...] = (
|
||||
'_args',
|
||||
'_bin',
|
||||
'_preprocess',
|
||||
'_table',
|
||||
'.cmake',
|
||||
'.txt',
|
||||
'.json',
|
||||
'.ld',
|
||||
'.a',
|
||||
'.in',
|
||||
'.ninja',
|
||||
)
|
||||
|
||||
# Doc and partition-table helper phonys often register as top-level names; hide by substring, not exact match only.
|
||||
HELP_PHONY_NAME_SUBSTRINGS: tuple[str, ...] = ('apidoc', 'partition_table')
|
||||
|
||||
IDF_PY_HELP_SKIP_TARGETS = frozenset(
|
||||
{
|
||||
# Generic clean/rule placeholder from Make/Ninja help output, not a project custom target.
|
||||
'C',
|
||||
'app_check_size',
|
||||
'builtin',
|
||||
'custom_bundle',
|
||||
'edit_cache',
|
||||
'encrypted-bootloader-flash',
|
||||
'encrypted-partition-table-flash',
|
||||
'everest',
|
||||
'help',
|
||||
'install',
|
||||
'lib',
|
||||
'list_install_components',
|
||||
'mbedcrypto',
|
||||
'mbedtls',
|
||||
'mbedx509',
|
||||
'p256m',
|
||||
'rebuild_cache',
|
||||
'tfpsacrypto',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def help_phony_name_passes_shape_policy(name: str) -> bool:
|
||||
"""True if ``name`` passes path / prefix / suffix / substring filters (no ``idf.py`` action or exact-skip check)."""
|
||||
if not name or '/' in name or '\\' in name:
|
||||
return False
|
||||
# Defensive: Ninja/CMake help text can contain tokens that are not real target names (e.g. separators like "|").
|
||||
# Target names in this section are expected to be single, whitespace-free identifiers.
|
||||
if any(c.isspace() for c in name) or '|' in name:
|
||||
return False
|
||||
if name.startswith(HELP_PHONY_NAME_PREFIXES):
|
||||
return False
|
||||
if name.endswith(HELP_PHONY_NAME_SUFFIXES):
|
||||
return False
|
||||
if any(s in name for s in HELP_PHONY_NAME_SUBSTRINGS):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def strip_cmake_help_listing_quotes(line: str) -> str:
|
||||
"""CMake/Make ``--target help`` sometimes wraps a target in matching quotes; unwrap one layer."""
|
||||
if len(line) >= 2 and line[0] in '\'"' and line[-1] == line[0]:
|
||||
return line[1:-1]
|
||||
return line
|
||||
|
||||
|
||||
def split_ninja_build_outputs(outputs: str) -> list[str]:
|
||||
"""Split a Ninja `build` output list (may be multi-output) into individual target names."""
|
||||
return [n for n in outputs.split() if n]
|
||||
|
||||
|
||||
def should_list_custom_target(name: str, defined: Set[str]) -> bool:
|
||||
"""Return True if ``name`` should be shown as a CMake custom target in ``idf.py --help``."""
|
||||
if not help_phony_name_passes_shape_policy(name):
|
||||
return False
|
||||
if name in defined or name in IDF_PY_HELP_SKIP_TARGETS:
|
||||
return False
|
||||
return True
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from test_build_system_helpers import EXT_IDF_PATH
|
||||
from test_build_system_helpers import EnvDict
|
||||
from test_build_system_helpers import IdfPyFunc
|
||||
from test_build_system_helpers import append_to_file
|
||||
@@ -22,6 +23,25 @@ from test_build_system_helpers import replace_in_file
|
||||
from test_build_system_helpers import run_idf_py
|
||||
|
||||
|
||||
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."""
|
||||
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')):
|
||||
break
|
||||
names.append(stripped.split(None, 1)[0])
|
||||
return names
|
||||
return []
|
||||
|
||||
|
||||
def get_subdirs_absolute_paths(path: Path) -> list[str]:
|
||||
"""
|
||||
Get a list of files with absolute path in a given `path` folder
|
||||
@@ -203,6 +223,79 @@ def test_fallback_to_build_system_target(idf_py: IdfPyFunc, test_app_copy: Path)
|
||||
assert msg in ret.stdout, 'Custom target did not produce expected output'
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('test_app_copy')
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('test_app_copy')
|
||||
def test_idf_py_help_after_configure_with_no_custom_targets_has_no_section(idf_py: IdfPyFunc) -> None:
|
||||
"""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
|
||||
|
||||
|
||||
@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:
|
||||
"""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(
|
||||
test_app_copy / 'CMakeLists.txt',
|
||||
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
|
||||
assert tgt in ret.stdout
|
||||
|
||||
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 IDF_PY_HELP_SKIP_TARGETS # noqa: E402
|
||||
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)
|
||||
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). '
|
||||
'Fix filtering in tools/idf.py or adjust HELP_PHONY_NAME_* / help_phony_name_passes_shape_policy in '
|
||||
'tools/idf_py_actions/help_custom_targets_skip.py.'
|
||||
)
|
||||
unexpected = sorted({n for n in names if n not in IDF_PY_HELP_SKIP_TARGETS and n != tgt})
|
||||
assert not unexpected, (
|
||||
f'Unexpected CMake custom target(s) in idf.py --help: {unexpected!r}.\n'
|
||||
'These are usually internal phony targets. Add each name to IDF_PY_HELP_SKIP_TARGETS in '
|
||||
'tools/idf_py_actions/help_custom_targets_skip.py (keep the set sorted), then re-run this test.\n'
|
||||
'If the leak matches a pattern (prefix, suffix, or substring), adjust HELP_PHONY_NAME_* in that module '
|
||||
'instead of the frozenset.\n'
|
||||
'If a name is intentionally user-visible for this test app only, extend the allowlist in '
|
||||
'test_idf_py_help_lists_cmake_custom_targets_after_configure in tools/test_build_system/test_common.py '
|
||||
f'(expected project target name: {tgt!r}).'
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
"""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'
|
||||
c = 'idf_py_help_multi_out_c'
|
||||
idf_py('reconfigure')
|
||||
|
||||
# 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')
|
||||
|
||||
names = _parse_idf_py_help_cmake_custom_target_names(ret.stdout)
|
||||
assert a in names
|
||||
assert b in names
|
||||
assert c in names
|
||||
assert f'{a} {b} {c}' not in names
|
||||
|
||||
|
||||
def test_create_component_project(idf_copy: Path) -> None:
|
||||
logging.info('Create project and component using idf.py and build it')
|
||||
run_idf_py('-C', 'projects', 'create-project', 'temp_test_project', workdir=idf_copy)
|
||||
|
||||
Reference in New Issue
Block a user