Skip to content

Commit

Permalink
Add custom build backend and update build options in documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
fbriol committed Nov 22, 2024
1 parent d76477a commit b7517e2
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 124 deletions.
70 changes: 70 additions & 0 deletions _custom_build/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Copyright (c) 2024 CNES
#
# All rights reserved. Use of this source code is governed by a
# BSD-style license that can be found in the LICENSE file.
import argparse
import sys

import setuptools.build_meta


def usage(args: dict[str, str | list[str] | None]) -> argparse.Namespace:
"""Parse the command line arguments."""
parser = argparse.ArgumentParser('Custom build backend')
parser.add_argument('--cxx-compiler', help='Preferred C++ compiler')
parser.add_argument('--generator', help='Selected CMake generator')
parser.add_argument('--cmake-args', help='Additional arguments for CMake')
parser.add_argument('--mkl', help='Using MKL as BLAS library')
return parser.parse_args(args=[f"--{k}={v}" for k, v in args.items()])


def decode_bool(value: str | None) -> bool:
"""Decode a boolean value."""
if value is None:
return False
value = value.lower()
return value in {'1', 'true', 'yes'}


class _CustomBuildMetaBackend(setuptools.build_meta._BuildMetaBackend):
"""Custom build backend.
This class is used to pass the option from pip to the setup.py script.
Reference: https://setuptools.pypa.io/en/latest/build_meta.html
"""

def run_setup(self, setup_script='setup.py'):
"""Run the setup script."""
args = usage(self.config_settings or {}) # type: ignore[arg-type]
setuptools_args = []
if args.cxx_compiler:
setuptools_args.append(f"--cxx-compiler={args.cxx_compiler}")
if args.generator:
setuptools_args.append(f"--generator={args.generator}")
if args.cmake_args:
setuptools_args.append(f"--cmake-args={args.cmake_args}")
if decode_bool(args.mkl):
setuptools_args.append('--mkl=yes')

if setuptools_args:
sys.argv = (sys.argv[:1] + ['build_ext'] + setuptools_args +
sys.argv[1:])
return super().run_setup(setup_script)

def build_wheel(
self,
wheel_directory,
config_settings=None,
metadata_directory=None,
):
"""Build the wheel."""
self.config_settings = config_settings
return super().build_wheel(
wheel_directory,
config_settings,
metadata_directory,
)


build_wheel = _CustomBuildMetaBackend().build_wheel
23 changes: 17 additions & 6 deletions docs/source/setup.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,23 @@ type the command ``python3 setup.py build`` at the root of the project.
You can specify, among other things, the following options to `build-ext`
command to customize the build:

* ``--boost-root`` to specify the Boost include directory,
* ``--cxx-compiler`` to select the C++ compiler to use,
* ``--debug`` to compile the C++ library in Debug mode,
* ``--eigen-root`` to specify the Eigen3 include directory,
* ``--generator`` to specify the generator used by CMake,
* ``--mkl-root`` to specify the MKL root.
.. list-table:: Build options
:header-rows: 1

* - Option
- Description
* - ``--cmake-args``
- Additional arguments for CMake
* - ``--cxx-compiler``
- Preferred C++ compiler
* - ``--generator``
- Selected CMake generator
* - ``--mkl``
- Use MKL as the BLAS library. The MKL library is searched in the
Python prefix path. Alternatively, you can set the environment variable
``MKLROOT`` to the MKL library path to help the build system locate it.
* - ``--reconfigure``
- Forces CMake to reconfigure this project

Run the ``python setup.py build-ext --help`` command to view all the options
available for building the library.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
[build-system]
requires = ["cmake", "setuptools>=45", "packaging>=20.0", "typing_extensions"]
build-backend = "backend"
backend-path = ["_custom_build"]

