diff --git a/examples/build_system/cmake/multi_config/CMakePresets.json b/examples/build_system/cmake/multi_config/CMakePresets.json new file mode 100644 index 00000000000..a8ee8dfe828 --- /dev/null +++ b/examples/build_system/cmake/multi_config/CMakePresets.json @@ -0,0 +1,34 @@ +{ + "version": 3, + "configurePresets": [ + { + "name": "default", + "binaryDir": "build/default", + "displayName": "Default (development)", + "description": "Development configuration", + "cacheVariables": { + "SDKCONFIG": "./build/default/sdkconfig" + } + }, + { + "name": "prod1", + "binaryDir": "build/prod1", + "displayName": "Product 1", + "description": "Production configuration for product 1", + "cacheVariables": { + "SDKCONFIG_DEFAULTS": "sdkconfig.defaults.prod_common;sdkconfig.defaults.prod1", + "SDKCONFIG": "./build/prod1/sdkconfig" + } + }, + { + "name": "prod2", + "binaryDir": "build/prod2", + "displayName": "Product 2", + "description": "Production configuration for product 2", + "cacheVariables": { + "SDKCONFIG_DEFAULTS": "sdkconfig.defaults.prod_common;sdkconfig.defaults.prod2", + "SDKCONFIG": "./build/prod2/sdkconfig" + } + } + ] +} diff --git a/examples/build_system/cmake/multi_config/README.md b/examples/build_system/cmake/multi_config/README.md index ddf11e894e1..0d0f94ed8a5 100644 --- a/examples/build_system/cmake/multi_config/README.md +++ b/examples/build_system/cmake/multi_config/README.md @@ -28,45 +28,48 @@ For each configuration, a few configuration options are set: ### Development build -In this example, Development configuration is specified in `sdkconfig.defaults`, so it is the default one. You can build the project as usual: +To build the development configuration (specified in `sdkconfig.defaults`), specify it using --preset argument: ``` -idf.py build +idf.py --preset default build ``` To flash the project and see the output, run: ``` -idf.py -p PORT flash monitor +idf.py --preset default -p PORT flash monitor ``` (To exit the serial monitor, type ``Ctrl-]``.) ### Production build -To build one of the Production configurations, specify a different build directory and `SDKCONFIG_DEFAULTS` file. For example, to build `prod1` configuration: +To build one of the Production configurations, specify the name using idf.py --preset argument: ``` -idf.py -B build_prod1 -D SDKCONFIG_DEFAULTS="sdkconfig.prod_common;sdkconfig.prod1" build +idf.py --preset prod1 build ``` -* `-B build_prod1` sets the build directory to `build_prod1` -* `-D SDKCONFIG_DEFAULTS="sdkconfig.prod_common;sdkconfig.prod1"` selects `sdkconfig.prod_common` and `sdkconfig.prod1` files to be used for creating app configuration (sdkconfig), instead of the usual `sdkconfig.defaults`. See the section below on how these two default configuration files are combined. - To flash the project and see the output, run: ``` -idf.py -B build_prod1 -p PORT flash monitor -``` - -Note that it is not necessary to repeat `-D SDKCONFIG_DEFAULTS=...` option once the build directory has been created and `sdkconfig` file generated. For example, to build the project again, run: - -``` -idf.py -B build_prod1 build +idf.py --preset prod1 -p PORT flash monitor ``` To build and run the app with `prod2` configuration, repeat the steps above, replacing `prod1` with `prod2`. +### Specifying the preset for multiple commands + +To avoid having to specify `--preset` argument every time you run `idf.py`, you can set `IDF_PRESET` environment variable: + + +```shell +export IDF_PRESET=prod1 +# subsequent commands will work with 'prod1' configuration: +idf.py build +idf.py flash monitor +``` + ### Combining multiple files in `SDKCONFIG_DEFAULTS` `SDKCONFIG_DEFAULTS` build system variable selects the file which contains the default app configuration, used when no `sdkconfig` file is present. If not specified, `SDKCONFIG_DEFAULTS` is set to `"sdkconfig.defaults"`. @@ -80,24 +83,6 @@ It is possible to specify multiple files in this variable, separating them with This way the common options do not need to be repeated in each of `sdkconfig.prodN` files. -### Create configuration profile files via @filename - -You can further enhance your build process by using configuration profile files. These profile files contain arguments that streamline the build process for specific scenarios. Let's have our example profile files: - -- [profiles/prod](profiles/prod) -- [profiles/debug](profiles/debug) - -You can use these profile files to quickly set up the build environment with specific configurations. - -- To build with the production profile: `idf.py @profiles/prod build` -- To build with the debug profile: `idf.py @profiles/debug build` - -This approach simplifies the process of specifying complex command-line arguments and allows for greater flexibility in managing different build scenarios. - -Moreover, you can combine arguments from a profile file with additional command line arguments. Anywhere on the idf.py command line, you can specify a file as @filename.txt to read one or more arguments from the text file. Arguments in the file can be separated by newlines or spaces and are expanded exactly as if they had appeared in that order on the idf.py command line. - -For example using [cutom_flash.txt](custom_flash.txt), you can expand the command: `idf.py -B build_production @custom_flash.txt monitor` - ### Generated `sdkconfig` file In this example, `sdkconfig` file is placed into the build directory, instead of the project root directory as it is done by default. This allows development and production builds to exist side by side. The location of `sdkconfig` file is set using `SDKCONFIG` variable in [project CMakeLists.txt](CMakeLists.txt) file. diff --git a/examples/build_system/cmake/multi_config/custom_flash.txt b/examples/build_system/cmake/multi_config/custom_flash.txt deleted file mode 100644 index 468a9cc0d18..00000000000 --- a/examples/build_system/cmake/multi_config/custom_flash.txt +++ /dev/null @@ -1 +0,0 @@ --p PORT flash diff --git a/examples/build_system/cmake/multi_config/profiles/debug b/examples/build_system/cmake/multi_config/profiles/debug deleted file mode 100644 index 0981c419616..00000000000 --- a/examples/build_system/cmake/multi_config/profiles/debug +++ /dev/null @@ -1 +0,0 @@ --B build-debug -DSDKCONFIG=build-debug/sdkconfig -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.debug" diff --git a/examples/build_system/cmake/multi_config/profiles/prod b/examples/build_system/cmake/multi_config/profiles/prod deleted file mode 100644 index 49ebc1cb971..00000000000 --- a/examples/build_system/cmake/multi_config/profiles/prod +++ /dev/null @@ -1 +0,0 @@ --B build-production -DSDKCONFIG=build-production/sdkconfig -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.prod" diff --git a/examples/build_system/cmake/multi_config/sdkconfig.prod1 b/examples/build_system/cmake/multi_config/sdkconfig.defaults.prod1 similarity index 100% rename from examples/build_system/cmake/multi_config/sdkconfig.prod1 rename to examples/build_system/cmake/multi_config/sdkconfig.defaults.prod1 diff --git a/examples/build_system/cmake/multi_config/sdkconfig.prod2 b/examples/build_system/cmake/multi_config/sdkconfig.defaults.prod2 similarity index 100% rename from examples/build_system/cmake/multi_config/sdkconfig.prod2 rename to examples/build_system/cmake/multi_config/sdkconfig.defaults.prod2 diff --git a/examples/build_system/cmake/multi_config/sdkconfig.prod_common b/examples/build_system/cmake/multi_config/sdkconfig.defaults.prod_common similarity index 100% rename from examples/build_system/cmake/multi_config/sdkconfig.prod_common rename to examples/build_system/cmake/multi_config/sdkconfig.defaults.prod_common diff --git a/tools/idf_py_actions/core_ext.py b/tools/idf_py_actions/core_ext.py index 47e688d847d..9d5b8098f1e 100644 --- a/tools/idf_py_actions/core_ext.py +++ b/tools/idf_py_actions/core_ext.py @@ -28,8 +28,11 @@ from idf_py_actions.tools import PropertyDict from idf_py_actions.tools import TargetChoice from idf_py_actions.tools import ensure_build_directory from idf_py_actions.tools import generate_hints +from idf_py_actions.tools import get_build_directory_from_preset +from idf_py_actions.tools import get_cmake_preset from idf_py_actions.tools import get_target from idf_py_actions.tools import idf_version +from idf_py_actions.tools import load_cmake_presets from idf_py_actions.tools import merge_action_lists from idf_py_actions.tools import run_target from idf_py_actions.tools import yellow_print @@ -263,10 +266,37 @@ def action_extensions(base_actions: dict, project_path: str) -> Any: 'Setting the build directory to the project directory is not supported. Suggest dropping ' "--build-dir option, the default is a 'build' subdirectory inside the project directory." ) + + # CMake presets have to be initialized once the project directory is known + # but before falling back to the default build directory + initialize_cmake_presets(args) + if args.build_dir is None: args.build_dir = os.path.join(args.project_dir, 'build') args.build_dir = os.path.realpath(args.build_dir) + def initialize_cmake_presets(args: PropertyDict) -> None: + # Load the CMake presets from the project directory and determine the preset name to use. + load_cmake_presets(args.project_dir, args.preset) + # Get the selected CMake configuration preset, if any + preset_info = get_cmake_preset() + if preset_info: + # If the preset specifies a build directory, use it + build_dir_from_preset = get_build_directory_from_preset() + if build_dir_from_preset: + if args.build_dir: + raise FatalError( + 'Build directory specified both in CMake preset and on the command line. This is not supported.' + ) + if not os.path.isabs(build_dir_from_preset): + build_dir_from_preset = os.path.join(args.project_dir, build_dir_from_preset) + # Store the build directory back into the args object, the rest of idf.py will look for it there + args.build_dir = build_dir_from_preset + else: + yellow_print( + f'Warning: preset {preset_info["name"]} doesn\'t specify the build directory ("binaryDir")' + ) + def idf_version_callback(ctx: Context, param: str, value: str) -> None: if not value or ctx.resilient_parsing: return @@ -387,6 +417,12 @@ def action_extensions(base_actions: dict, project_path: str) -> Any: 'type': click.Path(), 'default': None, }, + { + 'names': ['--preset'], + 'help': 'Configuration preset to use (defined in CMakePresets.json or CMakeUserPresets.json)', + 'envvar': 'IDF_PRESET', + 'default': None, + }, { 'names': ['-w/-n', '--cmake-warn-uninitialized/--no-warnings'], 'help': ( diff --git a/tools/idf_py_actions/tools.py b/tools/idf_py_actions/tools.py index 230f259f64e..555cacd9889 100644 --- a/tools/idf_py_actions/tools.py +++ b/tools/idf_py_actions/tools.py @@ -34,6 +34,9 @@ SHELL_COMPLETE_VAR = '_IDF.PY_COMPLETE' # was shell completion invoked? SHELL_COMPLETE_RUN = SHELL_COMPLETE_VAR in os.environ +# If a CMake preset with this name exists, it will be used by default when no '--preset' argument is given. +DEFAULT_CMAKE_PRESET_NAME = 'default' + # The ctx dict "abuses" how python evaluates default parameter values. # https://docs.python.org/3/reference/compound_stmts.html#function-definitions @@ -46,6 +49,7 @@ def get_build_context(ctx: dict = {}) -> dict: It returns dictionary with the following keys: 'proj_desc' - loaded project_description.json file + 'presets' - loaded CMake presets dictionary Please make sure that ensure_build_directory was called otherwise the build context dictionary will be empty. Also note that it might not be thread-safe to @@ -54,11 +58,11 @@ def get_build_context(ctx: dict = {}) -> dict: return ctx -def _set_build_context(args: 'PropertyDict') -> None: - # private helper to set global build context from ensure_build_directory +def _set_build_context_proj_desc(build_dir: str) -> None: + # private helper to save project description to global build context ctx = get_build_context() - proj_desc_fn = f'{args.build_dir}/project_description.json' + proj_desc_fn = f'{build_dir}/project_description.json' try: with open(proj_desc_fn, encoding='utf-8') as f: ctx['proj_desc'] = json.load(f) @@ -66,6 +70,13 @@ def _set_build_context(args: 'PropertyDict') -> None: raise FatalError(f'Cannot load {proj_desc_fn}: {e}') +def _set_build_context_presets(presets: list[dict[str, Any]], preset_name: str | None = None) -> None: + # private helper to save CMake presets to global build context + ctx = get_build_context() + ctx['presets'] = presets + ctx['preset_name'] = preset_name + + def executable_exists(args: list) -> bool: try: subprocess.check_output(args) @@ -611,6 +622,107 @@ def _detect_cmake_generator(prog_name: str) -> Any: raise FatalError(f"To use {prog_name}, either the 'ninja' or 'GNU make' build tool must be available in the PATH") +def load_cmake_presets(project_dir: str, preset_name: str | None = None) -> None: + """ + Try to load CMake presets from the project directory and determine the preset name to use. + + If a preset name is provided, check that such a preset exists and select it. + If no preset name is provided, check if the "default" preset exists and select it. + Otherwise, select the first preset. + """ + # See if we have loaded the presets already + cached_presets = get_build_context().get('presets') + if cached_presets: + return + + # Load CMake presets from the project directory + cmakepresets_file_names = ['CMakePresets.json', 'CMakeUserPresets.json'] + config_presets_info = [] + for cmakepresets_file_name in cmakepresets_file_names: + cmakepresets_file_path = os.path.join(project_dir, cmakepresets_file_name) + if not os.path.exists(cmakepresets_file_path): + continue + try: + with open(cmakepresets_file_path) as f_in: + presets_info = json.load(f_in) + for config_preset in presets_info['configurePresets']: + if not config_preset.get('name'): + yellow_print( + f'Found CMake preset without a name in {cmakepresets_file_name}, skipping the preset' + ) + continue + # add more preset validations here, if needed + config_presets_info.append(config_preset) + except Exception as err: + yellow_print(f'Failed to load CMake presets from {cmakepresets_file_name}, {str(err)}') + + preset_names = [preset['name'] for preset in config_presets_info] + print(preset_names) + if not preset_names: + if preset_name: + raise FatalError(f"Preset '{preset_name}' specified, but no CMake presets found") + return + + if not preset_name and DEFAULT_CMAKE_PRESET_NAME in preset_names: + yellow_print(f"CMake presets file found but no preset name given; using '{DEFAULT_CMAKE_PRESET_NAME}' preset") + preset_name = DEFAULT_CMAKE_PRESET_NAME + elif not preset_name: + preset_name = preset_names[0] + yellow_print(f"CMake presets file found but no preset name given; using first preset: '{preset_name}'") + elif preset_name not in preset_names: + raise FatalError(f"No preset '{preset_name}' found in CMake presets") + + # Stash the presets dictionary and the selected preset name in the global build context for future use + _set_build_context_presets(config_presets_info, preset_name) + + +def get_cmake_preset() -> dict[str, Any] | None: + """ + Get the selected CMake configuration preset, or None if no preset is selected. + + See the description of load_cmake_presets for details on how the preset is selected. + """ + ctx = get_build_context() + if not ctx.get('presets') or not ctx.get('preset_name'): + return None + + return next((p_info for p_info in ctx['presets'] if p_info['name'] == ctx['preset_name']), dict()) + + +def get_build_directory_from_preset() -> str | None: + """ + Get build directory (binaryDir) from the selected CMake configuration preset. + + See the description of load_cmake_presets for details on how the preset is selected. + """ + preset_info = get_cmake_preset() + preset_name = preset_info.get('name') # type: ignore + if not preset_info: + return None + + if preset_info.get('inherits'): + # TODO: here we also need to check if the preset inherits from other presets, + # and possibly get the build directory from the inherited presets. + yellow_print(f"Preset '{preset_name}' uses inheritance, which is not yet supported. YMMV.") + + binary_dir = preset_info.get('binaryDir', None) + if not binary_dir: + raise FatalError(f'Preset \'{preset_name}\' does not specify "binaryDir", this is not supported.') + + return str(binary_dir) + + +def get_generator_from_preset() -> str | None: + """Return the generator from the selected CMake configuration preset, if any. + + Returns ``None`` when no preset is selected or the preset omits the generator. + """ + preset_info = get_cmake_preset() + if not preset_info: + return None + return preset_info.get('generator') + + def ensure_build_directory( args: 'PropertyDict', prog_name: str, always_run_cmake: bool = False, env: dict | None = None ) -> None: @@ -655,13 +767,28 @@ def ensure_build_directory( _check_idf_target(args, prog_name, cache, cache_cmdl, env) if always_run_cmake or _new_cmakecache_entries(cache, cache_cmdl): - if args.generator is None: + # Check if CMake preset specifies the generator + generator_defined_by_preset = False + if args.preset: + generator = get_generator_from_preset() + if generator: + # Won't try to auto-detect the generator and pass it to CMake, as that would + # override the choice made by the preset. + generator_defined_by_preset = True + + # No generator specified in CLI or in preset, so auto-detect it + if args.generator is None and not generator_defined_by_preset: args.generator = _detect_cmake_generator(prog_name) + + # Pass the generator to CMake if one was specified in CLI or auto-detected + generator_args = [] + if args.generator: + generator_args = ['-G', args.generator] + try: cmake_args = [ 'cmake', - '-G', - args.generator, + *generator_args, '-DPYTHON_DEPS_CHECKED=1', f'-DPYTHON={sys.executable}', '-DESP_PLATFORM=1', @@ -673,6 +800,9 @@ def ensure_build_directory( cmake_args += ['-D' + d for d in args.define_cache_entry] cmake_args += [project_dir] + if args.preset: + cmake_args += ['--preset', args.preset] + hints = not args.no_hints RunTool('cmake', cmake_args, cwd=args.build_dir, env=env, hints=hints)() except Exception: @@ -718,8 +848,8 @@ def ensure_build_directory( except KeyError: pass - # set global build context - _set_build_context(args) + # Load project metadata file into global build context + _set_build_context_proj_desc(args.build_dir) def merge_action_lists(*action_lists: dict, custom_actions: dict[str, Any] | None = None) -> dict: