Skip to content

Commit 34f658a

Browse files
committed
Merge branch 'feat/load_idf_ext_from_components' into 'master'
feat(tools): Load idf.py extensions from project's component directories and python packages Closes IDF-5736 and IDF-4121 See merge request espressif/esp-idf!39875
2 parents f620ab6 + 6c6b8a5 commit 34f658a

File tree

5 files changed

+958
-145
lines changed

5 files changed

+958
-145
lines changed

docs/en/api-guides/tools/idf-py.rst

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,112 @@ Arguments from a file can be combined with additional command line arguments, an
301301

302302
A further example of how this argument file can be used, e.g., creating configuration profile files via @filename, is in the :example_file:`Multiple Build Configurations Example <build_system/cmake/multi_config/README.md>`.
303303

304+
Extending ``idf.py``
305+
====================
306+
307+
``idf.py`` can be extended with additional subcommands, global options, and callbacks provided by extension files in your project and components which participate in the build, as well as by external Python packages exposing entry points.
308+
309+
- **From components participating in the build**: Place a file named ``idf_ext.py`` in the project root or in a component's root directory that is registered in the project's ``CMakeLists.txt``. Component extensions are discovered after the project is configured - run ``idf.py build`` or ``idf.py reconfigure`` to make newly added commands available.
310+
- **From Python entry points**: Any installed Python package may contribute extensions by defining an entry point in the ``idf_extension`` group. Package installation is sufficient, no project build is required.
311+
312+
.. important::
313+
314+
Extensions must not define subcommands or options that have the same names as the core ``idf.py`` commands. Custom actions and options are checked for name collisions, overriding defaults is not possible and a warning is printed. For Python entry points, use unique identifiers as duplicate entry point names will be ignored with a warning.
315+
316+
Extension File Example
317+
----------------------
318+
319+
An extension file defines an ``action_extensions`` function which returns additional actions/options. The same structure is used for component-based extensions (``idf_ext.py``) and for package-based extensions (e.g., ``<package_name>_ext.py``):
320+
321+
.. code-block:: python
322+
323+
from typing import Any
324+
import click
325+
326+
def action_extensions(base_actions: dict, project_path: str) -> dict:
327+
def hello_test(subcommand_name: str, ctx: click.Context, global_args: dict, **action_args: Any) -> None:
328+
message = action_args.get('message')
329+
print(f"Running action: {subcommand_name}. Message: {message}")
330+
331+
def global_callback_detail(ctx: click.Context, global_args: dict, tasks: list) -> None:
332+
if getattr(global_args, 'detail', False):
333+
print(f"About to execute {len(tasks)} task(s): {[t.name for t in tasks]}")
334+
335+
return {
336+
"version": "1",
337+
"global_options": [
338+
{
339+
"names": ["--detail", "-d"],
340+
"is_flag": True,
341+
"help": "Enable detailed output",
342+
}
343+
],
344+
"global_action_callbacks": [global_callback_detail],
345+
"actions": {
346+
"hello": {
347+
"callback": hello_test,
348+
"short_help": "Hello from component",
349+
"help": "Test command from component extension",
350+
"options": [
351+
{
352+
"names": ["--message", "-m"],
353+
"help": "Custom message to display",
354+
"default": "Hi there!",
355+
"type": str,
356+
}
357+
]
358+
},
359+
},
360+
}
361+
362+
363+
Extension API Reference
364+
-----------------------
365+
366+
The ``action_extensions`` function takes arguments ``base_actions`` (all currently registered commands) and ``project_path`` (absolute project directory) and returns a dictionary with up to four keys:
367+
368+
- ``version``: A string representing the interface version of the extension. Currently, the API version is ``1``. **This key is mandatory** and must be provided.
369+
- ``global_options``: A list of options available globally for all commands. Each option is a dictionary with fields such as ``names``, ``help``, ``type``, ``is_flag``, ``scope``, etc.
370+
- ``global_action_callbacks``: A list of functions called once before any task execution. Each global action callback function accepts three arguments:
371+
372+
- ``ctx`` — The `click context`_
373+
- ``global_args`` — All available global arguments
374+
- ``tasks`` — The list of tasks to be executed. Task refer to the action / sub-command used with `idf.py`
375+
376+
- ``actions``: A dictionary of new subcommands. Each action has a ``callback`` function and may also include ``options``, ``arguments``, ``dependencies``, etc. Each action callback function accepts three to four arguments:
377+
378+
- ``subcommand_name`` — the name of the command (useful if multiple commands share the same callback)
379+
- ``ctx`` — the `click context`_
380+
- ``global_args`` — all available global arguments,
381+
- ``**action_args`` — (optional) arguments passed to the action
382+
383+
Basic Usage Examples
384+
--------------------
385+
386+
1) Provide an extension from a component in your project
387+
388+
Create ``idf_ext.py`` in the project root or in a registered component (for example ``components/my_component/idf_ext.py``). Use the extension file example above as your ``idf_ext.py`` implementation.
389+
390+
Run ``idf.py build`` or ``idf.py reconfigure`` to load the new command, then ``idf.py --help`` will show the new extension.
391+
392+
2) Provide an extension via a Python package entry point
393+
394+
Implement your extension in a module named ``<package_name>_ext.py`` using the extension file example above, and expose the ``action_extensions`` function via the ``idf_extension`` entry-point group. For example, with ``pyproject.toml``:
395+
396+
.. code-block:: TOML
397+
398+
[project]
399+
name = "my_comp"
400+
version = "0.1.0"
401+
402+
[project.entry-points.idf_extension]
403+
my_pkg_ext = "my_component.my_ext:action_extensions"
404+
405+
406+
Install the package into the same Python environment as ``idf.py`` (for example with ``pip install -e .`` in the package directory). It is recommended to use a unique module name (e.g., ``<package_name>_ext.py``) to avoid name conflicts. After successful installation, ``idf.py --help`` will show the new extension.
407+
304408
.. _cmake: https://cmake.org
305409
.. _ninja: https://ninja-build.org
306410
.. _esptool.py: https://github.com/espressif/esptool/#readme
307411
.. _CCache: https://ccache.dev/
412+
.. _click context: https://click.palletsprojects.com/en/stable/api/#context

