Source code for skbuild.platform_specifics.abstract

"""This module defines objects useful to discover which CMake generator is
supported on the current platform."""


from __future__ import annotations

import os
import shutil
import subprocess
import textwrap
from typing import Iterable, Mapping

from ..constants import CMAKE_DEFAULT_EXECUTABLE
from ..exceptions import SKBuildGeneratorNotFoundError
from ..utils import push_dir

test_folder = "_cmake_test_compile"


[docs] class CMakePlatform: """This class encapsulates the logic allowing to get the identifier of a working CMake generator. Derived class should at least set :attr:`default_generators`. """ def __init__(self) -> None: # default_generators is a property for mocking in tests self._default_generators: list[CMakeGenerator] = [] self.architecture: str | None = None @property def default_generators(self) -> list[CMakeGenerator]: """List of generators considered by :func:`get_best_generator()`.""" return self._default_generators @default_generators.setter def default_generators(self, generators: list[CMakeGenerator]) -> None: self._default_generators = generators @property def generator_installation_help(self) -> str: """Return message guiding the user for installing a valid toolchain.""" raise NotImplementedError() # pragma: no cover
[docs] @staticmethod def write_test_cmakelist(languages: Iterable[str]) -> None: """Write a minimal ``CMakeLists.txt`` useful to check if the requested ``languages`` are supported.""" if not os.path.exists(test_folder): os.makedirs(test_folder) with open(f"{test_folder}/CMakeLists.txt", "w", encoding="utf-8") as f: f.write("cmake_minimum_required(VERSION 2.8.12)\n") f.write("PROJECT(compiler_test NONE)\n") for language in languages: f.write(f"ENABLE_LANGUAGE({language:s})\n") f.write( 'if("${_SKBUILD_FORCE_MSVC}")\n' ' math(EXPR FORCE_MAX "${_SKBUILD_FORCE_MSVC}+9")\n' ' math(EXPR FORCE_MIN "${_SKBUILD_FORCE_MSVC}")\n' " if(NOT MSVC)\n" ' message(FATAL_ERROR "MSVC is required to pass this check.")\n' " elseif(MSVC_VERSION LESS FORCE_MIN OR MSVC_VERSION GREATER FORCE_MAX)\n" ' message(FATAL_ERROR "MSVC ${MSVC_VERSION} does pass this check.")\n' " endif()\n" "endif()\n" )
[docs] @staticmethod def cleanup_test() -> None: """Delete test project directory.""" if os.path.exists(test_folder): shutil.rmtree(test_folder)
[docs] def get_generator(self, generator_name: str) -> CMakeGenerator: """Loop over generators and return the first that matches the given name. """ for default_generator in self.default_generators: if default_generator.name == generator_name: return default_generator return CMakeGenerator(generator_name)
[docs] def get_generators(self, generator_name: str) -> list[CMakeGenerator]: """Loop over generators and return all that match the given name.""" return [ default_generator for default_generator in self.default_generators if default_generator.name == generator_name ]
# TODO: this method name is not great. Does anyone have a better idea for # renaming it?
[docs] def get_best_generator( self, generator_name: str | None = None, skip_generator_test: bool = False, languages: Iterable[str] = ("CXX", "C"), cleanup: bool = True, cmake_executable: str = CMAKE_DEFAULT_EXECUTABLE, cmake_args: Iterable[str] = (), architecture: str | None = None, ) -> CMakeGenerator: """Loop over generators to find one that works by configuring and compiling a test project. :param generator_name: If provided, uses only provided generator, \ instead of trying :attr:`default_generators`. :type generator_name: str | None :param skip_generator_test: If set to True and if a generator name is \ specified, the generator test is skipped. If no generator_name is specified \ and the option is set to True, the first available generator is used. :type skip_generator_test: bool :param languages: The languages you'll need for your project, in terms \ that CMake recognizes. :type languages: tuple :param cleanup: If True, cleans up temporary folder used to test \ generators. Set to False for debugging to see CMake's output files. :type cleanup: bool :param cmake_executable: Path to CMake executable used to configure \ and build the test project used to evaluate if a generator is working. :type cmake_executable: str :param cmake_args: List of CMake arguments to use when configuring \ the test project. Only arguments starting with ``-DCMAKE_`` are \ used. :type cmake_args: tuple :return: CMake Generator object :rtype: :class:`CMakeGenerator` or None :raises skbuild.exceptions.SKBuildGeneratorNotFoundError: """ candidate_generators: list[CMakeGenerator] = [] if generator_name is None: candidate_generators = self.default_generators else: # Lookup CMakeGenerator by name. Doing this allow to get a # generator object with its ``env`` property appropriately # initialized. # MSVC should be used in "-A arch" form if architecture is not None: self.architecture = architecture # Support classic names for generators generator_name, self.architecture = _parse_legacy_generator_name(generator_name, self.architecture) candidate_generators = [] for default_generator in self.default_generators: if default_generator.name == generator_name: candidate_generators.append(default_generator) if not candidate_generators: candidate_generators = [CMakeGenerator(generator_name)] self.write_test_cmakelist(languages) working_generator: CMakeGenerator | None if skip_generator_test: working_generator = candidate_generators[0] else: working_generator = self.compile_test_cmakelist(cmake_executable, candidate_generators, cmake_args) if working_generator is None: line = "*" * 80 installation_help = self.generator_installation_help msg = textwrap.dedent( f"""\ {line} scikit-build could not get a working generator for your system. Aborting build. {installation_help} {line}""" ) raise SKBuildGeneratorNotFoundError(msg) if cleanup: CMakePlatform.cleanup_test() return working_generator
[docs] @staticmethod @push_dir(directory=test_folder) def compile_test_cmakelist( cmake_exe_path: str, candidate_generators: Iterable[CMakeGenerator], cmake_args: Iterable[str] = () ) -> CMakeGenerator | None: """Attempt to configure the test project with each :class:`CMakeGenerator` from ``candidate_generators``. Only cmake arguments starting with ``-DCMAKE_`` are used to configure the test project. The function returns the first generator allowing to successfully configure the test project using ``cmake_exe_path``.""" # working generator is the first generator we find that works. working_generator = None # Include only -DCMAKE_* arguments cmake_args = [arg for arg in cmake_args if arg.startswith("-DCMAKE_")] # Do not complain about unused CMake arguments cmake_args.insert(0, "--no-warn-unused-cli") def _generator_discovery_status_msg(_generator: CMakeGenerator, suffix: str = "") -> None: outer = "-" * 80 inner = ["-" * ((idx * 5) - 3) for idx in range(1, 8)] print("\n".join(inner) if suffix else outer) print(f"-- Trying {_generator.description!r} generator{suffix}") print(outer if suffix else "\n".join(inner[::-1]), flush=True) for generator in candidate_generators: print("\n", flush=True) _generator_discovery_status_msg(generator) # clear the cache for each attempted generator type if os.path.isdir("build"): shutil.rmtree("build") with push_dir("build", make_directory=True): # call cmake to see if the compiler specified by this # generator works for the specified languages cmd = [cmake_exe_path, "../", "-G", generator.name] if generator.toolset: cmd.extend(["-T", generator.toolset]) if generator.architecture and "Visual Studio" in generator.name: cmd.extend(["-A", generator.architecture]) cmd.extend(cmake_args) cmd.extend(generator.args) status = subprocess.run(cmd, env=generator.env, check=False).returncode msg = "success" if status == 0 else "failure" _generator_discovery_status_msg(generator, f" - {msg}") print(flush=True) # cmake succeeded, this generator should work if status == 0: # we have a working generator, don't bother looking for more working_generator = generator break return working_generator
[docs] class CMakeGenerator: """Represents a CMake generator. .. automethod:: __init__ """
[docs] def __init__( self, name: str, env: Mapping[str, str] | None = None, toolset: str | None = None, arch: str | None = None, args: Iterable[str] | None = None, ) -> None: """Instantiate a generator object with the given ``name``. By default, ``os.environ`` is associated with the generator. Dictionary passed as ``env`` parameter will be merged with ``os.environ``. If an environment variable is set in both ``os.environ`` and ``env``, the variable in ``env`` is used. Some CMake generators support a ``toolset`` specification to tell the native build system how to choose a compiler. You can also include CMake arguments. """ self._generator_name = name self.args = list(args or []) self.env = dict(list(os.environ.items()) + list(env.items() if env else [])) self._generator_toolset = toolset self._generator_architecture = arch description_arch = name if arch is None else f"{name} {arch}" if toolset is None: self._description = description_arch else: self._description = f"{description_arch} {toolset}"
@property def name(self) -> str: """Name of CMake generator.""" return self._generator_name @property def toolset(self) -> str | None: """Toolset specification associated with the CMake generator.""" return self._generator_toolset @property def architecture(self) -> str | None: """Architecture associated with the CMake generator.""" return self._generator_architecture @property def description(self) -> str: """Name of CMake generator with properties describing the environment (e.g toolset)""" return self._description
def _parse_legacy_generator_name(generator_name: str, arch: str | None) -> tuple[str, str | None]: """ Support classic names for MSVC generators. Architecture is stripped from the name and "arch" is replaced with the arch string if a legacy name is given. """ if generator_name.startswith("Visual Studio"): if generator_name.endswith(" Win64"): arch = "x64" generator_name = generator_name[:-6] elif generator_name.endswith(" ARM"): arch = "ARM" generator_name = generator_name[:-4] return generator_name, arch