Files
pymol-open-source/setup.py
2026-02-20 16:46:25 -05:00

889 lines
24 KiB
Python

#!/usr/bin/env python
#
# This script only applies if you are performing a Python setuptools-based
# installation of PyMOL.
#
# It may assume that all of PyMOL's external dependencies are
# pre-installed into the system.
import argparse
import glob
import io as cStringIO
import os
import pathlib
import re
import shutil
import sys
import sysconfig
import time
from collections import defaultdict
from subprocess import PIPE, Popen
import numpy
from setuptools import Extension, setup
from setuptools.command.build_ext import build_ext
from setuptools.command.build_py import build_py
from setuptools.command.install import install
# non-empty DEBUG variable turns off optimization and adds -g flag
DEBUG = bool(os.getenv("DEBUG", ""))
WIN = sys.platform.startswith("win")
MAC = sys.platform.startswith("darwin")
# check for mingw compiler on windows
is_mingw = False
if WIN:
from setuptools._distutils import ccompiler
is_mingw = ccompiler.get_default_compiler() == 'mingw32'
# Have to copy from "create_shadertext.py" script due to the use of pyproject.toml
# Full explanation:
# https://github.com/pypa/setuptools/issues/3939
def create_all(generated_dir, pymoldir="."):
"""
Generate various stuff
"""
create_shadertext(
os.path.join(pymoldir, "data", "shaders"),
generated_dir,
os.path.join(generated_dir, "ShaderText.h"),
os.path.join(generated_dir, "ShaderText.cpp"),
)
create_buildinfo(generated_dir, pymoldir)
class openw(object):
"""
File-like object for writing files. File is actually only
written if the content changed.
"""
def __init__(self, filename):
if os.path.exists(filename):
self.out = cStringIO.StringIO()
self.filename = filename
else:
os.makedirs(os.path.dirname(filename), exist_ok=True)
self.out = open(filename, "w")
self.filename = None
def close(self):
if self.out.closed:
return
if self.filename:
with open(self.filename) as handle:
oldcontents = handle.read()
newcontents = self.out.getvalue()
if oldcontents != newcontents:
self.out = open(self.filename, "w")
self.out.write(newcontents)
self.out.close()
def __getattr__(self, name):
return getattr(self.out, name)
def __enter__(self):
return self
def __exit__(self, *a, **k):
self.close()
def __del__(self):
self.close()
def create_shadertext(shaderdir, shaderdir2, outputheader, outputfile):
outputheader = openw(outputheader)
outputfile = openw(outputfile)
include_deps = defaultdict(set)
ifdef_deps = defaultdict(set)
# get all *.gs *.vs *.fs *.shared from the two input directories
shaderfiles = set()
for sdir in [shaderdir, shaderdir2]:
for ext in ["gs", "vs", "fs", "shared", "tsc", "tse"]:
shaderfiles.update(
map(os.path.basename, sorted(glob.glob(os.path.join(sdir, "*." + ext))))
)
varname = "_shader_cache_raw"
outputheader.write("extern const char * %s[];\n" % varname)
outputfile.write("const char * %s[] = {\n" % varname)
for filename in sorted(shaderfiles):
shaderfile = os.path.join(shaderdir, filename)
if not os.path.exists(shaderfile):
shaderfile = os.path.join(shaderdir2, filename)
with open(shaderfile, "r") as handle:
contents = handle.read()
if True:
outputfile.write('"%s", ""\n' % (filename))
for line in contents.splitlines():
line = line.strip()
# skip blank lines and obvious comments
if not line or line.startswith("//") and not "*/" in line:
continue
# write line, quoted, escaped and with a line feed
outputfile.write(
'"%s\\n"\n' % line.replace("\\", "\\\\").replace('"', r"\"")
)
# include and ifdef dependencies
if line.startswith("#include"):
include_deps[line.split()[1]].add(filename)
elif line.startswith("#ifdef") or line.startswith("#ifndef"):
ifdef_deps[line.split()[1]].add(filename)
outputfile.write(",\n")
outputfile.write("0};\n")
# include and ifdef dependencies
for varname, deps in [("_include_deps", include_deps), ("_ifdef_deps", ifdef_deps)]:
outputheader.write("extern const char * %s[];\n" % varname)
outputfile.write("const char * %s[] = {\n" % varname)
for name, itemdeps in deps.items():
outputfile.write('"%s", "%s", 0,\n' % (name, '", "'.join(sorted(itemdeps))))
outputfile.write("0};\n")
outputheader.close()
outputfile.close()
def create_buildinfo(outputdir, pymoldir="."):
try:
sha = (
Popen(["git", "rev-parse", "HEAD"], cwd=pymoldir, stdout=PIPE)
.stdout.read()
.strip()
.decode()
)
except OSError:
sha = ""
with openw(os.path.join(outputdir, "PyMOLBuildInfo.h")) as out:
print(
"""
#define _PyMOL_BUILD_DATE %d
#define _PYMOL_BUILD_GIT_SHA "%s"
"""
% (int(os.environ.get('SOURCE_DATE_EPOCH', time.time())), sha),
file=out,
)
# handle extra arguments
def str2bool(v: str) -> bool:
if v.lower() in ("true", "yes"):
return True
elif v.lower() in ("false", "no"):
return False
else:
raise argparse.ArgumentTypeError("Boolean value expected.")
class options:
osx_frameworks = True
jobs = int(os.getenv("JOBS", 0))
libxml = True
glut = False
use_msgpackc = "guess"
testing = False
openvr = False
use_openmp = "no" if MAC else "yes"
use_vtkm = "no"
vmd_plugins = True
parser = argparse.ArgumentParser()
parser.add_argument(
"--glut", dest="glut", type=str2bool, help="link with GLUT (legacy GUI)"
)
parser.add_argument(
"--osx-frameworks",
dest="osx_frameworks",
help="on MacOS use XQuartz instead of native frameworks",
type=str2bool,
)
parser.add_argument(
"--jobs",
"-j",
type=int,
help="for parallel builds " "(defaults to number of processors)",
)
parser.add_argument(
"--libxml",
type=str2bool,
help="skip libxml2 dependency, disables COLLADA export",
)
parser.add_argument("--use-openmp", type=str2bool, help="Use OpenMP")
parser.add_argument(
"--use-vtkm",
choices=("2.0", "2.1", "2.2", "2.3", "no"),
help="Use VTK-m for isosurface generation",
)
parser.add_argument(
"--use-msgpackc",
choices=("c++11", "c", "guess", "no"),
help="c++11: use msgpack-c header-only library; c: link against "
"shared library; no: disable fast MMTF load support",
)
parser.add_argument("--testing", type=str2bool, help="Build C-level tests")
parser.add_argument("--openvr", dest="openvr", type=str2bool)
parser.add_argument(
"--vmd-plugins",
dest="vmd_plugins",
type=str2bool,
help="Disable VMD molfile plugins (libnetcdf dependency)",
)
options, sys.argv[1:] = parser.parse_known_args(namespace=options)
def get_prefix_path() -> list[str]:
"""
Return a list of paths which will be searched for "include",
"include/freetype2", "lib", "lib64" etc.
"""
paths = []
if (prefix_path := os.environ.get("PREFIX_PATH")) is not None:
paths += prefix_path.split(os.pathsep)
if sys.platform.startswith("freebsd"):
paths += ["/usr/local"]
if not options.osx_frameworks:
paths += ["usr/X11"]
if MAC:
for prefix in ["/sw", "/opt/local", "/usr/local"]:
if sys.base_prefix.startswith(prefix):
paths += [prefix]
if is_conda_env():
if WIN:
if "CONDA_PREFIX" in os.environ:
paths += [os.path.join(os.environ["CONDA_PREFIX"], "Library")]
paths += [os.path.join(sys.prefix, "Library")]
paths += [sys.prefix] + paths
if WIN and is_mingw:
if os.path.isdir(sys.prefix):
paths.append(os.path.normpath(sys.prefix))
paths += ["/usr"]
return paths
def is_conda_env():
return (
"conda" in sys.prefix
or "conda" in sys.version
or "Continuum" in sys.version
or sys.prefix == os.getenv("CONDA_PREFIX")
)
def guess_msgpackc():
for prefix in prefix_path:
for suffix in ["h", "hpp"]:
f = os.path.join(prefix, "include", "msgpack", f"version_master.{suffix}")
try:
m = re.search(r"MSGPACK_VERSION_MAJOR\s+(\d+)", open(f).read())
except EnvironmentError:
continue
if m is not None:
major = int(m.group(1))
if major > 1:
return "c++11"
return "no"
class CMakeExtension(Extension):
def __init__(
self,
name,
sources,
include_dirs=[],
libraries=[],
library_dirs=[],
define_macros=[],
extra_link_args=[],
extra_compile_args=[],
):
# don't invoke the original build_ext for this special extension
super().__init__(name, sources=[])
self.sources = sources
self.include_dirs = include_dirs
self.libraries = libraries
self.library_dirs = library_dirs
self.define_macros = define_macros
self.extra_link_args = extra_link_args
self.extra_compile_args = extra_compile_args
class build_ext_pymol(build_ext):
def initialize_options(self) -> None:
super().initialize_options()
if DEBUG and not WIN:
self.debug = False
def run(self):
for ext in self.extensions:
self.build_cmake(ext)
def build_cmake(self, ext):
cwd = pathlib.Path().absolute()
# these dirs will be created in build_py, so if you don't have
# any python sources to bundle, the dirs will be missing
name_split = ext.name.split(".")
target_name = name_split[-1]
build_temp = pathlib.Path(self.build_temp) / target_name
build_temp.mkdir(parents=True, exist_ok=True)
extdir = pathlib.Path(self.get_ext_fullpath(ext.name))
extdirabs = extdir.absolute()
extdir.parent.mkdir(parents=True, exist_ok=True)
def concat_paths(paths):
return "".join(path.replace("\\", "/") + ";" for path in paths)
config = "Debug" if DEBUG else "Release"
lib_output_dir = str(extdir.parent.absolute())
all_files = ext.sources
all_src = concat_paths(all_files)
all_defs = "".join(mac[0] + ";" for mac in ext.define_macros)
all_libs = "".join(f"{lib};" for lib in ext.libraries)
all_ext_link = " ".join(ext.extra_link_args)
all_comp_args = "".join(f"{arg};" for arg in ext.extra_compile_args)
all_lib_dirs = concat_paths(ext.library_dirs)
all_inc_dirs = concat_paths(ext.include_dirs)
lib_mode = "RUNTIME" if WIN else "LIBRARY"
shared_suffix = sysconfig.get_config_var("EXT_SUFFIX")
cmake_args = [
f"-DTARGET_NAME={target_name}",
f"-DCMAKE_{lib_mode}_OUTPUT_DIRECTORY={lib_output_dir}",
f"-DCMAKE_BUILD_TYPE={config}",
f"-DALL_INC_DIR={all_inc_dirs}",
f"-DALL_SRC={all_src}",
f"-DALL_DEF={all_defs}",
f"-DALL_LIB_DIR={all_lib_dirs}",
f"-DALL_LIB={all_libs}",
f"-DALL_COMP_ARGS={all_comp_args}",
f"-DALL_EXT_LINK={all_ext_link}",
f"-DSHARED_SUFFIX={shared_suffix}",
]
# example of build args
build_args = ["--config", config]
if not WIN: # Win /MP flag on compilation level
cpu_count = os.cpu_count() or 1
build_args += [f"-j{cpu_count}"]
os.chdir(str(build_temp))
self.spawn(["cmake", str(cwd)] + cmake_args)
if not self.dry_run:
self.spawn(["cmake", "--build", "."] + build_args)
if WIN:
# Move up from VS release folder
cmake_lib_loc = pathlib.Path(
lib_output_dir, "Release", f"{target_name}{shared_suffix}"
)
if cmake_lib_loc.exists():
shutil.move(cmake_lib_loc, extdirabs)
# Troubleshooting: if fail on line above then delete all possible
# temporary CMake files including "CMakeCache.txt" in top level dir.
os.chdir(str(cwd))
class build_py_pymol(build_py):
def run(self):
build_py.run(self)
class install_pymol(install):
pymol_path = None
bundled_pmw = False
no_launcher = False
user_options = install.user_options + [
("pymol-path=", None, "PYMOL_PATH"),
("bundled-pmw", None, "install bundled Pmw module"),
("no-launcher", None, "skip installation of the pymol launcher"),
]
def finalize_options(self):
install.finalize_options(self)
self.pymol_path_is_default = self.pymol_path is None
if self.pymol_path is None:
self.pymol_path = os.path.join(self.install_libbase, "pymol", "pymol_path")
elif self.root is not None:
self.pymol_path = install_pymol.change_root(self.root, self.pymol_path)
def run(self):
super().run()
assert self.pymol_path is not None
self.install_pymol_path(self.pymol_path)
if not self.no_launcher:
self.make_launch_script()
if self.bundled_pmw:
raise Exception(
"--bundled-pmw has been removed, please install Pmw from "
"https://github.com/schrodinger/pmw-patched"
)
def unchroot(self, name):
if self.root is not None and name.startswith(self.root):
return name[len(self.root) :]
return name
def copy_tree_nosvn(self, src, dst):
def ignore(src, names):
return set([]).intersection(names)
if os.path.exists(dst):
shutil.rmtree(dst)
print("copying %s -> %s" % (src, dst))
shutil.copytree(src, dst, ignore=ignore)
def copy(self, src, dst):
copy = self.copy_tree_nosvn if os.path.isdir(src) else self.copy_file
copy(src, dst)
def install_pymol_path(self, base_path):
self.mkpath(base_path)
for name in [
"LICENSE",
"data",
"test",
"examples",
]:
self.copy(name, os.path.join(base_path, name))
if options.openvr:
self.copy(
"contrib/vr/README.md", os.path.join(base_path, "README-VR.txt")
)
def make_launch_script(self):
if sys.platform.startswith("win"):
launch_script = "pymol.bat"
else:
launch_script = "pymol"
self.mkpath(self.install_scripts)
launch_script = os.path.join(self.install_scripts, launch_script)
python_exe = os.path.abspath(sys.executable)
site_packages_dir = sysconfig.get_path('purelib')
pymol_file = self.unchroot(
os.path.join(site_packages_dir, "pymol", "__init__.py")
)
pymol_path = self.unchroot(self.pymol_path)
with open(launch_script, "w") as out:
if WIN:
if not self.pymol_path_is_default:
out.write(f"set PYMOL_PATH={pymol_path}" + os.linesep)
if is_mingw:
python_exe = "%~dp0\\python.exe"
out.write('"%s" -m pymol' % python_exe)
else:
# paths relative to launcher, if possible
try:
python_exe = "%~dp0\\" + os.path.relpath(
python_exe, self.install_scripts
)
except ValueError:
pass
try:
pymol_file = "%~dp0\\" + os.path.relpath(
pymol_file, self.install_scripts
)
except ValueError:
pymol_file = os.path.abspath(pymol_file)
out.write('"%s" "%s"' % (python_exe, pymol_file))
out.write(" %*" + os.linesep)
else:
out.write("#!/bin/sh" + os.linesep)
if not self.pymol_path_is_default:
out.write(f'export PYMOL_PATH="{pymol_path}"' + os.linesep)
out.write('exec "%s" "%s" "$@"' % (python_exe, pymol_file) + os.linesep)
os.chmod(launch_script, 0o755)
# ============================================================================
# should be something like (build_base + "/generated"), but that's only
# known to build and install instances
generated_dir = os.path.join(os.environ.get("PYMOL_BLD", "build"), "generated")
create_all(generated_dir)
# can be changed with environment variable PREFIX_PATH
prefix_path = get_prefix_path()
inc_dirs = [
"include",
]
pymol_src_dirs = [
"ov/src",
"layer0",
"layer1",
"layer2",
"layer3",
"layer4",
"layer5",
"layerGraphics",
"layerGraphics/gl",
generated_dir,
]
def_macros = [
("_PYMOL_LIBPNG", None),
("_PYMOL_FREETYPE", None),
]
if DEBUG and not WIN:
def_macros += [
# bounds checking in STL containers
("_GLIBCXX_ASSERTIONS", None),
]
libs = ["png", "freetype"]
lib_dirs = []
ext_comp_args = []
if is_mingw or not WIN:
ext_comp_args.extend([
"-Werror=return-type",
"-Wunused-variable",
"-Wno-switch",
"-Wno-narrowing",
# legacy stuff
"-Wno-char-subscripts",
# optimizations
"-Og" if DEBUG else "-O3",
])
else: # MSVC
ext_comp_args.extend([
"/MP",
"/std:c++17",
])
ext_link_args = []
ext_objects = []
data_files = []
ext_modules = []
if options.use_openmp == "yes":
def_macros += [("PYMOL_OPENMP", None)]
if WIN and not is_mingw: # MSVC
ext_comp_args.append("/openmp")
elif MAC: # macOS Clang
ext_comp_args.extend(["-Xpreprocessor", "-fopenmp"])
libs.append("omp")
else: # GCC/Clang on Linux, and MinGW on Windows
ext_comp_args.append("-fopenmp")
ext_link_args.append("-fopenmp")
if options.vmd_plugins:
# VMD plugin support
inc_dirs += [
"contrib/uiuc/plugins/include",
]
pymol_src_dirs += [
"contrib/uiuc/plugins/molfile_plugin/src",
]
def_macros += [
("_PYMOL_VMD_PLUGINS", None),
]
if options.libxml:
# COLLADA support
def_macros += [("_HAVE_LIBXML", None)]
libs += ["xml2"]
if options.use_msgpackc == "guess":
options.use_msgpackc = guess_msgpackc()
if options.use_msgpackc == "no":
def_macros += [("_PYMOL_NO_MSGPACKC", None)]
else:
if options.use_msgpackc == "c++11":
def_macros += [
("MMTF_MSGPACK_USE_CPP11", None),
("MSGPACK_NO_BOOST", None),
]
else:
libs += ["msgpackc"]
pymol_src_dirs += ["contrib/mmtf-c"]
if not options.glut:
def_macros += [
("_PYMOL_NO_MAIN", None),
]
if options.testing:
pymol_src_dirs += ["layerCTest"]
def_macros += [("_PYMOL_CTEST", None)]
if options.openvr:
def_macros += [("_PYMOL_OPENVR", None)]
pymol_src_dirs += [
"contrib/vr",
]
inc_dirs += pymol_src_dirs
inc_dirs += ["contrib/pocketfft"]
# ============================================================================
if MAC:
libs += ["GLEW"]
def_macros += [("PYMOL_CURVE_VALIDATE", None)]
if options.osx_frameworks:
ext_link_args += [
"-framework OpenGL",
] + (options.glut) * [
"-framework GLUT",
]
def_macros += [
("_PYMOL_OSX", None),
]
else:
libs += [
"GL",
] + (options.glut) * [
"glut",
]
if WIN:
# clear
libs = []
def_macros += [
("WIN32", None),
]
libs += [
"Advapi32", # Registry (RegCloseKey etc.)
"Ws2_32", # htonl
]
libs += (
[
"glew32",
"freetype",
"libpng",
]
+ (options.glut)
* [
"freeglut",
]
+ (options.libxml)
* [
"libxml2",
]
)
if DEBUG:
if is_mingw:
ext_comp_args += ["-g"]
else:
ext_comp_args += ["/Z7"]
ext_link_args += ["/DEBUG"]
libs += [
"opengl32",
]
if not is_mingw:
# TODO: Remove when we move to setup-CMake
ext_comp_args += ["/std:c++17"]
if not (MAC or WIN):
libs += [
"GL",
"GLEW",
] + (options.glut) * [
"glut",
]
if options.use_vtkm != "no":
for prefix in prefix_path:
vtkm_inc_dir = os.path.join(prefix, "include", f"vtkm-{options.use_vtkm}")
if os.path.exists(vtkm_inc_dir):
break
else:
raise LookupError(
"VTK-m headers not found." f' PREFIX_PATH={":".join(prefix_path)}'
)
def_macros += [
("_PYMOL_VTKM", None),
]
inc_dirs += [
vtkm_inc_dir,
vtkm_inc_dir + "/vtkm/thirdparty/diy/vtkmdiy/include",
vtkm_inc_dir + "/vtkm/thirdparty/lcl/vtkmlcl",
]
libs += [
f"vtkm_cont-{options.use_vtkm}",
f"vtkm_filter_contour-{options.use_vtkm}",
f"vtkm_filter_core-{options.use_vtkm}",
]
if options.vmd_plugins:
libs += [
"netcdf",
]
if options.openvr:
libs += [
"openvr_api",
]
inc_dirs += [
numpy.get_include(),
]
def_macros += [
("_PYMOL_NUMPY", None),
]
for prefix in prefix_path:
for dirs, suffixes in [
[
inc_dirs,
[
("include",),
("include", "freetype2"),
("include", "libxml2"),
("include", "openvr"),
],
],
[lib_dirs, [("lib64",), ("lib",)]],
]:
dirs.extend(filter(os.path.isdir, [os.path.join(prefix, *s) for s in suffixes]))
# optimization currently causes a clang segfault on OS X 10.9 when
# compiling layer2/RepCylBond.cpp
if MAC:
ext_comp_args += ["-fno-strict-aliasing"]
def get_pymol_version():
return re.findall(r'_PyMOL_VERSION "(.*)"', open("layer0/Version.h").read())[0]
def get_sources(subdirs, suffixes=(".c", ".cpp")):
return sorted(
[f for d in subdirs for s in suffixes for f in glob.glob(d + "/*" + s)]
)
def get_packages(base, parent="", r=None):
from os.path import exists, join
if r is None:
r = []
if parent:
r.append(parent)
for name in os.listdir(join(base, parent)):
if "." not in name and exists(join(base, parent, name, "__init__.py")):
get_packages(base, join(parent, name), r)
return r
package_dir = dict(
(x, os.path.join(base, x)) for base in ["modules"] for x in get_packages(base)
)
# Python includes
inc_dirs.append(sysconfig.get_paths()["include"])
inc_dirs.append(sysconfig.get_paths()["platinclude"])
champ_inc_dirs = ["contrib/champ"]
champ_inc_dirs.append(sysconfig.get_paths()["include"])
champ_inc_dirs.append(sysconfig.get_paths()["platinclude"])
champ_libs = []
if WIN:
if not is_mingw:
# pyconfig.py forces linking against pythonXY.lib on MSVC
py_lib = pathlib.Path(sysconfig.get_paths()["stdlib"]).parent / "libs"
lib_dirs.append(str(py_lib))
else:
ldversion = (
sysconfig.get_config_var("LDVERSION")
or f"{sys.version_info.major}.{sys.version_info.minor}"
)
libs.append(f"python{ldversion}")
champ_libs.append(f"python{ldversion}")
mingw_libdir = (
sysconfig.get_config_var("LIBDIR")
or os.path.join(sys.prefix, "lib")
)
if mingw_libdir and os.path.isdir(mingw_libdir):
lib_dirs.append(mingw_libdir)
ext_modules += [
CMakeExtension(
name="pymol._cmd",
sources=get_sources(pymol_src_dirs),
include_dirs=inc_dirs,
libraries=libs,
library_dirs=lib_dirs,
define_macros=def_macros,
extra_link_args=ext_link_args,
extra_compile_args=ext_comp_args,
),
CMakeExtension(
name="chempy.champ._champ",
sources=get_sources(["contrib/champ"]),
include_dirs=champ_inc_dirs,
libraries=champ_libs,
library_dirs=lib_dirs,
),
]
setup(
cmdclass={
"build_ext": build_ext_pymol,
"build_py": build_py_pymol,
"install": install_pymol,
},
version=get_pymol_version(),
ext_modules=ext_modules,
)