diff --git a/docs/md/cases.md b/docs/md/cases.md deleted file mode 100644 index e7957cd..0000000 --- a/docs/md/cases.md +++ /dev/null @@ -1,64 +0,0 @@ -# Cases - -Constructing test models in code typically involves defining variables or `pytest` fixtures in the same test script as the test function. While this is quick and effective for manually defining new scenarios, it tightly couples test functions to test cases, makes reuse of the test case by other tests more difficult, and tends to lead to duplication, as test scripts may reproduce similar testing and data-generating procedures. - -A minimal framework is provided for self-describing test cases which can be plugged into arbitrary test functions. At its core is the `Case` class, which is just a `SimpleNamespace` with a few defaults and a `copy_update()` method. This pairs nicely with [`pytest-cases`](https://smarie.github.io/python-pytest-cases/). - -## Overview - -A `Case` requires only a `name`, and has a single default attribute, `xfail=False`, indicating whether the test case is expected to succeed. (Test functions may of course choose to use or ignore this.) - -## Usage - -### Parametrizing with `Case` - -`Case` can be used with `@pytest.mark.parametrize()` as usual. For instance: - -```python -import pytest -from modflow_devtools.case import Case - -template = Case(name="QA") -cases = [ - template.copy_update(name=template.name + "1", - question="What's the meaning of life, the universe, and everything?", - answer=42), - template.copy_update(name=template.name + "2", - question="Is a Case immutable?", - answer="No, but it's probably best not to mutate it.") -] - - -@pytest.mark.parametrize("case", cases) -def test_cases(case): - assert len(cases) == 2 - assert cases[0] != cases[1] -``` - -### Generating cases dynamically - -One pattern possible with `pytest-cases` is to programmatically generate test cases by parametrizing a function. This can be a convenient way to produce several similar test cases from a template: - -```python -from pytest_cases import parametrize, parametrize_with_cases -from modflow_devtools.case import Case - - -template = Case(name="QA") -gen_cases = [template.copy_update(name=f"{template.name}{i}", question=f"Q{i}", answer=f"A{i}") for i in range(3)] -info = "cases can be modified further in the generator function,"\ - " or the function may construct and return another object" - - -@parametrize(case=gen_cases, ids=[c.name for c in gen_cases]) -def qa_cases(case): - return case.copy_update(info=info) - - -@parametrize_with_cases("case", cases=".", prefix="qa_") -def test_qa(case): - assert "QA" in case.name - assert info == case.info - print(f"{case.name}:", f"{case.question}? {case.answer}") - print(case.info) -``` \ No newline at end of file diff --git a/docs/md/executables.md b/docs/md/executables.md index 5f7f026..d235cc5 100644 --- a/docs/md/executables.md +++ b/docs/md/executables.md @@ -7,7 +7,7 @@ The `Executables` class maps executable names to paths on the filesystem. This i For example, assuming development binaries live in `bin` relative to the project root (as is currently the convention for `modflow6`), the following `pytest` fixtures could be defined: ```python -from modflow_devtools.executables import build_default_exe_dict, Executables +from modflow_devtools.executables import Executables @pytest.fixture(scope="session") def bin_path() -> Path: @@ -16,7 +16,10 @@ def bin_path() -> Path: @pytest.fixture(scope="session") def targets(bin_path) -> Executables: - return Executables(**build_default_exe_dict(bin_path)) + exes = { + # ...map names to paths + } + return Executables(**exes) ``` The `targets` fixture can then be injected into test functions: @@ -27,8 +30,6 @@ def test_targets(targets): assert targets["mf6"] == targets.mf6 ``` -The `build_default_exe_dict` function is provided to create the default executable mapping used by MODFLOW 6 autotests. - There is also a convenience function for getting a program's version string. The function will automatically strip the program name from the output (assumed delimited with `:`). ```python diff --git a/modflow_devtools/case.py b/modflow_devtools/case.py deleted file mode 100644 index abe2175..0000000 --- a/modflow_devtools/case.py +++ /dev/null @@ -1,43 +0,0 @@ -from types import SimpleNamespace - - -class Case(SimpleNamespace): - """ - Minimal container for a reusable test case. - """ - - def __init__(self, case: "Case" = None, **kwargs): - if case is not None: - super().__init__(**case.__dict__.copy()) - return - - if "name" not in kwargs: - raise ValueError(f"Case name is required") - - # set defaults - if "xfail" not in kwargs: - kwargs["xfail"] = False - # if 'compare' not in kwargs: - # kwargs['compare'] = True - - super().__init__(**kwargs) - - def __repr__(self): - return self.name - - def copy(self): - """ - Copies the test case. - """ - - return Case(**self.__dict__.copy()) - - def copy_update(self, **kwargs): - """ - A utility method for copying a test case with changes. - Recommended for dynamically generating similar cases. - """ - - cpy = self.__dict__.copy() - cpy.update(kwargs) - return Case(**cpy) diff --git a/modflow_devtools/context.py b/modflow_devtools/context.py deleted file mode 100644 index 89d94e6..0000000 --- a/modflow_devtools/context.py +++ /dev/null @@ -1,249 +0,0 @@ -import os -import subprocess -import sys -from enum import Enum -from shutil import which - - -class MFTargetType(Enum): - TEST = 1 - RELEASE = 2 - REGRESSION = 3 - - -class MFTestTargets: - """define test targets for modflow tests""" - - def __init__( - self, - testbin: str = None, - releasebin: str = None, - builtbin: str = None, - use_path: bool = False, - ): - """MFTestTargets init""" - - self._exe_targets = { - "mf6": {"exe": "mf6", "type": MFTargetType.TEST}, - "mf5to6": {"exe": "mf5to6", "type": MFTargetType.TEST}, - "zbud6": {"exe": "zbud6", "type": MFTargetType.TEST}, - "libmf6": {"exe": None, "type": MFTargetType.TEST}, - "mf2005": {"exe": "mf2005dbl", "type": MFTargetType.RELEASE}, - "mfnwt": {"exe": "mfnwtdbl", "type": MFTargetType.RELEASE}, - "mfusg": {"exe": "mfusgdbl", "type": MFTargetType.RELEASE}, - "mflgr": {"exe": "mflgrdbl", "type": MFTargetType.RELEASE}, - "mf2005s": {"exe": "mf2005", "type": MFTargetType.RELEASE}, - "mt3dms": {"exe": "mt3dms", "type": MFTargetType.RELEASE}, - "mf6-regression": {"exe": "mf6", "type": MFTargetType.REGRESSION}, - } - - self._testbin = testbin - self._releasebin = releasebin - self._builtbin = builtbin - self._use_path = use_path - self._target_path_d = None - - def set_targets(self): - """ - set target paths from current bin directories - """ - self._set_targets() - - def target_paths(self): - """ - get the target path dictionary generated by set_targets - """ - return self._target_path_d - - def get_mf6_version(self, version=None): - """ - get version of mf6 entry in _exe_targets - """ - return self._mf6_target_version(target=version) - - def target_exe_d(self): - """ - get the _exe_targets dictionary - """ - return self._exe_targets - - def release_exe_names(self): - """ - get name list of release executables - """ - target_ext, target_so = self._extensions() - return [ - f"{self._exe_targets[t]['exe']}{target_ext}" - for t in self._exe_targets - if self._exe_targets[t]["type"] == MFTargetType.RELEASE - and self._exe_targets[t]["exe"] - ] - - def release_lib_names(self): - """ - get name list of release libs - """ - target_ext, target_so = self._extensions() - return [ - f"{self._exe_targets[t]}{target_so}" - for t in self._exe_targets - if self._exe_targets[t]["type"] == MFTargetType.RELEASE - and self._exe_targets[t]["exe"] is None - ] - - def regression_exe_names(self): - """ - get name list of regression executables - """ - target_ext, target_so = self._extensions() - return [ - f"{self._exe_targets[t]['exe']}{target_ext}" - for t in self._exe_targets - if self._exe_targets[t]["type"] == MFTargetType.REGRESSION - and self._exe_targets[t]["exe"] - ] - - def regression_lib_names(self): - """ - get name list of regression libs - """ - target_ext, target_so = self._extensions() - return [ - f"{self._exe_targets[t]}{target_so}" - for t in self._exe_targets - if self._exe_targets[t]["type"] == MFTargetType.REGRESSION - and self._exe_targets[t]["exe"] is None - ] - - def _target_pth(self, target, target_t=None, is_lib=False): - if target_t == MFTargetType.TEST: - path = self._testbin - elif target_t == MFTargetType.REGRESSION: - path = self._builtbin - elif target_t == MFTargetType.RELEASE: - path = self._releasebin - - if self._use_path: - exe_exists = which(target) - else: - exe_exists = which(target, path=path) - - if ( - exe_exists is None - and is_lib - and os.path.isfile(os.path.join(path, target)) - ): - exe_exists = os.path.join(path, target) - - if exe_exists is None: - print(target) - raise Exception( - f"{target} does not exist or is not executable in test context." - ) - - return os.path.abspath(exe_exists) - - def _run_exe(self, argv, ws="."): - buff = [] - proc = subprocess.Popen( - argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=ws - ) - result, error = proc.communicate() - if result is not None: - c = result.decode("utf-8") - c = c.rstrip("\r\n") - # print(f"{c}") - buff.append(c) - - return proc.returncode, buff - - def _mf6_target_version(self, target=None): - exe = self._target_path_d[target] - return_code, buff = self._run_exe((exe, "-v")) - if return_code == 0: - version = buff[0].split()[1] - else: - version = None - return version - - def _set_targets(self): - self._target_path_d = None - target_ext, target_so = self._extensions() - - self._target_path_d = {} - for t in list(self._exe_targets): - is_lib = False - if self._exe_targets[t]["exe"] is None: - name = f"{t}{target_so}" - is_lib = True - else: - name = f"{self._exe_targets[t]['exe']}{target_ext}" - - target = self._target_pth( - name, target_t=self._exe_targets[t]["type"], is_lib=is_lib - ) - self._target_path_d[t] = target - - def _extensions(self): - target_ext = "" - target_so = ".so" - sysinfo = sys.platform.lower() - if sysinfo.lower() == "win32": - target_ext = ".exe" - target_so = ".dll" - elif sysinfo.lower() == "darwin": - target_so = ".dylib" - - return target_ext, target_so - - -class MFTestContext: - """setup test context for modflow tests""" - - def __init__( - self, - testbin: str = None, - use_path: bool = False, - update_exe: bool = False, - ): - """MFTestContext init""" - - self._testbin = os.path.abspath(testbin) - self._releasebin = os.path.abspath( - os.path.join(os.path.dirname(__file__), "bin") - ) - - builtbin = os.path.join(self._releasebin, "rebuilt") - - self._update = update_exe - - self._targets = MFTestTargets( - testbin=testbin, - releasebin=self._releasebin, - builtbin=builtbin, - use_path=use_path, - ) - - self._update_context() - - def get_target_dictionary(self): - """ - get target path dictionary - """ - return self._targets.target_paths() - - def get_mf6_version(self, version="mf6"): - """ - get mf6 version - """ - return self._targets.get_mf6_version(version=version) - - def _update_context(self): - if not self._exe.verify_exe() or ( - self._update and not self._exe.releases_current() - ): - self._exe.cleanup() - self._exe.download_releases() - self._exe.build_mf6_release() - - self._targets.set_targets() diff --git a/modflow_devtools/executables.py b/modflow_devtools/executables.py index ac58bc5..28c966d 100644 --- a/modflow_devtools/executables.py +++ b/modflow_devtools/executables.py @@ -38,47 +38,3 @@ def get_version(path: PathLike = None, flag: str = "-v") -> Optional[str]: return out.split(":")[1].strip() else: return None - - -def build_default_exe_dict(bin_path: PathLike) -> Dict[str, Path]: - d_bin = Path(bin_path) - d = dict() - - # paths to executables for previous versions of MODFLOW - dl_bin = d_bin / "downloaded" - rb_bin = d_bin / "rebuilt" - - # get platform-specific filename extensions - ext, so = get_suffixes(sys.platform) - - # downloaded executables - d["mf2000"] = dl_bin / f"mf2000{ext}" - d["mf2005"] = dl_bin / f"mf2005dbl{ext}" - d["mfnwt"] = dl_bin / f"mfnwtdbl{ext}" - d["mfusg"] = dl_bin / f"mfusgdbl{ext}" - d["mflgr"] = dl_bin / f"mflgrdbl{ext}" - d["mf2005s"] = dl_bin / f"mf2005{ext}" - d["mt3dms"] = dl_bin / f"mt3dms{ext}" - d["crt"] = dl_bin / f"crt{ext}" - d["gridgen"] = dl_bin / f"gridgen{ext}" - d["mp6"] = dl_bin / f"mp6{ext}" - d["mp7"] = dl_bin / f"mp7{ext}" - d["swtv4"] = dl_bin / f"swtv4{ext}" - d["sutra"] = dl_bin / f"sutra{ext}" - d["triangle"] = dl_bin / f"triangle{ext}" - d["vs2dt"] = dl_bin / f"vs2dt{ext}" - d["zonbudusg"] = dl_bin / f"zonbudusg{ext}" - - # executables rebuilt from last release - d["mf6_regression"] = rb_bin / f"mf6{ext}" - d["libmf6_regression"] = rb_bin / f"libmf6{so}" - d["mf5to6_regression"] = rb_bin / f"mf5to6{ext}" - d["zbud6_regression"] = rb_bin / f"zbud6{ext}" - - # local development version - d["mf6"] = d_bin / f"mf6{ext}" - d["libmf6"] = d_bin / f"libmf6{so}" - d["mf5to6"] = d_bin / f"mf5to6{ext}" - d["zbud6"] = d_bin / f"zbud6{ext}" - - return d diff --git a/modflow_devtools/fixtures.py b/modflow_devtools/fixtures.py index 83cba6a..66da777 100644 --- a/modflow_devtools/fixtures.py +++ b/modflow_devtools/fixtures.py @@ -5,9 +5,11 @@ from shutil import copytree, rmtree from typing import Dict, List, Optional -import pytest +from modflow_devtools.imports import import_optional_dependency from modflow_devtools.misc import get_namefile_paths, get_packages +pytest = import_optional_dependency("pytest") + # temporary directory fixtures diff --git a/modflow_devtools/imports.py b/modflow_devtools/imports.py new file mode 100644 index 0000000..2521fcc --- /dev/null +++ b/modflow_devtools/imports.py @@ -0,0 +1,150 @@ +# Adapted from https://github.com/pandas-dev/pandas/blob/master/pandas/compat/_optional.py + +# This file is dual licensed under the terms of the BSD 3-Clause License. +# BSD 3-Clause License +# +# Copyright (c) 2008-2011, AQR Capital Management, LLC, Lambda Foundry, Inc. and PyData Development Team +# All rights reserved. +# +# Copyright (c) 2011-2021, Open source contributors. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import annotations + +import importlib +import sys +import types +import warnings + +from packaging.version import Version + +VERSIONS = {} + + +# A mapping from import name to package name (on PyPI) for packages where +# these two names are different. +INSTALL_MAPPING = {} + + +def get_version(module: types.ModuleType) -> str: + version = getattr(module, "__version__", None) + if version is None: + # xlrd uses a capitalized attribute name + version = getattr(module, "__VERSION__", None) + + if version is None: + raise ImportError(f"Can't determine version for {module.__name__}") + return version + + +def import_optional_dependency( + name: str, + error_message: str = "", + errors: str = "raise", + min_version: str | None = None, +): + """ + Import an optional dependency. + + By default, if a dependency is missing an ImportError with a nice + message will be raised. If a dependency is present, but too old, + we raise. + + Parameters + ---------- + name : str + The module name. + error_message : str + Additional text to include in the ImportError message. + errors : str {'raise', 'warn', 'ignore'} + What to do when a dependency is not found or its version is too old. + + * raise : Raise an ImportError + * warn : Only applicable when a module's version is to old. + Warns that the version is too old and returns None + * ignore: If the module is not installed, return None, otherwise, + return the module, even if the version is too old. + It's expected that users validate the version locally when + using ``errors="ignore"`` (see. ``io/html.py``) + * silent: Same as "ignore" except warning message is not written to + the screen. + min_version : str, default None + Specify a minimum version that is different from the global + minimum version required. + Returns + ------- + maybe_module : Optional[ModuleType] + The imported module, when found and the version is correct. + None is returned when the package is not found and `errors` + is False, or when the package's version is too old and `errors` + is ``'warn'``. + """ + + assert errors in {"warn", "raise", "ignore", "silent"} + + package_name = INSTALL_MAPPING.get(name) + install_name = package_name if package_name is not None else name + + msg = ( + f"Missing optional dependency '{install_name}'. {error_message} " + f"Use pip or conda to install {install_name}." + ) + try: + module = importlib.import_module(name) + except ImportError: + if errors == "raise": + raise ImportError(msg) + else: + if errors != "silent": + print(msg) + return None + + # Handle submodules: if we have submodule, grab parent module from sys.modules + parent = name.split(".")[0] + if parent != name: + install_name = parent + module_to_get = sys.modules[install_name] + else: + module_to_get = module + minimum_version = ( + min_version if min_version is not None else VERSIONS.get(parent) + ) + if minimum_version: + version = get_version(module_to_get) + if Version(version) < Version(minimum_version): + msg = ( + f"Version '{minimum_version}' " + f"or newer of '{parent}' required" + f"('{version}' currently installed)." + ) + if errors == "warn": + warnings.warn(msg, UserWarning) + return None + elif errors == "raise": + raise ImportError(msg) + + return module diff --git a/modflow_devtools/markers.py b/modflow_devtools/markers.py index 7099930..0aa4b25 100644 --- a/modflow_devtools/markers.py +++ b/modflow_devtools/markers.py @@ -1,6 +1,6 @@ from platform import system -import pytest +from modflow_devtools.imports import import_optional_dependency from modflow_devtools.misc import ( get_current_branch, has_exe, @@ -9,6 +9,8 @@ is_in_ci, ) +pytest = import_optional_dependency("pytest") + def requires_exe(*exes): missing = {exe for exe in exes if not has_exe(exe)} diff --git a/modflow_devtools/test/test_case.py b/modflow_devtools/test/test_case.py deleted file mode 100644 index 88c2744..0000000 --- a/modflow_devtools/test/test_case.py +++ /dev/null @@ -1,80 +0,0 @@ -import pytest -from modflow_devtools.case import Case -from pytest_cases import parametrize, parametrize_with_cases - - -def test_requires_name(): - with pytest.raises(ValueError): - Case() - - -def test_defaults(): - assert not Case(name="test").xfail - - -def test_copy(): - case = Case(name="test", foo="bar") - copy = case.copy() - - assert case is not copy - assert case == copy - - -def test_copy_update(): - case = Case(name="test", foo="bar") - copy = case.copy_update() - - assert case is not copy - assert case == copy - - copy2 = case.copy_update(foo="baz") - - assert copy is not copy2 - assert copy.foo == "bar" - assert copy2.foo == "baz" - - -template = Case(name="QA") -cases = [ - template.copy_update( - name=template.name + "1", - question="What's the meaning of life, the universe, and everything?", - answer=42, - ), - template.copy_update( - name=template.name + "2", - question="Is a Case immutable?", - answer="No, but it's probably best not to mutate it.", - ), -] - - -@pytest.mark.parametrize("case", cases) -def test_cases(case): - assert len(cases) == 2 - assert cases[0] != cases[1] - - -gen_cases = [ - template.copy_update( - name=f"{template.name}{i}", question=f"Q{i}", answer=f"A{i}" - ) - for i in range(3) -] -info = ( - "cases can be modified further in the generator function," - " or the function may construct and return another object" -) - - -@parametrize(case=gen_cases, ids=[c.name for c in gen_cases]) -def qa_cases(case): - return case.copy_update(info=info) - - -@parametrize_with_cases("case", cases=".", prefix="qa_") -def test_qa(case): - assert "QA" in case.name - assert info == case.info - print(f"{case.name}:", f"{case.question}? {case.answer}") - print(case.info) diff --git a/modflow_devtools/test/test_executables.py b/modflow_devtools/test/test_executables.py index 338270f..483103a 100644 --- a/modflow_devtools/test/test_executables.py +++ b/modflow_devtools/test/test_executables.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from modflow_devtools.executables import Executables, build_default_exe_dict +from modflow_devtools.executables import Executables from modflow_devtools.misc import add_sys_path, get_suffixes _bin_path = Path(environ.get("BIN_PATH")).expanduser().absolute() @@ -17,7 +17,11 @@ def exes(): pytest.skip(f"BIN_PATH ({_bin_path}) is not a directory") with add_sys_path(str(_bin_path)): - yield Executables(**build_default_exe_dict(_bin_path)) + yield Executables( + **{ + "mf6": _bin_path / f"mf6{_ext}", + } + ) def test_get_version(exes): @@ -38,4 +42,3 @@ def test_mapping(exes): exes.mf6 == exes["mf6"] ) # should support both attribute and dictionary access assert exes.mf6 == _bin_path / f"mf6{_ext}" # should be the correct path - assert exes.mf6_regression.parent.parent == _bin_path