setup.py
7ed34389
 #!/usr/bin/env python3
e6ae9762
 
cec7d4a2
 # Copyright 2011-2018 Kwant authors.
e001ab42
 #
c2b5f339
 # This file is part of Kwant.  It is subject to the license terms in the file
 # LICENSE.rst found in the top-level directory of this distribution and at
fa012f86
 # http://kwant-project.org/license.  A list of Kwant authors can be found in
c2b5f339
 # the file AUTHORS.rst at the top-level directory of this distribution and at
e001ab42
 # http://kwant-project.org/authors.
 
554d0fb1
 from __future__ import print_function
 
43faf533
 import sys
12fe5156
 
707bfa20
 import re
43faf533
 import os
1a077ad2
 import glob
2fbec07b
 import importlib
43faf533
 import subprocess
3d73b19c
 import configparser
16f4d000
 import collections
1d45a300
 from setuptools import setup, find_packages, Extension, Command
f837ca97
 from distutils.errors import DistutilsError, CCompilerError
06484062
 from distutils.command.build import build as build_orig
 from setuptools.command.sdist import sdist as sdist_orig
 from setuptools.command.build_ext import build_ext as build_ext_orig
04edb898
 from setuptools.command.test import test as test_orig
c2ac7c90
 
43faf533
 
20171914
 STATIC_VERSION_PATH = ('kwant', '_kwant_version.py')
c2ac7c90
 
cd3242d1
 distr_root = os.path.dirname(os.path.abspath(__file__))
 
 
16f4d000
 def configure_extensions(exts, aliases=(), build_summary=None):
     """Modify extension configuration according to the configuration file
 
     `exts` must be a dict of (name, kwargs) tuples that can be used like this:
     `Extension(name, **kwargs).  This function modifies the kwargs according to
092f8c28
     the configuration file.
 
     This function modifies `sys.argv`.
16f4d000
     """
092f8c28
     global config_file, config_file_present
 
     #### Determine the name of the configuration file.
     config_file_option = '--configfile'
e21b2c93
     config_file_option_present = False
092f8c28
     # Handle command line option
     for i, opt in enumerate(sys.argv):
         if not opt.startswith(config_file_option):
             continue
e21b2c93
         config_file_option_present = True
092f8c28
         l, _, config_file = opt.partition('=')
         if l != config_file_option or not config_file:
d256d076
             print('Error: Expecting {}=PATH'.format(config_file_option),
092f8c28
                   file=sys.stderr)
c867f67a
             sys.exit(1)
092f8c28
         sys.argv.pop(i)
         break
     else:
         config_file = 'build.conf'
16f4d000
 
     #### Read build configuration file.
     configs = configparser.ConfigParser()
     try:
092f8c28
         with open(config_file) as f:
16f4d000
             configs.read_file(f)
     except IOError:
         config_file_present = False
e21b2c93
         if config_file_option_present:
             print("Error: '{}' option was provided, but '{}' does not exist"
                   .format(config_file_option, config_file))
             sys.exit(1)
16f4d000
     else:
         config_file_present = True
 
     #### Handle section aliases.
     for short, long in aliases:
         if short in configs:
             if long in configs:
                 print('Error: both {} and {} sections present in {}.'.format(
092f8c28
                     short, long, config_file))
c867f67a
                 sys.exit(1)
16f4d000
             configs[long] = configs[short]
             del configs[short]
 
     #### Apply config from file.  Use [DEFAULT] section for missing sections.
     defaultconfig = configs.defaults()
     for name, kwargs in exts.items():
         config = configs[name] if name in configs else defaultconfig
         for key, value in config.items():
 
             # Most, but not all, keys are lists of strings
             if key == 'language':
                 pass
             elif key == 'optional':
                 value = bool(int(value))
             else:
                 value = value.split()
 
             if key == 'define_macros':
                 value = [tuple(entry.split('=', maxsplit=1))
                          for entry in value]
                 value = [(entry[0], None) if len(entry) == 1 else entry
                          for entry in value]
 
             if key in kwargs:
                 msg = 'Caution: user config in file {} shadows {}.{}.'
                 if build_summary is not None:
092f8c28
                     build_summary.append(msg.format(config_file, name, key))
16f4d000
             kwargs[key] = value
 
092f8c28
         kwargs.setdefault('depends', []).append(config_file)
16f4d000
         if config is not defaultconfig:
             del configs[name]
 
     unknown_sections = configs.sections()
     if unknown_sections:
         print('Error: Unknown sections in file {}: {}'.format(
092f8c28
             config_file, ', '.join(unknown_sections)))
c867f67a
         sys.exit(1)
