diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f06b653 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +# _cmac_build.py is a build-time helper referenced by +# [tool.setuptools.cmdclass]; it is not part of any package, so it has to be +# added to the sdist explicitly or building from the sdist fails. +include _cmac_build.py diff --git a/_cmac_build.py b/_cmac_build.py new file mode 100644 index 0000000..217e12e --- /dev/null +++ b/_cmac_build.py @@ -0,0 +1,129 @@ +"""Build-time customization for the ``cmac.calc_kdp_ray_fir`` Cython extension. + +Three parts of the extension build cannot be expressed statically in +``pyproject.toml``: NumPy's C header directory is only discoverable once +NumPy is importable, ``[tool.setuptools] ext-modules`` cannot express +``define-macros`` two-tuples, and the interpreter's own ``CFLAGS`` may name +options the available compiler does not accept. All three are applied here +and wired up through ``[tool.setuptools.cmdclass]``, so no ``setup.py`` is +needed. +""" + +import os +import re +import subprocess +import tempfile + +from setuptools.command.build_ext import build_ext as _build_ext + +# Compile against the stable NumPy C API rather than the deprecated one. +NUMPY_API_MACRO = ("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION") + +# gcc: "unrecognized command-line option '-partition=none'" +# clang: "unknown argument: '-partition=none'" +# GCC quotes with U+2018/U+2019 under a UTF-8 locale, so the quoting around +# the flag is matched loosely rather than assumed to be ASCII. +QUOTES = "\"'`\u2018\u2019\u201c\u201d" +UNSUPPORTED_FLAG = re.compile( + r"(?:unrecognized\s+(?:command[-\s]line\s+)?option" + r"|unknown\s+argument:?)" + r"\s*[" + QUOTES + r"]*\s*" + r"(-[^\s,;()" + QUOTES + r"]+)", + re.IGNORECASE, +) + +# Ask the compiler for ASCII diagnostics so the pattern above has the easiest +# possible job; the loose quoting stays as a backstop if this is ignored. +C_LOCALE_ENV = dict(os.environ, LC_ALL="C", LANG="C") + +# Every compiler/linker command line distutils may hand us. Which of these +# exist varies with the setuptools version, so they are probed by name. +FLAG_ATTRS = ( + "compiler", + "compiler_so", + "compiler_so_cxx", + "compiler_cxx", + "linker_so", + "linker_so_cxx", + "linker_exe", +) + + +def _rejected_flags(command, source): + """Return the flags in ``command`` that the compiler refuses outright. + + Returns an empty set when the trial compile succeeds, and also when it + fails for any other reason -- this is a best-effort cleanup, so a real + build error is left for the real build to report. + """ + probe = subprocess.run( + list(command) + ["-c", source, "-o", os.devnull], + capture_output=True, + text=True, + env=C_LOCALE_ENV, + ) + if probe.returncode == 0: + return set() + return set(UNSUPPORTED_FLAG.findall(probe.stderr)) + + +def drop_unsupported_flags(compiler): + """Strip options the compiler rejects from ``compiler``'s command lines. + + A conda-forge interpreter records the flags of whichever GCC built it in + ``sysconfig``'s ``CFLAGS``, and ``customize_compiler`` copies those into + every command line verbatim. When the compiler doing the building is not + that same GCC -- an older system gcc, or a conda toolchain pinned a + release behind -- the build dies on an option it has never heard of, e.g. + ``-partition=none`` from GCC 16. Dropping those flags costs nothing: they + are optimization and LTO tuning, not semantics. + """ + if not getattr(compiler, "compiler_so", None): + return set() + + dropped = set() + with tempfile.TemporaryDirectory() as tmpdir: + source = os.path.join(tmpdir, "flag_probe.c") + with open(source, "w") as handle: + handle.write("int main(void) { return 0; }\n") + + # Each pass can only surface the flags the compiler reaches before + # giving up, so re-probe until it stops complaining. The bound just + # guarantees termination if a flag somehow survives removal. + for _ in range(8): + rejected = _rejected_flags(compiler.compiler_so, source) - dropped + if not rejected: + break + dropped |= rejected + for attr in FLAG_ATTRS: + command = getattr(compiler, attr, None) + if command: + setattr( + compiler, + attr, + [arg for arg in command if arg not in rejected], + ) + return dropped + + +class build_ext(_build_ext): + """``build_ext`` that resolves NumPy's headers and sanitizes CFLAGS.""" + + def finalize_options(self): + super().finalize_options() + import numpy + + self.include_dirs.append(numpy.get_include()) + for ext in self.distribution.ext_modules or []: + ext.define_macros.append(NUMPY_API_MACRO) + + def build_extensions(self): + # Runs after distutils has built self.compiler from sysconfig. + dropped = drop_unsupported_flags(self.compiler) + if dropped: + self.announce( + "dropping compiler flags not supported by this compiler: " + + " ".join(sorted(dropped)), + level=2, + ) + super().build_extensions() diff --git a/pyproject.toml b/pyproject.toml index 4d64153..b0d7ed5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,47 @@ [build-system] requires = [ - "setuptools>=45", + # >=77 for SPDX `license` / `license-files` support. + "setuptools>=77", "wheel", "cython", - "numpy>=2.0; python_version>='3.9'", + # Building against NumPy 2 yields an extension that also runs on NumPy 1. + "numpy>=2.0", ] build-backend = "setuptools.build_meta" [project] name = "cmac" +version = "0.2.0" description = "Corrected Moments in Antenna Coordinates" -dynamic = ["readme", "version"] +readme = "README.rst" +requires-python = ">=3.10" +license = "BSD-3-Clause" +license-files = ["LICENSE", "LICENSE_GPL.txt"] +authors = [ + { name = "Scott Collis" }, + { name = "Zachary Sherman" }, + { name = "Robert Jackson" }, +] +maintainers = [ + { name = "Data Informatics and Geophysical Retrievals (DIGR)" }, +] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Atmospheric Science", +] + +[project.urls] +Homepage = "https://github.com/ARM-Development/cmac" +Source = "https://github.com/ARM-Development/cmac" +Documentation = "https://www.arm.gov/data/data-sources/cmac-69" [project.optional-dependencies] # Install with `pip install -e .[test]` to get the testing extras. @@ -25,12 +56,22 @@ test = [ "act-atmos"] [tool.setuptools] -# Explicitly register the extension modules to be compiled +script-files = [ + "scripts/cmac", + "scripts/cmac_animation", + "scripts/cmac_dask", +] +# Explicitly register the extension modules to be compiled. The NumPy include +# directory and the NumPy API macro are added by _cmac_build.build_ext below. ext-modules = [ { name = "cmac.calc_kdp_ray_fir", sources = ["cmac/calc_kdp_ray_fir.pyx"] } ] -[tool.cibuildwheel] -build = "cp310-* cp311-* cp312-* cp313-*" -skip = "*-musllinux_i686 *-manylinux_i686 pp*" -build-verbosity = 1 +[tool.setuptools.packages.find] +# namespaces = false matches the old find_packages() behaviour: cmac/tests has +# no __init__.py and is deliberately left out of the distribution. +include = ["cmac*"] +namespaces = false + +[tool.setuptools.cmdclass] +build_ext = "_cmac_build.build_ext" diff --git a/setup.py b/setup.py deleted file mode 100644 index 231950d..0000000 --- a/setup.py +++ /dev/null @@ -1,138 +0,0 @@ -""" CMAC Corrected Precipitation Radar Moments in Antenna Coordinates - -Using fuzzy logic, scipy, and more to identify gates as rain, melting, -snow, no clutter, and second trip. Many fields such as reflectivity and -coorelation coefficient are used, but also SNR and sounding data is used. -More information can be found at https://www.arm.gov/data/data-sources/cmac-69 - -""" - - -import os -import subprocess -from setuptools import setup, find_packages, Extension - -import numpy as np -from Cython.Build import cythonize - -DOCLINES = __doc__.split("\n") - -CLASSIFIERS = """\ -Development Status :: 2 - Pre-Alpha -Intended Audience :: Science/Research -Intended Audience :: Developers -License :: OSI Approved :: BSD License -Programming Language :: Python -Programming Language :: Python :: 3.6 -Topic :: Scientific/Engineering -Topic :: Scientific/Engineering :: Atmospheric Science -Operating System :: POSIX :: Linux -""" - -NAME = 'cmac' -AUTHOR = 'Scott Collis, Zachary Sherman, Robert Jackson' -MAINTAINER = 'Data Informatics and Geophysical Retrievals (DIGR)' -DESCRIPTION = DOCLINES[0] -LONG_DESCRIPTION = "\n".join(DOCLINES[2:]) -URL = 'https://github.com/EVS-ATMOS/cmac2.0' -LICENSE = 'BSD' -CLASSIFIERS = filter(None, CLASSIFIERS.split('\n')) -MAJOR = 0 -MINOR = 1 -MICRO = 0 -VERSION = '%d.%d.%d' % (MAJOR, MINOR, MICRO) - - -# Return the git revision as a string -def git_version(): - def _minimal_ext_cmd(cmd): - # construct minimal environment - env = {} - for k in ['SYSTEMROOT', 'PATH']: - v = os.environ.get(k) - if v is not None: - env[k] = v - # LANGUAGE is used on win32 - env['LANGUAGE'] = 'C' - env['LANG'] = 'C' - env['LC_ALL'] = 'C' - out = subprocess.Popen( - cmd, stdout=subprocess.PIPE, env=env).communicate()[0] - return out - - try: - out = _minimal_ext_cmd(['git', 'rev-parse', 'HEAD']) - GIT_REVISION = out.strip().decode('ascii') - except OSError: - GIT_REVISION = "Unknown" - - return GIT_REVISION - - -def write_version_py(filename='cmac/version.py'): - cnt = """ -# THIS FILE IS GENERATED FROM PYART SETUP.PY -short_version = '%(version)s' -version = '%(version)s' -full_version = '%(full_version)s' -git_revision = '%(git_revision)s' -release = %(isrelease)s - -if not release: - version = full_version -""" - # Adding the git rev number needs to be done inside write_version_py(), - # otherwise the import of cmac.version messes up the build under Python 3. - FULLVERSION = VERSION - if os.path.exists('.git'): - GIT_REVISION = git_version() - elif os.path.exists('cmac/version.py'): - # must be a source distribution, use existing version file - try: - from cmac.version import git_revision as GIT_REVISION - except ImportError: - raise ImportError("Unable to import git_revision. Try removing " - "cmac/version.py and the build directory " - "before building.") - else: - GIT_REVISION = "Unknown" - - if not ISRELEASED: - FULLVERSION += '.dev+' + GIT_REVISION[:7] - - a = open(filename, 'w') - try: - a.write(cnt % {'version': VERSION, - 'full_version': FULLVERSION, - 'git_revision': GIT_REVISION, - 'isrelease': str(ISRELEASED)}) - finally: - a.close() - - -extensions = [ - Extension( - 'cmac.calc_kdp_ray_fir', - sources=['cmac/calc_kdp_ray_fir.pyx'], - include_dirs=[np.get_include()], - define_macros=[('NPY_NO_DEPRECATED_API', 'NPY_1_7_API_VERSION')], - ), -] - - -setup( - name=NAME, - version=VERSION, - description=DESCRIPTION, - long_description=LONG_DESCRIPTION, - url=URL, - author=AUTHOR, - maintainer=MAINTAINER, - license=LICENSE, - classifiers=CLASSIFIERS, - packages=find_packages(), - ext_modules=cythonize(extensions), - scripts=['scripts/cmac', - 'scripts/cmac_animation', - 'scripts/cmac_dask'] -)