From c21d05e612fbb91aa04aebdf26a534cf9dabc4b4 Mon Sep 17 00:00:00 2001 From: Jakub Kocka Date: Wed, 4 Feb 2026 09:36:44 +0100 Subject: [PATCH] feat(tools): Added listing of CMake custom targets in idf.py --help --- tools/idf.py | 108 +++++++++++++++++ .../help_custom_targets_skip.py | 112 ++++++++++++++++++ tools/test_build_system/test_common.py | 93 +++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 tools/idf_py_actions/help_custom_targets_skip.py diff --git a/tools/idf.py b/tools/idf.py index 028bac48c91..9c49c5ad249 100755 --- a/tools/idf.py +++ b/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') diff --git a/tools/idf_py_actions/help_custom_targets_skip.py b/tools/idf_py_actions/help_custom_targets_skip.py new file mode 100644 index 00000000000..b3d25d75bd9 --- /dev/null +++ b/tools/idf_py_actions/help_custom_targets_skip.py @@ -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 : 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 diff --git a/tools/test_build_system/test_common.py b/tools/test_build_system/test_common.py index 567cddbe5d7..34c9cd41cde 100644 --- a/tools/test_build_system/test_common.py +++ b/tools/test_build_system/test_common.py @@ -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)