docs/zh_CN/api-guides/tools/idf-py.rst

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,112 @@ uf2 二进制文件也可以通过 :ref:`idf.py uf2 <generate-uf2-binary>` 生
301301

302302
关于参数文件的更多示例,如通过 @filename 创建配置文件概要,请参阅 :example_file:`多个构建配置示例 <build_system/cmake/multi_config/README.md>`。
303303

304+
扩展 ``idf.py``
305+
====================
306+
307+
``idf.py`` 支持扩展功能。通过项目中的扩展文件以及参与构建的组件中的扩展文件,可以增加额外的子命令、全局选项和回调函数;通过暴露入口点的外部 Python 包,可以提供新的扩展功能。
308+
309+
- **参与构建的组件**:在项目根目录,或注册在项目 ``CMakeLists.txt`` 中的组件根目录,放置名为 ``idf_ext.py`` 的文件,该文件会在项目配置完成后得到识别。运行 ``idf.py build`` 或 ``idf.py reconfigure``,新添加的命令即可生效。
310+
- **Python 入口点**:对于任何已安装的 Python 包,在 ``idf_extension`` 组中定义入口点后,就可以提供扩展功能。只要安装了 Python 包就可以使用扩展功能,无需重新构建项目。
311+
312+
.. important::
313+
314+
扩展不能定义与 ``idf.py`` 命令同名的子命令或选项。系统会检查自定义的动作和选项名称是否存在冲突,不允许覆盖默认命令,如有冲突会打印警告。对于 Python 入口点,必须使用唯一标识符,否则会忽略重复的入口点名称并发出警告。
315+
316+
扩展文件示例
317+
----------------------
318+
319+
扩展文件需要定义一个 ``action_extensions`` 函数,用于返回扩展的动作或选项。组件扩展 ``idf_ext.py`` 和基于包的扩展(例如 ``<package_name>_ext.py``)使用相同的结构,如下所示:
320+
321+
.. code-block:: python
322+
323+
from typing import Any
324+
import click
325+
326+
def action_extensions(base_actions: dict, project_path: str) -> dict:
327+
def hello_test(subcommand_name: str, ctx: click.Context, global_args: dict, **action_args: Any) -> None:
328+
message = action_args.get('message')
329+
print(f"Running action: {subcommand_name}. Message: {message}")
330+
331+
def global_callback_detail(ctx: click.Context, global_args: dict, tasks: list) -> None:
332+
if getattr(global_args, 'detail', False):
333+
print(f"About to execute {len(tasks)} task(s): {[t.name for t in tasks]}")
334+
335+
return {
336+
"version": "1",
337+
"global_options": [
338+
{
339+
"names": ["--detail", "-d"],
340+
"is_flag": True,
341+
"help": "Enable detailed output",
342+
}
343+
],
344+
"global_action_callbacks": [global_callback_detail],
345+
"actions": {
346+
"hello": {
347+
"callback": hello_test,
348+
"short_help": "Hello from component",
349+
"help": "Test command from component extension",
350+
"options": [
351+
{
352+
"names": ["--message", "-m"],
353+
"help": "Custom message to display",
354+
"default": "Hi there!",
355+
"type": str,
356+
}
357+
]
358+
},
359+
},
360+
}
361+
362+
363+
扩展 API 参考
364+
-----------------------
365+
366+
``action_extensions`` 函数接收两个参数: ``base_actions`` 表示当前已注册的所有命令, ``project_path`` 表示项目的绝对路径。该函数返回一个包含最多四个键的字典:
367+
368+
- ``version``:表示扩展接口版本。当前 API 版本为 ``1``。此键为必填项。
369+
- ``global_options``:一组全局选项,适用于所有命令。每个选项都是一个字典,包含 ``names``、 ``help``、 ``type``、 ``is_flag``、 ``scope`` 等字段。
370+
- ``global_action_callbacks``:表示一组全局回调函数,在执行任何任务之前都会调用一次。每个全局回调函数接受三个参数:
371+
372+
- ``ctx``:即 `click context`_
373+
- ``global_args``:所有可用的全局参数
374+
- ``tasks``:将要执行的任务列表。任务指的是运行 ``idf.py`` 时所调用的具体动作或子命令
375+
376+
- ``actions``:子命令字典,用于定义新的子命令。每个子命令都有一个 ``callback`` 函数,并且可以包含 ``options``、 ``arguments``、 ``dependencies`` 等。每个回调函数接受三到四个参数:
377+
378+
- ``subcommand_name``:命令的名称(在多个命令共享同一回调时很有用)
379+
- ``ctx``:即 `click context`_
380+
- ``global_args``:所有可用的全局参数
381+
- ``**action_args``:传递给该子命令的具体参数,可选
382+
383+
基本用法示例
384+
--------------------
385+
386+
1) **通过项目组件提供扩展**
387+
388+
在项目根目录或某个已注册的组件目录下创建 ``idf_ext.py`` (例如 ``components/my_component/idf_ext.py`` )。实现内容可参考上面的扩展文件示例。
389+
390+
运行 ``idf.py build`` 或 ``idf.py reconfigure`` 加载新命令,然后执行 ``idf.py --help`` 即可看到新扩展。
391+
392+
2) **通过 Python 包入口点提供扩展**
393+
394+
使用上述扩展文件示例,在名为 ``<package_name>_ext.py`` 的模块中实现扩展,并通过 ``idf_extension`` 入口点组暴露 ``action_extensions`` 函数。例如,在 ``pyproject.toml`` 中配置:
395+
396+
.. code-block:: TOML
397+
398+
[project]
399+
name = "my_comp"
400+
version = "0.1.0"
401+
402+
[project.entry-points.idf_extension]
403+
my_pkg_ext = "my_component.my_ext:action_extensions"
404+
405+
406+
将该包安装到与 ``idf.py`` 相同的 Python 环境中(例如在包目录下执行 ``pip install -e .``)。建议使用唯一的模块名(例如 ``<package_name>_ext.py``)避免命名冲突。安装成功后,运行 ``idf.py --help`` 就可以看到新扩展命令。
407+
304408
.. _cmake: https://cmake.org
305409
.. _ninja: https://ninja-build.org
306410
.. _esptool.py: https://github.com/espressif/esptool/#readme
307411
.. _CCache: https://ccache.dev/
412+
.. _click context: https://click.palletsprojects.com/en/stable/api/#context

