diff --git a/HISTORY.md b/HISTORY.md index f7d4e74..906d6a1 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,13 @@ +### Version 1.0.0 + +#### New features + +* [feat(ostags)](https://github.com/MODFLOW-USGS/modflow-devtools/commit/33ab22a5f7e1c88258038e9881f22c6cd537965c): Add OS tag conversion utilities (#99). Committed by w-bonelli on 2023-08-05. + +#### Refactoring + +* [refactor](https://github.com/MODFLOW-USGS/modflow-devtools/commit/07bd60fff92a0dab08721c167293344a827d6345): Multiple (#100). Committed by w-bonelli on 2023-08-05. + ### Version 0.3.0 #### Refactoring diff --git a/README.md b/README.md index d9f2e34..75711ea 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Python development tools for MODFLOW 6. This is a small toolkit for developing MODFLOW 6, FloPy, and related projects. It includes standalone utilities and optional [Pytest](https://github.com/pytest-dev/pytest) extensions. -Standalone utilities include a very minimal GitHub API client, mainly for retrieving release information and downloading assets, and a `ZipFile` subclass that [preserves file permissions](https://stackoverflow.com/questions/39296101/python-zipfile-removes-execute-permissions-from-binaries) (workaround for [Python #15795](https://bugs.python.org/issue15795)) +The former include a very minimal GitHub API client for retrieving release information and downloading assets, a `ZipFile` subclass that [preserves file permissions](https://stackoverflow.com/questions/39296101/python-zipfile-removes-execute-permissions-from-binaries) (workaround for [Python #15795](https://bugs.python.org/issue15795)), and other release/distribution-related tools. Pytest features include: diff --git a/docs/conf.py b/docs/conf.py index 8a04df4..0048764 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,7 +8,7 @@ project = "modflow-devtools" author = "MODFLOW Team" -release = "0.3.0" +release = "1.0.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/index.rst b/docs/index.rst index 91030de..cbeaa40 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,7 +19,6 @@ The `modflow-devtools` package provides a set of tools for developing and testin :maxdepth: 2 :caption: Test fixtures - md/cases.md md/executables.md md/fixtures.md md/markers.md @@ -30,6 +29,7 @@ The `modflow-devtools` package provides a set of tools for developing and testin :caption: Miscellaneous md/download.md + md/ostags.md md/zip.md 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/docs/md/ostags.md b/docs/md/ostags.md new file mode 100644 index 0000000..e140d98 --- /dev/null +++ b/docs/md/ostags.md @@ -0,0 +1,45 @@ +# OS Tags + +MODFLOW 6, Python3, build servers, and other systems may refer to operating systems by different names. Utilities are provided in the `modflow_devtools.ostags` module to convert between + +- the output of `platform.system()` +- GitHub Actions `runner.os` tags +- MODFLOW 6 release asset OS tags + +Only Linux, Mac and Windows are supported. + +## Tag specification + +Python3's `platform.system()` returns "Linux", "Darwin", and "Windows", respectively. + +GitHub Actions (e.g. `runner.os` context) use "Linux", "macOS" and "Windows". + +MODFLOW 6 release asset names end with "linux", "mac" or "win64". + +## Getting tags + +To get the MODFLOW 6 or GitHub tag for the current OS, use: + +- `get_modflow_ostag()` +- `get_github_ostag()` + +## Converting tags + +Conversion functions are available for each direction: + +- `python_to_modflow_ostag(tag)` +- `modflow_to_python_ostag(tag)` +- `modflow_to_github_ostag(tag)` +- `github_to_modflow_ostag(tag)` +- `python_to_github_ostag(tag)` +- `github_to_python_ostag(tag)` + +Alternatively: + +```python +OSTag.convert(platform.system(), "py2mf") +``` + +The second argument specifies the mapping in format `2`, where `` and `` may take values `py`, `mf`, or `gh`. + +**Note**: source and target must be different. diff --git a/modflow_devtools/__init__.py b/modflow_devtools/__init__.py index 5db56f7..ea1d042 100644 --- a/modflow_devtools/__init__.py +++ b/modflow_devtools/__init__.py @@ -1,6 +1,6 @@ __author__ = "Joseph D. Hughes" -__date__ = "Aug 04, 2023" -__version__ = "0.3.0" +__date__ = "Aug 05, 2023" +__version__ = "1.0.0" __maintainer__ = "Joseph D. Hughes" __email__ = "jdhughes@usgs.gov" __status__ = "Production" 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/ostags.py b/modflow_devtools/ostags.py new file mode 100644 index 0000000..55e7706 --- /dev/null +++ b/modflow_devtools/ostags.py @@ -0,0 +1,147 @@ +""" +MODFLOW 6, Python3, and build servers may all refer to operating +systems by different names. This module contains conversion utilities. +""" + + +from enum import Enum +from platform import system + +_system = system() + + +def get_modflow_ostag() -> str: + if _system == "Windows": + return "win64" + elif _system == "Linux": + return "linux" + elif _system == "Darwin": + return "mac" + else: + raise NotImplementedError(f"Unsupported system: {_system}") + + +def get_github_ostag() -> str: + if _system in ("Windows", "Linux"): + return _system + elif _system == "Darwin": + return "macOS" + else: + raise NotImplementedError(f"Unsupported system: {_system}") + + +def python_to_modflow_ostag(tag: str) -> str: + """ + Convert a platform.system() string to an ostag as expected + by MODFLOW 6. + + Parameters + ---------- + platform_system : str + The platform.system() string. + + Returns + ------- + str + """ + + if tag == "Windows": + return "win64" + elif tag == "Linux": + return "linux" + elif tag == "Darwin": + return "mac" + else: + raise ValueError(f"Invalid or unsupported tag: {tag}") + + +def modflow_to_python_ostag(tag: str) -> str: + """ + Convert a MODFLOW os tag to a platform.system() string. + + Parameters + ---------- + tag : str + The MODFLOW os tag. + + Returns + ------- + str + """ + + if tag == "win64": + return "Windows" + elif tag == "linux": + return "Linux" + elif tag == "mac": + return "Darwin" + else: + raise ValueError(f"Invalid or unsupported tag: {tag}") + + +def modflow_to_github_ostag(tag: str) -> str: + if tag == "win64": + return "Windows" + elif tag == "linux": + return "Linux" + elif tag == "mac": + return "macOS" + else: + raise ValueError(f"Invalid modflow os tag: {tag}") + + +def github_to_modflow_ostag(tag: str) -> str: + if tag == "Windows": + return "win64" + elif tag == "Linux": + return "linux" + elif tag == "macOS": + return "mac" + else: + raise ValueError(f"Invalid github os tag: {tag}") + + +def python_to_github_ostag(tag: str) -> str: + return modflow_to_github_ostag(python_to_modflow_ostag(tag)) + + +def github_to_python_ostag(tag: str) -> str: + return modflow_to_python_ostag(github_to_modflow_ostag(tag)) + + +def get_ostag(kind: str = "modflow") -> str: + if kind == "modflow": + return get_modflow_ostag() + elif kind == "github": + return get_github_ostag() + else: + raise ValueError(f"Invalid kind: {kind}") + + +class OSTagCvt(Enum): + py2mf = "py2mf" + mf2py = "mf2py" + gh2mf = "gh2mf" + mf2gh = "mf2gh" + py2gh = "py2gh" + gh2py = "gh2py" + + +class OSTag: + @staticmethod + def convert(tag: str, cvt: str) -> str: + cvt = OSTagCvt(cvt) + if cvt == OSTagCvt.py2mf: + return python_to_modflow_ostag(tag) + elif cvt == OSTagCvt.mf2py: + return modflow_to_python_ostag(tag) + elif cvt == OSTagCvt.gh2mf: + return github_to_modflow_ostag(tag) + elif cvt == OSTagCvt.mf2gh: + return modflow_to_github_ostag(tag) + elif cvt == OSTagCvt.py2gh: + return python_to_github_ostag(tag) + elif cvt == OSTagCvt.gh2py: + return github_to_python_ostag(tag) + else: + raise ValueError(f"Unsupported mapping: {cvt}") 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 diff --git a/modflow_devtools/test/test_ostags.py b/modflow_devtools/test/test_ostags.py new file mode 100644 index 0000000..02d9adb --- /dev/null +++ b/modflow_devtools/test/test_ostags.py @@ -0,0 +1,55 @@ +from platform import system + +import pytest +from modflow_devtools.ostags import OSTag, get_github_ostag, get_modflow_ostag + +_system = system() + + +def test_get_modflow_ostag(): + t = get_modflow_ostag() + if _system == "Windows": + assert t == "win64" + elif _system == "Linux": + assert t == "linux" + elif _system == "Darwin": + assert t == "mac" + else: + pytest.skip(reason="Unsupported platform") + + +def test_get_github_ostag(): + t = get_github_ostag() + if _system in ("Windows", "Linux"): + assert t == _system + elif _system == "Darwin": + assert t == "macOS" + else: + pytest.skip(reason="Unsupported platform") + + +@pytest.mark.parametrize( + "cvt,tag,exp", + [ + ("py2mf", "Windows", "win64"), + ("mf2py", "win64", "Windows"), + ("py2mf", "Darwin", "mac"), + ("mf2py", "mac", "Darwin"), + ("py2mf", "Linux", "linux"), + ("mf2py", "linux", "Linux"), + ("gh2mf", "Windows", "win64"), + ("mf2gh", "win64", "Windows"), + ("gh2mf", "macOS", "mac"), + ("mf2gh", "mac", "macOS"), + ("gh2mf", "Linux", "linux"), + ("mf2gh", "linux", "Linux"), + ("py2gh", "Windows", "Windows"), + ("gh2py", "Windows", "Windows"), + ("py2gh", "Darwin", "macOS"), + ("gh2py", "macOS", "Darwin"), + ("py2gh", "Linux", "Linux"), + ("gh2py", "Linux", "Linux"), + ], +) +def test_ostag_convert(cvt, tag, exp): + assert OSTag.convert(tag, cvt) == exp diff --git a/pyproject.toml b/pyproject.toml index 78beab2..3dedbcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ keywords = [ readme = "README.md" license = {text = "CC0"} classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Science/Research", "License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", "Programming Language :: Python :: 3 :: Only", diff --git a/version.txt b/version.txt index 9325c3c..afaf360 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.3.0 \ No newline at end of file +1.0.0 \ No newline at end of file