From e912023132131a19850715d6f7b842d2c1f3de79 Mon Sep 17 00:00:00 2001 From: James Sunseri Date: Fri, 27 Mar 2026 23:14:04 -0400 Subject: [PATCH 1/2] implemented the new feature --- pyccl/boltzmann.py | 33 +++- pyccl/tests/test_boltzmann_missing_deps.py | 183 ++++++++++++++++++ pyccl/tests/test_power_mu_sigma_sigma8norm.py | 4 +- pyproject.toml | 3 + 4 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 pyccl/tests/test_boltzmann_missing_deps.py diff --git a/pyccl/boltzmann.py b/pyccl/boltzmann.py index 923f79cb8..0bee8ea40 100644 --- a/pyccl/boltzmann.py +++ b/pyccl/boltzmann.py @@ -25,8 +25,16 @@ def get_camb_pk_lin(cosmo, *, nonlin=False): spectrum. If ``nonlin=True``, returns a tuple \ ``(pk_lin, pk_nonlin)``. """ - import camb - import camb.model + try: + import camb + import camb.model + except ImportError as e: + raise CCLError( + "CAMB is required to use the 'boltzmann_camb' transfer function " + "but could not be imported. Install it with:\n" + " pip install pyccl[camb]\n" + "or: pip install camb" + ) from e # Get extra CAMB parameters that were specified extra_camb_params = {} @@ -222,8 +230,15 @@ def get_isitgr_pk_lin(cosmo): :class:`~pyccl.pk2d.Pk2D`: Power spectrum \ object. The linear power spectrum. """ - import isitgr # noqa: F811 - import isitgr.model + try: + import isitgr # noqa: F811 + import isitgr.model + except ImportError as e: + raise CCLError( + "ISiTGR is required to use the 'boltzmann_isitgr' transfer " + "function but could not be imported. Install it with: " + "pip install isitgr" + ) from e # Get extra CAMB parameters that were specified extra_camb_params = {} @@ -396,7 +411,15 @@ def get_class_pk_lin(cosmo): :class:`~pyccl.pk2d.Pk2D`: Power spectrum object.\ The linear power spectrum. """ - import classy + try: + import classy + except ImportError as e: + raise CCLError( + "CLASS (classy) is required to use the 'boltzmann_class' transfer " + "function but could not be imported. Install it with:\n" + " pip install pyccl[class]\n" + "or: pip install classy" + ) from e params = { "output": "mPk", diff --git a/pyccl/tests/test_boltzmann_missing_deps.py b/pyccl/tests/test_boltzmann_missing_deps.py new file mode 100644 index 000000000..8cdc2bac5 --- /dev/null +++ b/pyccl/tests/test_boltzmann_missing_deps.py @@ -0,0 +1,183 @@ +"""Tests that verify helpful CCLError messages when boltzmann optional +dependencies (camb, classy, isitgr) are not installed. + +These tests use monkeypatching to simulate missing packages so they do not +require camb or classy to be installed. +""" + +import builtins +import sys + +import numpy as np +import pytest + +import pyccl as ccl + + +def _make_import_blocker(*blocked_roots): + """Return a replacement for builtins.__import__ that raises + ImportError for any module whose root name is in *blocked_roots*. + Using ImportError (parent of ModuleNotFoundError) mirrors the behaviour of + ``sys.modules[name] = None`` and covers both flavours of import failure. + """ + real_import = builtins.__import__ + + def _mock_import(name, *args, **kwargs): + root = name.split(".")[0] + if root in blocked_roots: + raise ImportError(f"No module named '{name}'") + return real_import(name, *args, **kwargs) + + return _mock_import + + +@pytest.fixture() +def _block_camb(monkeypatch): + """Remove camb from sys.modules and block all subsequent imports of it.""" + for key in list(sys.modules): + if key == "camb" or key.startswith("camb."): + monkeypatch.delitem(sys.modules, key) + monkeypatch.setattr(builtins, "__import__", _make_import_blocker("camb")) + + +@pytest.fixture() +def _block_classy(monkeypatch): + """Remove classy from sys.modules and block all subsequent imports.""" + monkeypatch.delitem(sys.modules, "classy", raising=False) + monkeypatch.setattr(builtins, "__import__", _make_import_blocker("classy")) + + +@pytest.fixture() +def _block_isitgr(monkeypatch): + """Remove isitgr from sys.modules and block all subsequent imports.""" + for key in list(sys.modules): + if key == "isitgr" or key.startswith("isitgr."): + monkeypatch.delitem(sys.modules, key) + monkeypatch.setattr(builtins, "__import__", _make_import_blocker("isitgr")) + + +# --------------------------------------------------------------------------- +# Direct function tests +# --------------------------------------------------------------------------- + +def test_camb_missing_raises_cclerror(_block_camb): + """get_camb_pk_lin raises CCLError (not bare ModuleNotFoundError) with + an actionable install message when camb is not available.""" + cosmo = ccl.Cosmology( + Omega_b=0.0492, Omega_c=0.2650, h=0.6724, + sigma8=0.811, n_s=0.9645, + transfer_function="boltzmann_camb", + ) + with pytest.raises(ccl.CCLError, match="CAMB"): + cosmo.get_camb_pk_lin() + + +def test_camb_missing_error_has_install_hint(_block_camb): + """Error message contains pip install hint.""" + cosmo = ccl.Cosmology( + Omega_b=0.0492, Omega_c=0.2650, h=0.6724, + sigma8=0.811, n_s=0.9645, + transfer_function="boltzmann_camb", + ) + with pytest.raises(ccl.CCLError, match="pip install"): + cosmo.get_camb_pk_lin() + + +def test_class_missing_raises_cclerror(_block_classy): + """get_class_pk_lin raises CCLError when classy is not available.""" + cosmo = ccl.Cosmology( + Omega_b=0.0492, Omega_c=0.2650, h=0.6724, + sigma8=0.811, n_s=0.9645, + transfer_function="boltzmann_class", + ) + with pytest.raises(ccl.CCLError, match="CLASS|classy"): + cosmo.get_class_pk_lin() + + +def test_class_missing_error_has_install_hint(_block_classy): + """CLASS error message contains pip install hint.""" + cosmo = ccl.Cosmology( + Omega_b=0.0492, Omega_c=0.2650, h=0.6724, + sigma8=0.811, n_s=0.9645, + transfer_function="boltzmann_class", + ) + with pytest.raises(ccl.CCLError, match="pip install"): + cosmo.get_class_pk_lin() + + +def test_isitgr_missing_raises_cclerror(_block_isitgr): + """get_isitgr_pk_lin raises CCLError when isitgr is not available.""" + cosmo = ccl.Cosmology( + Omega_b=0.0492, Omega_c=0.2650, h=0.6724, + sigma8=0.811, n_s=0.9645, + transfer_function="boltzmann_isitgr", + ) + with pytest.raises(ccl.CCLError, match="ISiTGR"): + cosmo.get_isitgr_pk_lin() + + +# --------------------------------------------------------------------------- +# Full pipeline tests (reproduces the scenario in test.py) +# --------------------------------------------------------------------------- + +def test_halo_massfunction_camb_missing_raises_cclerror(_block_camb): + """Calling a halo mass function with a boltzmann_camb cosmology raises + CCLError (not bare ModuleNotFoundError) when camb is not installed. + + This replicates the exact scenario in test.py. + """ + cosmo = ccl.Cosmology( + Omega_b=0.0492, Omega_c=0.2650, h=0.6724, + sigma8=0.811, n_s=0.9645, + transfer_function="boltzmann_camb", + ) + hmd = ccl.halos.MassDef200m + nM = ccl.halos.MassFuncTinker08(mass_def=hmd) + m_arr = np.geomspace(1e8, 1e17, 16) + + with pytest.raises(ccl.CCLError, match="CAMB"): + nM(cosmo, m_arr, 1.0) + + +def test_halo_massfunction_class_missing_raises_cclerror(_block_classy): + """Calling a halo mass function with a boltzmann_class cosmology raises + CCLError (not bare ModuleNotFoundError) when classy is not installed. + """ + cosmo = ccl.Cosmology( + Omega_b=0.0492, Omega_c=0.2650, h=0.6724, + sigma8=0.811, n_s=0.9645, + transfer_function="boltzmann_class", + ) + hmd = ccl.halos.MassDef200m + nM = ccl.halos.MassFuncTinker08(mass_def=hmd) + m_arr = np.geomspace(1e8, 1e17, 16) + + with pytest.raises(ccl.CCLError, match="CLASS|classy"): + nM(cosmo, m_arr, 1.0) + + +def test_error_is_cclerror_not_module_not_found_error(_block_camb): + """Verify the exception type is CCLError, not ModuleNotFoundError. + + Before this fix, users would get an unhelpful ModuleNotFoundError deep + in the call stack. The fix ensures it is always a CCLError. + """ + cosmo = ccl.Cosmology( + Omega_b=0.0492, Omega_c=0.2650, h=0.6724, + sigma8=0.811, n_s=0.9645, + transfer_function="boltzmann_camb", + ) + hmd = ccl.halos.MassDef200m + nM = ccl.halos.MassFuncTinker08(mass_def=hmd) + m_arr = np.geomspace(1e8, 1e17, 16) + + with pytest.raises(ccl.CCLError): + nM(cosmo, m_arr, 1.0) + + # Also confirm it is NOT a raw ImportError/ModuleNotFoundError + try: + nM(cosmo, m_arr, 1.0) + except ccl.CCLError: + pass # expected + except (ImportError, ModuleNotFoundError): + pytest.fail("Got bare ImportError/ModuleNotFoundError instead of CCLError") diff --git a/pyccl/tests/test_power_mu_sigma_sigma8norm.py b/pyccl/tests/test_power_mu_sigma_sigma8norm.py index 55fb1732d..3f078f8e7 100644 --- a/pyccl/tests/test_power_mu_sigma_sigma8norm.py +++ b/pyccl/tests/test_power_mu_sigma_sigma8norm.py @@ -39,9 +39,9 @@ def test_power_mu_sigma_sigma8norm(tf): assert np.allclose(pk_rat, gfac) with mock.patch.dict(sys.modules, {'isitgr': None}): - with pytest.raises(ModuleNotFoundError): + with pytest.raises(ccl.CCLError): get_isitgr_pk_lin(cosmo) - # Importing ccl without isitgr is fine. No ModuleNotFoundError triggered. + # Importing ccl without isitgr is fine. No error triggered. with mock.patch.dict(sys.modules, {'isitgr': None}): reload(ccl.boltzmann) diff --git a/pyproject.toml b/pyproject.toml index f78dfb4ae..e9321cf31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,9 @@ dev = [ "pytest", "pytest-cov", ] +camb = ["camb>=1.3.0"] +class = ["classy"] +boltzmann = ["camb>=1.3.0", "classy"] [tool.setuptools.packages.find] include = ["pyccl*"] From 706f53d58e555b66e4897ca361fe884fbdcbbeea Mon Sep 17 00:00:00 2001 From: James Sunseri Date: Sat, 28 Mar 2026 01:18:34 -0400 Subject: [PATCH 2/2] working updates for not using boltzmann as the default and extra optional package presets --- README.md | 14 ++ pyccl/boltzmann.py | 6 +- pyccl/cosmology.py | 12 +- pyccl/tests/test_boltzmann_missing_deps.py | 183 --------------------- pyproject.toml | 8 +- readthedocs/source/installation.rst | 23 +++ 6 files changed, 56 insertions(+), 190 deletions(-) delete mode 100644 pyccl/tests/test_boltzmann_missing_deps.py diff --git a/README.md b/README.md index 59f2a0de8..4edf08358 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,20 @@ See [Getting CMake](https://ccl.readthedocs.io/en/latest/source/installation.htm and [Installing SWIG](https://pypi.org/project/swig/) for instructions. Note that the code only supports Linux or Mac OS, but no Windows. +### Optional dependencies + +For extended functionality, install one of the optional extra groups: + +```bash +pip install pyccl[boltzmann] # Boltzmann codes: CAMB, CLASS (classy), ISiTGR +pip install pyccl[pt] # Perturbation theory: FAST-PT, velocileptors +pip install pyccl[emulators] # Emulators: BaccoEmu, MiraTitan, Dark Emulator +pip install pyccl[full] # All of the above +``` + +Without `pyccl[boltzmann]`, the default `transfer_function` is `'eisenstein_hu'`. +With `pyccl[boltzmann]` (or a full `conda` install), it defaults to `'boltzmann_camb'`. + Once you have the code installed, you can take it for a spin! ```python diff --git a/pyccl/boltzmann.py b/pyccl/boltzmann.py index 0bee8ea40..e95a05d47 100644 --- a/pyccl/boltzmann.py +++ b/pyccl/boltzmann.py @@ -32,7 +32,7 @@ def get_camb_pk_lin(cosmo, *, nonlin=False): raise CCLError( "CAMB is required to use the 'boltzmann_camb' transfer function " "but could not be imported. Install it with:\n" - " pip install pyccl[camb]\n" + " pip install pyccl[boltzmann]\n" "or: pip install camb" ) from e @@ -237,7 +237,7 @@ def get_isitgr_pk_lin(cosmo): raise CCLError( "ISiTGR is required to use the 'boltzmann_isitgr' transfer " "function but could not be imported. Install it with: " - "pip install isitgr" + "pip install pyccl[boltzmann] or pip install isitgr" ) from e # Get extra CAMB parameters that were specified @@ -417,7 +417,7 @@ def get_class_pk_lin(cosmo): raise CCLError( "CLASS (classy) is required to use the 'boltzmann_class' transfer " "function but could not be imported. Install it with:\n" - " pip install pyccl[class]\n" + " pip install pyccl[boltzmann]\n" "or: pip install classy" ) from e diff --git a/pyccl/cosmology.py b/pyccl/cosmology.py index 8ba4e2ef2..a31acf53e 100644 --- a/pyccl/cosmology.py +++ b/pyccl/cosmology.py @@ -77,6 +77,16 @@ class MatterPowerSpectra(Enum): 'emulator': lib.emulator_nlpk } +def _camb_available(): + try: + import camb # noqa: F401 + return True + except ImportError: + return False + + +_DEFAULT_TRANSFER_FUNCTION = 'boltzmann_camb' if _camb_available() else 'eisenstein_hu' + _TOP_LEVEL_MODULES = ("",) @@ -238,7 +248,7 @@ def __init__( Neff=None, m_nu=0., mass_split='normal', w0=-1., wa=0., T_CMB=DefaultParams.T_CMB, T_ncdm=DefaultParams.T_ncdm, - transfer_function='boltzmann_camb', + transfer_function=_DEFAULT_TRANSFER_FUNCTION, matter_power_spectrum='halofit', baryonic_effects=None, mg_parametrization=None, diff --git a/pyccl/tests/test_boltzmann_missing_deps.py b/pyccl/tests/test_boltzmann_missing_deps.py deleted file mode 100644 index 8cdc2bac5..000000000 --- a/pyccl/tests/test_boltzmann_missing_deps.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Tests that verify helpful CCLError messages when boltzmann optional -dependencies (camb, classy, isitgr) are not installed. - -These tests use monkeypatching to simulate missing packages so they do not -require camb or classy to be installed. -""" - -import builtins -import sys - -import numpy as np -import pytest - -import pyccl as ccl - - -def _make_import_blocker(*blocked_roots): - """Return a replacement for builtins.__import__ that raises - ImportError for any module whose root name is in *blocked_roots*. - Using ImportError (parent of ModuleNotFoundError) mirrors the behaviour of - ``sys.modules[name] = None`` and covers both flavours of import failure. - """ - real_import = builtins.__import__ - - def _mock_import(name, *args, **kwargs): - root = name.split(".")[0] - if root in blocked_roots: - raise ImportError(f"No module named '{name}'") - return real_import(name, *args, **kwargs) - - return _mock_import - - -@pytest.fixture() -def _block_camb(monkeypatch): - """Remove camb from sys.modules and block all subsequent imports of it.""" - for key in list(sys.modules): - if key == "camb" or key.startswith("camb."): - monkeypatch.delitem(sys.modules, key) - monkeypatch.setattr(builtins, "__import__", _make_import_blocker("camb")) - - -@pytest.fixture() -def _block_classy(monkeypatch): - """Remove classy from sys.modules and block all subsequent imports.""" - monkeypatch.delitem(sys.modules, "classy", raising=False) - monkeypatch.setattr(builtins, "__import__", _make_import_blocker("classy")) - - -@pytest.fixture() -def _block_isitgr(monkeypatch): - """Remove isitgr from sys.modules and block all subsequent imports.""" - for key in list(sys.modules): - if key == "isitgr" or key.startswith("isitgr."): - monkeypatch.delitem(sys.modules, key) - monkeypatch.setattr(builtins, "__import__", _make_import_blocker("isitgr")) - - -# --------------------------------------------------------------------------- -# Direct function tests -# --------------------------------------------------------------------------- - -def test_camb_missing_raises_cclerror(_block_camb): - """get_camb_pk_lin raises CCLError (not bare ModuleNotFoundError) with - an actionable install message when camb is not available.""" - cosmo = ccl.Cosmology( - Omega_b=0.0492, Omega_c=0.2650, h=0.6724, - sigma8=0.811, n_s=0.9645, - transfer_function="boltzmann_camb", - ) - with pytest.raises(ccl.CCLError, match="CAMB"): - cosmo.get_camb_pk_lin() - - -def test_camb_missing_error_has_install_hint(_block_camb): - """Error message contains pip install hint.""" - cosmo = ccl.Cosmology( - Omega_b=0.0492, Omega_c=0.2650, h=0.6724, - sigma8=0.811, n_s=0.9645, - transfer_function="boltzmann_camb", - ) - with pytest.raises(ccl.CCLError, match="pip install"): - cosmo.get_camb_pk_lin() - - -def test_class_missing_raises_cclerror(_block_classy): - """get_class_pk_lin raises CCLError when classy is not available.""" - cosmo = ccl.Cosmology( - Omega_b=0.0492, Omega_c=0.2650, h=0.6724, - sigma8=0.811, n_s=0.9645, - transfer_function="boltzmann_class", - ) - with pytest.raises(ccl.CCLError, match="CLASS|classy"): - cosmo.get_class_pk_lin() - - -def test_class_missing_error_has_install_hint(_block_classy): - """CLASS error message contains pip install hint.""" - cosmo = ccl.Cosmology( - Omega_b=0.0492, Omega_c=0.2650, h=0.6724, - sigma8=0.811, n_s=0.9645, - transfer_function="boltzmann_class", - ) - with pytest.raises(ccl.CCLError, match="pip install"): - cosmo.get_class_pk_lin() - - -def test_isitgr_missing_raises_cclerror(_block_isitgr): - """get_isitgr_pk_lin raises CCLError when isitgr is not available.""" - cosmo = ccl.Cosmology( - Omega_b=0.0492, Omega_c=0.2650, h=0.6724, - sigma8=0.811, n_s=0.9645, - transfer_function="boltzmann_isitgr", - ) - with pytest.raises(ccl.CCLError, match="ISiTGR"): - cosmo.get_isitgr_pk_lin() - - -# --------------------------------------------------------------------------- -# Full pipeline tests (reproduces the scenario in test.py) -# --------------------------------------------------------------------------- - -def test_halo_massfunction_camb_missing_raises_cclerror(_block_camb): - """Calling a halo mass function with a boltzmann_camb cosmology raises - CCLError (not bare ModuleNotFoundError) when camb is not installed. - - This replicates the exact scenario in test.py. - """ - cosmo = ccl.Cosmology( - Omega_b=0.0492, Omega_c=0.2650, h=0.6724, - sigma8=0.811, n_s=0.9645, - transfer_function="boltzmann_camb", - ) - hmd = ccl.halos.MassDef200m - nM = ccl.halos.MassFuncTinker08(mass_def=hmd) - m_arr = np.geomspace(1e8, 1e17, 16) - - with pytest.raises(ccl.CCLError, match="CAMB"): - nM(cosmo, m_arr, 1.0) - - -def test_halo_massfunction_class_missing_raises_cclerror(_block_classy): - """Calling a halo mass function with a boltzmann_class cosmology raises - CCLError (not bare ModuleNotFoundError) when classy is not installed. - """ - cosmo = ccl.Cosmology( - Omega_b=0.0492, Omega_c=0.2650, h=0.6724, - sigma8=0.811, n_s=0.9645, - transfer_function="boltzmann_class", - ) - hmd = ccl.halos.MassDef200m - nM = ccl.halos.MassFuncTinker08(mass_def=hmd) - m_arr = np.geomspace(1e8, 1e17, 16) - - with pytest.raises(ccl.CCLError, match="CLASS|classy"): - nM(cosmo, m_arr, 1.0) - - -def test_error_is_cclerror_not_module_not_found_error(_block_camb): - """Verify the exception type is CCLError, not ModuleNotFoundError. - - Before this fix, users would get an unhelpful ModuleNotFoundError deep - in the call stack. The fix ensures it is always a CCLError. - """ - cosmo = ccl.Cosmology( - Omega_b=0.0492, Omega_c=0.2650, h=0.6724, - sigma8=0.811, n_s=0.9645, - transfer_function="boltzmann_camb", - ) - hmd = ccl.halos.MassDef200m - nM = ccl.halos.MassFuncTinker08(mass_def=hmd) - m_arr = np.geomspace(1e8, 1e17, 16) - - with pytest.raises(ccl.CCLError): - nM(cosmo, m_arr, 1.0) - - # Also confirm it is NOT a raw ImportError/ModuleNotFoundError - try: - nM(cosmo, m_arr, 1.0) - except ccl.CCLError: - pass # expected - except (ImportError, ModuleNotFoundError): - pytest.fail("Got bare ImportError/ModuleNotFoundError instead of CCLError") diff --git a/pyproject.toml b/pyproject.toml index e9321cf31..f0dad2744 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,11 @@ dev = [ "pytest", "pytest-cov", ] -camb = ["camb>=1.3.0"] -class = ["classy"] -boltzmann = ["camb>=1.3.0", "classy"] + +boltzmann = ["camb", "classy", "isitgr"] +pt = ["velocileptors", "fast-pt"] +emulators = ["baccoemu", "MiraTitanHMFemulator", "dark_emulator"] +full = ["camb", "classy", "isitgr", "velocileptors", "fast-pt", "baccoemu", "MiraTitanHMFemulator", "dark_emulator"] [tool.setuptools.packages.find] include = ["pyccl*"] diff --git a/readthedocs/source/installation.rst b/readthedocs/source/installation.rst index b2125bd2e..33b58ad1f 100644 --- a/readthedocs/source/installation.rst +++ b/readthedocs/source/installation.rst @@ -43,6 +43,23 @@ Once you have ``CMake``, simply run: $ pip install pyccl +Installing with optional extras +-------------------------------- + +CCL supports four optional dependency groups installable as pip extras: + +.. code-block:: bash + + $ pip install pyccl[boltzmann] # CAMB, CLASS (classy), ISiTGR + $ pip install pyccl[pt] # FAST-PT and velocileptors + $ pip install pyccl[emulators] # BaccoEmu, MiraTitan HMF, Dark Emulator + $ pip install pyccl[full] # All of the above + +Without ``pyccl[boltzmann]``, the default ``transfer_function`` is +``'eisenstein_hu'``. With it (or a ``conda`` install), the default is +``'boltzmann_camb'``. + + Google Colab ============ @@ -61,6 +78,8 @@ To install ``pyccl`` on https://colab.research.google.com then one way is the fo Getting a Boltzmann Code ======================== +These packages are included in ``pip install pyccl[boltzmann]``. + In order to use CCL with a Boltzmann code, you will need the ``Python`` wrappers for either ``CLASS`` or ``CAMB``. @@ -112,6 +131,8 @@ should already be in your environment. Getting PT packages =================== +These packages are included in ``pip install pyccl[pt]``. + Getting FAST-PT --------------- @@ -142,6 +163,8 @@ See full instructions in the ``velocileptors`` Getting emulators ================= +These packages are included in ``pip install pyccl[emulators]``. + The following emulators with external dependencies are currently supported in CCL.