16f4d000
 
     return exts
 
 
7fc080d5
 def check_python_version(min_version):
     installed_version = sys.version_info[:3]
     if installed_version < min_version:
         print('Error: Python {} required, but {} is installed'.format(
               '.'.join(map(str, min_version)),
               '.'.join(map(str, installed_version)))
         )
         sys.exit(1)
 
 
874b6c29
 def check_versions():
cd3242d1
     global version, version_is_from_git
 
     # Let Kwant itself determine its own version.  We cannot simply import
     # kwant, as it is not built yet.
2fbec07b
     spec = importlib.util.spec_from_file_location('version', 'kwant/version.py')
     version_module = importlib.util.module_from_spec(spec)
     spec.loader.exec_module(version_module)
cd3242d1
 
874b6c29
     version_module.ensure_python()
     version = version_module.version
     version_is_from_git = version_module.version_is_from_git
cd3242d1
 
 
 def init_cython():
eba071b8
     """Set the global variable `cythonize` (and other related globals).
 
     The variable `cythonize` can be in three states:
 
     * If Cython should be run and is ready, it contains the `cythonize()`
       function.
 
     * If Cython is not to be run, it contains `False`.
 
     * If Cython should, but cannot be run it contains `None`.  A help message
       on how to solve the problem is stored in `cython_help`.
 
     This function modifies `sys.argv`.
     """
10faf93a
     global cythonize, cython_help
ff6b2b54
 
55b46a70
     cython_option = '--cython'
60f02546
     required_cython_version = (0, 24)
557f595d
     try:
55b46a70
         sys.argv.remove(cython_option)
eba071b8
         cythonize = True
cd3242d1
     except ValueError:
eba071b8
         cythonize = version_is_from_git
cd3242d1
 
eba071b8
     if cythonize:
cd3242d1
         try:
             import Cython
             from Cython.Build import cythonize
         except ImportError:
eba071b8
             cythonize = None
cd3242d1
         else:
eba071b8
             #### Get Cython version.
cd3242d1
             match = re.match('([0-9.]*)(.*)', Cython.__version__)
             cython_version = [int(n) for n in match.group(1).split('.')]
             # Decrease version if the version string contains a suffix.
             if match.group(2):
                 while cython_version[-1] == 0:
                     cython_version.pop()
                 cython_version[-1] -= 1
             cython_version = tuple(cython_version)
557f595d
 
55b46a70
             if cython_version < required_cython_version:
eba071b8
                 cythonize = None
 
f837ca97
         if cythonize is None:
             msg = ("Install Cython >= {0} or use"
                     " a source distribution (tarball) of Kwant.")
             ver = '.'.join(str(e) for e in required_cython_version)
             cython_help = msg.format(ver)
eba071b8
     else:
         msg = "Run setup.py with the {} option to enable Cython."
55b46a70
         cython_help = msg.format(cython_option)
eba071b8
 
e4830940
 
aa7fcf46
 def banner(title=''):
     starred = title.center(79, '*')
     return '\n' + starred if title else starred
c2ac7c90
 
9997ecc2
 
06484062
 class build_ext(build_ext_orig):
43faf533
     def run(self):
7ee7b487
         if not config_file_present:
             # Create an empty config file if none is present so that the
             # extensions will not be rebuilt each time.  Only depending on the
             # config file if it is present would make it impossible to detect a
             # necessary rebuild due to a deleted config file.
092f8c28
             with open(config_file, 'w') as f:
                 f.write('# Build configuration created by setup.py '
                         '- feel free to modify.\n')
7ee7b487
 
43faf533
         try:
d36b3aa3
             super().run()
43faf533
         except (DistutilsError, CCompilerError):
7327aa5d
             error_msg = self.__error_msg.format(
                 header=banner(' Error '), sep=banner())
092f8c28
             print(error_msg.format(file=config_file, summary=build_summary),
c2ac7c90
                   file=sys.stderr)
43faf533
             raise
16f4d000
         print(banner(' Build summary '), *build_summary, sep='\n')
aa7fcf46
         print(banner())
43faf533
 
7327aa5d
     __error_msg = """{header}
 The compilation of Kwant has failed.  Please examine the error message
 above and consult the installation instructions in README.rst.
 You might have to customize {{file}}.
 
 Build configuration was:
 
 {{summary}}
 {sep}
 """
 
e6ae9762
 
06484062
 class build(build_orig):
20171914
     def run(self):
d36b3aa3
         super().run()
20171914
         write_version(os.path.join(self.build_lib, *STATIC_VERSION_PATH))
 
e4830940
 
 def git_lsfiles():
