Source code for ci_exec.parsers.cmake_parser

########################################################################################
# Copyright 2019-2021 Stephen McDowell                                                 #
#                                                                                      #
# Licensed under the Apache License, Version 2.0 (the "License");                      #
# you may not use this file except in compliance with the License.                     #
# You may obtain a copy of the License at                                              #
#                                                                                      #
#     http://www.apache.org/licenses/LICENSE-2.0                                       #
#                                                                                      #
# Unless required by applicable law or agreed to in writing, software                  #
# distributed under the License is distributed on an "AS IS" BASIS,                    #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.             #
# See the License for the specific language governing permissions and                  #
# limitations under the License.                                                       #
########################################################################################
"""Module for `CMake <https://cmake.org/>`_ focused argument parser |CMakeParser|."""

import argparse
from typing import Any, Dict, Optional

from .utils import env_or_platform_default


[docs]class CMakeParser(argparse.ArgumentParser): """ A `CMake <https://cmake.org/>`_ focused argument parser. The goal of this parser is to fold some of the more common flags used by many CMake projects and parse them into ``cmake_configure_args`` and ``cmake_build_args`` automatically. Expected workflow is:: from ci_exec import CMakeParser, cd, which parser = CMakeParser(description="Mylib CI Builder") # ... add any extra arguments specific to project ... # parser.add_argument("--foo", ...) # Parse arguments. cmake_{configure,build}_args are parsed for you, and are a # list of strings (possibly empty). args = parser.parse_args() configure_args = args.cmake_configure_args build_args = args.cmake_build_args # Use the configure / build args created for you by CMakeParser. cmake = which("cmake") with cd("build", create=True): cmake("..", *configure_args) cmake("--build", ".", *build_args) This is helpful for situations where how users are expected to interact with CMake changes depending on the generator chosen. A reputable example being the build type for single-config vs multi-config generators: - Single-config: configure with ``-DCMAKE_BUILD_TYPE=<build_type>`` - Multi-config: build with ``--config <build_type>`` The command line arguments added here should be appropriate for most projects that obey typical CMake practices, but is as customizable as possible. Command-line argument values such as the ``help`` string or ``default`` value can be changed with :func:`set_argument`. Arguments added by this parser that are not desired can be removed using :func:`remove`. .. note:: The documentation makes a distinction between *registered* and *unregistered* arguments. **Registered** A flag that this parser adds in the constructor, and will later use to populate ``cmake_configure_args`` and ``cmake_build_args``. **Unregistered** Any arguments that the user adds. This parser does not keep track of them (``parser.add_argument("--foo", ...)`` in example above). The registered command-line arguments added are as follows: ``-G`` (``args.generator``) -- default: ``Ninja`` The CMake generator to use. Pass-through configure argument ``cmake -G``. Generator choices are validated, however: 1. `Extra Generators`__ are not supported. 2. The now deprecated ``Visual Studio XX YYYY Win64`` format with ``Win64`` is parsed as an **error**. Users should take advantage of ``-A``. __ https://cmake.org/cmake/help/latest/manual/cmake-generators.7.html#extra-generators ``-A`` (``args.architecture``) -- default: ``None`` The CMake architecture to build. Pass-through configure argument ``cmake -A``. **Not validated**, invalid arguments (e.g., ``-A`` provided when generator does not support it) will result in the cmake configure step failing. ``-T`` (``args.toolset``) -- default: ``None`` The CMake toolset to use. Pass-through configure argument ``cmake -T``. **Not validated**, invalid arguments (e.g., ``-T`` provided when generator does not support it) will result in the cmake configure step failing. ``--shared`` (``args.shared``) -- default: ``False`` Add ``-DBUILD_SHARED_LIBS=ON`` to configure arguments? Conflicts with ``--static`` flag. See also: ``shared_or_static_required`` constructor parameter. ``--static`` (``args.static``) -- default: ``False`` Add ``-DBUILD_SHARED_LIBS=OFF`` to configure arguments? Conflicts with ``--shared`` flag. See also: ``shared_or_static_required`` constructor parameter. ``--cc`` (``args.cc``) -- default: *platform dependent* The C compiler to use. If the generator requested is a single-config generator, then the ``-DCMAKE_C_COMPILER={args.cc}`` configure argument. Multi-config generators such as Visual Studio or Xcode will ignore this flag. Default: ``$CC`` environment variable **if set**, otherwise: +----------+------------+ | Platform | Default | +==========+============+ | Windows | ``cl.exe`` | +----------+------------+ | Darwin | ``clang`` | +----------+------------+ | Other | ``gcc`` | +----------+------------+ ``--cxx`` (``args.cxx``) -- default: *platform dependent* The C++ compiler to use. Like ``--cc``, adds ``-DCMAKE_CXX_COMPILER={args.cxx}`` configure argument for single-config generators. Default: ``$CXX`` environment variable **if set**, otherwise: +----------+-------------+ | Platform | Default | +==========+=============+ | Windows | ``cl.exe`` | +----------+-------------+ | Darwin | ``clang++`` | +----------+-------------+ | Other | ``g++`` | +----------+-------------+ ``--build-type`` (``args.build_type``) -- default: ``Release`` For single-config generators, this will result in a configure argument of ``-DCMAKE_BUILD_TYPE={args.build_type}``. For multi-config generators, results in ``["--config", "{args.build_type}"]`` in the ``cmake_build_args``. Choices: ``Release``, ``Debug``, ``RelWithDebInfo``, and ``MinSizeRel``. ``[extra_args]`` (``args.extra_args``) -- default: ``[]`` By default, a positional argument with ``nargs="*"`` (meaning 0 or more) will be added. These are parsed as anything after the ``--`` sequence, and will be added directly to ``cmake_configure_args``. This supports users doing something like: .. code-block:: console $ python .ci/build.py --shared # args.extra_args = [] $ python .ci/build.py --shared -- -Werror=dev -DMYLIB_DEV=ON # args.extra_args = ["-Werror=dev", "-DMYLIB_DEV=ON"] $ python .ci/build.py --shared -- -DMYLIB_DEV=OFF # args.extra_args = ["-DMYLIB_DEV=OFF"] .. note:: This positional argument can be disabled two ways:: # Option 1: at construction. parser = CMakeParser(add_extra_args=False) # Option 2: set attribute to False *BEFORE* calling parse_args() parser = CMakeParser() parser.add_extra_args = False args = parser.parse_args() Since this is a positional argument consuming ``nargs="*"``, it must be added last in order for users to add their own positional arguments. The way this is implemented is by having :func:`parse_args` actually add the argument, which means: 1. It must be disabled before ``parser.parse_args()`` is called to prevent. 2. Unlike other arguments, it's attributes such as default value of ``[]`` or help string cannot be changed. Parameters ---------- add_extra_args : bool Default: ``True``, support ``[extra_args]`` CMake configure arguments at the end of the command-line, after the ``--`` sequence (see above). shared_or_static_required : bool Default: ``False``. The ``--shared`` and ``--static`` flags are added using :meth:`~python:argparse.ArgumentParser.add_mutually_exclusive_group`, this is a pass-through parameter:: shared_or_static = self.add_mutually_exclusive_group( required=shared_or_static_required ) - When ``False``, if neither ``--shared`` nor ``--static`` are supplied, then ``args.cmake_configure_args`` will **not** contain any ``-DBUILD_SHARED_LIBS=[val]`` entries. - When ``True``, one of ``--shared`` or ``--static`` must be provided, meaning that ``args.cmake_configure_args`` will **always** contain either ``-DBUILD_SHARED_LIBS=ON`` or ``-DBUILD_SHARED_LIBS=OFF``. Typically CMake projects will ``option(BUILD_SHARED_LIBS "Build shared libraries?" OFF)``, meaning that if not specified ``--static`` is implied. This is because the default behavior of ``add_library`` with no explicit ``SHARED|STATIC`` is ``STATIC``. However, if a project defaults ``BUILD_SHARED_LIBS`` to ``ON``, requiring ``--shared`` or ``--static`` be explicitly provided can help ensure that dependencies etc will all receive the same ``BUILD_SHARED_LIBS`` arguments. **kwargs All other parameters are forwarded to :class:`python:argparse.ArgumentParser`. Note that every parameter to the |CMakeParser| class must be specified as a keyword-only argument. Positional arguments are disabled. Attributes ---------- add_extra_args : bool Whether or not CMake configure arguments after ``--`` sequence will be added. flag_map : dict Mapping of string flag keys (e.g., ``"-G"``, or ``"--build-type"``) to the actual :class:`~python:argparse.Action` of all registered arguments. Direct usage discouraged by users, use :func:`get_argument` or :func:`set_argument` instead. dest_map : dict Mapping of string ``dest`` keys (e.g., ``"generator"`` or ``"build_type"``) to the actual :class:`~python:argparse.Action` of all registered arguments. Direct usage discouraged by users, use :func:`get_argument` or :func:`set_argument` instead. """ # noqa: E501 makefile_generators = { "Borland Makefiles", "MSYS Makefiles", "MinGW Makefiles", "NMake Makefiles", "NMake Makefiles JOM", "Unix Makefiles", "Watcom WMake" } """ The `Makefile Generators`__. __ https://cmake.org/cmake/help/latest/manual/cmake-generators.7.html#makefile-generators """ # noqa: E501 ninja_generator = {"Ninja"} """ The `Ninja Generator`__. __ https://cmake.org/cmake/help/latest/generator/Ninja.html """ ninja_multi_generator = {"Ninja Multi-Config"} """ The `Ninja Multi-Config Generator`__. __ https://cmake.org/cmake/help/latest/generator/Ninja%20Multi-Config.html """ visual_studio_generators = { "Visual Studio 9 2008", "Visual Studio 10 2010", "Visual Studio 11 2012", "Visual Studio 12 2013", "Visual Studio 14 2015", "Visual Studio 15 2017", "Visual Studio 16 2019", "Visual Studio 17 2022" } """ The `Visual Studio Generators`__. __ https://cmake.org/cmake/help/latest/manual/cmake-generators.7.html#visual-studio-generators """ # noqa: E501 other_generators = {"Green Hills MULTI", "Xcode"} """ The `Other Generators`__. __ https://cmake.org/cmake/help/latest/manual/cmake-generators.7.html#other-generators """ # noqa: E501
[docs] @classmethod def is_multi_config_generator(cls, generator: str) -> bool: """Whether or not string ``generator`` is a multi-config generator.""" return generator in (cls.visual_studio_generators | cls.other_generators | cls.ninja_multi_generator)
[docs] @classmethod def is_single_config_generator(cls, generator: str) -> bool: """Whether or not string ``generator`` is a single-config generator.""" return generator in (cls.makefile_generators | cls.ninja_generator)
def __init__(self, *, add_extra_args: bool = True, shared_or_static_required: bool = False, **kwargs): if "formatter_class" not in kwargs: kwargs["formatter_class"] = argparse.ArgumentDefaultsHelpFormatter self.add_extra_args = add_extra_args # see parse_args self.flag_map = {} # type: Dict[str, argparse.Action] self.dest_map = {} # type: Dict[str, argparse.Action] super().__init__(**kwargs) # The generator to use. Slightly restrictive: "Visual Studio X YYYY Win64" will # fail, need to do -G "Visual Studio X YYY" -A x64 self._register_argument( "-G", dest="generator", type=str, default="Ninja", metavar="GENERATOR", help="Generator to use (CMake -G flag).", choices=sorted( self.makefile_generators | self.ninja_generator | self.ninja_multi_generator | self.visual_studio_generators | self.other_generators ) ) # Architecture configure argument. self._register_argument( "-A", dest="architecture", type=str, default=None, help=( "Target architecture (CMake -A flag). Not validated. Example: -G " "'Visual Studio 16 2019' -A x64" ) ) # Toolset configure argument. self._register_argument( "-T", dest="toolset", type=str, default=None, help=( "Toolset to use (CMake -T flag). Not validated, must be valid for " "specified generator / architecture." ) ) # BUILD_SHARED_LIBS. Only populated if explicitly requested. shared_or_static = self.add_mutually_exclusive_group( required=shared_or_static_required ) shared = shared_or_static.add_argument( "--shared", dest="shared", action="store_true", help="Build shared libraries? Adds -DBUILD_SHARED_LIBS=ON configure arg." ) self.flag_map["--shared"] = shared self.dest_map["shared"] = shared static = shared_or_static.add_argument( "--static", dest="static", action="store_true", help="Build static libraries? Adds -DBUILD_SHARED_LIBS=OFF configure arg." ) self.flag_map["--static"] = static self.dest_map["static"] = static # The default C compiler to use. cc = env_or_platform_default( env="CC", windows="cl.exe", darwin="clang", other="gcc" ) self._register_argument( "--cc", dest="cc", type=str, default=cc, help="The CMAKE_C_COMPILER to use for single-config generators." ) # The default C++ compiler to use. cxx = env_or_platform_default( env="CXX", windows="cl.exe", darwin="clang++", other="g++" ) self._register_argument( "--cxx", dest="cxx", type=str, default=cxx, help="The CMAKE_CXX_COMPILER to use for single-config generators." ) # CMAKE_BUILD_TYPE or --config <build_type> for multiconfig build. self._register_argument( "--build-type", dest="build_type", type=str, default="Release", choices=["Release", "Debug", "RelWithDebInfo", "MinSizeRel"], help=( "For single-config generators, specifies the CMAKE_BUILD_TYPE to " "configure with. For multi-config generators, ['--config', " "'<build_type>'] is returned for use with `cmake --build`." ) )
[docs] def add_argument(self, *args, **kwargs) -> argparse.Action: """ Add an argument to the parser. .. |add_argument| replace:: :meth:`~python:argparse.ArgumentParser.add_argument` Parameters ---------- *args Positional arguments to pass directly to |add_argument|. **kwargs Keyword arguments to pass directly to |add_argument|. Return ------ argparse.Action The return value of |add_argument| (return value often not needed). Raises ------ ValueError If ``cmake_configure_args`` or ``cmake_build_args`` are in the positional ``*args``. These are reserved attribute names that get populated after parsing the arguments. ValueError If :attr:`add_extra_args` is ``True``, then ``extra_args`` is also reserved and a value error will be raised if it is found in the positional ``*args``. """ if "cmake_configure_args" in args: raise ValueError("'cmake_configure_args' name is reserved.") if "cmake_build_args" in args: raise ValueError("'cmake_build_args' name is reserved.") if self.add_extra_args and "extra_args" in args: raise ValueError( "'extra_args' is reserved. Set `add_extra_args = False` first." ) return super().add_argument(*args, **kwargs)
[docs] def get_argument(self, arg: str) -> Optional[argparse.Action]: """ Get the :class:`~python:argparse.Action` instance for the specified argument. Parameters ---------- arg : str The command-line flag (e.g., ``"-G"``, ``"--shared"``) or the ``dest`` (e.g., ``"generator"``, ``"shared"``) to look for. Return ------ argparse.Action or None The argument action instance (created from :func:`add_argument`). If ``arg`` does not describe a command-line flag or ``dest``, ``None`` is returned. """ # Try and get from flag / dest mappings first. registered = self._get_registered_argument(arg) if registered: return registered # If still not found, search all actions added. for action in self._actions: if arg == action.dest or arg in action.option_strings: return action return None # Not found x0
def _get_registered_argument(self, arg: str) -> Optional[argparse.Action]: # Search based off flags: --shared, -G, etc. if arg in self.flag_map: return self.flag_map[arg] # Search based of dest: shared, generator, etc if arg in self.dest_map: return self.dest_map[arg] return None # Not found x0
[docs] def parse_args(self, args=None, namespace=None): # TODO: type hints??? """ Parse the command-line arguments. Typically, no arguments are needed:: parser = CMakeParser() # ... add your own arguments ... args = parser.parse_args() # uses sys.argv Parameters ---------- args See :meth:`~python:argparse.ArgumentParser.parse_args`. namespace See :meth:`~python:argparse.ArgumentParser.parse_args`. Return ------ argparse.Namespace The parsed command-line arguments in a wrapper struct. Will also have ``cmake_configure_args`` and ``cmake_build_args`` (both will be lists of strings) attributes populated. """ # NOTE: this must be last! That is why it gets done here, since user will be # finished adding their own arguments. if self.add_extra_args: self.add_extra_args = False # only add once self.add_argument( # NOTE: default must be set. See: https://bugs.python.org/issue28609 "extra_args", nargs="*", default=[], help=( "Any extra *configure* arguments to pass to CMake, supplied after " "the `--` sequence. For example, " "`%(prog)s [args] -- -Werror=dev -DSOME_OPTION=ON` will result in " "two extra CMake configure arguments: `-Werror=dev` and " "`-DSOME_OPTION=ON`." ) ) parsed_args = super().parse_args(args=args, namespace=namespace) # NOTE: hasattr then check value pattern is because of the remove method, the # user may have removed the argument so check it exists first. The only one # that is not allowed to be removed is the generator argument. cmake_configure_args = [] cmake_build_args = [] # Initial generator / architecture / toolset setup. cmake_configure_args.extend(["-G", parsed_args.generator]) if hasattr(parsed_args, "architecture") and parsed_args.architecture: cmake_configure_args.extend(["-A", parsed_args.architecture]) if hasattr(parsed_args, "toolset") and parsed_args.toolset: cmake_configure_args.extend(["-T", parsed_args.toolset]) # Setup BUILD_SHARED_LIBS if either --shared or --static requested. if (hasattr(parsed_args, "shared") and parsed_args.shared) or \ (hasattr(parsed_args, "static") and parsed_args.static): cmake_configure_args.append( f"-DBUILD_SHARED_LIBS={'ON' if parsed_args.shared else 'OFF'}") # Setup the default compilers for single config generators. if self.is_single_config_generator(parsed_args.generator): if hasattr(parsed_args, "cc") and parsed_args.cc: cmake_configure_args.append(f"-DCMAKE_C_COMPILER={parsed_args.cc}") if hasattr(parsed_args, "cxx") and parsed_args.cxx: cmake_configure_args.append(f"-DCMAKE_CXX_COMPILER={parsed_args.cxx}") # CMAKE_BUILD_TYPE at configure time for single config generators, and # --config build_type build args for multi config generators. if hasattr(parsed_args, "build_type") and parsed_args.build_type: if self.is_multi_config_generator(parsed_args.generator): cmake_build_args.extend(["--config", parsed_args.build_type]) else: cmake_configure_args.append( f"-DCMAKE_BUILD_TYPE={parsed_args.build_type}") # Add any extra arguments that may be requested after -- sequence. if hasattr(parsed_args, "extra_args"): for arg in parsed_args.extra_args: cmake_configure_args.append(arg) # Make the parsed results available to the user and return. parsed_args.cmake_configure_args = cmake_configure_args parsed_args.cmake_build_args = cmake_build_args return parsed_args
def _register_argument(self, flag: str, *, dest: str, **kwargs): arg = self.add_argument(flag, dest=dest, **kwargs) self.flag_map[flag] = arg self.dest_map[dest] = arg
[docs] def remove(self, *args: str): """ Remove any registered argument(s). This method may be used to remove any arguments not desired. Only arguments that have been created by instantiating a |CMakeParser| can be removed. Example:: parser = CMakeParser() parser.remove("--shared", "--static") # Remove by flags, or parser.remove("shared", "static") # remove by dest. Arguments --------- *args : str Arguments to remove, listed by either flag or dest names. See |CMakeParser| docs for all flags / dest names added. Raises ------ ValueError If ``"-G"`` or ``"generator"`` in ``args``. The generator argument may not be removed. ValueError If ``"extra_args"`` in ``args``. This is to be prevented from being added, see :func:`parse_args`. ValueError If any arguments requested to be removed have not been found. This should only happen if (a) there was a typo, or (b) a user tries to remove an argument that was not registered. """ if "generator" in args or "-G" in args: raise ValueError("'generator' argument may not be removed.") if "extra_args" in args: raise ValueError( "'extra_args' cannot be removed, it must be prevented. Set " "`add_extra_args = False`." ) missing = [] for item in args: # Only support removing options that this parser class added (or more # specifically, were registered). found_arg = self._get_registered_argument(item) if found_arg: if item in self.flag_map: del self.flag_map[item] del self.dest_map[found_arg.dest] else: del self.flag_map[found_arg.option_strings[0]] del self.dest_map[item] # See: https://bugs.python.org/issue19462#msg251739 # Only ok because we only support removing optional arguments, not # positional arguments. self._handle_conflict_resolve( # type: ignore found_arg, [(found_arg.option_strings[0], found_arg)] ) else: missing.append(item) if missing: raise ValueError(f"Cannot remove unregistered arg(s): {missing}")
[docs] def set_argument(self, arg: str, **attrs: Dict[str, Any]): """ Set attributes for ``arg`` argument. Example:: parser = CMakeParser() # Change default generator from Ninja to Unix Makefiles. parser.set_argument("generator", default="Unix Makefiles") # Change default build type from Release to Debug, only allow Release and # Debug builds (only as demonstration...not useful in practice). parser.set_argument("build_type", choices={"Release", "Debug"}, default="Debug") Parameters ---------- arg : str May either be the command-line flag (e.g., ``"--shared"`` or ``"-G"``), or the ``dest`` of the argument (e.g., ``"shared"`` or ``"generator"``). **attrs The attributes to set. Only the following attributes are allowed to be changed via this method: - `default <default_>`_: value returned if not specified on command-line. - `choices <choices_>`_: the list of valid values to validate against. - `required <required_>`_: whether user must specify. - `help <help_>`_: the help string for the argument. - `metavar <metavar_>`_: how the argument is displayed in usage. .. _default: https://docs.python.org/3/library/argparse.html#default .. _choices: https://docs.python.org/3/library/argparse.html#choices .. _required: https://docs.python.org/3/library/argparse.html#required .. _help: https://docs.python.org/3/library/argparse.html#help .. _metavar: https://docs.python.org/3/library/argparse.html#metavar .. note:: Other values such as ``dest`` or ``nargs`` are disallowed from being changed as doing so will break all functionality this class provides. Raises ------ ValueError If the argument described by ``arg`` cannot be found. ValueError The keys of ``**attrs`` are not supported to be changed. E.g., ``dest`` may not be changed. ValueError ``arg in {"-G", "generator"}`` and ``"choices" in attrs``. The generator choices may not be changed (detection of single vs multi config generators will not be reliable). """ supported_keys = {"default", "choices", "required", "help", "metavar"} if not attrs.keys() <= supported_keys: disallowed = attrs.keys() - supported_keys raise ValueError( f"Setting attribute{'' if len(disallowed) == 1 else 's'} " f"{disallowed} not supported.") if arg in {"-G", "generator"} and "choices" in attrs: raise ValueError( "Changing 'generator' attribute 'choices' is not supported." ) arg_obj = self.get_argument(arg) # Nothing to set... if arg_obj is None: raise ValueError(f"Cannot set attrs of '{arg}', argument not found.") # Set all the attributes to what the user requested. for key, val in attrs.items(): setattr(arg_obj, key, val)