From 9c03578845bbd2658c6be196d67148da9f16cf3c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Nov 2023 16:52:13 +0000 Subject: [PATCH 01/10] ci(release): update to development version 1.4.0.dev0 --- docs/conf.py | 2 +- modflow_devtools/__init__.py | 2 +- version.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 50b020a..b13c9f2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,7 +8,7 @@ project = "modflow-devtools" author = "MODFLOW Team" -release = "1.3.1" +release = "1.4.0.dev0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/modflow_devtools/__init__.py b/modflow_devtools/__init__.py index a082e8c..0399214 100644 --- a/modflow_devtools/__init__.py +++ b/modflow_devtools/__init__.py @@ -1,6 +1,6 @@ __author__ = "Joseph D. Hughes" __date__ = "Nov 21, 2023" -__version__ = "1.3.1" +__version__ = "1.4.0.dev0" __maintainer__ = "Joseph D. Hughes" __email__ = "jdhughes@usgs.gov" __status__ = "Production" diff --git a/version.txt b/version.txt index 6261a05..b58da95 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.3.1 \ No newline at end of file +1.4.0.dev0 \ No newline at end of file From 3129417dae2de3aece80c8056a2ac50eede56b91 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 18 Dec 2023 12:45:57 -0500 Subject: [PATCH 02/10] feat(Executables): support collection-style membership test (#131) --- autotest/test_executables.py | 3 +++ modflow_devtools/executables.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/autotest/test_executables.py b/autotest/test_executables.py index c06edc3..7f9f330 100644 --- a/autotest/test_executables.py +++ b/autotest/test_executables.py @@ -26,3 +26,6 @@ def test_access(exes): # .get() works too assert exes.get("not a target") is None assert exes.get("not a target", exes["pytest"]) == exes["pytest"] + # membership test + assert "not a target" not in exes + assert "pytest" in exes diff --git a/modflow_devtools/executables.py b/modflow_devtools/executables.py index 9e34149..59408ae 100644 --- a/modflow_devtools/executables.py +++ b/modflow_devtools/executables.py @@ -16,6 +16,9 @@ class Executables(SimpleNamespace): def __init__(self, **kwargs): super().__init__(**kwargs) + def __contains__(self, item): + return item in self.__dict__ + def __setitem__(self, key, item): self.__dict__[key] = item From 6728859a984a3080f8fd4f1135de36bc17454098 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 9 Jan 2024 12:49:08 -0500 Subject: [PATCH 03/10] feat: add latex and plot style utilities (#132) * move figspec and latex utilities from mf6 examples repo * add matplotlib as optional dependency * update readme --- .github/workflows/ci.yml | 2 +- README.md | 10 +- autotest/test_figspec.py | 5 + autotest/test_latex.py | 0 docs/index.rst | 2 + docs/md/figspec.md | 33 +++ docs/md/latex.md | 3 + modflow_devtools/figspec.py | 561 ++++++++++++++++++++++++++++++++++++ modflow_devtools/latex.py | 97 +++++++ pyproject.toml | 4 + 10 files changed, 714 insertions(+), 3 deletions(-) create mode 100644 autotest/test_figspec.py create mode 100644 autotest/test_latex.py create mode 100644 docs/md/figspec.md create mode 100644 docs/md/latex.md create mode 100644 modflow_devtools/figspec.py create mode 100644 modflow_devtools/latex.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd5a40c..c6227ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,7 @@ jobs: working-directory: modflow-devtools run: | pip install . - pip install ".[test]" + pip install ".[test, optional]" - name: Cache modflow6 examples id: cache-examples diff --git a/README.md b/README.md index 1ae2c7d..7f04b87 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,13 @@ Python development tools for MODFLOW 6. ## Use cases -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. +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) and [Matplotlib](https://matplotlib.org/stable/) extensions. -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. +Utilities include: + +* a 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)) +* other release/distribution-related tools Pytest features include: @@ -46,6 +50,8 @@ Pytest features include: - `MODFLOW-USGS/modflow6-testmodels` - `MODFLOW-USGS/modflow6-largetestmodels` +Matplotlib styles are provided in the `modflow_devtools.figspecs` module. + ## Requirements Python3.8+, dependency-free, but pairs well with `pytest` and select plugins, e.g. diff --git a/autotest/test_figspec.py b/autotest/test_figspec.py new file mode 100644 index 0000000..3337582 --- /dev/null +++ b/autotest/test_figspec.py @@ -0,0 +1,5 @@ +from modflow_devtools.figspec import USGSFigure + + +def test_usgs_figure(): + fig = USGSFigure() diff --git a/autotest/test_latex.py b/autotest/test_latex.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/index.rst b/docs/index.rst index e71195d..ebd2c55 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,6 +29,8 @@ The `modflow-devtools` package provides a set of tools for developing and testin :caption: Miscellaneous md/download.md + md/figspec.md + md/latex.md md/ostags.md md/zip.md md/timed.md diff --git a/docs/md/figspec.md b/docs/md/figspec.md new file mode 100644 index 0000000..1399bae --- /dev/null +++ b/docs/md/figspec.md @@ -0,0 +1,33 @@ +# Plot styles + +Matplotlib is an optional dependency, installable via e.g. `pip install modflow_devtools[optional]`. + +## `USGSFigure` + +A convenience class `modflow_devtools.figspec.USGSFigure` is provided to create figures with the default USGS style sheet. For instance: + +```python +# create figure +fs = USGSFigure(figure_type="graph", verbose=False) + +# ...add some plots + +# add a heading +title = f"Layer {ilay + 1}" +letter = chr(ord("@") + idx + 2) +fs.heading(letter=letter, heading=title) + +# add an annotation +fs.add_annotation( + ax=ax, + text="Well 1, layer 2", + bold=False, + italic=False, + xy=w1loc, + xytext=(w1loc[0] - 3200, w1loc[1] + 1500), + ha="right", + va="center", + zorder=100, + arrowprops=arrow_props, +) +``` \ No newline at end of file diff --git a/docs/md/latex.md b/docs/md/latex.md new file mode 100644 index 0000000..61d76a5 --- /dev/null +++ b/docs/md/latex.md @@ -0,0 +1,3 @@ +# LaTeX utilities + +The `modflow_devtools.latex` module provides utility functions for building LaTeX tables from arrays. \ No newline at end of file diff --git a/modflow_devtools/figspec.py b/modflow_devtools/figspec.py new file mode 100644 index 0000000..568db57 --- /dev/null +++ b/modflow_devtools/figspec.py @@ -0,0 +1,561 @@ +import numpy as np + +from modflow_devtools.imports import import_optional_dependency + +mpl = import_optional_dependency("matplotlib") +import sys + +import numpy as np + + +class USGSFigure: + def __init__( + self, figure_type="map", family="Arial Narrow", verbose=False + ): + """Create a USGSFigure object + + Parameters + ---------- + figure_type : str + figure type ("map", "graph") + family : str + font family name (default is Arial Narrow) + verbose : bool + boolean that define if debug information should be written + """ + # initialize members + self.family = None + self.figure_type = None + self.verbose = verbose + self.family = self._set_fontfamily(family) + self.figure_type = self._validate_figure_type(figure_type) + + def graph_legend(self, ax=None, handles=None, labels=None, **kwargs): + """Add a USGS-style legend to a matplotlib axis object + + Parameters + ---------- + ax : axis object + matplotlib axis object (default is None) + handles : list + list of legend handles + labels : list + list of labels for legend handles + kwargs : kwargs + matplotlib legend kwargs + + Returns + ------- + leg : object + matplotlib legend object + + """ + if ax is None: + ax = mpl.pyplot.gca() + + font = self._set_fontspec(bold=True, italic=False) + if handles is None or labels is None: + handles, labels = ax.get_legend_handles_labels() + leg = ax.legend(handles, labels, prop=font, **kwargs) + + # add title to legend + if "title" in kwargs: + title = kwargs.pop("title") + else: + title = None + leg = self.graph_legend_title(leg, title=title) + return leg + + def graph_legend_title(self, leg, title=None): + """Set the legend title for a matplotlib legend object + + Parameters + ---------- + leg : legend object + matplotlib legend object + title : str + title for legend + + Returns + ------- + leg : object + matplotlib legend object + + """ + if title is None: + title = "EXPLANATION" + elif title.lower() == "none": + title = None + font = self._set_fontspec(bold=True, italic=False) + leg.set_title(title, prop=font) + return leg + + def heading( + self, ax=None, letter=None, heading=None, x=0.00, y=1.01, idx=None + ): + """Add a USGS-style heading to a matplotlib axis object + + Parameters + ---------- + ax : axis object + matplotlib axis object (default is None) + letter : str + string that defines the subplot (A, B, C, etc.) + heading : str + text string + x : float + location of the heading in the x-direction in normalized plot dimensions + ranging from 0 to 1 (default is 0.00) + y : float + location of the heading in the y-direction in normalized plot dimensions + ranging from 0 to 1 (default is 1.01) + idx : int + index for programatically generating the heading letter when letter + is None and idx is not None. idx = 0 will generate A (default is None) + + Returns + ------- + text : object + matplotlib text object + + """ + if ax is None: + ax = mpl.pyplot.gca() + + if letter is None and idx is not None: + letter = chr(ord("A") + idx) + + text = None + if letter is not None: + font = self._set_fontspec(bold=True, italic=True) + if heading is None: + letter = letter.replace(".", "") + else: + letter = letter.rstrip() + if letter[-1] != ".": + letter += "." + letter += " " + ax.text( + x, + y, + letter, + va="bottom", + ha="left", + fontdict=font, + transform=ax.transAxes, + ) + bbox = ax.get_window_extent().transformed( + mpl.pyplot.gcf().dpi_scale_trans.inverted() + ) + width = bbox.width * 25.4 # inches to mm + x += len(letter) * 1.0 / width + if heading is not None: + font = self._set_fontspec(bold=True, italic=False) + text = ax.text( + x, + y, + heading, + va="bottom", + ha="left", + fontdict=font, + transform=ax.transAxes, + ) + return text + + def add_text( + self, + ax=None, + text="", + x=0.0, + y=0.0, + transform=True, + bold=True, + italic=True, + fontsize=9, + ha="left", + va="bottom", + **kwargs, + ): + """Add USGS-style text to a axis object + + Parameters + ---------- + ax : axis object + matplotlib axis object (default is None) + text : str + text string + x : float + x-location of text string (default is 0.) + y : float + y-location of text string (default is 0.) + transform : bool + boolean that determines if a transformed (True) or data (False) coordinate + system is used to define the (x, y) location of the text string + (default is True) + bold : bool + boolean indicating if bold font (default is True) + italic : bool + boolean indicating if italic font (default is True) + fontsize : int + font size (default is 9 points) + ha : str + matplotlib horizontal alignment keyword (default is left) + va : str + matplotlib vertical alignment keyword (default is bottom) + kwargs : dict + dictionary with valid matplotlib text object keywords + + Returns + ------- + text_obj : object + matplotlib text object + + """ + if ax is None: + ax = mpl.pyplot.gca() + + if transform: + transform = ax.transAxes + else: + transform = ax.transData + + font = self._set_fontspec(bold=bold, italic=italic, fontsize=fontsize) + + text_obj = ax.text( + x, + y, + text, + va=va, + ha=ha, + fontdict=font, + transform=transform, + **kwargs, + ) + return text_obj + + def add_annotation( + self, + ax=None, + text="", + xy=None, + xytext=None, + bold=True, + italic=True, + fontsize=9, + ha="left", + va="bottom", + **kwargs, + ): + """Add an annotation to a axis object + + Parameters + ---------- + ax : axis object + matplotlib axis object (default is None) + text : str + text string + xy : tuple + tuple with the location of the annotation (default is None) + xytext : tuple + tuple with the location of the text + bold : bool + boolean indicating if bold font (default is True) + italic : bool + boolean indicating if italic font (default is True) + fontsize : int + font size (default is 9 points) + ha : str + matplotlib horizontal alignment keyword (default is left) + va : str + matplotlib vertical alignment keyword (default is bottom) + kwargs : dict + dictionary with valid matplotlib annotation object keywords + + Returns + ------- + ann_obj : object + matplotlib annotation object + + """ + if ax is None: + ax = mpl.pyplot.gca() + + if xy is None: + xy = (0.0, 0.0) + + if xytext is None: + xytext = (0.0, 0.0) + + font = self._set_fontspec(bold=bold, italic=italic, fontsize=fontsize) + + # add font information to kwargs + if kwargs is None: + kwargs = font + else: + for key, value in font.items(): + kwargs[key] = value + + # create annotation + ann_obj = ax.annotate(text, xy, xytext, va=va, ha=ha, **kwargs) + + return ann_obj + + def remove_edge_ticks(self, ax=None): + """Remove unnecessary ticks on the edges of the plot + + Parameters + ---------- + ax : axis object + matplotlib axis object (default is None) + + Returns + ------- + ax : axis object + matplotlib axis object + + """ + if ax is None: + ax = mpl.pyplot.gca() + + # update tick objects + mpl.pyplot.draw() + + # get min and max value and ticks + ymin, ymax = ax.get_ylim() + + # check for condition where y-axis values are reversed + if ymax < ymin: + y = ymin + ymin = ymax + ymax = y + yticks = ax.get_yticks() + + if self.verbose: + print("y-axis: ", ymin, ymax) + print(yticks) + + # remove edge ticks on y-axis + ticks = ax.yaxis.majorTicks + for iloc in [0, -1]: + if np.allclose(float(yticks[iloc]), ymin): + ticks[iloc].tick1line.set_visible = False + ticks[iloc].tick2line.set_visible = False + if np.allclose(float(yticks[iloc]), ymax): + ticks[iloc].tick1line.set_visible = False + ticks[iloc].tick2line.set_visible = False + + # get min and max value and ticks + xmin, xmax = ax.get_xlim() + + # check for condition where x-axis values are reversed + if xmax < xmin: + x = xmin + xmin = xmax + xmax = x + + xticks = ax.get_xticks() + if self.verbose: + print("x-axis: ", xmin, xmax) + print(xticks) + + # remove edge ticks on y-axis + ticks = ax.xaxis.majorTicks + for iloc in [0, -1]: + if np.allclose(float(xticks[iloc]), xmin): + ticks[iloc].tick1line.set_visible = False + ticks[iloc].tick2line.set_visible = False + if np.allclose(float(xticks[iloc]), xmax): + ticks[iloc].tick1line.set_visible = False + ticks[iloc].tick2line.set_visible = False + + return ax + + # private methods + + def _validate_figure_type(self, figure_type): + """Set figure type after validation of specified figure type + + Parameters + ---------- + figure_type : str + figure type ("map", "graph") + + Returns + ------- + figure_type : str + validated figure_type + + """ + # validate figure type + valid_types = ("map", "graph") + if figure_type not in valid_types: + errmsg = "invalid figure_type specified ({}) ".format( + figure_type + ) + "valid types are '{}'.".format(", ".join(valid_types)) + raise ValueError(errmsg) + + # set figure_type + if figure_type == "map": + self._set_map_specifications() + elif figure_type == "graph": + self._set_map_specifications() + + return figure_type + + def _set_graph_specifications(self): + """Set matplotlib rcparams to USGS-style specifications for graphs + + Returns + ------- + + """ + rc_dict = { + "font.family": self.family, + "font.size": 7, + "axes.labelsize": 9, + "axes.titlesize": 9, + "axes.linewidth": 0.5, + "xtick.labelsize": 8, + "xtick.top": True, + "xtick.bottom": True, + "xtick.major.size": 7.2, + "xtick.minor.size": 3.6, + "xtick.major.width": 0.5, + "xtick.minor.width": 0.5, + "xtick.direction": "in", + "ytick.labelsize": 8, + "ytick.left": True, + "ytick.right": True, + "ytick.major.size": 7.2, + "ytick.minor.size": 3.6, + "ytick.major.width": 0.5, + "ytick.minor.width": 0.5, + "ytick.direction": "in", + "pdf.fonttype": 42, + "savefig.dpi": 300, + "savefig.transparent": True, + "legend.fontsize": 9, + "legend.frameon": False, + "legend.markerscale": 1.0, + } + mpl.rcParams.update(rc_dict) + + def _set_map_specifications(self): + """Set matplotlib rcparams to USGS-style specifications for maps + + Returns + ------- + + """ + rc_dict = { + "font.family": self.family, + "font.size": 7, + "axes.labelsize": 9, + "axes.titlesize": 9, + "axes.linewidth": 0.5, + "xtick.labelsize": 7, + "xtick.top": True, + "xtick.bottom": True, + "xtick.major.size": 7.2, + "xtick.minor.size": 3.6, + "xtick.major.width": 0.5, + "xtick.minor.width": 0.5, + "xtick.direction": "in", + "ytick.labelsize": 7, + "ytick.left": True, + "ytick.right": True, + "ytick.major.size": 7.2, + "ytick.minor.size": 3.6, + "ytick.major.width": 0.5, + "ytick.minor.width": 0.5, + "ytick.direction": "in", + "pdf.fonttype": 42, + "savefig.dpi": 300, + "savefig.transparent": True, + "legend.fontsize": 9, + "legend.frameon": False, + "legend.markerscale": 1.0, + } + mpl.rcParams.update(rc_dict) + + def _set_fontspec( + self, + bold=True, + italic=True, + fontsize=9, + verbose=False, + ): + """Create fontspec dictionary for matplotlib pyplot objects + + Parameters + ---------- + bold : bool + boolean indicating if font is bold (default is True) + italic : bool + boolean indicating if font is italic (default is True) + fontsize : int + font size (default is 9 point) + + + Returns + ------- + + """ + univers = "Univers" in self.family + family = None if univers else self.family + + if bold: + weight = "bold" + if univers: + family = "Univers 67" + else: + weight = "normal" + if univers: + family = "Univers 57" + + if italic: + if univers: + family += " Condensed Oblique" + style = "oblique" + else: + style = "italic" + else: + if univers: + family += " Condensed" + style = "normal" + + # define fontspec dictionary + fontspec = { + "family": family, + "size": fontsize, + "weight": weight, + "style": style, + } + + if verbose: + sys.stdout.write("font specifications:\n ") + for key, value in fontspec.items(): + sys.stdout.write(f"{key}={value} ") + sys.stdout.write("\n") + + return fontspec + + def _set_fontfamily(self, family): + """Set font family to Liberation Sans Narrow on linux if default Arial Narrow + is being used + + Parameters + ---------- + family : str + font family name (default is Arial Narrow) + + Returns + ------- + family : str + font family name + + """ + if sys.platform.lower() in ("linux",): + if family == "Arial Narrow": + family = "Liberation Sans Narrow" + return family diff --git a/modflow_devtools/latex.py b/modflow_devtools/latex.py new file mode 100644 index 0000000..5d6f5eb --- /dev/null +++ b/modflow_devtools/latex.py @@ -0,0 +1,97 @@ +import os + + +def build_table(caption, fpth, arr, headings=None, col_widths=None): + if headings is None: + headings = arr.dtype.names + ncols = len(arr.dtype.names) + if not fpth.endswith(".tex"): + fpth += ".tex" + label = "tab:{}".format(os.path.basename(fpth).replace(".tex", "")) + + line = get_header(caption, label, headings, col_widths=col_widths) + + for idx in range(arr.shape[0]): + if idx % 2 != 0: + line += "\t\t\\rowcolor{Gray}\n" + line += "\t\t" + for jdx, name in enumerate(arr.dtype.names): + line += f"{arr[name][idx]}" + if jdx < ncols - 1: + line += " & " + line += " \\\\\n" + + # footer + line += get_footer() + + with open(fpth, "w") as f: + f.write(line) + + +def get_header( + caption, label, headings, col_widths=None, center=True, firsthead=False +): + ncol = len(headings) + if col_widths is None: + dx = 0.8 / float(ncol) + col_widths = [dx for idx in range(ncol)] + if center: + align = "p" + else: + align = "p" + + header = "\\small\n" + header += "\\begin{longtable}[!htbp]{\n" + for col_width in col_widths: + header += ( + 38 * " " + + f"{align}" + + f"{{{col_width}\\linewidth-2\\arraycolsep}}\n" + ) + header += 38 * " " + "}\n" + header += f"\t\\caption{{{caption}}} \\label{{{label}}} \\\\\n\n" + + if firsthead: + header += "\t\\hline \\hline\n" + header += "\t\\rowcolor{Gray}\n" + header += "\t" + for idx, s in enumerate(headings): + header += f"\\textbf{{{s}}}" + if idx < len(headings) - 1: + header += " & " + header += " \\\\\n" + header += "\t\\hline\n" + header += "\t\\endfirsthead\n\n" + + header += "\t\\hline \\hline\n" + header += "\t\\rowcolor{Gray}\n" + header += "\t" + for idx, s in enumerate(headings): + header += f"\\textbf{{{s}}}" + if idx < len(headings) - 1: + header += " & " + header += " \\\\\n" + header += "\t\\hline\n" + header += "\t\\endhead\n\n" + + return header + + +def get_footer(): + return "\t\\hline \\hline\n\\end{longtable}\n\\normalsize\n\n" + + +def exp_format(v): + s = f"{v:.2e}" + s = s.replace("e-0", "e-") + s = s.replace("e+0", "e+") + # s = s.replace("e", " \\times 10^{") + "}$" + return s + + +def float_format(v, fmt="{:.2f}"): + return fmt.format(v) + + +def int_format(v): + return f"{v:d}" diff --git a/pyproject.toml b/pyproject.toml index 4d2a701..fe49f7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,10 @@ lint = [ "isort", "pylint" ] +optional = [ + "matplotlib", + "pytest", +] test = [ "modflow-devtools[lint]", "coverage", From fd215000c6215b0891e78ee621e40abb2a20b28a Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 9 Jan 2024 16:35:40 -0500 Subject: [PATCH 04/10] fix: drop plot styles (already in flopy) (#133) --- .github/workflows/ci.yml | 2 +- README.md | 4 +- autotest/test_figspec.py | 5 - docs/index.rst | 1 - docs/md/figspec.md | 33 --- modflow_devtools/figspec.py | 561 ------------------------------------ pyproject.toml | 4 - 7 files changed, 2 insertions(+), 608 deletions(-) delete mode 100644 autotest/test_figspec.py delete mode 100644 docs/md/figspec.md delete mode 100644 modflow_devtools/figspec.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6227ac..dd5a40c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,7 @@ jobs: working-directory: modflow-devtools run: | pip install . - pip install ".[test, optional]" + pip install ".[test]" - name: Cache modflow6 examples id: cache-examples diff --git a/README.md b/README.md index 7f04b87..688cd9e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Python development tools for MODFLOW 6. ## Use cases -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) and [Matplotlib](https://matplotlib.org/stable/) extensions. +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. Utilities include: @@ -50,8 +50,6 @@ Pytest features include: - `MODFLOW-USGS/modflow6-testmodels` - `MODFLOW-USGS/modflow6-largetestmodels` -Matplotlib styles are provided in the `modflow_devtools.figspecs` module. - ## Requirements Python3.8+, dependency-free, but pairs well with `pytest` and select plugins, e.g. diff --git a/autotest/test_figspec.py b/autotest/test_figspec.py deleted file mode 100644 index 3337582..0000000 --- a/autotest/test_figspec.py +++ /dev/null @@ -1,5 +0,0 @@ -from modflow_devtools.figspec import USGSFigure - - -def test_usgs_figure(): - fig = USGSFigure() diff --git a/docs/index.rst b/docs/index.rst index ebd2c55..b02faca 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,7 +29,6 @@ The `modflow-devtools` package provides a set of tools for developing and testin :caption: Miscellaneous md/download.md - md/figspec.md md/latex.md md/ostags.md md/zip.md diff --git a/docs/md/figspec.md b/docs/md/figspec.md deleted file mode 100644 index 1399bae..0000000 --- a/docs/md/figspec.md +++ /dev/null @@ -1,33 +0,0 @@ -# Plot styles - -Matplotlib is an optional dependency, installable via e.g. `pip install modflow_devtools[optional]`. - -## `USGSFigure` - -A convenience class `modflow_devtools.figspec.USGSFigure` is provided to create figures with the default USGS style sheet. For instance: - -```python -# create figure -fs = USGSFigure(figure_type="graph", verbose=False) - -# ...add some plots - -# add a heading -title = f"Layer {ilay + 1}" -letter = chr(ord("@") + idx + 2) -fs.heading(letter=letter, heading=title) - -# add an annotation -fs.add_annotation( - ax=ax, - text="Well 1, layer 2", - bold=False, - italic=False, - xy=w1loc, - xytext=(w1loc[0] - 3200, w1loc[1] + 1500), - ha="right", - va="center", - zorder=100, - arrowprops=arrow_props, -) -``` \ No newline at end of file diff --git a/modflow_devtools/figspec.py b/modflow_devtools/figspec.py deleted file mode 100644 index 568db57..0000000 --- a/modflow_devtools/figspec.py +++ /dev/null @@ -1,561 +0,0 @@ -import numpy as np - -from modflow_devtools.imports import import_optional_dependency - -mpl = import_optional_dependency("matplotlib") -import sys - -import numpy as np - - -class USGSFigure: - def __init__( - self, figure_type="map", family="Arial Narrow", verbose=False - ): - """Create a USGSFigure object - - Parameters - ---------- - figure_type : str - figure type ("map", "graph") - family : str - font family name (default is Arial Narrow) - verbose : bool - boolean that define if debug information should be written - """ - # initialize members - self.family = None - self.figure_type = None - self.verbose = verbose - self.family = self._set_fontfamily(family) - self.figure_type = self._validate_figure_type(figure_type) - - def graph_legend(self, ax=None, handles=None, labels=None, **kwargs): - """Add a USGS-style legend to a matplotlib axis object - - Parameters - ---------- - ax : axis object - matplotlib axis object (default is None) - handles : list - list of legend handles - labels : list - list of labels for legend handles - kwargs : kwargs - matplotlib legend kwargs - - Returns - ------- - leg : object - matplotlib legend object - - """ - if ax is None: - ax = mpl.pyplot.gca() - - font = self._set_fontspec(bold=True, italic=False) - if handles is None or labels is None: - handles, labels = ax.get_legend_handles_labels() - leg = ax.legend(handles, labels, prop=font, **kwargs) - - # add title to legend - if "title" in kwargs: - title = kwargs.pop("title") - else: - title = None - leg = self.graph_legend_title(leg, title=title) - return leg - - def graph_legend_title(self, leg, title=None): - """Set the legend title for a matplotlib legend object - - Parameters - ---------- - leg : legend object - matplotlib legend object - title : str - title for legend - - Returns - ------- - leg : object - matplotlib legend object - - """ - if title is None: - title = "EXPLANATION" - elif title.lower() == "none": - title = None - font = self._set_fontspec(bold=True, italic=False) - leg.set_title(title, prop=font) - return leg - - def heading( - self, ax=None, letter=None, heading=None, x=0.00, y=1.01, idx=None - ): - """Add a USGS-style heading to a matplotlib axis object - - Parameters - ---------- - ax : axis object - matplotlib axis object (default is None) - letter : str - string that defines the subplot (A, B, C, etc.) - heading : str - text string - x : float - location of the heading in the x-direction in normalized plot dimensions - ranging from 0 to 1 (default is 0.00) - y : float - location of the heading in the y-direction in normalized plot dimensions - ranging from 0 to 1 (default is 1.01) - idx : int - index for programatically generating the heading letter when letter - is None and idx is not None. idx = 0 will generate A (default is None) - - Returns - ------- - text : object - matplotlib text object - - """ - if ax is None: - ax = mpl.pyplot.gca() - - if letter is None and idx is not None: - letter = chr(ord("A") + idx) - - text = None - if letter is not None: - font = self._set_fontspec(bold=True, italic=True) - if heading is None: - letter = letter.replace(".", "") - else: - letter = letter.rstrip() - if letter[-1] != ".": - letter += "." - letter += " " - ax.text( - x, - y, - letter, - va="bottom", - ha="left", - fontdict=font, - transform=ax.transAxes, - ) - bbox = ax.get_window_extent().transformed( - mpl.pyplot.gcf().dpi_scale_trans.inverted() - ) - width = bbox.width * 25.4 # inches to mm - x += len(letter) * 1.0 / width - if heading is not None: - font = self._set_fontspec(bold=True, italic=False) - text = ax.text( - x, - y, - heading, - va="bottom", - ha="left", - fontdict=font, - transform=ax.transAxes, - ) - return text - - def add_text( - self, - ax=None, - text="", - x=0.0, - y=0.0, - transform=True, - bold=True, - italic=True, - fontsize=9, - ha="left", - va="bottom", - **kwargs, - ): - """Add USGS-style text to a axis object - - Parameters - ---------- - ax : axis object - matplotlib axis object (default is None) - text : str - text string - x : float - x-location of text string (default is 0.) - y : float - y-location of text string (default is 0.) - transform : bool - boolean that determines if a transformed (True) or data (False) coordinate - system is used to define the (x, y) location of the text string - (default is True) - bold : bool - boolean indicating if bold font (default is True) - italic : bool - boolean indicating if italic font (default is True) - fontsize : int - font size (default is 9 points) - ha : str - matplotlib horizontal alignment keyword (default is left) - va : str - matplotlib vertical alignment keyword (default is bottom) - kwargs : dict - dictionary with valid matplotlib text object keywords - - Returns - ------- - text_obj : object - matplotlib text object - - """ - if ax is None: - ax = mpl.pyplot.gca() - - if transform: - transform = ax.transAxes - else: - transform = ax.transData - - font = self._set_fontspec(bold=bold, italic=italic, fontsize=fontsize) - - text_obj = ax.text( - x, - y, - text, - va=va, - ha=ha, - fontdict=font, - transform=transform, - **kwargs, - ) - return text_obj - - def add_annotation( - self, - ax=None, - text="", - xy=None, - xytext=None, - bold=True, - italic=True, - fontsize=9, - ha="left", - va="bottom", - **kwargs, - ): - """Add an annotation to a axis object - - Parameters - ---------- - ax : axis object - matplotlib axis object (default is None) - text : str - text string - xy : tuple - tuple with the location of the annotation (default is None) - xytext : tuple - tuple with the location of the text - bold : bool - boolean indicating if bold font (default is True) - italic : bool - boolean indicating if italic font (default is True) - fontsize : int - font size (default is 9 points) - ha : str - matplotlib horizontal alignment keyword (default is left) - va : str - matplotlib vertical alignment keyword (default is bottom) - kwargs : dict - dictionary with valid matplotlib annotation object keywords - - Returns - ------- - ann_obj : object - matplotlib annotation object - - """ - if ax is None: - ax = mpl.pyplot.gca() - - if xy is None: - xy = (0.0, 0.0) - - if xytext is None: - xytext = (0.0, 0.0) - - font = self._set_fontspec(bold=bold, italic=italic, fontsize=fontsize) - - # add font information to kwargs - if kwargs is None: - kwargs = font - else: - for key, value in font.items(): - kwargs[key] = value - - # create annotation - ann_obj = ax.annotate(text, xy, xytext, va=va, ha=ha, **kwargs) - - return ann_obj - - def remove_edge_ticks(self, ax=None): - """Remove unnecessary ticks on the edges of the plot - - Parameters - ---------- - ax : axis object - matplotlib axis object (default is None) - - Returns - ------- - ax : axis object - matplotlib axis object - - """ - if ax is None: - ax = mpl.pyplot.gca() - - # update tick objects - mpl.pyplot.draw() - - # get min and max value and ticks - ymin, ymax = ax.get_ylim() - - # check for condition where y-axis values are reversed - if ymax < ymin: - y = ymin - ymin = ymax - ymax = y - yticks = ax.get_yticks() - - if self.verbose: - print("y-axis: ", ymin, ymax) - print(yticks) - - # remove edge ticks on y-axis - ticks = ax.yaxis.majorTicks - for iloc in [0, -1]: - if np.allclose(float(yticks[iloc]), ymin): - ticks[iloc].tick1line.set_visible = False - ticks[iloc].tick2line.set_visible = False - if np.allclose(float(yticks[iloc]), ymax): - ticks[iloc].tick1line.set_visible = False - ticks[iloc].tick2line.set_visible = False - - # get min and max value and ticks - xmin, xmax = ax.get_xlim() - - # check for condition where x-axis values are reversed - if xmax < xmin: - x = xmin - xmin = xmax - xmax = x - - xticks = ax.get_xticks() - if self.verbose: - print("x-axis: ", xmin, xmax) - print(xticks) - - # remove edge ticks on y-axis - ticks = ax.xaxis.majorTicks - for iloc in [0, -1]: - if np.allclose(float(xticks[iloc]), xmin): - ticks[iloc].tick1line.set_visible = False - ticks[iloc].tick2line.set_visible = False - if np.allclose(float(xticks[iloc]), xmax): - ticks[iloc].tick1line.set_visible = False - ticks[iloc].tick2line.set_visible = False - - return ax - - # private methods - - def _validate_figure_type(self, figure_type): - """Set figure type after validation of specified figure type - - Parameters - ---------- - figure_type : str - figure type ("map", "graph") - - Returns - ------- - figure_type : str - validated figure_type - - """ - # validate figure type - valid_types = ("map", "graph") - if figure_type not in valid_types: - errmsg = "invalid figure_type specified ({}) ".format( - figure_type - ) + "valid types are '{}'.".format(", ".join(valid_types)) - raise ValueError(errmsg) - - # set figure_type - if figure_type == "map": - self._set_map_specifications() - elif figure_type == "graph": - self._set_map_specifications() - - return figure_type - - def _set_graph_specifications(self): - """Set matplotlib rcparams to USGS-style specifications for graphs - - Returns - ------- - - """ - rc_dict = { - "font.family": self.family, - "font.size": 7, - "axes.labelsize": 9, - "axes.titlesize": 9, - "axes.linewidth": 0.5, - "xtick.labelsize": 8, - "xtick.top": True, - "xtick.bottom": True, - "xtick.major.size": 7.2, - "xtick.minor.size": 3.6, - "xtick.major.width": 0.5, - "xtick.minor.width": 0.5, - "xtick.direction": "in", - "ytick.labelsize": 8, - "ytick.left": True, - "ytick.right": True, - "ytick.major.size": 7.2, - "ytick.minor.size": 3.6, - "ytick.major.width": 0.5, - "ytick.minor.width": 0.5, - "ytick.direction": "in", - "pdf.fonttype": 42, - "savefig.dpi": 300, - "savefig.transparent": True, - "legend.fontsize": 9, - "legend.frameon": False, - "legend.markerscale": 1.0, - } - mpl.rcParams.update(rc_dict) - - def _set_map_specifications(self): - """Set matplotlib rcparams to USGS-style specifications for maps - - Returns - ------- - - """ - rc_dict = { - "font.family": self.family, - "font.size": 7, - "axes.labelsize": 9, - "axes.titlesize": 9, - "axes.linewidth": 0.5, - "xtick.labelsize": 7, - "xtick.top": True, - "xtick.bottom": True, - "xtick.major.size": 7.2, - "xtick.minor.size": 3.6, - "xtick.major.width": 0.5, - "xtick.minor.width": 0.5, - "xtick.direction": "in", - "ytick.labelsize": 7, - "ytick.left": True, - "ytick.right": True, - "ytick.major.size": 7.2, - "ytick.minor.size": 3.6, - "ytick.major.width": 0.5, - "ytick.minor.width": 0.5, - "ytick.direction": "in", - "pdf.fonttype": 42, - "savefig.dpi": 300, - "savefig.transparent": True, - "legend.fontsize": 9, - "legend.frameon": False, - "legend.markerscale": 1.0, - } - mpl.rcParams.update(rc_dict) - - def _set_fontspec( - self, - bold=True, - italic=True, - fontsize=9, - verbose=False, - ): - """Create fontspec dictionary for matplotlib pyplot objects - - Parameters - ---------- - bold : bool - boolean indicating if font is bold (default is True) - italic : bool - boolean indicating if font is italic (default is True) - fontsize : int - font size (default is 9 point) - - - Returns - ------- - - """ - univers = "Univers" in self.family - family = None if univers else self.family - - if bold: - weight = "bold" - if univers: - family = "Univers 67" - else: - weight = "normal" - if univers: - family = "Univers 57" - - if italic: - if univers: - family += " Condensed Oblique" - style = "oblique" - else: - style = "italic" - else: - if univers: - family += " Condensed" - style = "normal" - - # define fontspec dictionary - fontspec = { - "family": family, - "size": fontsize, - "weight": weight, - "style": style, - } - - if verbose: - sys.stdout.write("font specifications:\n ") - for key, value in fontspec.items(): - sys.stdout.write(f"{key}={value} ") - sys.stdout.write("\n") - - return fontspec - - def _set_fontfamily(self, family): - """Set font family to Liberation Sans Narrow on linux if default Arial Narrow - is being used - - Parameters - ---------- - family : str - font family name (default is Arial Narrow) - - Returns - ------- - family : str - font family name - - """ - if sys.platform.lower() in ("linux",): - if family == "Arial Narrow": - family = "Liberation Sans Narrow" - return family diff --git a/pyproject.toml b/pyproject.toml index fe49f7e..4d2a701 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,10 +50,6 @@ lint = [ "isort", "pylint" ] -optional = [ - "matplotlib", - "pytest", -] test = [ "modflow-devtools[lint]", "coverage", From a9b801932866a26a996ed3a45f16048b15246472 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sat, 20 Jan 2024 21:15:16 -0500 Subject: [PATCH 05/10] feat(misc): parse literals from environment variables (#135) --- autotest/test_misc.py | 23 ++++++++++ modflow_devtools/misc.py | 99 ++++++++++++++++++++++++++-------------- 2 files changed, 89 insertions(+), 33 deletions(-) diff --git a/autotest/test_misc.py b/autotest/test_misc.py index 7b65641..283e5ff 100644 --- a/autotest/test_misc.py +++ b/autotest/test_misc.py @@ -9,6 +9,7 @@ import pytest from modflow_devtools.misc import ( + get_env, get_model_paths, get_namefile_paths, get_packages, @@ -280,3 +281,25 @@ def sleep1dec(): cap = capfd.readouterr() print(cap.out) assert re.match(r"sleep1dec took \d+\.\d+ ms", cap.out) + + +def test_get_env(): + assert get_env("NO_VALUE") is None + + with set_env(TEST_VALUE=str(True)): + assert get_env("NO_VALUE", True) == True + assert get_env("TEST_VALUE") == True + assert get_env("TEST_VALUE", default=False) == True + assert get_env("TEST_VALUE", default=1) == 1 + + with set_env(TEST_VALUE=str(1)): + assert get_env("NO_VALUE", 1) == 1 + assert get_env("TEST_VALUE") == 1 + assert get_env("TEST_VALUE", default=2) == 1 + assert get_env("TEST_VALUE", default=2.1) == 2.1 + + with set_env(TEST_VALUE=str(1.1)): + assert get_env("NO_VALUE", 1.1) == 1.1 + assert get_env("TEST_VALUE") == 1.1 + assert get_env("TEST_VALUE", default=2.1) == 1.1 + assert get_env("TEST_VALUE", default=False) == False diff --git a/modflow_devtools/misc.py b/modflow_devtools/misc.py index 4915d8c..c7fb0de 100644 --- a/modflow_devtools/misc.py +++ b/modflow_devtools/misc.py @@ -2,6 +2,7 @@ import socket import sys import traceback +from ast import literal_eval from contextlib import contextmanager from functools import wraps from importlib import metadata @@ -31,39 +32,6 @@ def set_dir(path: PathLike): print(f"Returned to previous directory: {origin}") -@contextmanager -def set_env(*remove, **update): - """ - Temporarily updates the ``os.environ`` dictionary in-place. - - Referenced from https://stackoverflow.com/a/34333710/6514033. - - The ``os.environ`` dictionary is updated in-place so that the modification - is sure to work in all situations. - - :param remove: Environment variables to remove. - :param update: Dictionary of environment variables and values to add/update. - """ - env = environ - update = update or {} - remove = remove or [] - - # List of environment variables being updated or removed. - stomped = (set(update.keys()) | set(remove)) & set(env.keys()) - # Environment variables and values to restore on exit. - update_after = {k: env[k] for k in stomped} - # Environment variables and values to remove on exit. - remove_after = frozenset(k for k in update if k not in env) - - try: - env.update(update) - [env.pop(k, None) for k in remove] - yield - finally: - env.update(update_after) - [env.pop(k) for k in remove_after] - - class add_sys_path: """ Context manager to add temporarily to the system path. @@ -486,3 +454,68 @@ def call(): return res return _timed + + +def get_env(name: str, default: object = None) -> Optional[object]: + """ + Try to parse the given environment variable as the type of the given + default value, if one is provided, otherwise any type is acceptable. + If the types of the parsed value and default value don't match, the + default value is returned. The environment variable is parsed as a + Python literal with `ast.literal_eval()`. + + Parameters + ---------- + name : str + The environment variable name + default : object + The default value if the environment variable does not exist + + Returns + ------- + The value of the environment variable, parsed as a Python literal, + otherwise the default value if the environment variable is not set. + """ + try: + v = environ.get(name) + if isinstance(default, bool): + v = v.lower().title() + v = literal_eval(v) + except: + return default + if default is None: + return v + return v if isinstance(v, type(default)) else default + + +@contextmanager +def set_env(*remove, **update): + """ + Temporarily updates the ``os.environ`` dictionary in-place. + + Referenced from https://stackoverflow.com/a/34333710/6514033. + + The ``os.environ`` dictionary is updated in-place so that the modification + is sure to work in all situations. + + :param remove: Environment variables to remove. + :param update: Dictionary of environment variables and values to add/update. + """ + env = environ + update = update or {} + remove = remove or [] + + # List of environment variables being updated or removed. + stomped = (set(update.keys()) | set(remove)) & set(env.keys()) + # Environment variables and values to restore on exit. + update_after = {k: env[k] for k in stomped} + # Environment variables and values to remove on exit. + remove_after = frozenset(k for k in update if k not in env) + + try: + env.update(update) + [env.pop(k, None) for k in remove] + yield + finally: + env.update(update_after) + [env.pop(k) for k in remove_after] From 9356e067ea813aeeeda2582cf7ec174c11d80159 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 25 Jan 2024 18:17:01 -0500 Subject: [PATCH 06/10] refactor: remove executables module/class (#136) * unused by any consuming projects * of questionable utility, just use dict --- .github/workflows/ci.yml | 4 ++-- autotest/test_executables.py | 31 ---------------------------- docs/index.rst | 1 - docs/md/executables.md | 33 ------------------------------ docs/md/fixtures.md | 2 +- docs/md/install.md | 6 +++--- modflow_devtools/executables.py | 36 --------------------------------- 7 files changed, 6 insertions(+), 107 deletions(-) delete mode 100644 autotest/test_executables.py delete mode 100644 docs/md/executables.md delete mode 100644 modflow_devtools/executables.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd5a40c..f2becf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,8 +150,8 @@ jobs: - name: Build modflow6 example models if: steps.cache-examples.outputs.cache-hit != 'true' - working-directory: modflow6-examples/etc - run: python ci_build_files.py + working-directory: modflow6-examples/autotest + run: pytest -v -n auto test_scripts.py --init - name: Run local tests working-directory: modflow-devtools/autotest diff --git a/autotest/test_executables.py b/autotest/test_executables.py deleted file mode 100644 index 7f9f330..0000000 --- a/autotest/test_executables.py +++ /dev/null @@ -1,31 +0,0 @@ -import sys -from pathlib import Path -from shutil import which - -import pytest - -from modflow_devtools.executables import Executables -from modflow_devtools.misc import add_sys_path, get_suffixes - -ext, _ = get_suffixes(sys.platform) -exe_stem = "pytest" -exe_path = Path(which(exe_stem)) -bin_path = exe_path.parent -exe = f"{exe_stem}{ext}" - - -@pytest.fixture -def exes(): - with add_sys_path(bin_path): - yield Executables(**{exe_stem: bin_path / exe}) - - -def test_access(exes): - # support both attribute and dictionary style access - assert exes.pytest == exes["pytest"] == exe_path - # .get() works too - assert exes.get("not a target") is None - assert exes.get("not a target", exes["pytest"]) == exes["pytest"] - # membership test - assert "not a target" not in exes - assert "pytest" in exes diff --git a/docs/index.rst b/docs/index.rst index b02faca..7e97db0 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/executables.md md/fixtures.md md/markers.md diff --git a/docs/md/executables.md b/docs/md/executables.md deleted file mode 100644 index ef1085f..0000000 --- a/docs/md/executables.md +++ /dev/null @@ -1,33 +0,0 @@ -# Executables - -The `Executables` class maps executable names to paths on the filesystem. This is useful mainly for testing multiple versions of the same program. - -## Usage - -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 Executables - -@pytest.fixture(scope="session") -def bin_path() -> Path: - return project_root_path / "bin" - - -@pytest.fixture(scope="session") -def targets(bin_path) -> Executables: - exes = { - # ...map names to paths - } - return Executables(**exes) -``` - -The `targets` fixture can then be injected into test functions: - -```python -def test_targets(targets): - # attribute- and dictionary-style access is supported - assert targets["mf6"] == targets.mf6 - # .get() works too - assert targets.get("not a target") is None -``` diff --git a/docs/md/fixtures.md b/docs/md/fixtures.md index ff1b2f5..750e7fd 100644 --- a/docs/md/fixtures.md +++ b/docs/md/fixtures.md @@ -123,7 +123,7 @@ def test_example_scenario(tmp_path, example_scenario): # ... ``` -**Note**: example models must first be built by running the `ci_build_files.py` script in `modflow6-examples/etc` before running tests using the `example_scenario` fixture. See the [install docs](https://modflow-devtools.readthedocs.io/en/latest/md/install.html) for more info. +**Note**: example models must first be built by running `pytest -v -n auto test_scripts.py --init` in `modflow6-examples/autotest` before running tests using the `example_scenario` fixture. See the [install docs](https://modflow-devtools.readthedocs.io/en/latest/md/install.html) for more info. ### Filtering diff --git a/docs/md/install.md b/docs/md/install.md index 473bc1e..f56c81a 100644 --- a/docs/md/install.md +++ b/docs/md/install.md @@ -67,10 +67,10 @@ cd modflow6-examples/etc pip install -r requirements.pip.txt ``` -Then, still from the `etc` folder, run: +Then, from the `autotest` folder, run: ```shell -python ci_build_files.py +pytest -v -n auto test_scripts.py --init ``` -This will build the examples for subsequent use by the tests. +This will build the examples for subsequent use by the tests. To save time, models will not be run — to run the models too, omit `--init`. diff --git a/modflow_devtools/executables.py b/modflow_devtools/executables.py deleted file mode 100644 index 59408ae..0000000 --- a/modflow_devtools/executables.py +++ /dev/null @@ -1,36 +0,0 @@ -from pathlib import Path -from types import SimpleNamespace -from typing import Dict - -from modflow_devtools.misc import get_suffixes - -# re-export for backwards-compatibility (used to be here) -get_suffixes = get_suffixes - - -class Executables(SimpleNamespace): - """ - Container mapping executable names to their paths. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - def __contains__(self, item): - return item in self.__dict__ - - def __setitem__(self, key, item): - self.__dict__[key] = item - - def __getitem__(self, key): - return self.__dict__[key] - - def get(self, key, default=None): - return self.as_dict().get(key, default) - - def as_dict(self) -> Dict[str, Path]: - """ - Returns a dictionary mapping executable names to paths. - """ - - return self.__dict__.copy() From 613ad010ff6fc782f231b7fa21d1cc660732e7be Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 31 Jan 2024 13:00:43 -0500 Subject: [PATCH 07/10] refactor(fixtures): support pytest>=8, drop pytest-cases dependency (#137) --- .gitignore | 1 + autotest/test_fixtures.py | 2 +- modflow_devtools/fixtures.py | 2 +- modflow_devtools/ostags.py | 1 - pyproject.toml | 1 - scripts/update_version.py | 6 +++--- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 20eb2ac..258cf83 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,4 @@ app # in case developer installs modflow executables in the project root bin +**.DS_Store \ No newline at end of file diff --git a/autotest/test_fixtures.py b/autotest/test_fixtures.py index f959d0c..dcd7122 100644 --- a/autotest/test_fixtures.py +++ b/autotest/test_fixtures.py @@ -186,7 +186,7 @@ def test_keep_session_scoped_tmpdir(tmp_path, arg, request): ] assert pytest.main(args) == ExitCode.OK assert Path( - tmp_path / f"{request.session.name}0" / test_keep_fname + tmp_path / f"{request.config.rootpath.name}0" / test_keep_fname ).is_file() diff --git a/modflow_devtools/fixtures.py b/modflow_devtools/fixtures.py index 0700cf8..4504f72 100644 --- a/modflow_devtools/fixtures.py +++ b/modflow_devtools/fixtures.py @@ -71,7 +71,7 @@ def module_tmpdir(tmpdir_factory, request) -> Path: @pytest.fixture(scope="session") def session_tmpdir(tmpdir_factory, request) -> Path: - temp = Path(tmpdir_factory.mktemp(request.session.name)) + temp = Path(tmpdir_factory.mktemp(request.config.rootpath.name)) yield temp keep = request.config.option.KEEP diff --git a/modflow_devtools/ostags.py b/modflow_devtools/ostags.py index 3c68ed3..44f6abf 100644 --- a/modflow_devtools/ostags.py +++ b/modflow_devtools/ostags.py @@ -3,7 +3,6 @@ systems differently. This module contains conversion utilities. """ - import sys from enum import Enum from platform import system diff --git a/pyproject.toml b/pyproject.toml index 4d2a701..3ed78e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,6 @@ test = [ "ninja", "numpy", "pytest", - "pytest-cases", "pytest-cov", "pytest-dotenv", "pytest-xdist", diff --git a/scripts/update_version.py b/scripts/update_version.py index 32878b9..c79e5bf 100644 --- a/scripts/update_version.py +++ b/scripts/update_version.py @@ -106,7 +106,7 @@ def update_version( else: update_version( timestamp=datetime.now(), - version=Version(args.version) - if args.version - else _current_version, + version=( + Version(args.version) if args.version else _current_version + ), ) From c3ec63ec5e80883a2140451579e7526be968cbdc Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 8 Feb 2024 09:21:54 -0500 Subject: [PATCH 08/10] ci(release): remove reset step from release workflow (#138) * follow recent decisions in flopy, modflowapi, elsewhere * ignore ci.yml on changes to release.yml or .gitignore --- .github/workflows/ci.yml | 4 +++ .github/workflows/release.yml | 67 +---------------------------------- .gitignore | 3 +- 3 files changed, 7 insertions(+), 67 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2becf3..549431c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,12 +3,16 @@ on: push: paths-ignore: - '**.md' + - '.github/workflows/release.yml' + - '.gitignore' pull_request: branches: - main - develop paths-ignore: - '**.md' + - '.github/workflows/release.yml' + - '.gitignore' jobs: lint: name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a2bd3a..890bdaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: body=' # Release '$ver' - The release can be approved by merging this pull request into `main`. This will trigger jobs to publish the release to PyPI and reset `develop` from `main`, incrementing the patch version number. + The release can be approved by merging this pull request into `main`. This will trigger a job to publish the release to PyPI. ## Changelog @@ -200,68 +200,3 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - - reset: - name: Draft reset PR - if: ${{ github.event_name == 'release' }} - runs-on: ubuntu-22.04 - permissions: - contents: write - pull-requests: write - steps: - - - name: Checkout main branch - uses: actions/checkout@v3 - with: - ref: main - - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: 3.8 - cache: 'pip' - cache-dependency-path: pyproject.toml - - - name: Install Python dependencies - run: | - pip install --upgrade pip - pip install . - pip install ".[lint, test]" - - - name: Get release tag - uses: oprypin/find-latest-tag@v1 - id: latest_tag - with: - repository: ${{ github.repository }} - releases-only: true - - - name: Draft pull request - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - # create reset branch from main - reset_branch="post-release-${{ steps.latest_tag.outputs.tag }}-reset" - git switch -c $reset_branch - - # increment minor version - major_version=$(echo "${{ steps.latest_tag.outputs.tag }}" | cut -d. -f1) - minor_version=$(echo "${{ steps.latest_tag.outputs.tag }}" | cut -d. -f2) - version="$major_version.$((minor_version + 1)).0.dev0" - python scripts/update_version.py -v "$version" - python scripts/lint.py - - # commit and push reset branch - git config core.sharedRepository true - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "ci(release): update to development version $version" - git push -u origin $reset_branch - - # create PR into develop - body=' - # Reinitialize for development - - Updates the `develop` branch from `main` following a successful release. Increments the patch version number. - ' - gh pr create -B "develop" -H "$reset_branch" --title "Reinitialize develop branch" --draft --body "$body" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 258cf83..d5fb944 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,5 @@ app # in case developer installs modflow executables in the project root bin -**.DS_Store \ No newline at end of file +**.DS_Store +data_backup \ No newline at end of file From 0ad10751ea6ce752e59d83e8cd6275906d73fa70 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sun, 18 Feb 2024 08:58:19 -0500 Subject: [PATCH 09/10] feat(ostags): apple silicon (#139) * add OS tag for apple silicon macs: macarm * remove OSTags class and OSTagCvt enum * refactor OSTags.convert() -> convert_ostag() * update docs and tests --- autotest/test_ostags.py | 17 ++++---- docs/md/ostags.md | 5 ++- modflow_devtools/ostags.py | 82 +++++++++++++++++--------------------- 3 files changed, 48 insertions(+), 56 deletions(-) diff --git a/autotest/test_ostags.py b/autotest/test_ostags.py index 22e93f1..fef2d17 100644 --- a/autotest/test_ostags.py +++ b/autotest/test_ostags.py @@ -1,15 +1,16 @@ -from platform import system +from platform import processor, system import pytest from modflow_devtools.ostags import ( - OSTag, + convert_ostag, get_binary_suffixes, get_github_ostag, get_modflow_ostag, ) _system = system() +_processor = processor() def test_get_modflow_ostag(): @@ -19,7 +20,7 @@ def test_get_modflow_ostag(): elif _system == "Linux": assert t == "linux" elif _system == "Darwin": - assert t == "mac" + assert t == "macarm" if _processor == "arm" else "mac" else: pytest.skip(reason="Unsupported platform") @@ -35,17 +36,17 @@ def test_get_github_ostag(): @pytest.mark.parametrize( - "cvt,tag,exp", + "map,tag,exp", [ ("py2mf", "Windows", "win64"), ("mf2py", "win64", "Windows"), - ("py2mf", "Darwin", "mac"), + ("py2mf", "Darwin", "macarm" if _processor == "arm" else "mac"), ("mf2py", "mac", "Darwin"), ("py2mf", "Linux", "linux"), ("mf2py", "linux", "Linux"), ("gh2mf", "Windows", "win64"), ("mf2gh", "win64", "Windows"), - ("gh2mf", "macOS", "mac"), + ("gh2mf", "macOS", "macarm" if _processor == "arm" else "mac"), ("mf2gh", "mac", "macOS"), ("gh2mf", "Linux", "linux"), ("mf2gh", "linux", "Linux"), @@ -57,8 +58,8 @@ def test_get_github_ostag(): ("gh2py", "Linux", "Linux"), ], ) -def test_ostag_convert(cvt, tag, exp): - assert OSTag.convert(tag, cvt) == exp +def test_convert_ostag(map, tag, exp): + assert convert_ostag(tag, map) == exp def test_get_binary_suffixes(): diff --git a/docs/md/ostags.md b/docs/md/ostags.md index 6108dc7..76e156f 100644 --- a/docs/md/ostags.md +++ b/docs/md/ostags.md @@ -14,7 +14,7 @@ Python3's `platform.system()` returns "Linux", "Darwin", and "Windows", respecti GitHub Actions (e.g. `runner.os` context) use "Linux", "macOS" and "Windows". -MODFLOW 6 release asset names end with "linux", "mac" or "win64". +MODFLOW 6 release asset names end with "linux", "mac" (Intel), "macarm", "win32", or "win64". ## Getting tags @@ -37,7 +37,8 @@ Conversion functions are available for each direction: Alternatively: ```python -OSTag.convert(platform.system(), "py2mf") +convert_ostag(platform.system(), "py2mf") # prints linux, mac, macarm, win32, or win64 +convert_ostag(platform.system(), "py2mf") # prints Linux, macOS, or Windows ``` The second argument specifies the mapping in format `2`, where `` and `` may take values `py`, `mf`, or `gh`. diff --git a/modflow_devtools/ostags.py b/modflow_devtools/ostags.py index 44f6abf..653f533 100644 --- a/modflow_devtools/ostags.py +++ b/modflow_devtools/ostags.py @@ -4,11 +4,13 @@ """ import sys -from enum import Enum -from platform import system +from platform import processor, system from typing import Tuple _system = system() +_processor = processor() + +SUPPORTED_OSTAGS = ["linux", "mac", "macarm", "win32", "win64"] def get_modflow_ostag() -> str: @@ -17,7 +19,7 @@ def get_modflow_ostag() -> str: elif _system == "Linux": return "linux" elif _system == "Darwin": - return "mac" + return "macarm" if _processor == "arm" else "mac" else: raise NotImplementedError(f"Unsupported system: {_system}") @@ -31,6 +33,15 @@ def get_github_ostag() -> str: raise NotImplementedError(f"Unsupported system: {_system}") +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}") + + def get_binary_suffixes(ostag: str = None) -> Tuple[str, str]: """ Returns executable and library suffixes for the given OS tag, if provided, @@ -55,10 +66,10 @@ def _suffixes(tag): return ".exe", ".dll" elif tag == "linux": return "", ".so" - elif tag == "mac" or tag == "darwin": + elif tag == "darwin" or "mac" in tag: return "", ".dylib" else: - raise KeyError(f"unrecognized OS tag: {tag!r}") + raise KeyError(f"Invalid OS tag: {tag!r}") try: return _suffixes(ostag.lower()) @@ -89,9 +100,9 @@ def python_to_modflow_ostag(tag: str) -> str: elif tag == "Linux": return "linux" elif tag == "Darwin": - return "mac" + return "macarm" if _processor == "arm" else "mac" else: - raise ValueError(f"Invalid or unsupported tag: {tag}") + raise ValueError(f"Invalid tag: {tag}") def modflow_to_python_ostag(tag: str) -> str: @@ -112,10 +123,10 @@ def modflow_to_python_ostag(tag: str) -> str: return "Windows" elif tag == "linux": return "Linux" - elif tag == "mac": + elif "mac" in tag: return "Darwin" else: - raise ValueError(f"Invalid or unsupported tag: {tag}") + raise ValueError(f"Invalid tag: {tag}") def modflow_to_github_ostag(tag: str) -> str: @@ -123,7 +134,7 @@ def modflow_to_github_ostag(tag: str) -> str: return "Windows" elif tag == "linux": return "Linux" - elif tag == "mac": + elif "mac" in tag: return "macOS" else: raise ValueError(f"Invalid modflow os tag: {tag}") @@ -135,7 +146,7 @@ def github_to_modflow_ostag(tag: str) -> str: elif tag == "Linux": return "linux" elif tag == "macOS": - return "mac" + return "macarm" if _processor == "arm" else "mac" else: raise ValueError(f"Invalid github os tag: {tag}") @@ -148,39 +159,18 @@ 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() +def convert_ostag(tag: str, mapping: str) -> str: + if mapping == "py2mf": + return python_to_modflow_ostag(tag) + elif mapping == "mf2py": + return modflow_to_python_ostag(tag) + elif mapping == "gh2mf": + return github_to_modflow_ostag(tag) + elif mapping == "mf2gh": + return modflow_to_github_ostag(tag) + elif mapping == "py2gh": + return python_to_github_ostag(tag) + elif mapping == "gh2py": + return github_to_python_ostag(tag) 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}") + raise ValueError(f"Invalid mapping: {mapping}") From d05f607aef780477c83dbaecb32dc2c48870b55e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 19 Feb 2024 16:11:13 +0000 Subject: [PATCH 10/10] ci(release): set version to 1.4.0, update changelog --- HISTORY.md | 18 ++++++++++++++++++ docs/conf.py | 2 +- modflow_devtools/__init__.py | 4 ++-- version.txt | 2 +- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 9cd4f2c..f927df2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,21 @@ +### Version 1.4.0 + +#### New features + +* [feat(Executables)](https://github.com/MODFLOW-USGS/modflow-devtools/commit/3129417dae2de3aece80c8056a2ac50eede56b91): Support collection-style membership test (#131). Committed by wpbonelli on 2023-12-18. +* [feat](https://github.com/MODFLOW-USGS/modflow-devtools/commit/6728859a984a3080f8fd4f1135de36bc17454098): Add latex and plot style utilities (#132). Committed by wpbonelli on 2024-01-09. +* [feat(misc)](https://github.com/MODFLOW-USGS/modflow-devtools/commit/a9b801932866a26a996ed3a45f16048b15246472): Parse literals from environment variables (#135). Committed by wpbonelli on 2024-01-21. +* [feat(ostags)](https://github.com/MODFLOW-USGS/modflow-devtools/commit/0ad10751ea6ce752e59d83e8cd6275906d73fa70): Apple silicon (#139). Committed by wpbonelli on 2024-02-18. + +#### Bug fixes + +* [fix](https://github.com/MODFLOW-USGS/modflow-devtools/commit/fd215000c6215b0891e78ee621e40abb2a20b28a): Drop plot styles (already in flopy) (#133). Committed by wpbonelli on 2024-01-09. + +#### Refactoring + +* [refactor](https://github.com/MODFLOW-USGS/modflow-devtools/commit/9356e067ea813aeeeda2582cf7ec174c11d80159): Remove executables module/class (#136). Committed by wpbonelli on 2024-01-25. +* [refactor(fixtures)](https://github.com/MODFLOW-USGS/modflow-devtools/commit/613ad010ff6fc782f231b7fa21d1cc660732e7be): Support pytest>=8, drop pytest-cases dependency (#137). Committed by wpbonelli on 2024-01-31. + ### Version 1.3.1 #### Refactoring diff --git a/docs/conf.py b/docs/conf.py index b13c9f2..127aeb0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,7 +8,7 @@ project = "modflow-devtools" author = "MODFLOW Team" -release = "1.4.0.dev0" +release = "1.4.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/modflow_devtools/__init__.py b/modflow_devtools/__init__.py index 0399214..80b4f29 100644 --- a/modflow_devtools/__init__.py +++ b/modflow_devtools/__init__.py @@ -1,6 +1,6 @@ __author__ = "Joseph D. Hughes" -__date__ = "Nov 21, 2023" -__version__ = "1.4.0.dev0" +__date__ = "Feb 19, 2024" +__version__ = "1.4.0" __maintainer__ = "Joseph D. Hughes" __email__ = "jdhughes@usgs.gov" __status__ = "Production" diff --git a/version.txt b/version.txt index b58da95..e21e727 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.4.0.dev0 \ No newline at end of file +1.4.0 \ No newline at end of file