diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b67d04e3..7a58497e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,18 @@ Changelog ========= +v0.18.1 (2025-04-16) +-------------------- + +New features +^^^^^^^^^^^^ +* RavenPy no longer requires `raven-hydro` to be installed. The Raven model executable can now be provided by explicitly setting the `RAVENPY_RAVEN_BINARY_PATH` environment variable. (PR #486). + +Internal changes +^^^^^^^^^^^^^^^^ +* `pydap` has been pinned below v3.5.5 temporarily until `xarray` offers support for it. (PR #486). +* More than 7500 DeprecationWarnings emitted during the testing suite have been addressed. Minimum supported `pydantic` has been raised to v2.11. (PR #487). + v0.18.0 (2025-04-03) -------------------- diff --git a/docs/installation.rst b/docs/installation.rst index 8b06ac15..191bb88d 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -57,10 +57,16 @@ Then, from your python environment, run: .. code-block:: console - python -m pip install ravenpy[gis] + python -m pip install ravenpy[gis,raven-hydro] If desired, the core functions of `RavenPy` can be installed without its GIS functionalities as well. This implementation of RavenPy is much lighter on dependencies and can be installed easily with `pip`, without the need for `conda` or `virtualenv`. + .. code-block:: console + + python -m pip install ravenpy[raven-hydro] + +Finally, if you wish to provide your own `Raven` binary, you can install `RavenPy` without installing the `raven-hydro` package: + .. code-block:: console python -m pip install ravenpy diff --git a/environment-dev.yml b/environment-dev.yml index d5f56dc6..2c058ebb 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -23,8 +23,8 @@ dependencies: - pandas >=2.2.0 - pint >=0.24.4 - platformdirs >=4.3.6 - - pydantic >=2.0 - - pydap >=3.4.0 # Note: As of 2025-03-18 (v3.5.4) does not support Python 3.13 + - pydantic >=2.11 + - pydap >=3.4.0,<3.5.5 # pydap 3.5.5 is not currently supported by `xarray` (v2025.3.1) - pymetalink >=6.5.2 - pymbolic >=2024.2 - pyproj >=3.3.0 diff --git a/environment-docs.yml b/environment-docs.yml index 88383d81..c680058d 100644 --- a/environment-docs.yml +++ b/environment-docs.yml @@ -31,7 +31,7 @@ dependencies: - netCDF4 >=1.7.2 - numpy >=1.24.0 - notebook - - pydantic >=2.0 + - pydantic >=2.11 - pymetalink >=6.5.2 - s3fs - salib diff --git a/pyproject.toml b/pyproject.toml index 428a4e25..ecede85c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,10 +49,9 @@ dependencies = [ "pandas >=2.2.0", "pint >=0.24.4", "platformdirs >=4.3.6", - "pydantic >=2.0", - "pydap >=3.4.0", # Note: As of 2025-03-18 (v3.5.4) does not support Python 3.13 + "pydantic >=2.11", + "pydap >=3.4.0,<3.5.5", # pydap 3.5.5 is not currently supported by `xarray` (v2025.3.1) "pymbolic >=2024.2", - "raven-hydro >=0.4.0,<1.0", "scipy >=1.11.0", "spotpy >=1.6.1", "statsmodels >=0.14.2", @@ -137,10 +136,14 @@ gis = [ "setuptools >=71.0", "shapely >=2.0" ] +raven-hydro = [ + "raven-hydro >=0.4.0,<1.0" +] all = [ "ravenpy[dev]", "ravenpy[docs]", - "ravenpy[gis]" + "ravenpy[gis]", + "ravenpy[raven-hydro]" ] [project.scripts] diff --git a/src/ravenpy/__init__.py b/src/ravenpy/__init__.py index da5670bc..ad8fc5b9 100644 --- a/src/ravenpy/__init__.py +++ b/src/ravenpy/__init__.py @@ -24,10 +24,9 @@ # SOFTWARE. ################################################################################### +from ._raven import RAVEN_EXEC_PATH, __raven_version__ # noqa: F401 from .ravenpy import Emulator, EnsembleReader, OutputReader, RavenWarning, run -__all__ = ["Emulator", "EnsembleReader", "OutputReader", "RavenWarning", "run"] - __author__ = """David Huard""" __email__ = "huard.david@ouranos.ca" __version__ = "0.18.0" diff --git a/src/ravenpy/_raven.py b/src/ravenpy/_raven.py new file mode 100644 index 00000000..886c0187 --- /dev/null +++ b/src/ravenpy/_raven.py @@ -0,0 +1,16 @@ +"""Configurations for raven-hydro.""" + +import os +import shutil + +RAVEN_EXEC_PATH = os.getenv("RAVENPY_RAVEN_BINARY_PATH") or shutil.which("raven") + +if not RAVEN_EXEC_PATH: + raise RuntimeError( + "Could not find raven binary in PATH and RAVENPY_RAVEN_BINARY_PATH env variable is not set." + ) + +try: + from raven_hydro import __raven_version__ +except ImportError: + __raven_version__ = "0.0.0" diff --git a/src/ravenpy/config/base.py b/src/ravenpy/config/base.py index 3bc46716..585b9085 100644 --- a/src/ravenpy/config/base.py +++ b/src/ravenpy/config/base.py @@ -144,7 +144,8 @@ def __subcommands__(self) -> tuple[dict[str, str], list]: """Return dictionary of class attributes that are Raven models.""" cmds = {} recs = [] - for key, field in self.model_fields.items(): + cls = self.__class__ + for key, field in cls.model_fields.items(): obj = self.__dict__[key] if obj is not None: if issubclass(obj.__class__, _Record): @@ -225,8 +226,9 @@ class LineCommand(FlatCommand): """ def to_rv(self): - out = [f":{self.__class__.__name__:<20}"] - for field in self.model_fields.keys(): + cls = self.__class__ + out = [f":{cls.__name__:<20}"] + for field in cls.model_fields.keys(): out.append(str(getattr(self, field))) # noqa: PERF401 return " ".join(out) + "\n" diff --git a/src/ravenpy/config/defaults.py b/src/ravenpy/config/defaults.py index 3b5eee6e..bd063bca 100644 --- a/src/ravenpy/config/defaults.py +++ b/src/ravenpy/config/defaults.py @@ -1,4 +1,4 @@ -from raven_hydro import __raven_version__ +from ravenpy import __raven_version__ units = { "PRECIP": "mm/d", @@ -65,5 +65,5 @@ def default_nc_attrs(): return { "history": f"Created on {now} by Raven {version}", "references": "Craig, J.R., and the Raven Development Team, Raven user's and developer's manual " - f"(Version {version}), URL: https://raven.uwaterloo.ca/ (2025).", + f"(Version {version}), URL: https://raven.uwaterloo.ca/ ({dt.datetime.today().year}).", } diff --git a/src/ravenpy/config/rvs.py b/src/ravenpy/config/rvs.py index 5c4683ca..460d4b84 100644 --- a/src/ravenpy/config/rvs.py +++ b/src/ravenpy/config/rvs.py @@ -1,18 +1,25 @@ import datetime as dt +import zipfile from collections.abc import Sequence from dataclasses import asdict, fields, is_dataclass from pathlib import Path +from textwrap import dedent from typing import Any, Optional, Union import cftime from pydantic import ConfigDict, Field, ValidationInfo, field_validator -from raven_hydro import __raven_version__ from ..config import commands as rc from ..config import options as o from ..config import processes as rp from .base import RV, Sym, optfield, parse_symbolic +try: + from raven_hydro import __raven_version__ +except ImportError: + __raven_version__ = "0.0.0" + + """ Generic Raven model configuration. @@ -264,16 +271,12 @@ class Config(RVI, RVC, RVH, RVT, RVP, RVE): @staticmethod def header(rv): """Return the header to print at the top of each RV file.""" - from textwrap import dedent - import ravenpy - version = __raven_version__ - return dedent( f""" ########################################################################################################### - :FileType {rv.upper()} Raven {version} + :FileType {rv.upper()} Raven {__raven_version__} :WrittenBy RavenPy {ravenpy.__version__} based on setups provided by James Craig and Juliane Mai :CreationDate {dt.datetime.now().isoformat(timespec="seconds")} #---------------------------------------------------------------------------------------------------------- @@ -492,8 +495,6 @@ def zip( overwrite : bool If True, overwrite existing configuration zip file. """ - import zipfile - workdir = Path(workdir) if not workdir.exists(): workdir.mkdir(parents=True) diff --git a/src/ravenpy/ravenpy.py b/src/ravenpy/ravenpy.py index 7b5e299d..707b111f 100644 --- a/src/ravenpy/ravenpy.py +++ b/src/ravenpy/ravenpy.py @@ -12,11 +12,11 @@ import xarray as xr +from ravenpy import RAVEN_EXEC_PATH + from .config import parsers from .config.rvs import Config -RAVEN_EXEC_PATH = os.getenv("RAVENPY_RAVEN_BINARY_PATH") or shutil.which("raven") - class Emulator: def __init__( @@ -278,18 +278,14 @@ def run( overwrite : bool If True, overwrite existing files. verbose : bool - If True, always display Raven warnings. If False, warnings will only be printed if an error occurs. + If True, always display Raven warnings. + If False, warnings will only be printed if an error occurs. Returns ------- Path - Path to model outputs. + The path to the model outputs. """ - if not RAVEN_EXEC_PATH: - raise RuntimeError( - "Could not find raven binary in PATH, and RAVENPY_RAVEN_BINARY_PATH env variable is not set" - ) - # Confirm configdir exists configdir = Path(configdir).absolute() if not configdir.exists(): @@ -318,7 +314,7 @@ def run( ) stdout, stderr = process.communicate(input="\n") - returncode = process.wait() + return_code = process.wait() # Deal with errors and warnings messages = parsers.parse_raven_messages(outputdir / "Raven_errors.txt") @@ -332,8 +328,8 @@ def run( "\n".join([f"Config directory: {configdir}"] + messages["ERROR"]) ) - if returncode != 0: - raise OSError(f"Raven Error (code: {returncode}): \n{stdout}\n{stderr}") + if return_code != 0: + raise OSError(f"Raven Error (code: {return_code}): \n{stdout}\n{stderr}") return outputdir diff --git a/tests/test_missing.py b/tests/test_missing.py new file mode 100644 index 00000000..055c65eb --- /dev/null +++ b/tests/test_missing.py @@ -0,0 +1,71 @@ +import os +import shutil +import sys + +import pytest + + +def filter_raven(): + # Find the real conda-installed tool path + real_tool = shutil.which("raven") + assert real_tool is not None, "raven must be installed for this test" + + # Get the directory the conda tool is in + conda_tool_dir = os.path.dirname(real_tool) + + # Create a new PATH that includes everything *except* the conda env's tool directory + filtered_path = os.pathsep.join( + [ + p + for p in os.environ["PATH"].split(os.pathsep) + if os.path.abspath(p) != os.path.abspath(conda_tool_dir) + ] + ) + + return filtered_path + + +@pytest.fixture +def hide_module(monkeypatch): + def _hide(name): + monkeypatch.setitem(sys.modules, name, None) + + return _hide + + +class TestMissing: + + def test_missing_raven_binary(self, monkeypatch, tmpdir, hide_module): + """Test for behaviour when binary is missing from the system path.""" + + # Set up a temporary directory to simulate the absence of the raven binary + filtered_path = filter_raven() + monkeypatch.setenv("PATH", f"{tmpdir}{os.pathsep}{filtered_path}") + + # Hide the raven_hydro module + hide_module("raven_hydro") + hide_module("raven_hydro._version") + hide_module("raven_hydro.libraven") + + # Force the raven modules to be reloaded + del sys.modules["ravenpy"] + del sys.modules["ravenpy._raven"] + del sys.modules["ravenpy.config.defaults"] + + # Now the tool should be "missing" + assert shutil.which("raven") is None + + # Loading the module should raise a RuntimeError + with pytest.raises(RuntimeError): + import ravenpy # noqa: F401 + + # Check that setting the RAVENPY_RAVEN_BINARY_PATH environment variable works + monkeypatch.setenv("RAVENPY_RAVEN_BINARY_PATH", f"some/path/to/raven") + import ravenpy + + assert ravenpy.RAVEN_EXEC_PATH == "some/path/to/raven" + + # Check that the raven_hydro library is not imported + from ravenpy.config.defaults import __raven_version__ + + assert __raven_version__ == "0.0.0" diff --git a/tests/test_rvs.py b/tests/test_rvs.py index c59589eb..d142cbb0 100644 --- a/tests/test_rvs.py +++ b/tests/test_rvs.py @@ -18,7 +18,7 @@ class Test(RV): a: bool = optfield(alias="a") t = Test() - assert not t.model_fields["a"].is_required() + assert not t.__class__.model_fields["a"].is_required() def test_rvi_datetime(): diff --git a/tests/test_utils.py b/tests/test_utils.py index 856f8078..9e8ff7aa 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -20,8 +20,8 @@ def test_nc_specs_bad(bad_netcdf): @pytest.mark.online def test_dap_specs(): # Link to THREDDS Data Server netCDF testdata - TDS = "https://pavics.ouranos.ca/twitcher/ows/proxy/thredds/dodsC/birdhouse/testdata/raven" - fn = f"{TDS}/raven-gr4j-cemaneige/Salmon-River-Near-Prince-George_meteo_daily.nc" + tds = "https://pavics.ouranos.ca/twitcher/ows/proxy/thredds/dodsC/birdhouse/testdata/raven" + fn = f"{tds}/raven-gr4j-cemaneige/Salmon-River-Near-Prince-George_meteo_daily.nc" attrs = nc_specs(fn, "PRECIP", station_idx=1, alt_names=("rain",), engine="pydap") assert "units" in attrs diff --git a/tox.ini b/tox.ini index 39b66dff..4ed94689 100644 --- a/tox.ini +++ b/tox.ini @@ -58,6 +58,7 @@ passenv = extras = dev gis + raven-hydro download = true install_command = python -m pip install --no-user {opts} {packages}