1#!/usr/bin/env python
2
3# Bootstrap setuptools installation. We require setuptools >= 3.2 because of a
4# bug in earlier versions regarding C++ sources generated with Cython. See:
5#    https://pypi.python.org/pypi/setuptools/3.6#id171
6try:
7    import pkg_resources
8
9    pkg_resources.require("setuptools >= 3.2")
10except pkg_resources.ResolutionError:
11    from ez_setup import use_setuptools
12
13    use_setuptools()
14
15import os
16import errno
17import fnmatch
18import sys
19import shlex
20from distutils.sysconfig import get_config_var, get_config_vars
21from subprocess import check_output, CalledProcessError, check_call
22from setuptools import setup
23from setuptools.dist import Distribution
24from distutils.command.build_clib import build_clib
25from distutils.errors import DistutilsExecError
26from distutils.dir_util import mkpath
27from distutils.file_util import copy_file
28from distutils import log
29
30# Apple switched default C++ standard libraries (from gcc's libstdc++ to
31# clang's libc++), but some pre-packaged Python environments such as Anaconda
32# are built against the old C++ standard library. Luckily, we don't have to
33# actually detect which C++ standard library was used to build the Python
34# interpreter. We just have to propagate MACOSX_DEPLOYMENT_TARGET from the
35# configuration variables to the environment.
36#
37# This workaround fixes <https://github.com/healpy/healpy/issues/151>.
38if (
39    get_config_var("MACOSX_DEPLOYMENT_TARGET")
40    and not "MACOSX_DEPLOYMENT_TARGET" in os.environ
41):
42    os.environ["MACOSX_DEPLOYMENT_TARGET"] = get_config_var("MACOSX_DEPLOYMENT_TARGET")
43
44
45# If the Cython-generated C++ files are absent, then fetch and install Cython
46# as an egg. If the Cython-generated files are present, then only use Cython if
47# a sufficiently new version of Cython is already present on the system.
48cython_require = "Cython >= 0.16"
49log.info("checking if Cython-generated files have been built")
50try:
51    open("healpy/src/_query_disc.cpp")
52except IOError:
53    log.info("Cython-generated files are absent; installing Cython locally")
54    Distribution().fetch_build_eggs(cython_require)
55else:
56    log.info("Cython-generated files are present")
57try:
58    log.info("Checking for %s", cython_require)
59    pkg_resources.require(cython_require)
60except pkg_resources.ResolutionError:
61    log.info("%s is not installed; not using Cython")
62    from setuptools.command.build_ext import build_ext
63    from setuptools import Extension
64else:
65    log.info("%s is installed; using Cython")
66    from Cython.Distutils import build_ext, Extension
67
68
69class build_external_clib(build_clib):
70    """Subclass of Distutils' standard build_clib subcommand. Adds support for
71    libraries that are installed externally and detected with pkg-config, with
72    an optional fallback to build from a local configure-make-install style
73    distribution."""
74
75    def __init__(self, dist):
76        build_clib.__init__(self, dist)
77        self.build_args = {}
78
79    def autotools_path(self):
80        """
81        Install Autotools locally if we are building on Read The Docs, because
82        we will be building from git and need to generate the healpix_cxx build
83        system.
84        """
85        on_rtd = os.environ.get("READTHEDOCS", None) == "True"
86        if not on_rtd:
87            return None
88
89        log.info("checking if autotools is installed")
90        try:
91            check_output(["autoreconf", "--version"])
92        except OSError as e:
93            if e.errno != errno.ENOENT:
94                raise
95            log.info("autotools is not installed")
96        else:
97            log.info("autotools is already installed")
98            return None
99
100        urls = [
101            "https://ftp.gnu.org/gnu/m4/m4-1.4.17.tar.gz",
102            "https://ftp.gnu.org/gnu/libtool/libtool-2.4.6.tar.gz",
103            "https://ftp.gnu.org/gnu/autoconf/autoconf-2.69.tar.gz",
104            "https://ftp.gnu.org/gnu/automake/automake-1.15.tar.gz",
105            "https://pkg-config.freedesktop.org/releases/pkg-config-0.29.1.tar.gz",
106        ]
107
108        # Use a subdirectory of build_temp as the build directory.
109        build_temp = os.path.realpath(self.build_temp)
110        prefix = os.path.join(build_temp, "autotools")
111        mkpath(prefix)
112
113        env = dict(os.environ)
114        path = os.path.join(prefix, "bin")
115        try:
116            path += ":" + env["PATH"]
117        except KeyError:
118            pass
119        env["PATH"] = path
120
121        # Check again if autotools is available, now that we have added the
122        # temporary path.
123        try:
124            check_output(["autoreconf", "--version"], env=env)
125        except OSError as e:
126            if e.errno != errno.ENOENT:
127                raise
128            log.info("building autotools from source")
129        else:
130            log.info("using autotools built from source")
131            return path
132
133        # Otherwise, build from source.
134        for url in urls:
135            _, _, tarball = url.rpartition("/")
136            pkg_version = tarball.replace(".tar.gz", "")
137            log.info("downloading %s", url)
138            check_call(["curl", "-O", url], cwd=build_temp)
139            log.info("extracting %s", tarball)
140            check_call(["tar", "-xzf", tarball], cwd=build_temp)
141            cwd = os.path.join(build_temp, pkg_version)
142            log.info("configuring %s", pkg_version)
143            check_call(
144                ["./configure", "--prefix", prefix, "--with-internal-glib"],
145                env=env,
146                cwd=cwd,
147            )
148            log.info("making %s", pkg_version)
149            check_call(["make", "install"], env=env, cwd=cwd)
150        return path
151
152    def env(self):
153        """Construct an environment dictionary suitable for having pkg-config
154        pick up .pc files in the build_clib directory."""
155        # Test if pkg-config is present. If not, fall back to pykg-config.
156        try:
157            env = self._env
158        except AttributeError:
159            env = dict(os.environ)
160
161            path = self.autotools_path()
162            if path is not None:
163                env["PATH"] = path
164
165            try:
166                check_output(["pkg-config", "--version"])
167            except OSError as e:
168                if e.errno != errno.ENOENT:
169                    raise
170                log.warn("pkg-config is not installed, falling back to pykg-config")
171                env["PKG_CONFIG"] = (
172                    sys.executable + " " + os.path.abspath("run_pykg_config.py")
173                )
174            else:
175                env["PKG_CONFIG"] = "pkg-config"
176
177            build_clib = os.path.realpath(self.build_clib)
178            pkg_config_path = (
179                os.path.join(build_clib, "lib64", "pkgconfig")
180                + ":"
181                + os.path.join(build_clib, "lib", "pkgconfig")
182            )
183            try:
184                pkg_config_path += ":" + env["PKG_CONFIG_PATH"]
185            except KeyError:
186                pass
187            env["PKG_CONFIG_PATH"] = pkg_config_path
188
189            self._env = env
190        return env
191
192    def pkgconfig(self, *packages):
193        env = self.env()
194        PKG_CONFIG = tuple(shlex.split(env["PKG_CONFIG"], posix=(os.sep == "/")))
195        kw = {}
196        index_key_flag = (
197            (2, "--cflags-only-I", ("include_dirs",)),
198            (0, "--cflags-only-other", ("extra_compile_args", "extra_link_args")),
199            (2, "--libs-only-L", ("library_dirs", "runtime_library_dirs")),
200            (2, "--libs-only-l", ("libraries",)),
201            (0, "--libs-only-other", ("extra_link_args",)),
202        )
203        for index, flag, keys in index_key_flag:
204            cmd = PKG_CONFIG + (flag,) + tuple(packages)
205            log.debug("%s", " ".join(cmd))
206            args = [
207                token[index:].decode() for token in check_output(cmd, env=env).split()
208            ]
209            if args:
210                for key in keys:
211                    kw.setdefault(key, []).extend(args)
212        return kw
213
214    def finalize_options(self):
215        """Run 'autoreconf -i' for any bundled libraries to generate the
216        configure script."""
217        build_clib.finalize_options(self)
218        env = self.env()
219
220        for lib_name, build_info in self.libraries:
221            if "sources" not in build_info:
222                log.info(
223                    "checking if configure script for library '%s' exists", lib_name
224                )
225                if not os.path.exists(
226                    os.path.join(build_info["local_source"], "configure")
227                ):
228                    log.info("running 'autoreconf -i' for library '%s'", lib_name)
229                    check_call(
230                        ["autoreconf", "-i"], cwd=build_info["local_source"], env=env
231                    )
232
233    def build_library(
234        self,
235        library,
236        pkg_config_name,
237        local_source=None,
238        supports_non_srcdir_builds=True,
239    ):
240        log.info("checking if library '%s' is installed", library)
241        try:
242            build_args = self.pkgconfig(pkg_config_name)
243            log.info("found '%s' installed, using it", library)
244        except CalledProcessError:
245
246            # If local_source is not specified, then immediately fail.
247            if local_source is None:
248                raise DistutilsExecError("library '%s' is not installed", library)
249
250            log.info("building library '%s' from source", library)
251
252            env = self.env()
253
254            # Determine which compilers we are to use, and what flags.
255            # This is based on what distutils.sysconfig.customize_compiler()
256            # does, but that function has a problem that it doesn't produce
257            # necessary (e.g. architecture) flags for C++ compilers.
258            cc, cxx, opt, cflags = get_config_vars("CC", "CXX", "OPT", "CFLAGS")
259            cxxflags = cflags
260
261            if "CC" in env:
262                cc = env["CC"]
263            if "CXX" in env:
264                cxx = env["CXX"]
265            if "CFLAGS" in env:
266                cflags = opt + " " + env["CFLAGS"]
267            if "CXXFLAGS" in env:
268                cxxflags = opt + " " + env["CXXFLAGS"]
269
270            # Use a subdirectory of build_temp as the build directory.
271            build_temp = os.path.realpath(os.path.join(self.build_temp, library))
272
273            # Destination for headers and libraries is build_clib.
274            build_clib = os.path.realpath(self.build_clib)
275
276            # Create build directories if they do not yet exist.
277            mkpath(build_temp)
278            mkpath(build_clib)
279
280            if not supports_non_srcdir_builds:
281                self._stage_files_recursive(local_source, build_temp)
282
283            # Run configure.
284            cmd = [
285                "/bin/sh",
286                os.path.join(os.path.realpath(local_source), "configure"),
287                "--prefix=" + build_clib,
288                "--disable-shared",
289                "--with-pic",
290                "--disable-maintainer-mode",
291            ]
292
293            log.info("%s", " ".join(cmd))
294            check_call(
295                cmd,
296                cwd=build_temp,
297                env=dict(env, CC=cc, CXX=cxx, CFLAGS=cflags, CXXFLAGS=cxxflags),
298            )
299
300            # Run make install.
301            cmd = ["make", "install"]
302            log.info("%s", " ".join(cmd))
303            check_call(cmd, cwd=build_temp, env=env)
304
305            build_args = self.pkgconfig(pkg_config_name)
306
307        return build_args
308        # Done!
309
310    @staticmethod
311    def _list_files_recursive(path, skip=(".*", "*.o", "autom4te.cache")):
312        """Yield paths to all of the files contained within the given path,
313        following symlinks. If skip is a tuple of fnmatch()-style wildcard
314        strings, skip any directory or filename matching any of the patterns in
315        skip."""
316        for dirpath, dirnames, filenames in os.walk(path, followlinks=True):
317            if not any(
318                any(fnmatch.fnmatch(p, s) for s in skip) for p in dirpath.split(os.sep)
319            ):
320                for filename in filenames:
321                    if not any(fnmatch.fnmatch(filename, s) for s in skip):
322                        yield os.path.join(dirpath, filename)
323
324    @staticmethod
325    def _stage_files_recursive(src, dest, skip=None):
326        """Hard link or copy all of the files in the path src into the path dest.
327        Subdirectories are created as needed, and files in dest are overwritten."""
328        # Use hard links if they are supported on this system.
329        if hasattr(os, "link"):
330            link = "hard"
331        elif hasattr(os, "symlink"):
332            link = "sym"
333        else:
334            link = None
335
336        for dirpath, dirnames, filenames in os.walk(src, followlinks=True):
337            if not any(p.startswith(".") for p in dirpath.split(os.sep)):
338                dest_dirpath = os.path.join(
339                    dest, dirpath.split(src, 1)[1].lstrip(os.sep)
340                )
341                mkpath(dest_dirpath)
342                for filename in filenames:
343                    if not filename.startswith("."):
344                        src_path = os.path.join(dirpath, filename)
345                        dest_path = os.path.join(dest_dirpath, filename)
346                        if not os.path.exists(dest_path):
347                            copy_file(
348                                os.path.join(dirpath, filename),
349                                os.path.join(dest_dirpath, filename),
350                            )
351
352    def get_source_files(self):
353        """Copied from Distutils' own build_clib, but modified so that it is not
354        an error for a build_info dictionary to lack a 'sources' key. If there
355        is no 'sources' key, then all files contained within the path given by
356        the 'local_sources' value are returned."""
357        self.check_library_list(self.libraries)
358        filenames = []
359        for (lib_name, build_info) in self.libraries:
360            sources = build_info.get("sources")
361            if sources is None or not isinstance(sources, (list, tuple)):
362                sources = list(self._list_files_recursive(build_info["local_source"]))
363
364            filenames.extend(sources)
365        return filenames
366
367    def build_libraries(self, libraries):
368        # Build libraries that have no 'sources' key, accumulating the output
369        # from pkg-config.
370        for lib_name, build_info in libraries:
371            if "sources" not in build_info:
372                for key, value in self.build_library(lib_name, **build_info).items():
373                    if key in self.build_args:
374                        self.build_args[key].extend(value)
375                    else:
376                        self.build_args[key] = value
377
378        # Use parent method to build libraries that have a 'sources' key.
379        build_clib.build_libraries(
380            self,
381            (
382                (lib_name, build_info)
383                for lib_name, build_info in libraries
384                if "sources" in build_info
385            ),
386        )
387
388
389class custom_build_ext(build_ext):
390
391    def finalize_options(self):
392        build_ext.finalize_options(self)
393
394        # Make sure that Numpy is importable
395        # (does the same thing as setup_requires=['numpy'])
396        self.distribution.fetch_build_eggs("numpy")
397        # Prevent numpy from thinking it is still in its setup process:
398        # See http://stackoverflow.com/questions/19919905
399        from six.moves import builtins
400
401        builtins.__NUMPY_SETUP__ = False
402
403        # Add Numpy header search path path
404        import numpy
405
406        self.include_dirs.append(numpy.get_include())
407
408    def run(self):
409        # If we were asked to build any C/C++ libraries, add the directory
410        # where we built them to the include path. (It's already on the library
411        # path.)
412        if self.distribution.has_c_libraries():
413            self.run_command("build_clib")
414            build_clib = self.get_finalized_command("build_clib")
415            for key, value in build_clib.build_args.items():
416                for ext in self.extensions:
417                    if not hasattr(ext, key) or getattr(ext, key) is None:
418                        setattr(ext, key, value)
419                    else:
420                        getattr(ext, key).extend(value)
421        build_ext.run(self)
422
423
424exec(open("healpy/version.py").read())
425
426def readme():
427    with open('README.rst') as f:
428        return f.read()
429
430
431setup(
432    name="healpy",
433    version=__version__,
434    description="Healpix tools package for Python",
435    long_description=readme(),
436    classifiers=[
437        "Development Status :: 5 - Production/Stable",
438        "Environment :: Console",
439        "Intended Audience :: Science/Research",
440        "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)",
441        "Operating System :: POSIX",
442        "Programming Language :: C++",
443        "Programming Language :: Python :: 2.7",
444        "Programming Language :: Python :: 3.4",
445        "Programming Language :: Python :: 3.5",
446        "Programming Language :: Python :: 3.6",
447        "Programming Language :: Python :: 3.7",
448        "Topic :: Scientific/Engineering :: Astronomy",
449        "Topic :: Scientific/Engineering :: Visualization",
450    ],
451    author="C. Rosset, A. Zonca",
452    author_email="cyrille.rosset@apc.univ-paris-diderot.fr",
453    url="http://github.com/healpy",
454    packages=["healpy", "healpy.test"],
455    libraries=[
456        (
457            "cfitsio",
458            {
459                "pkg_config_name": "cfitsio",
460                "local_source": "cfitsio",
461                "supports_non_srcdir_builds": False,
462            },
463        ),
464        (
465            "healpix_cxx",
466            {
467                "pkg_config_name": "healpix_cxx >= 3.40.0",
468                "local_source": "healpixsubmodule/src/cxx/autotools",
469            },
470        ),
471    ],
472    py_modules=[
473        "healpy.pixelfunc",
474        "healpy.sphtfunc",
475        "healpy.visufunc",
476        "healpy.fitsfunc",
477        "healpy.projector",
478        "healpy.rotator",
479        "healpy.projaxes",
480        "healpy.version",
481    ],
482    cmdclass={"build_ext": custom_build_ext, "build_clib": build_external_clib},
483    ext_modules=[
484        Extension(
485            "healpy._healpy_pixel_lib",
486            sources=["healpy/src/_healpy_pixel_lib.cc"],
487            language="c++",
488        ),
489        Extension(
490            "healpy._healpy_sph_transform_lib",
491            sources=["healpy/src/_healpy_sph_transform_lib.cc"],
492            language="c++",
493        ),
494        Extension(
495            "healpy._query_disc",
496            ["healpy/src/_query_disc.pyx"],
497            language="c++",
498            cython_directives=dict(embedsignature=True),
499        ),
500        Extension(
501            "healpy._sphtools",
502            ["healpy/src/_sphtools.pyx"],
503            language="c++",
504            cython_directives=dict(embedsignature=True),
505        ),
506        Extension(
507            "healpy._pixelfunc",
508            ["healpy/src/_pixelfunc.pyx"],
509            language="c++",
510            cython_directives=dict(embedsignature=True),
511        ),
512    ],
513    package_data={
514        "healpy": [
515            "data/*.fits",
516            "data/totcls.dat",
517            "test/data/*.fits",
518            "test/data/*.fits.gz",
519            "test/data/*.sh",
520        ]
521    },
522    install_requires=["matplotlib", "numpy>=1.13", "six", "astropy", "scipy"],
523    setup_requires=["pytest-runner", "six"],
524    tests_require=["pytest", "pytest-cython"],
525    test_suite="healpy",
526    license="GPLv2",
527)
528