Source code for skbuild.platform_specifics.abstract
"""This module defines objects useful to discover which CMake generator is
supported on the current platform."""
import os
import shutil
import subprocess
import textwrap
from typing import Iterable, List, Mapping, Optional, Tuple
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: Optional[str] = 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: Optional[str] = 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: Optional[str] = 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: string or 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: string
: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: Optional["CMakeGenerator"]
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:
raise SKBuildGeneratorNotFoundError(
textwrap.dedent(
"""
{line}
scikit-build could not get a working generator for your system. Aborting build.
{installation_help}
{line}
"""
)
.strip()
.format(line="*" * 80, installation_help=self.generator_installation_help) # noqa: E501
)
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] = ()
) -> Optional["CMakeGenerator"]:
"""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(outer if suffix == "" else "\n".join(inner))
print(f'-- Trying "{_generator.description}" 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:
cmd.extend(["-A", generator.architecture])
cmd.extend(cmake_args)
cmd.extend(generator.args)
status = subprocess.call(cmd, env=generator.env)
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: Optional[Mapping[str, str]] = None,
toolset: Optional[str] = None,
arch: Optional[str] = None,
args: Optional[Iterable[str]] = 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
if arch is None:
description_arch = name
else:
description_arch = 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) -> Optional[str]:
"""Toolset specification associated with the CMake generator."""
return self._generator_toolset
@property
def architecture(self) -> Optional[str]:
"""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: Optional[str]) -> Tuple[str, Optional[str]]:
"""
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