[tool.setuptools_scm]
write_to = "src/python/pyfes/version.py"
Expand Down
166 changes: 48 additions & 118 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# All rights reserved. Use of this source code is governed by a
# BSD-style license that can be found in the LICENSE file.
# Working directory
from typing import Any, List, Optional, Tuple
from typing import List, Tuple
import os
import pathlib
import platform
Expand Down Expand Up @@ -117,140 +117,77 @@ def __init__(self, name):
class BuildExt(setuptools.command.build_ext.build_ext):
"""Build everything needed to install."""
user_options = setuptools.command.build_ext.build_ext.user_options
user_options += [
('boost-root=', None, 'Preferred Boost installation prefix'),
('conda-forge', None, 'Generation of the conda-forge package'),
('cxx-compiler=', None, 'Preferred C++ compiler'),
('eigen-root=', None, 'Preferred Eigen3 include directory'),
('generator=', None, 'Selected CMake generator'),
('mkl-root=', None, 'Preferred MKL installation prefix'),
('mkl=', None, 'Using MKL as BLAS library'),
('reconfigure', None, 'Forces CMake to reconfigure this project')
]
user_options += [('cmake-args=', None, 'Additional arguments for CMake'),
('cxx-compiler=', None, 'Preferred C++ compiler'),
('generator=', None, 'Selected CMake generator'),
('mkl=', None, 'Using MKL as BLAS library'),
('reconfigure', None,
'Forces CMake to reconfigure this project')]

boolean_options = setuptools.command.build_ext.build_ext.boolean_options
boolean_options += ['mkl', 'conda-forge']
boolean_options += ['mkl']

def initialize_options(self) -> None:
"""Set default values for all the options that this command
supports."""
super().initialize_options()
self.boost_root = None
self.conda_forge = None
self.cmake_args = None
self.cxx_compiler = None
self.eigen_root = None
self.generator = None
self.mkl = None
self.mkl_root = None
self.reconfigure = None

def finalize_options(self) -> None:
"""Set final values for all the options that this command supports."""
super().finalize_options()
if self.mkl_root is not None:
self.mkl = True
if not self.mkl and self.mkl_root:
raise RuntimeError(
'argument --mkl_root not allowed with argument --mkl=no')

def run(self) -> None:
"""Carry out the action."""
for ext in self.extensions:
self.build_cmake(ext)
super().run()

def boost(self) -> Optional[List[str]]:
"""Return the Boost installation prefix."""
# Do not search system for Boost & disable the search for boost-cmake
boost_option = '-DBoost_NO_SYSTEM_PATHS=TRUE ' \
'-DBoost_NO_BOOST_CMAKE=TRUE'
boost_root = pathlib.Path(sys.prefix)
if (boost_root / 'include' / 'boost').exists():
return f'{boost_option} -DBOOSTROOT={boost_root}'.split()
boost_root = pathlib.Path(sys.prefix, 'Library', 'include')
if not boost_root.exists():
if self.conda_forge:
raise RuntimeError(
'Unable to find the Boost library in the conda '
'distribution used.')
return None
return f'{boost_option} -DBoost_INCLUDE_DIR={boost_root}'.split()

def eigen(self) -> Optional[str]:
"""Return the Eigen3 installation prefix."""
eigen_include_dir = pathlib.Path(sys.prefix, 'include', 'eigen3')
if eigen_include_dir.exists():
return f'-DEIGEN3_INCLUDE_DIR={eigen_include_dir}'
eigen_include_dir = pathlib.Path(sys.prefix, 'Library', 'include',
'eigen3')
if not eigen_include_dir.exists():
eigen_include_dir = eigen_include_dir.parent
if not eigen_include_dir.exists():
if self.conda_forge:
raise RuntimeError(
'Unable to find the Eigen3 library in the conda '
'distribution used.')
return None
return f'-DEIGEN3_INCLUDE_DIR={eigen_include_dir}'

@staticmethod
def set_conda_mklroot() -> None:
"""Set the MKLROOT environment variable."""
def set_mklroot() -> None:
"""Set the MKLROOT environment variable if the MKL header is found."""
mkl_header = pathlib.Path(sys.prefix, 'include', 'mkl.h')
if not mkl_header.exists():
mkl_header = pathlib.Path(sys.prefix, 'Library', 'include',
'mkl.h')

if mkl_header.exists():
os.environ['MKLROOT'] = sys.prefix
return