tools/idf.py

Lines changed: 119 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
# their specific function instead.
1515
import codecs
1616
import glob
17+
import importlib.metadata
18+
import importlib.util
1719
import json
1820
import locale
1921
import os.path
@@ -246,6 +248,8 @@ def __call__(
246248
self.callback(self.name, context, global_args, **action_args)
247249

248250
class Action(click.Command):
251+
callback: Callable
252+
249253
def __init__(
250254
self,
251255
name: str | None = None,
@@ -721,6 +725,90 @@ def execute_tasks(self, tasks: list, **kwargs: str) -> OrderedDict:
721725

722726
return tasks_to_run
723727

728+
def load_cli_extension_from_dir(ext_dir: str) -> Any | None:
729+
"""Load extension 'idf_ext.py' from directory and return the action_extensions function"""
730+
ext_file = os.path.join(ext_dir, 'idf_ext.py')
731+
if not os.path.exists(ext_file):
732+
return None
733+
734+
try:
735+
module_name = f'idf_ext_{os.path.basename(ext_dir)}'
736+
spec = importlib.util.spec_from_file_location(module_name, ext_file)
737+
if spec is None or spec.loader is None:
738+
raise ImportError('Failed to load python module')
739+
ext_module = importlib.util.module_from_spec(spec)
740+
sys.modules[module_name] = ext_module
741+
spec.loader.exec_module(ext_module)
742+
743+
if hasattr(ext_module, 'action_extensions'):
744+
return ext_module.action_extensions
745+
else:
746+
print_warning(f"Warning: Extension {ext_file} has no attribute 'action_extensions'")
747+
748+
except (ImportError, SyntaxError) as e:
749+
print_warning(f'Warning: Failed to import extension {ext_file}: {e}')
750+
751+
return None
752+
753+
def load_cli_extensions_from_entry_points() -> list[tuple[str, Any]]:
754+
"""Load extensions from Python entry points"""
755+
extensions: list[tuple[str, Any]] = []
756+
eps = importlib.metadata.entry_points(group='idf_extension')
757+
758+
# declarative value is the path-like identifier of entry point defined in the components config file
759+
# having same declarative value for multiple entry points results in loading only one of them (undeterministic)
760+
eps_declarative_values: list[str] = []
761+
for ep in eps:
762+
if ep.value in eps_declarative_values:
763+
conflicting_names = [e.name for e in eps if e.value == ep.value]
764+
print_warning(
765+
f"Warning: Entry point's declarative value [extension_file_name:method_name] "
766+
f'name collision detected for - {ep.value}. The same {ep.value} is used by '
767+
f'{conflicting_names} entry points. To ensure successful loading, please use'
768+
' a different extension file name or method name for the entry point.'
769+
)
770+
# Remove any already loaded extensions with conflicting names
771+
extensions[:] = [ext for ext in extensions if ext[0] not in conflicting_names]
772+
continue
773+
774+
if ep.value == 'idf_ext:action_extensions':
775+
print_warning(
776+
f'Entry point "{ep.name}" has declarative value "{ep.value}". For external components, '
777+
'it is recommended to use name like <<COMPONENT_NAME>>_ext:action_extensions, '
778+
"so it does not interfere with the project's idf_ext.py file."
779+
)
780+
781+
eps_declarative_values.append(ep.value)
782+
try:
783+
extension_func = ep.load()
784+
extensions.append((ep.name, extension_func))
785+
except Exception as e:
786+
print_warning(f'Warning: Failed to load entry point extension "{ep.name}": {e}')
787+
788+
return extensions
789+
790+
def resolve_build_dir() -> str:
791+
"""Resolve build directory from command line arguments
792+
return build path if explicitly set, otherwise default build path"""
793+
import argparse
794+
795+
parser = argparse.ArgumentParser(add_help=False)
796+
parser.add_argument('-B', '--build-dir', default=os.path.join(project_dir, 'build'))
797+
args, _ = parser.parse_known_args()
798+
build_dir: str = args.build_dir
799+
return os.path.abspath(build_dir)
800+
801+
def _extract_relevant_path(path: str) -> str:
802+
"""
803+
Returns part of the path starting from 'components' or 'managed_components'.
804+
If neither is found, returns the full path.
805+
"""
806+
for keyword in ('components', 'managed_components'):
807+
# arg path is loaded from project_description.json, where paths are always defined with '/'
808+
if keyword in path.split('/'):
809+
return keyword + path.split(keyword, 1)[1]
810+
return path
811+
724812
# That's a tiny parser that parse project-dir even before constructing
725813
# fully featured click parser to be sure that extensions are loaded from the right place
726814
@click.command(
@@ -774,21 +862,40 @@ def parse_project_dir(project_dir: str) -> Any:
774862
except AttributeError:
775863
print_warning(f'WARNING: Cannot load idf.py extension "{name}"')
776864

777-
# Load extensions from project dir
778-
if os.path.exists(os.path.join(project_dir, 'idf_ext.py')):
779-
sys.path.append(project_dir)
865+
component_idf_ext_dirs = []
866+
# Get component directories with idf extensions that participate in the build
867+
build_dir_path = resolve_build_dir()
868+
project_description_json_file = os.path.join(build_dir_path, 'project_description.json')
869+
if os.path.exists(project_description_json_file):
780870
try:
781-
from idf_ext import action_extensions
782-
except ImportError:
783-
print_warning('Error importing extension file idf_ext.py. Skipping.')
784-
print_warning(
785-
"Please make sure that it contains implementation (even if it's empty) of add_action_extensions"
786-
)
871+
with open(project_description_json_file, encoding='utf-8') as f:
872+
project_desc = json.load(f)
873+
all_component_info = project_desc.get('build_component_info', {})
874+
for _, comp_info in all_component_info.items():
875+
comp_dir = comp_info.get('dir')
876+
if comp_dir and os.path.isdir(comp_dir) and os.path.exists(os.path.join(comp_dir, 'idf_ext.py')):
877+
component_idf_ext_dirs.append(comp_dir)
878+
except (OSError, json.JSONDecodeError) as e:
879+
print_warning(f'Warning: Failed to read component info from project_description.json: {e}')
880+
# Load extensions from directories that participate in the build (components and project)
881+
for ext_dir in component_idf_ext_dirs + [project_dir]:
882+
extension_func = load_cli_extension_from_dir(ext_dir)
883+
if extension_func:
884+
try:
885+
all_actions = merge_action_lists(all_actions, custom_actions=extension_func(all_actions, project_dir))
886+
except Exception as e:
887+
print_warning(f'WARNING: Cannot load directory extension from "{ext_dir}": {e}')
888+
else:
889+
if ext_dir != project_dir:
890+
print(f'INFO: Loaded component extension from "{_extract_relevant_path(ext_dir)}"')
787891

892+
# Load extensions from Python entry points
893+
entry_point_extensions = load_cli_extensions_from_entry_points()
894+
for name, extension_func in entry_point_extensions:
788895
try:
789-
all_actions = merge_action_lists(all_actions, action_extensions(all_actions, project_dir))
790-
except NameError:
791-
pass
896+
all_actions = merge_action_lists(all_actions, custom_actions=extension_func(all_actions, project_dir))
897+
except Exception as e:
898+
print_warning(f'WARNING: Cannot load entry point extension "{name}": {e}')
792899

793900
cli_help = (
794901
'ESP-IDF CLI build management tool. '

0 commit comments

Comments
 (0)