20171914
     if not version_is_from_git:
a9f326a9
         return
 
e4830940
     try:
cf363628
         p = subprocess.Popen(['git', 'ls-files'], cwd=distr_root,
e4830940
                              stdout=subprocess.PIPE, stderr=subprocess.PIPE)
     except OSError:
         return
 
     if p.wait() != 0:
         return
a215c8cf
     return p.communicate()[0].decode().split('\n')[:-1]
1a077ad2
 
 
6c683464
 # Make the command "sdist" depend on "build".  This verifies that the
 # distribution in the current state actually builds.  It also makes sure that
4c4442e5
 # the Cython-made C files will be up-to-date and included in the source.
06484062
 class sdist(sdist_orig):
     sub_commands = [('build', None)] + sdist_orig.sub_commands
e4830940
 
     def run(self):
cf77f221
         """Create MANIFEST.in from git if possible, otherwise check that
         MANIFEST.in is present.
9997ecc2
 
         Right now (2015) generating MANIFEST.in seems to be the only way to
         include files in the source distribution that setuptools does not think
         should be there.  Setting include_package_data to True makes setuptools
         include *.pyx and other source files in the binary distribution.
         """
55b46a70
         manifest_in_file = 'MANIFEST.in'
         manifest = os.path.join(distr_root, manifest_in_file)
e4830940
         names = git_lsfiles()
         if names is None:
9997ecc2
             if not (os.path.isfile(manifest) and os.access(manifest, os.R_OK)):
55b46a70
                 print("Error:", manifest_in_file,
9997ecc2
                       "file is missing and Git is not available"
                       " to regenerate it.", file=sys.stderr)
c867f67a
                 sys.exit(1)
e4830940
         else:
9997ecc2
             with open(manifest, 'w') as f:
e4830940
                 for name in names:
                     a, sep, b = name.rpartition('/')
                     if b == '.gitignore':
                         continue
                     stem, dot, extension = b.rpartition('.')
9997ecc2
                     f.write('include {}'.format(name))
e4830940
                     if extension == 'pyx':
9997ecc2
                         f.write(''.join([' ', a, sep, stem, dot, 'c']))
                     f.write('\n')
e4830940
 
d36b3aa3
         super().run()
e4830940
 
         if names is None:
4b0ba762
             msg = ("Git was not available to generate the list of files to be "
                    "included in the\nsource distribution. The old {} was used.")
55b46a70
             msg = msg.format(manifest_in_file)
4b0ba762
             print(banner(' Caution '), msg, banner(), sep='\n', file=sys.stderr)
6c683464
 
20171914
     def make_release_tree(self, base_dir, files):
d36b3aa3
         super().make_release_tree(base_dir, files)
20171914
         write_version(os.path.join(base_dir, *STATIC_VERSION_PATH))
e6ae9762
 
43faf533
 
d36b3aa3
 # The following class is based on a recipe in
 # http://doc.pytest.org/en/latest/goodpractices.html#manual-integration.
04edb898
 class test(test_orig):
d36b3aa3
     user_options = [('pytest-args=', 'a', "Arguments to pass to pytest")]
 
     def initialize_options(self):
         super().initialize_options()
         self.pytest_args = ''
 
     def run_tests(self):
         import shlex
04edb898
         try:
d36b3aa3
             import pytest
         except:
             print('The Python package "pytest" is required to run tests.',
04edb898
                   file=sys.stderr)
c867f67a
             sys.exit(1)
d36b3aa3
         errno = pytest.main(shlex.split(self.pytest_args))
         sys.exit(errno)
04edb898
 
 
20171914
 def write_version(fname):
     # This could be a hard link, so try to delete it first.  Is there any way
     # to do this atomically together with opening?
e6ae9762
     try:
20171914
         os.remove(fname)
     except OSError:
         pass
     with open(fname, 'w') as f:
         f.write("# This file has been created by setup.py.\n")
40b9b37b
         f.write("version = '{}'\n".format(version))
43faf533
 
 
8ee281a9
 def long_description():
     text = []
     try:
79efdfee
         with open('README.rst', encoding='utf8') as f:
8ee281a9
             for line in f:
55b46a70
                 if line.startswith('See also in this directory:'):
8ee281a9
                     break
                 text.append(line.rstrip())
c6e82aa6
             while text[-1] == "":
                 text.pop()
8ee281a9
     except:
         return ''
     return '\n'.join(text)
 
 
2f82b693
 def search_libs(libs):
783253a5
     cmd = ['gcc']
     cmd.extend(['-l' + lib for lib in libs])
     cmd.extend(['-o/dev/null', '-xc', '-'])
