diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index eac14e25..00000000 --- a/.coveragerc +++ /dev/null @@ -1,5 +0,0 @@ -[run] -source = geoana -plugins = Cython.Coverage -omit = - */setup.py diff --git a/.github/environment_ci.yml b/.github/environment_ci.yml deleted file mode 100644 index 0ba145e5..00000000 --- a/.github/environment_ci.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: geoana-test -channels: - - conda-forge -dependencies: - - numpy>=1.22.4 - - scipy>=1.8 - - libdlf - - # optionals - - matplotlib - - utm - - # documentation - - sphinx - - pydata-sphinx-theme==0.15.4 - - sphinx-gallery>=0.1.13 - - numpydoc>=1.5 - - jupyter - - graphviz - - pillow - - # testing - - pytest - - pytest-cov - - sympy - - # Building - - pip - - meson-python>=0.15.0 - - meson - - ninja - - cython>=3.0.8 - - setuptools_scm - - python-build \ No newline at end of file diff --git a/.github/make_ci_environ.py b/.github/make_ci_environ.py new file mode 100644 index 00000000..6754ce8d --- /dev/null +++ b/.github/make_ci_environ.py @@ -0,0 +1,89 @@ +import tomllib +from pathlib import Path +import yaml +import os + +def parse_pyproject(path: str, optional_sections_to_skip=None): + with open(path, "rb") as f: + data = tomllib.load(f) + + deps = set() + + # project.dependencies (PEP 621) + for dep in data.get("project", {}).get("dependencies", []): + if "numpy" in dep: + # numpy is also listed in build requirements with a higher version number + # so we skip it here to avoid conflicts. + continue + deps.add(dep) + + # optional dependencies (PEP 621) + if optional_sections_to_skip is None: + optional_sections_to_skip = [] + for group, group_deps in data.get("project", {}).get("optional-dependencies", {}).items(): + if group in optional_sections_to_skip: + print("Skipping optional dependency group:", group) + continue + deps.update(group_deps) + + deps.discard("geoana[all]") + deps.discard("geoana[doc,all]") + deps.discard("geoana[plot,extras,jittable]") + + if "matplotlib" in deps: + deps.remove("matplotlib") + deps.add("matplotlib-base") + return deps + +def create_env_yaml(deps, name="env", python_version=None, free_threaded=False): + conda_pkgs = [] + pip_pkgs = [] + + for dep in deps: + # crude split: try to detect conda vs pip-only packages + if any(dep.startswith(pip_only) for pip_only in ["git+", "http:", "https:", "file:"]): + pip_pkgs.append(dep) + else: + conda_pkgs.append(dep) + + dependencies = conda_pkgs + if pip_pkgs: + dependencies.append({"pip": pip_pkgs}) + + if python_version: + if free_threaded: + dependencies.insert(0, f"python-freethreading={python_version}") + else: + dependencies.insert(0, f"python={python_version}") + + return { + "name": name, + "channels": ["conda-forge"], + "dependencies": dependencies, + } + +if __name__ == "__main__": + pyproject_path = Path("pyproject.toml") + + py_vers = os.environ.get("PYTHON_VERSION", "3.11") + is_free_threaded = os.environ.get("FREE_THREADED", "false").lower() == "true" + no_doc = os.environ.get("NO_DOC_BUILD", "false").lower() == "true" + no_numba = os.environ.get("NO_NUMBA", "false").lower() == "true" + env_name = os.environ.get("ENV_NAME", "geoana_env") + + skips = ["all"] + if no_numba or is_free_threaded: + skips.append("jittable") + if no_doc: + skips.append("doc") + + deps = parse_pyproject(pyproject_path, optional_sections_to_skip=skips) + if is_free_threaded: + deps.discard("matplotlib-base") + env_data = create_env_yaml(deps, name=env_name, python_version=py_vers, free_threaded=is_free_threaded) + + out_name = "environment_ci.yml" + with open(out_name, "w") as f: + yaml.safe_dump(env_data, f, sort_keys=False) + + print("✅ Generated environment_ci.yml with", len(deps), "dependencies") \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31ac9703..40a01575 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ -name: Release -on: - push: - # Sequence of patterns matched against refs/tags - tags: - - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 +name: Build Distribution artifacts + +on: [push, pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build_wheels: @@ -11,16 +11,16 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - # macos-13 is an intel runner, macos-14 is apple silicon - os: [ubuntu-latest, windows-latest, macos-13, macos-14] + # macos-15-intel is an Intel runner, macos-14 is Apple silicon + os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, windows-11-arm, macos-15-intel, macos-14] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Build wheels - uses: pypa/cibuildwheel@v2.21.3 + uses: pypa/cibuildwheel@v3.2.0 - uses: actions/upload-artifact@v4 with: @@ -31,7 +31,9 @@ jobs: name: Build source distribution runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + fetch-depth: 0 - name: Build sdist run: pipx run build --sdist @@ -45,9 +47,9 @@ jobs: # We can host it here on github though for those that need it (re: jupyter-light). pure_python: name: Create pure-python wheel - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Python @@ -68,20 +70,35 @@ jobs: distribute: name: Distribute documentation runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') defaults: run: shell: bash -l {0} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.13" + + - name: Create Conda environment file + run: | + python -m pip install pyyaml + python .github/make_ci_environ.py + env: + PYTHON_VERSION: "3.13" + ENV_NAME: geoana-test + NO_NUMBA: "true" + - name: Setup Conda uses: conda-incubator/setup-miniconda@v3 with: auto-update-conda: true environment-file: .github/environment_ci.yml - activate-environment: geoana-test - python-version: "3.11" + activate-environment: geoana-test - name: Install Our Package run: | pip install --no-build-isolation --editable . --config-settings=setup-args="-Dwith_extensions=true" @@ -108,6 +125,7 @@ jobs: pure_python ] runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') defaults: run: shell: bash -l {0} diff --git a/.github/workflows/test_with_conda.yml b/.github/workflows/test_with_conda.yml index 79e814a1..84f72c2b 100644 --- a/.github/workflows/test_with_conda.yml +++ b/.github/workflows/test_with_conda.yml @@ -1,15 +1,12 @@ name: Testing With Conda +on: [push, pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true -on: - push: - branches: - - '*' - pull_request: - branches: - - '*' jobs: build_and_test: - name: Testing (${{ matrix.python-version }}, ${{ matrix.os }}) + name: Testing (${{ matrix.python-version }}${{ matrix.free-threaded && 't' || '' }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} defaults: run: @@ -18,39 +15,47 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest"] - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] + free-threaded: [false, true] + include: + - free-threaded: true + no_doc: true + - python-version: "3.14" + no_numba: true + exclude: + - python-version: "3.11" + free-threaded: true + - python-version: "3.12" + free-threaded: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.13" + + - name: Create Conda environment file + run: | + python -m pip install pyyaml + python .github/make_ci_environ.py + env: + PYTHON_VERSION: ${{ matrix.python-version}} + FREE_THREADED: ${{ matrix.free-threaded && 'true' || 'false' }} + NO_DOC_BUILD: ${{ matrix.no_doc && 'true' || 'false' }} + NO_NUMBA: ${{ matrix.no_numba && 'true' || 'false' }} + ENV_NAME: geoana-test + + - name: Setup Conda uses: conda-incubator/setup-miniconda@v3 with: auto-update-conda: true - environment-file: .github/environment_ci.yml + environment-file: environment_ci.yml activate-environment: geoana-test - python-version: ${{ matrix.python-version }} - - - name: Install numba - if: matrix.python-version != '3.13' - # Numba doesn't work on python 3.13 just yet: - run: | - conda install --yes -c conda-forge numba - - # Install discretize from it's repo until wheels/conda-forge built against numpy2.0 available: - - name: Pull Discretize - uses: actions/checkout@v4 - with: - fetch-depth: 0 - repository: 'simpeg/discretize' - ref: 'main' - path: 'discretize' - - - name: Install discretize - run: | - pip install ./discretize - name: Conda information run: | @@ -64,7 +69,7 @@ jobs: - name: Run Tests run: | - pytest tests --cov-config=.coveragerc --cov=geoana --cov-report=xml -s -v -W ignore::DeprecationWarning + pytest tests --cov-config=.coveragerc --cov=geoana --cov-report=xml -s -v -W ignore::DeprecationWarning ${{ matrix.NO_DOC_BUILD && ' -k "not docs"'|| ''}} - name: "Upload coverage to Codecov" if: matrix.python-version == '3.11' diff --git a/.gitignore b/.gitignore index 1c44a0ee..32c99885 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,7 @@ geoana/kernels/_extensions/potential_field_prism_api.h geoana/version.py .idea/ + +.vscode/ + +environment_ci.yml diff --git a/docs/conf.py b/docs/conf.py index d66b243a..aeadd224 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -441,9 +441,9 @@ # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { 'python': ('https://docs.python.org/3/', None), - 'numpy': ('https://docs.scipy.org/doc/numpy/', None), - 'scipy': ('https://docs.scipy.org/doc/scipy/reference/', None), - 'matplotlib': ('http://matplotlib.org/', None), + 'numpy': ('https://numpy.org/doc/stable/', None), + 'scipy': ('https://docs.scipy.org/doc/scipy/', None), + 'matplotlib': ('https://matplotlib.org/stable/', None), } diff --git a/geoana/kernels/_extensions/meson.build b/geoana/kernels/_extensions/meson.build index 1d9f8e0a..2a863659 100644 --- a/geoana/kernels/_extensions/meson.build +++ b/geoana/kernels/_extensions/meson.build @@ -1,3 +1,25 @@ +add_languages('c', 'cpp', 'cython') + +py_dep = py.dependency() +cc = meson.get_compiler('c') +cpp = meson.get_compiler('cpp') +cy = meson.get_compiler('cython') + +_global_c_args = cc.get_supported_arguments( + '-Wno-unused-but-set-variable', + '-Wno-unused-function', + '-Wno-conversion', + '-Wno-misleading-indentation', +) +add_project_arguments(_global_c_args, language : 'c') + +# We need -lm for all C code (assuming it uses math functions, which is safe to +# assume for SciPy). For C++ it isn't needed, because libstdc++/libc++ is +# guaranteed to depend on it. +m_dep = cc.find_library('m', required : false) +if m_dep.found() + add_project_link_arguments('-lm', language : 'c') +endif numpy_nodepr_api = ['-DNPY_NO_DEPRECATED_API=NPY_1_22_API_VERSION'] diff --git a/geoana/kernels/_extensions/potential_field_prism.pyx b/geoana/kernels/_extensions/potential_field_prism.pyx index 07134a39..ff6a4ddd 100644 --- a/geoana/kernels/_extensions/potential_field_prism.pyx +++ b/geoana/kernels/_extensions/potential_field_prism.pyx @@ -1,3 +1,6 @@ +# cython: freethreading_compatible = True +# cython: language_level=3 +# cython: embedsignature=True cimport cython from libc.math cimport sqrt, log, atan diff --git a/geoana/kernels/_extensions/rTE.pyx b/geoana/kernels/_extensions/rTE.pyx index b7fb64e8..418965ae 100644 --- a/geoana/kernels/_extensions/rTE.pyx +++ b/geoana/kernels/_extensions/rTE.pyx @@ -1,6 +1,7 @@ # distutils: language=c++ # cython: language_level=3 # cython: embedsignature=True +# cython: freethreading_compatible = True import numpy as np cimport numpy as np cimport cython @@ -112,8 +113,9 @@ def rTE_forward(frequencies, lamb, sigma, mu, thicknesses): if n_layers > 1: h_p = &hs[0] - rTE(out_p, &f[0], &lam[0], sig_p, &c_mu[0, 0], h_p, - n_frequency, n_filter, n_layers) + with nogil: + rTE(out_p, &f[0], &lam[0], sig_p, &c_mu[0, 0], h_p, + n_frequency, n_filter, n_layers) return np.array(out) @@ -200,7 +202,8 @@ def rTE_gradient(frequencies, lamb, sigma, mu, thicknesses): h_p = &hs[0] gh_p = &gh[0, 0, 0] - rTEgrad(gsig_p, gmu_p, gh_p, &f[0], &lam[0], sig_p, &c_mu[0, 0], h_p, - n_frequency, n_filter, n_layers) + with nogil: + rTEgrad(gsig_p, gmu_p, gh_p, &f[0], &lam[0], sig_p, &c_mu[0, 0], h_p, + n_frequency, n_filter, n_layers) return np.array(gsig), np.array(gh), np.array(gmu) diff --git a/meson.build b/meson.build index b217447a..0b666787 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,5 @@ project( 'geoana', - 'c', 'cpp', 'cython', # Note that the git commit hash cannot be added dynamically here # (it is dynamically generated though setuptools_scm) version: run_command('python', @@ -13,7 +12,7 @@ print(get_version())''' check: true ).stdout().strip(), license: 'MIT', - meson_version: '>= 1.1.0', + meson_version: '>= 1.4.0', default_options: [ 'buildtype=debugoptimized', 'b_ndebug=if-release', @@ -26,30 +25,5 @@ with_extensions = get_option('with_extensions') # https://mesonbuild.com/Python-module.html py_mod = import('python') py = py_mod.find_installation(pure: not with_extensions) -py_dep = py.dependency() - -if with_extensions - cc = meson.get_compiler('c') - cpp = meson.get_compiler('cpp') - cy = meson.get_compiler('cython') - # generator() doesn't accept compilers, only found programs - cast it. - cython = find_program(cy.cmd_array()[0]) - - _global_c_args = cc.get_supported_arguments( - '-Wno-unused-but-set-variable', - '-Wno-unused-function', - '-Wno-conversion', - '-Wno-misleading-indentation', - ) - add_project_arguments(_global_c_args, language : 'c') - - # We need -lm for all C code (assuming it uses math functions, which is safe to - # assume for SciPy). For C++ it isn't needed, because libstdc++/libc++ is - # guaranteed to depend on it. - m_dep = cc.find_library('m', required : false) - if m_dep.found() - add_project_link_arguments('-lm', language : 'c') - endif -endif subdir('geoana') \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 3c005348..e47a2208 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ build-backend = 'mesonpy' requires = [ "meson-python>=0.15.0", - "Cython>=3.0.8", + "Cython>=3.1.0", "setuptools_scm[toml]>=6.2", "numpy>=2.0.0rc1", ] @@ -13,7 +13,7 @@ name = 'geoana' dynamic = ["version"] description = 'Analytic expressions for geophysical responses' readme = 'README.rst' -requires-python = '>=3.10' +requires-python = '>=3.11' authors = [ {name = 'SimPEG developers', email = 'lindseyheagy@gmail.com'}, ] @@ -26,7 +26,7 @@ keywords = [ # https://scipy.github.io/devdocs/dev/core-dev/index.html#version-ranges-for-numpy-and-other-dependencies dependencies = [ "numpy>=1.22.4", - "scipy>=1.8", + "scipy>=1.12", "libdlf", ] classifiers = [ @@ -55,11 +55,10 @@ extras = ["utm"] jittable = ["numba"] all = ["geoana[plot,extras,jittable]"] doc = [ - "sphinx!=4.1.0", - "pydata-sphinx-theme==0.15.4", - "sphinx-gallery>=0.1.13", - "numpydoc>=1.5", - "discretize", + "sphinx==8.1.3", + "pydata-sphinx-theme==0.16.1", + "sphinx-gallery==0.19.0", + "numpydoc==1.9.0", "jupyter", "graphviz", "pillow", @@ -68,14 +67,17 @@ doc = [ test = [ "pytest", "pytest-cov", + "sympy", + "discretize", + "requests", "geoana[doc,all]", ] build = [ - "meson-python>=0.14.0", + "meson-python>=0.15.0", "meson", "ninja", - "numpy>=1.22.4", - "cython>=0.29.35", + "numpy>=2.0.0rc1", + "cython>=3.1.0", "setuptools_scm", ] @@ -89,9 +91,9 @@ Repository = 'https://github.com/simpeg/geoana.git' [tool.cibuildwheel] # skip building wheels for python 3.6, 3.7, all pypy versions, and specialty linux # processors (still does arm builds though). -# skip windows 32bit -skip = "cp36-* cp37-* cp38-* cp39-* pp* *_ppc64le *_i686 *_s390x *-win32 cp38-musllinux_* *-musllinux_aarch64" -build-verbosity = "3" +skip = "cp38-* cp39-* cp310-* *_ppc64le *_i686 *_s390x *-win32 cp310-win_arm64" +build-verbosity = 3 +enable = ["cpython-freethreading"] # test importing geoana to make sure externals are loadable. test-command = 'python -c "import geoana; geoana.show_config()"' @@ -116,3 +118,33 @@ filterwarnings = [ ] + +[tool.coverage.run] +branch = true +source = ["geoana"] +# plugins = [ +# "Cython.Coverage", +# ] + +[tool.coverage.report] +ignore_errors = false +show_missing = true +# Regexes for lines to exclude from consideration +exclude_also = [ + # Don't complain about missing debug-only code: + "def __repr__", + "if self\\.debug", + + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + "AbstractMethodError", + + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + + # Don't complain about abstract methods, they aren't run: + "@(abc\\.)?abstractmethod", +] + diff --git a/tests/test_docs.py b/tests/test_docs.py index 32224e4c..16017e52 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,8 +1,13 @@ import os -import unittest -from sphinx.application import Sphinx +import pytest -class TestDoc(unittest.TestCase): +try: + from sphinx.application import Sphinx +except ImportError: + Sphinx = None + +@pytest.mark.skipif(Sphinx is None, reason="Sphinx not installed") +class TestDoc(): @property def path_to_docs(self): @@ -22,7 +27,3 @@ def test_linkcheck(self): doctree_dir = os.path.sep.join([src_dir, "_build", "doctree"]) app = Sphinx(src_dir, config_dir, output_dir, doctree_dir, buildername="linkcheck", warningiserror=False) app.build() - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_earthquake_oksar.py b/tests/test_earthquake_oksar.py index 69ebddbd..7cbb62c9 100644 --- a/tests/test_earthquake_oksar.py +++ b/tests/test_earthquake_oksar.py @@ -24,7 +24,3 @@ def test_los(self): # compare against fortran code. true = np.array([0.427051, -0.090772, 0.899660]) np.testing.assert_allclose(true, LOS, rtol=1E-5) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_plotting_utils.py b/tests/test_plotting_utils.py index 6e5bff8a..9716dafd 100644 --- a/tests/test_plotting_utils.py +++ b/tests/test_plotting_utils.py @@ -5,7 +5,14 @@ from geoana.em.static import MagneticDipoleWholeSpace from geoana.em.static import ElectrostaticSphere +try: + import matplotlib + matplotlib.use('Agg') +except ImportError: + matplotlib = None + +@pytest.mark.skipif(matplotlib is None, reason="matplotlib not installed") def test_plot_2d_data(): xyz = np.array([np.linspace(-2, 2, 20), np.linspace(-2, 2, 20), np.linspace(-2, 2, 20)]).T location = np.r_[0., 0., 0.] diff --git a/tests/test_spatial.py b/tests/test_spatial.py index c99c2770..e69fbeeb 100644 --- a/tests/test_spatial.py +++ b/tests/test_spatial.py @@ -241,7 +241,7 @@ def test_aliases(self): def test_rotation(source_vector, target_vector, as_matrix): rot = rotation_matrix_from_normals(source_vector, target_vector, as_matrix=as_matrix) - atol = 1E-15 + atol = 1E-14 if as_matrix: npt.assert_allclose(rot @ source_vector, target_vector, atol=atol) npt.assert_allclose(rot.T @ target_vector, source_vector, atol=atol) @@ -254,6 +254,3 @@ def test_rotation_errors(): rotation_matrix_from_normals([0, 1, 2, 3], [0, 1, 3]) with pytest.raises(ValueError, match="v1 shape should be.*"): rotation_matrix_from_normals([0, 1, 2], [0, 1, 3, 3]) - -if __name__ == '__main__': - unittest.main()