@staticmethod
def is_conda() -> bool:
"""Return True if the current Python distribution is conda."""
result = pathlib.Path(sys.prefix, 'conda-meta').exists()
if not result:
try:
# pylint: disable=unused-import,import-outside-toplevel
import conda # noqa: F401

# pylint: enable=unused-import,import-outside-toplevel
except ImportError:
result = False
else:
result = True
return result

def set_cmake_user_options(self) -> List[str]:
"""Set the CMake user options."""
cmake_variable: Any

is_conda = self.is_conda()
def conda_prefix() -> str | None:
"""Returns the conda prefix."""
if 'CONDA_PREFIX' in os.environ:
return os.environ['CONDA_PREFIX']
return None

def set_cmake_user_options(self) -> list[str]:
"""Sets the options defined by the user."""
result = []

conda_prefix = self.conda_prefix()

if self.cxx_compiler is not None:
result.append('-DCMAKE_CXX_COMPILER=' + self.cxx_compiler)

if self.conda_forge:
result.append('-DCONDA_FORGE=ON')

if self.boost_root is not None:
result.append('-DBOOSTROOT=' + self.boost_root)
elif is_conda:
cmake_variable = self.boost()
if cmake_variable:
result += cmake_variable
if conda_prefix is not None:
result.append('-DCMAKE_PREFIX_PATH=' + conda_prefix)

if self.eigen_root is not None:
result.append('-DEIGEN3_INCLUDE_DIR=' + self.eigen_root)
elif is_conda:
cmake_variable = self.eigen()
if cmake_variable:
result.append(cmake_variable)

if self.mkl_root is not None:
os.environ['MKLROOT'] = self.mkl_root
elif is_conda and self.mkl:
self.set_conda_mklroot()
if self.mkl:
self.set_mklroot()

return result

def cmake_arguments(self, cfg: str, extdir: str) -> list[str]:
"""Returns the cmake arguments."""
cmake_args: list[str] = [
'-DCMAKE_BUILD_TYPE=' + cfg,
'-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=' + extdir,
'-DPython3_EXECUTABLE=' + sys.executable,
*self.set_cmake_user_options()
]
return cmake_args

def build_cmake(self, ext) -> None:
"""Execute cmake to build the Python extension."""
# These dirs will be created in build_py, so if you don't have
Expand All @@ -262,23 +199,16 @@ def build_cmake(self, ext) -> None:

cfg = 'Debug' if self.debug else 'Release'

cmake_args = [
'-DCMAKE_BUILD_TYPE=' + cfg, '-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=' +
str(extdir), '-DPython3_EXECUTABLE=' + sys.executable,
'-DFES_BUILD_PYTHON_BINDINGS=ON'
] + self.set_cmake_user_options()

cmake_args = self.cmake_arguments(cfg, extdir)
build_args = ['--config', cfg]

is_windows = platform.system() == 'Windows'

if self.generator is not None:
cmake_args.append('-G' + self.generator)
elif is_windows:
cmake_args.append('-G' + 'Visual Studio 17 2022')

if self.verbose: # type: ignore
build_args += ['--verbose']
cmake_args.append(
'-G' + os.environ.get('CMAKE_GEN', 'Visual Studio 17 2022'))

if not is_windows:
build_args += ['--', f'-j{os.cpu_count()}']
Expand All @@ -293,15 +223,15 @@ def build_cmake(self, ext) -> None:
]
build_args += ['--', '/m']

if self.cmake_args:
cmake_args.extend(self.cmake_args.split())

os.chdir(str(build_temp))

# Has CMake ever been executed?
if pathlib.Path(build_temp, 'CMakeFiles',
'TargetDirectories.txt').exists():
# The user must force the reconfiguration
configure = self.reconfigure is not None
else:
configure = True
configure = (self.reconfigure is not None) if pathlib.Path(
build_temp, 'CMakeFiles',
'TargetDirectories.txt').exists() else True

if configure:
self.spawn(['cmake', str(WORKING_DIRECTORY)] + cmake_args)
Expand Down

0 comments on commit b7517e2

Please sign in to comment.