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 3.5)\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'
' if("${_SKBUILD_FORCE_MSVC}" STREQUAL "1930")\n'
' math(EXPR FORCE_MAX "${_SKBUILD_FORCE_MSVC}+19")\n'
" else()\n"
' math(EXPR FORCE_MAX "${_SKBUILD_FORCE_MSVC}+9")\n'
" endif()\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