43faf533
     try:
783253a5
         p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
43faf533
     except OSError:
         pass
     else:
3d73b19c
         p.communicate(input=b'int main() {}\n')
783253a5
         if p.wait() == 0:
2f82b693
             return libs
 
 
 def search_mumps():
     """Return the configuration for MUMPS if it is available in a known way.
 
     This is known to work with the MUMPS provided by the Debian package
     libmumps-scotch-dev and the MUMPS binaries in the conda-forge channel."""
     lib_sets = [
         # Debian
014df638
         ['zmumps_scotch', 'mumps_common_scotch', 'mpiseq_scotch',
          'pord', 'gfortran'],
2f82b693
         # Conda (via conda-forge).
014df638
         ['zmumps_seq', 'mumps_common_seq'],
2f82b693
     ]
     for libs in lib_sets:
014df638
         found_libs = search_libs(libs)
2f82b693
         if found_libs:
             return found_libs
     return []
 
 
16f4d000
 def configure_special_extensions(exts, build_summary):
     #### Special config for MUMPS.
     mumps = exts['kwant.linalg._mumps']
     if 'libraries' in mumps:
43faf533
         build_summary.append('User-configured MUMPS')
     else:
2f82b693
         mumps_libs = search_mumps()
         if mumps_libs:
             mumps['libraries'] = mumps_libs
783253a5
             build_summary.append('Auto-configured MUMPS')
16f4d000
         else:
             mumps = None
f837ca97
             del exts['kwant.linalg._mumps']
16f4d000
             build_summary.append('No MUMPS support')
43faf533
 
16f4d000
     return exts
43faf533
 
16f4d000
 
 def maybe_cythonize(exts):
e315de5a
     """Prepare a list of `Extension` instances, ready for `setup()`.
50a8240f
 
16f4d000
     The argument `exts` must be a mapping of names to kwargs to be passed
     on to `Extension`.
e315de5a
 
     If Cython is to be run, create the extensions and calls `cythonize()` on
     them.  If Cython is not to be run, replace .pyx file with .c or .cpp,
     check timestamps, and create the extensions.
50a8240f
     """
eba071b8
     if cythonize:
16f4d000
         return cythonize([Extension(name, **kwargs)
                           for name, kwargs in exts.items()],
                          language_level=3,
10faf93a
                          compiler_directives={'linetrace': True})
b4b6375d
 
     # Cython is not going to be run: replace pyx extension by that of
     # the shipped translated file.
 
43faf533
     result = []
557f595d
     problematic_files = []
16f4d000
     for name, kwargs in exts.items():
         language = kwargs.get('language')
b4b6375d
         if language is None:
             ext = '.c'
         elif language == 'c':
             ext = '.c'
         elif language == 'c++':
             ext = '.cpp'
         else:
             print('Unknown language: {}'.format(language), file=sys.stderr)
c867f67a
             sys.exit(1)
b4b6375d
 
         pyx_files = []
         cythonized_files = []
16f4d000
         sources = []
         for f in kwargs['sources']:
b4b6375d
             if f.endswith('.pyx'):
                 pyx_files.append(f)
                 f = f.rstrip('.pyx') + ext
                 cythonized_files.append(f)
16f4d000
             sources.append(f)
         kwargs['sources'] = sources
b4b6375d
 
         # Complain if cythonized files are older than Cython source files.
         try:
             cythonized_oldest = min(os.stat(f).st_mtime
                                     for f in cythonized_files)
         except OSError:
eba071b8
             msg = "Cython-generated file {} is missing."
             print(banner(" Error "), msg.format(f), "",
                   cython_help, banner(), sep="\n", file=sys.stderr)
c867f67a
             sys.exit(1)
b4b6375d
 
16f4d000
         for f in pyx_files + kwargs.get('depends', []):
092f8c28
             if f == config_file:
b4b6375d
                 # The config file is only a dependency for the compilation
                 # of the cythonized file, not for the cythonization.
                 continue
             if os.stat(f).st_mtime > cythonized_oldest:
                 problematic_files.append(f)
43faf533
 
16f4d000
         result.append(Extension(name, **kwargs))
43faf533
 
557f595d
     if problematic_files:
eba071b8
         msg = ("Some Cython source files are newer than files that have "
                "been derived from them:\n{}")
         msg = msg.format(", ".join(problematic_files))
 
         # Cython should be run but won't.  Signal an error if this is because
         # Cython *cannot* be run, warn otherwise.
         error = cythonize is None
         if cythonize is False:
             dontworry = ('(Do not worry about this if you are building Kwant '
                          'from unmodified sources,\n'
                          'e.g. with "pip install".)\n\n')
             msg = dontworry + msg
 
         print(banner(" Error " if error else " Caution "), msg, "",
               cython_help, banner(), sep="\n", file=sys.stderr)
         if error:
