Require pybind 2.6 from environment for gmxapi Python package extension module.
[alexxy/gromacs.git] / python_packaging / src / setup.py
1 #
2 # This file is part of the GROMACS molecular simulation package.
3 #
4 # Copyright (c) 2019,2020,2021, by the GROMACS development team, led by
5 # Mark Abraham, David van der Spoel, Berk Hess, and Erik Lindahl,
6 # and including many others, as listed in the AUTHORS file in the
7 # top-level source directory and at http://www.gromacs.org.
8 #
9 # GROMACS is free software; you can redistribute it and/or
10 # modify it under the terms of the GNU Lesser General Public License
11 # as published by the Free Software Foundation; either version 2.1
12 # of the License, or (at your option) any later version.
13 #
14 # GROMACS is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
17 # Lesser General Public License for more details.
18 #
19 # You should have received a copy of the GNU Lesser General Public
20 # License along with GROMACS; if not, see
21 # http://www.gnu.org/licenses, or write to the Free Software Foundation,
22 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA.
23 #
24 # If you want to redistribute modifications to GROMACS, please
25 # consider that scientific software is very special. Version
26 # control is crucial - bugs must be traceable. We will be happy to
27 # consider code for inclusion in the official distribution, but
28 # derived work must not be called official GROMACS. Details are found
29 # in the README & COPYING files - if they are missing, get the
30 # official version at http://www.gromacs.org.
31 #
32 # To help us fund GROMACS development, we humbly ask that you cite
33 # the research papers on the package. Check out http://www.gromacs.org.
34
35 # Note: most of this file is borrowed from
36 # https://github.com/pybind/cmake_example/commit/31bc276d91985c9bb94e2b4ec12f3fd528971f2c
37
38 """Python setuptools script to build and install the gmxapi Python interface.
39
40 The `gmxapi` package requires an existing GROMACS installation, version 2020 or higher.
41 To specify the GROMACS installation to use, provide a GMXTOOLCHAINDIR
42 environment variable when running setup.py or `pip`.
43
44 Example:
45     GMXTOOLCHAINDIR=/path/to/gromacs/share/cmake/gromacs pip install gmxapi
46
47 See https://manual.gromacs.org/current/gmxapi/userguide/install.html for more information.
48 """
49
50
51 import os
52 import re
53 import subprocess
54 import sys
55 import typing
56 import warnings
57
58 from setuptools import setup, Extension
59 from setuptools.command.build_ext import build_ext
60
61 usage = __doc__[2:]
62
63 # gmxapi does not officially support Windows environments because GROMACS does not have automated testing
64 # infrastructure to verify correct functionality. However, we can try to be friendly or prepare for a possible future
65 # in which we can support more platforms.
66 # Convert distutils Windows platform specifiers to CMake -A arguments
67 PLAT_TO_CMAKE = {
68     "win32": "Win32",
69     "win-amd64": "x64",
70     "win-arm32": "ARM",
71     "win-arm64": "ARM64",
72 }
73
74
75 # A CMakeExtension needs a sourcedir instead of a file list.
76 # The name must be the _single_ output extension from the CMake build.
77 class CMakeExtension(Extension):
78     def __init__(self, name, sourcedir=""):
79         Extension.__init__(self, name, sources=[])
80         self.sourcedir = os.path.abspath(sourcedir)
81
82
83 class CMakeBuild(build_ext):
84     """Derived distutils Command for build_extension.
85
86     See https://github.com/pybind/cmake_example for the current version
87     of the sample project from which this is borrowed.
88     """
89     def build_extension(self, ext):
90         import pybind11
91
92         extdir = os.path.abspath(os.path.dirname(self.get_ext_fullpath(ext.name)))
93
94         # required for auto-detection & inclusion of auxiliary "native" libs
95         if not extdir.endswith(os.path.sep):
96             extdir += os.path.sep
97
98         debug = int(os.environ.get("DEBUG", 0)) if self.debug is None else self.debug
99         cfg = "Debug" if debug else "Release"
100
101         # CMake lets you override the generator - we need to check this.
102         # Can be set with Conda-Build, for example.
103         cmake_generator = os.environ.get("CMAKE_GENERATOR", "")
104
105         cmake_args = [
106             "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={}".format(extdir),
107             "-DCMAKE_BUILD_TYPE={}".format(cfg),  # not used on MSVC, but no harm
108         ]
109         build_args = []
110         # Adding CMake arguments set as environment variable
111         # (needed e.g. to build for ARM OSx on conda-forge)
112         if "CMAKE_ARGS" in os.environ:
113             cmake_args += [item for item in os.environ["CMAKE_ARGS"].split(" ") if item]
114
115         if self.compiler.compiler_type != "msvc":
116             # Using Ninja-build since it a) is available as a wheel and b)
117             # multithreads automatically. MSVC would require all variables be
118             # exported for Ninja to pick it up, which is a little tricky to do.
119             # Users can override the generator with CMAKE_GENERATOR in CMake
120             # 3.15+.
121             if not cmake_generator:
122                 try:
123                     import ninja  # noqa: F401
124
125                     cmake_args += ["-GNinja"]
126                 except ImportError:
127                     pass
128
129         else:
130
131             # Single config generators are handled "normally"
132             single_config = any(x in cmake_generator for x in {"NMake", "Ninja"})
133
134             # CMake allows an arch-in-generator style for backward compatibility
135             contains_arch = any(x in cmake_generator for x in {"ARM", "Win64"})
136
137             # Specify the arch if using MSVC generator, but only if it doesn't
138             # contain a backward-compatibility arch spec already in the
139             # generator name.
140             if not single_config and not contains_arch:
141                 cmake_args += ["-A", PLAT_TO_CMAKE[self.plat_name]]
142
143             # Multi-config generators have a different way to specify configs
144             if not single_config:
145                 cmake_args += [
146                     "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{}={}".format(cfg.upper(), extdir)
147                 ]
148                 build_args += ["--config", cfg]
149
150         if sys.platform.startswith("darwin"):
151             # Cross-compile support for macOS - respect ARCHFLAGS if set
152             archs = re.findall(r"-arch (\S+)", os.environ.get("ARCHFLAGS", ""))
153             if archs:
154                 cmake_args += ["-DCMAKE_OSX_ARCHITECTURES={}".format(";".join(archs))]
155
156         # Set CMAKE_BUILD_PARALLEL_LEVEL to control the parallel build level
157         # across all generators.
158         if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ:
159             # self.parallel is a Python 3 only way to set parallel jobs by hand
160             # using -j in the build_ext call, not supported by pip or PyPA-build.
161             if hasattr(self, "parallel") and self.parallel:
162                 # CMake 3.12+ only.
163                 build_args += ["-j{}".format(self.parallel)]
164
165         if not os.path.exists(self.build_temp):
166             os.makedirs(self.build_temp)
167
168         update_gromacs_client_cmake_args(cmake_args)
169
170         has_pybind = False
171         for arg in cmake_args:
172             if arg.upper().startswith('-DPYBIND11_ROOT'):
173                 has_pybind = True
174         if not has_pybind:
175             pybind_root = pybind11.get_cmake_dir()
176             if pybind_root:
177                 cmake_args.append(f'-Dpybind11_ROOT={pybind_root}')
178
179         subprocess.check_call(
180             ["cmake", ext.sourcedir] + cmake_args, cwd=self.build_temp
181         )
182         subprocess.check_call(
183             ["cmake", "--build", "."] + build_args, cwd=self.build_temp
184         )
185
186
187 def _find_first_gromacs_suffix(directory):
188     dir_contents = os.listdir(directory)
189     for entry in dir_contents:
190         if entry.startswith('gromacs'):
191             return entry.strip('gromacs')
192
193
194 def update_gromacs_client_cmake_args(args: typing.List[str]):
195     """Try to convert information from command line environment to usable client CMake stuff.
196
197     This function compartmentalizes details that are likely to evolve with issues
198     https://gitlab.com/gromacs/gromacs/-/issues/3273
199     and
200     https://gitlab.com/gromacs/gromacs/-/issues/3279
201
202     See linked issues for more discussion or to join in the conversation.
203     """
204     has_gmxapi_dir = False
205     gmxapi_DIR = None
206     for arg in args:
207         if arg.upper().startswith('-DGMXAPI_DIR'):
208             gmxapi_DIR = arg.split('=')[1]
209             if gmxapi_DIR:
210                 has_gmxapi_dir = True
211             break
212     if not has_gmxapi_dir:
213         gmxapi_DIR = os.getenv('gmxapi_DIR')
214     if not gmxapi_DIR:
215         # Infer from GMXRC exports, if available.
216         gmxapi_DIR = os.getenv('GROMACS_DIR')
217
218     has_toolchain_file = False
219     gmx_toolchain = None
220     for arg in args:
221         if arg.upper().startswith('-DCMAKE_TOOLCHAIN_FILE'):
222             gmx_toolchain = arg.split('=')[1]
223             if gmx_toolchain:
224                 has_toolchain_file = True
225
226     if has_toolchain_file and has_gmxapi_dir:
227         return
228
229     gmx_toolchain_dir = os.getenv('GMXTOOLCHAINDIR')
230     if gmx_toolchain:
231         if gmx_toolchain_dir:
232             warnings.warn('Overriding GMXTOOLCHAINDIR environment variable because CMAKE_TOOLCHAIN_FILE CMake '
233                           'variable was specified.')
234         gmx_toolchain_dir = os.path.dirname(gmx_toolchain)
235
236     if gmx_toolchain_dir is None:
237         # Try to guess from standard GMXRC environment variables.
238         if gmxapi_DIR is not None:
239             if os.path.exists(gmxapi_DIR) and os.path.isdir(gmxapi_DIR):
240                 share_cmake = os.path.join(gmxapi_DIR, 'share', 'cmake')
241                 suffix = _find_first_gromacs_suffix(share_cmake)
242                 if suffix is not None:
243                     gmx_toolchain_dir = os.path.join(share_cmake, 'gromacs' + suffix)
244
245     if gmx_toolchain_dir is None:
246         print(usage)
247         raise GmxapiInstallError('Could not configure for GROMACS installation. Provide GMXTOOLCHAINDIR.')
248
249     suffix = os.path.basename(gmx_toolchain_dir).strip('gromacs')
250     gmx_toolchain = os.path.abspath(os.path.join(gmx_toolchain_dir, 'gromacs-toolchain' + suffix + '.cmake'))
251
252     if not gmxapi_DIR:
253         # Example: given /usr/local/gromacs/share/cmake/gromacs/gromacs-toolchain.cmake,
254         # we would want /usr/local/gromacs.
255         # Note that we could point more directly to the gmxapi-config.cmake but,
256         # so far, we have relied on CMake automatically looking into
257         # <package>_DIR/share/cmake/<package>/ for such a file.
258         # We would need a slightly different behavior for packages that link against
259         # libgromacs directly, as sample_restraint currently does.
260         gmxapi_DIR = os.path.join(os.path.dirname(gmx_toolchain), '..', '..', '..')
261
262     gmxapi_DIR = os.path.abspath(gmxapi_DIR)
263
264     if not os.path.exists(gmxapi_DIR) or not os.path.isdir(gmxapi_DIR):
265         print(usage)
266         raise GmxapiInstallError('Please set a valid gmxapi_DIR.')
267
268     if gmxapi_DIR != os.path.commonpath([gmxapi_DIR, gmx_toolchain]):
269         raise GmxapiInstallError('GROMACS toolchain file {} is not in gmxapi_DIR {}'.format(
270             gmx_toolchain,
271             gmxapi_DIR
272         ))
273
274     if not has_gmxapi_dir:
275         args.append(f'-Dgmxapi_ROOT={gmxapi_DIR}')
276     if not has_toolchain_file:
277         args.append(f'-DCMAKE_TOOLCHAIN_FILE={gmx_toolchain}')
278
279
280 class GmxapiInstallError(Exception):
281     """Error processing setup.py for gmxapi Python package."""
282
283 setup(
284     ext_modules=[CMakeExtension("gmxapi._gmxapi")],
285     cmdclass={"build_ext": CMakeBuild},
286     zip_safe=False
287 )