c867f67a
             sys.exit(1)
557f595d
 
43faf533
     return result
 
b4b6375d
 
b9c2b0c2
 def maybe_add_numpy_include(exts):
     # Add NumPy header path to include_dirs of all the extensions.
     try:
         import numpy
     except ImportError:
         print(banner(' Caution '), 'NumPy header directory cannot be determined'
               ' ("import numpy" failed).', banner(), sep='\n', file=sys.stderr)
     else:
         numpy_include = numpy.get_include()
         for ext in exts.values():
             ext.setdefault('include_dirs', []).append(numpy_include)
     return exts
 
 
43faf533
 def main():
39b65acd
     check_python_version((3, 6))
874b6c29
     check_versions()
 
55b46a70
     exts = collections.OrderedDict([
         ('kwant._system',
          dict(sources=['kwant/_system.pyx'],
               include_dirs=['kwant/graph'])),
         ('kwant.operator',
          dict(sources=['kwant/operator.pyx'],
               include_dirs=['kwant/graph'])),
         ('kwant.graph.core',
          dict(sources=['kwant/graph/core.pyx'],
               depends=['kwant/graph/core.pxd', 'kwant/graph/defs.h',
                        'kwant/graph/defs.pxd'])),
9ad36a5c
         ('kwant.graph.dijkstra',
          dict(sources=['kwant/graph/dijkstra.pyx'])),
55b46a70
         ('kwant.linalg.lapack',
3d8db694
          dict(sources=['kwant/linalg/lapack.pyx'])),
55b46a70
         ('kwant.linalg._mumps',
          dict(sources=['kwant/linalg/_mumps.pyx'],
               depends=['kwant/linalg/cmumps.pxd']))])
 
3d8db694
     aliases = [('mumps', 'kwant.linalg._mumps')]
16f4d000
 
cd3242d1
     init_cython()
 
55b46a70
     global build_summary
16f4d000
     build_summary = []
55b46a70
     exts = configure_extensions(exts, aliases, build_summary)
16f4d000
     exts = configure_special_extensions(exts, build_summary)
b9c2b0c2
     exts = maybe_add_numpy_include(exts)
16f4d000
     exts = maybe_cythonize(exts)
 
55b46a70
     classifiers = """\
         Development Status :: 5 - Production/Stable
         Intended Audience :: Science/Research
         Intended Audience :: Developers
         Programming Language :: Python :: 3 :: Only
         Topic :: Software Development
         Topic :: Scientific/Engineering
         Operating System :: POSIX
         Operating System :: Unix
         Operating System :: MacOS :: MacOS X
         Operating System :: Microsoft :: Windows"""
 
e9557cf7
     packages = find_packages('.')
43faf533
     setup(name='kwant',
20171914
           version=version,
c2ac7c90
           author='C. W. Groth (CEA), M. Wimmer, '
                  'A. R. Akhmerov, X. Waintal (CEA), and others',
b8ec32a7
           author_email='authors@kwant-project.org',
8f5e866d
           description=("Package for numerical quantum transport calculations "
ec006432
                        "(Python 3 version)"),
8ee281a9
           long_description=long_description(),
           platforms=["Unix", "Linux", "Mac OS-X", "Windows"],
e001ab42
           url="http://kwant-project.org/",
           license="BSD",
e9557cf7
           packages=packages,
           package_data={p: ['*.pxd', '*.h'] for p in packages},
06484062
           cmdclass={'build': build,
                     'sdist': sdist,
                     'build_ext': build_ext,
04edb898
                     'test': test},
16f4d000
           ext_modules=exts,
39b65acd
           install_requires=['numpy >= 1.13.3', 'scipy >= 0.19.1',
6eb2c201
                             'tinyarray >= 1.2'],
7cb76722
           extras_require={
42dd420d
               # The oldest versions between: Debian stable, Ubuntu LTS
245d4985
               'plotting': ['matplotlib >= 2.1.1',
                            'plotly >= 2.2.2'],
39b65acd
               'continuum': 'sympy >= 1.1.1',
cec7d4a2
               # qsymm is only packaged on PyPI
8c0f0a9b
               'qsymm': 'qsymm >= 1.2.6',
7cb76722
           },
55b46a70
           classifiers=[c.strip() for c in classifiers.split('\n')])
a1e88e83
 
43faf533
 if __name__ == '__main__':
     main()