From 4f8e317693864f40ecff437796fbdbd8685366d6 Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Thu, 9 Nov 2023 13:22:26 -0700 Subject: [PATCH 01/15] Generalize version comparison --- src/metpy/testing.py | 23 +++++++++++++---------- tests/plots/test_declarative.py | 6 +++--- tests/plots/test_skewt.py | 8 ++++---- tests/plots/test_util.py | 6 +++--- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/metpy/testing.py b/src/metpy/testing.py index da9416ad216..f76aedb6050 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -8,9 +8,9 @@ * code for testing matplotlib figures """ import contextlib +import importlib import functools -import matplotlib import numpy as np import numpy.testing from packaging.version import Version @@ -23,14 +23,13 @@ from .deprecation import MetpyDeprecationWarning from .units import units -MPL_VERSION = Version(matplotlib.__version__) - - -def mpl_version_before(ver): - """Return whether the active matplotlib is before a certain version. +def module_version_before(modname, ver): + """Return whether the active module is before a certain version. Parameters ---------- + modname : str + The module name to import ver : str The version string for a certain release @@ -38,14 +37,17 @@ def mpl_version_before(ver): ------- bool : whether the current version was released before the passed in one """ - return MPL_VERSION < Version(ver) + module = importlib.import_module(modname) + return Version(module.__version__) < Version(ver) -def mpl_version_equal(ver): - """Return whether the active matplotlib is equal to a certain version. +def module_version_equal(modname, ver): + """Return whether the active module is equal to a certain version. Parameters ---------- + modname : str + The module name to import ver : str The version string for a certain release @@ -53,7 +55,8 @@ def mpl_version_equal(ver): ------- bool : whether the current version is equal to the passed in one """ - return MPL_VERSION == Version(ver) + module = importlib.import_module(modname) + return Version(module.__version__) == Version(ver) def needs_module(module): diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index 4370ed25d82..065ddc2ef7d 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -20,7 +20,7 @@ from metpy.io.metar import parse_metar_file from metpy.plots import (ArrowPlot, BarbPlot, ContourPlot, FilledContourPlot, ImagePlot, MapPanel, PanelContainer, PlotGeometry, PlotObs, RasterPlot) -from metpy.testing import mpl_version_before, needs_cartopy +from metpy.testing import module_version_before, needs_cartopy from metpy.units import units @@ -335,7 +335,7 @@ def test_declarative_contour_cam(): @pytest.mark.mpl_image_compare(remove_text=True, - tolerance=3.71 if mpl_version_before('3.8') else 0.74) + tolerance=3.71 if module_version_before('matplotlib', '3.8') else 0.74) @needs_cartopy def test_declarative_contour_options(): """Test making a contour plot.""" @@ -429,7 +429,7 @@ def test_declarative_additional_layers_plot_options(): @pytest.mark.mpl_image_compare(remove_text=True, - tolerance=2.74 if mpl_version_before('3.8') else 1.91) + tolerance=2.74 if module_version_before('matplotlib', '3.8') else 1.91) @needs_cartopy def test_declarative_contour_convert_units(): """Test making a contour plot.""" diff --git a/tests/plots/test_skewt.py b/tests/plots/test_skewt.py index ab5d5c14933..39bf7a44cfa 100644 --- a/tests/plots/test_skewt.py +++ b/tests/plots/test_skewt.py @@ -11,7 +11,7 @@ import pytest from metpy.plots import Hodograph, SkewT -from metpy.testing import mpl_version_before, mpl_version_equal +from metpy.testing import module_version_before, module_version_equal from metpy.units import units @@ -155,8 +155,8 @@ def test_skewt_units(): skew.ax.axvline(-10, color='orange') # On Matplotlib <= 3.6, ax[hv]line() doesn't trigger unit labels - assert skew.ax.get_xlabel() == ('degree_Celsius' if mpl_version_equal('3.7.0') else '') - assert skew.ax.get_ylabel() == ('hectopascal' if mpl_version_equal('3.7.0') else '') + assert skew.ax.get_xlabel() == ('degree_Celsius' if module_version_equal('matplotlib', '3.7.0') else '') + assert skew.ax.get_ylabel() == ('hectopascal' if module_version_equal('matplotlib', '3.7.0') else '') # Clear them for the image test skew.ax.set_xlabel('') @@ -319,7 +319,7 @@ def test_hodograph_api(): @pytest.mark.mpl_image_compare(remove_text=True, - tolerance=0.6 if mpl_version_before('3.5') else 0.) + tolerance=0.6 if module_version_equal('matplotlib', '3.5') else 0.) def test_hodograph_units(): """Test passing quantities to Hodograph.""" fig = plt.figure(figsize=(9, 9)) diff --git a/tests/plots/test_util.py b/tests/plots/test_util.py index 2f973c4f0d1..399c7679c5a 100644 --- a/tests/plots/test_util.py +++ b/tests/plots/test_util.py @@ -11,7 +11,7 @@ import xarray as xr from metpy.plots import add_metpy_logo, add_timestamp, add_unidata_logo, convert_gempak_color -from metpy.testing import get_test_data, mpl_version_before +from metpy.testing import get_test_data, module_version_before @pytest.mark.mpl_image_compare(tolerance=2.638, remove_text=True) @@ -91,7 +91,7 @@ def test_add_logo_invalid_size(): add_metpy_logo(fig, size='jumbo') -@pytest.mark.mpl_image_compare(tolerance=1.072 if mpl_version_before('3.5') else 0, +@pytest.mark.mpl_image_compare(tolerance=1.072 if module_version_before('matplotlib', '3.5') else 0, remove_text=True) def test_gempak_color_image_compare(): """Test creating a plot with all the GEMPAK colors.""" @@ -111,7 +111,7 @@ def test_gempak_color_image_compare(): return fig -@pytest.mark.mpl_image_compare(tolerance=1.215 if mpl_version_before('3.5') else 0, +@pytest.mark.mpl_image_compare(tolerance=1.215 if module_version_before('matplotlib', '3.5') else 0, remove_text=True) def test_gempak_color_xw_image_compare(): """Test creating a plot with all the GEMPAK colors using xw style.""" From 9d2d3f9453955545c5ac8ab81b5688b4609d5e38 Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Thu, 9 Nov 2023 13:23:30 -0700 Subject: [PATCH 02/15] Add xfail condition for old pint --- tests/calc/test_indices.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/calc/test_indices.py b/tests/calc/test_indices.py index a7959835b37..b8fd0c2dd66 100644 --- a/tests/calc/test_indices.py +++ b/tests/calc/test_indices.py @@ -12,7 +12,7 @@ from metpy.calc import (bulk_shear, bunkers_storm_motion, critical_angle, mean_pressure_weighted, precipitable_water, significant_tornado, supercell_composite, weighted_continuous_average) -from metpy.testing import assert_almost_equal, assert_array_almost_equal, get_upper_air_data +from metpy.testing import assert_almost_equal, assert_array_almost_equal, get_upper_air_data, module_version_before from metpy.units import concatenate, units @@ -130,7 +130,7 @@ def test_weighted_continuous_average(): assert_almost_equal(v, 6.900543760612305 * units('m/s'), 7) -@pytest.mark.xfail(reason='hgrecco/pint#1593') +@pytest.mark.xfail(condition=module_version_before('pint', '0.21'), reason='hgrecco/pint#1593') def test_weighted_continuous_average_temperature(): """Test pressure-weighted mean temperature function with vertical interpolation.""" data = get_upper_air_data(datetime(2016, 5, 22, 0), 'DDC') From 4fcccfed2c456f9a0aad417bcf700f585199567e Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Thu, 9 Nov 2023 14:16:10 -0700 Subject: [PATCH 03/15] Lint --- src/metpy/testing.py | 3 ++- tests/calc/test_indices.py | 3 ++- tests/plots/test_declarative.py | 10 ++++++---- tests/plots/test_skewt.py | 12 +++++++----- tests/plots/test_util.py | 10 ++++++---- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/metpy/testing.py b/src/metpy/testing.py index f76aedb6050..7be5e504252 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -8,8 +8,8 @@ * code for testing matplotlib figures """ import contextlib -import importlib import functools +import importlib import numpy as np import numpy.testing @@ -23,6 +23,7 @@ from .deprecation import MetpyDeprecationWarning from .units import units + def module_version_before(modname, ver): """Return whether the active module is before a certain version. diff --git a/tests/calc/test_indices.py b/tests/calc/test_indices.py index b8fd0c2dd66..162299c3be2 100644 --- a/tests/calc/test_indices.py +++ b/tests/calc/test_indices.py @@ -12,7 +12,8 @@ from metpy.calc import (bulk_shear, bunkers_storm_motion, critical_angle, mean_pressure_weighted, precipitable_water, significant_tornado, supercell_composite, weighted_continuous_average) -from metpy.testing import assert_almost_equal, assert_array_almost_equal, get_upper_air_data, module_version_before +from metpy.testing import (assert_almost_equal, assert_array_almost_equal, get_upper_air_data, + module_version_before) from metpy.units import concatenate, units diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index 065ddc2ef7d..862704a1dce 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -334,8 +334,9 @@ def test_declarative_contour_cam(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, - tolerance=3.71 if module_version_before('matplotlib', '3.8') else 0.74) +@pytest.mark.mpl_image_compare( + remove_text=True, + tolerance=3.71 if module_version_before('matplotlib', '3.8') else 0.74) @needs_cartopy def test_declarative_contour_options(): """Test making a contour plot.""" @@ -428,8 +429,9 @@ def test_declarative_additional_layers_plot_options(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, - tolerance=2.74 if module_version_before('matplotlib', '3.8') else 1.91) +@pytest.mark.mpl_image_compare( + remove_text=True, + tolerance=2.74 if module_version_before('matplotlib', '3.8') else 1.91) @needs_cartopy def test_declarative_contour_convert_units(): """Test making a contour plot.""" diff --git a/tests/plots/test_skewt.py b/tests/plots/test_skewt.py index 39bf7a44cfa..483e5fee134 100644 --- a/tests/plots/test_skewt.py +++ b/tests/plots/test_skewt.py @@ -11,7 +11,7 @@ import pytest from metpy.plots import Hodograph, SkewT -from metpy.testing import module_version_before, module_version_equal +from metpy.testing import module_version_equal from metpy.units import units @@ -155,8 +155,10 @@ def test_skewt_units(): skew.ax.axvline(-10, color='orange') # On Matplotlib <= 3.6, ax[hv]line() doesn't trigger unit labels - assert skew.ax.get_xlabel() == ('degree_Celsius' if module_version_equal('matplotlib', '3.7.0') else '') - assert skew.ax.get_ylabel() == ('hectopascal' if module_version_equal('matplotlib', '3.7.0') else '') + assert skew.ax.get_xlabel() == ( + 'degree_Celsius' if module_version_equal('matplotlib', '3.7.0') else '') + assert skew.ax.get_ylabel() == ( + 'hectopascal' if module_version_equal('matplotlib', '3.7.0') else '') # Clear them for the image test skew.ax.set_xlabel('') @@ -318,8 +320,8 @@ def test_hodograph_api(): return fig -@pytest.mark.mpl_image_compare(remove_text=True, - tolerance=0.6 if module_version_equal('matplotlib', '3.5') else 0.) +@pytest.mark.mpl_image_compare( + remove_text=True, tolerance=0.6 if module_version_equal('matplotlib', '3.5') else 0.) def test_hodograph_units(): """Test passing quantities to Hodograph.""" fig = plt.figure(figsize=(9, 9)) diff --git a/tests/plots/test_util.py b/tests/plots/test_util.py index 399c7679c5a..b526c2a9da4 100644 --- a/tests/plots/test_util.py +++ b/tests/plots/test_util.py @@ -91,8 +91,9 @@ def test_add_logo_invalid_size(): add_metpy_logo(fig, size='jumbo') -@pytest.mark.mpl_image_compare(tolerance=1.072 if module_version_before('matplotlib', '3.5') else 0, - remove_text=True) +@pytest.mark.mpl_image_compare( + tolerance=1.072 if module_version_before('matplotlib', '3.5') else 0, + remove_text=True) def test_gempak_color_image_compare(): """Test creating a plot with all the GEMPAK colors.""" c = range(32) @@ -111,8 +112,9 @@ def test_gempak_color_image_compare(): return fig -@pytest.mark.mpl_image_compare(tolerance=1.215 if module_version_before('matplotlib', '3.5') else 0, - remove_text=True) +@pytest.mark.mpl_image_compare( + tolerance=1.215 if module_version_before('matplotlib', '3.5') else 0, + remove_text=True) def test_gempak_color_xw_image_compare(): """Test creating a plot with all the GEMPAK colors using xw style.""" c = range(32) From bdf9c915c040406ffb691023f59c75fa3e883d01 Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Thu, 9 Nov 2023 14:18:58 -0700 Subject: [PATCH 04/15] Fail tests on xpass --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index d7c33a83700..ba816b8c960 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,7 @@ markers = "xfail_dask: marks tests as expected to fail with Dask arrays" norecursedirs = "build docs .idea" doctest_optionflags = "NORMALIZE_WHITESPACE" mpl-results-path = "test_output" +xfail_strict = true [tool.ruff] line-length = 95 From 0dc554d8860cfe2cdaa28f66ebfc7037aef9ccef Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Thu, 9 Nov 2023 15:31:20 -0700 Subject: [PATCH 05/15] Update test value --- tests/calc/test_indices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/calc/test_indices.py b/tests/calc/test_indices.py index 162299c3be2..8831379fbb3 100644 --- a/tests/calc/test_indices.py +++ b/tests/calc/test_indices.py @@ -139,7 +139,7 @@ def test_weighted_continuous_average_temperature(): data['temperature'], height=data['height'], depth=6000 * units('meter')) - assert_almost_equal(t, 279.3275828240889 * units('kelvin'), 7) + assert_almost_equal(t, 279.07450928270185 * units('kelvin'), 7) def test_weighted_continuous_average_elevated(): From 1c6e00689a6752b03c9d1e879a704c7d3e2c85bc Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Fri, 10 Nov 2023 14:19:10 -0700 Subject: [PATCH 06/15] Refactor to parse version spec Simplify one more time down to a single version comparison helper that parses a package spec string and compares that spec to the currently installed package. --- src/metpy/testing.py | 56 +++++++++++++++++++-------------- tests/calc/test_indices.py | 4 +-- tests/plots/test_declarative.py | 6 ++-- tests/plots/test_skewt.py | 8 ++--- tests/plots/test_util.py | 6 ++-- tests/test_testing.py | 21 ++++++++++++- 6 files changed, 65 insertions(+), 36 deletions(-) diff --git a/src/metpy/testing.py b/src/metpy/testing.py index 7be5e504252..11cf1ec7485 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -10,6 +10,7 @@ import contextlib import functools import importlib +import re import numpy as np import numpy.testing @@ -24,40 +25,49 @@ from .units import units -def module_version_before(modname, ver): - """Return whether the active module is before a certain version. +def module_version_check(version_spec): + """Return comparison between the active module and a requested version number. Parameters ---------- - modname : str - The module name to import - ver : str - The version string for a certain release + version_spec : str + Module version specification to validate against installed package. Must take the form + of `f'{module_name}{comparison_operator}{version_number}'` where `comparison_operator` + must be one of `['==', '=', '!=', '<', '<=', '>', '>=']`. Returns ------- - bool : whether the current version was released before the passed in one + bool : Whether the installed package validates against the provided specification """ - module = importlib.import_module(modname) - return Version(module.__version__) < Version(ver) + comparison_operators = { + '==': lambda x, y: x == y, '=': lambda x, y: x == y, '!=': lambda x, y: x != y, + '<': lambda x, y: x < y, '<=': lambda x, y: x <= y, + '>': lambda x, y: x > y, '>=': lambda x, y: x >= y, + } + # Match version_spec for groups of module name, + # comparison operator, and requested module version + pattern = re.compile(r'(\w+)\s*([<>!=]+)\s*([\d.]+)') + match = pattern.match(version_spec) -def module_version_equal(modname, ver): - """Return whether the active module is equal to a certain version. + if match: + module_name = match.group(1) + comparison = match.group(2) + version_number = match.group(3) + else: + raise ValueError('No valid version specification string matched.') - Parameters - ---------- - modname : str - The module name to import - ver : str - The version string for a certain release + module = importlib.import_module(module_name) - Returns - ------- - bool : whether the current version is equal to the passed in one - """ - module = importlib.import_module(modname) - return Version(module.__version__) == Version(ver) + installed_version = Version(module.__version__) + specified_version = Version(version_number) + + try: + return comparison_operators[comparison](installed_version, specified_version) + except KeyError: + raise ValueError( + "Comparison operator not one of ['==', '=', '!=', '<', '<=', '>', '>=']." + ) from None def needs_module(module): diff --git a/tests/calc/test_indices.py b/tests/calc/test_indices.py index 8831379fbb3..3b03979e4a5 100644 --- a/tests/calc/test_indices.py +++ b/tests/calc/test_indices.py @@ -13,7 +13,7 @@ mean_pressure_weighted, precipitable_water, significant_tornado, supercell_composite, weighted_continuous_average) from metpy.testing import (assert_almost_equal, assert_array_almost_equal, get_upper_air_data, - module_version_before) + module_version_check) from metpy.units import concatenate, units @@ -131,7 +131,7 @@ def test_weighted_continuous_average(): assert_almost_equal(v, 6.900543760612305 * units('m/s'), 7) -@pytest.mark.xfail(condition=module_version_before('pint', '0.21'), reason='hgrecco/pint#1593') +@pytest.mark.xfail(condition=module_version_check('pint<0.21'), reason='hgrecco/pint#1593') def test_weighted_continuous_average_temperature(): """Test pressure-weighted mean temperature function with vertical interpolation.""" data = get_upper_air_data(datetime(2016, 5, 22, 0), 'DDC') diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index 862704a1dce..d9e3e5f1411 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -20,7 +20,7 @@ from metpy.io.metar import parse_metar_file from metpy.plots import (ArrowPlot, BarbPlot, ContourPlot, FilledContourPlot, ImagePlot, MapPanel, PanelContainer, PlotGeometry, PlotObs, RasterPlot) -from metpy.testing import module_version_before, needs_cartopy +from metpy.testing import module_version_check, needs_cartopy from metpy.units import units @@ -336,7 +336,7 @@ def test_declarative_contour_cam(): @pytest.mark.mpl_image_compare( remove_text=True, - tolerance=3.71 if module_version_before('matplotlib', '3.8') else 0.74) + tolerance=3.71 if module_version_check('matplotlib<3.8') else 0.74) @needs_cartopy def test_declarative_contour_options(): """Test making a contour plot.""" @@ -431,7 +431,7 @@ def test_declarative_additional_layers_plot_options(): @pytest.mark.mpl_image_compare( remove_text=True, - tolerance=2.74 if module_version_before('matplotlib', '3.8') else 1.91) + tolerance=2.74 if module_version_check('matplotlib<3.8') else 1.91) @needs_cartopy def test_declarative_contour_convert_units(): """Test making a contour plot.""" diff --git a/tests/plots/test_skewt.py b/tests/plots/test_skewt.py index 483e5fee134..e87be2f6ce9 100644 --- a/tests/plots/test_skewt.py +++ b/tests/plots/test_skewt.py @@ -11,7 +11,7 @@ import pytest from metpy.plots import Hodograph, SkewT -from metpy.testing import module_version_equal +from metpy.testing import module_version_check from metpy.units import units @@ -156,9 +156,9 @@ def test_skewt_units(): # On Matplotlib <= 3.6, ax[hv]line() doesn't trigger unit labels assert skew.ax.get_xlabel() == ( - 'degree_Celsius' if module_version_equal('matplotlib', '3.7.0') else '') + 'degree_Celsius' if module_version_check('matplotlib==3.7.0') else '') assert skew.ax.get_ylabel() == ( - 'hectopascal' if module_version_equal('matplotlib', '3.7.0') else '') + 'hectopascal' if module_version_check('matplotlib==3.7.0') else '') # Clear them for the image test skew.ax.set_xlabel('') @@ -321,7 +321,7 @@ def test_hodograph_api(): @pytest.mark.mpl_image_compare( - remove_text=True, tolerance=0.6 if module_version_equal('matplotlib', '3.5') else 0.) + remove_text=True, tolerance=0.6 if module_version_check('matplotlib==3.5') else 0.) def test_hodograph_units(): """Test passing quantities to Hodograph.""" fig = plt.figure(figsize=(9, 9)) diff --git a/tests/plots/test_util.py b/tests/plots/test_util.py index b526c2a9da4..b33acd18287 100644 --- a/tests/plots/test_util.py +++ b/tests/plots/test_util.py @@ -11,7 +11,7 @@ import xarray as xr from metpy.plots import add_metpy_logo, add_timestamp, add_unidata_logo, convert_gempak_color -from metpy.testing import get_test_data, module_version_before +from metpy.testing import get_test_data, module_version_check @pytest.mark.mpl_image_compare(tolerance=2.638, remove_text=True) @@ -92,7 +92,7 @@ def test_add_logo_invalid_size(): @pytest.mark.mpl_image_compare( - tolerance=1.072 if module_version_before('matplotlib', '3.5') else 0, + tolerance=1.072 if module_version_check('matplotlib<3.5') else 0, remove_text=True) def test_gempak_color_image_compare(): """Test creating a plot with all the GEMPAK colors.""" @@ -113,7 +113,7 @@ def test_gempak_color_image_compare(): @pytest.mark.mpl_image_compare( - tolerance=1.215 if module_version_before('matplotlib', '3.5') else 0, + tolerance=1.215 if module_version_check('matplotlib<3.5') else 0, remove_text=True) def test_gempak_color_xw_image_compare(): """Test creating a plot with all the GEMPAK colors using xw style.""" diff --git a/tests/test_testing.py b/tests/test_testing.py index 8fa0c398a4a..1f8a34eace8 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -10,7 +10,7 @@ from metpy.deprecation import MetpyDeprecationWarning from metpy.testing import (assert_array_almost_equal, check_and_drop_units, - check_and_silence_deprecation) + check_and_silence_deprecation, module_version_check) # Test #1183: numpy.testing.assert_array* ignores any masked value, so work-around @@ -42,3 +42,22 @@ def test_check_and_drop_units_with_dataarray(): assert isinstance(actual, np.ndarray) assert isinstance(desired, np.ndarray) np.testing.assert_array_almost_equal(actual, desired) + + +def test_module_version_check(): + """Test parsing and version comparison of installed package.""" + assert module_version_check('numpy>0.0.0') + assert module_version_check('numpy >= 0.0') + assert module_version_check('numpy!=0') + + +def test_module_version_check_nonsense(): + """Test failed pattern match of package specification.""" + with pytest.raises(ValueError, match='No valid version '): + module_version_check('thousands of birds picking packages') + + +def test_module_version_check_invalid_comparison(): + """Test invalid operator in version comparison.""" + with pytest.raises(ValueError, match='Comparison operator not '): + module_version_check('numpy<<36') From e6d77c8970567e8801770bf5b8e523a2dfd540ce Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Mon, 13 Nov 2023 16:47:30 -0700 Subject: [PATCH 07/15] Add minimum required validator Add validation to requested package spec to fail if the spec is out of date and irrelevant according to package metadata. --- src/metpy/testing.py | 62 ++++++++++++++++++++++++++++++++++--------- tests/test_testing.py | 13 ++++++--- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/src/metpy/testing.py b/src/metpy/testing.py index 11cf1ec7485..97c5d8d2d8d 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -9,7 +9,7 @@ """ import contextlib import functools -import importlib +from importlib.metadata import requires, version import re import numpy as np @@ -28,6 +28,8 @@ def module_version_check(version_spec): """Return comparison between the active module and a requested version number. + Will also validate specification against package metadata to alert if spec is irrelevant. + Parameters ---------- version_spec : str @@ -47,29 +49,63 @@ def module_version_check(version_spec): # Match version_spec for groups of module name, # comparison operator, and requested module version - pattern = re.compile(r'(\w+)\s*([<>!=]+)\s*([\d.]+)') - match = pattern.match(version_spec) - - if match: - module_name = match.group(1) - comparison = match.group(2) - version_number = match.group(3) - else: - raise ValueError('No valid version specification string matched.') + module_name, comparison, version_number = _parse_version_spec(version_spec) - module = importlib.import_module(module_name) + # Check MetPy metadata for minimum required version of same package + _, _, minimum_version_number = _parse_version_spec(_get_metadata_spec(module_name)) - installed_version = Version(module.__version__) + installed_version = Version(version(module_name)) specified_version = Version(version_number) + minimum_version = Version(minimum_version_number) + + if specified_version < minimum_version: + raise ValueError('Specified package version older than MetPy minimum requirement.') try: - return comparison_operators[comparison](installed_version, specified_version) + return comparison_operators[comparison](specified_version, installed_version) except KeyError: raise ValueError( "Comparison operator not one of ['==', '=', '!=', '<', '<=', '>', '>=']." ) from None +def _parse_version_spec(version_spec): + """Parse module name, comparison, and version from pip-style package spec string. + + Parameters + ---------- + version_spec : str + Package spec to parse + + Returns + ------- + tuple of str : Parsed specification groups of package name, comparison, and version + + """ + pattern = re.compile(r'(\w+)\s*([<>!=]+)\s*([\d.]+)') + match = pattern.match(version_spec) + + if not match: + raise ValueError('No valid version specification string matched.') + else: + return match.groups() + + +def _get_metadata_spec(module_name): + """Get package spec string for requested module from package metadata. + + Parameters + ---------- + module_name : str + Name of MetPy required package to look up + + Returns + ------- + str : Package spec string for request module + """ + return [entry for entry in requires('metpy') if module_name.lower() in entry.lower()][0] + + def needs_module(module): """Decorate a test function or fixture as requiring a module. diff --git a/tests/test_testing.py b/tests/test_testing.py index 1f8a34eace8..28987c286a6 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -46,9 +46,14 @@ def test_check_and_drop_units_with_dataarray(): def test_module_version_check(): """Test parsing and version comparison of installed package.""" - assert module_version_check('numpy>0.0.0') - assert module_version_check('numpy >= 0.0') - assert module_version_check('numpy!=0') + numpy_version = np.__version__ + assert module_version_check(f'numpy >={numpy_version}') + + +def test_module_version_check_outdated_spec(): + """Test checking test version specs against package metadata.""" + with pytest.raises(ValueError, match='Specified package version '): + module_version_check('numpy>0.0.0') def test_module_version_check_nonsense(): @@ -60,4 +65,4 @@ def test_module_version_check_nonsense(): def test_module_version_check_invalid_comparison(): """Test invalid operator in version comparison.""" with pytest.raises(ValueError, match='Comparison operator not '): - module_version_check('numpy<<36') + module_version_check('numpy << 36') From d74e3bc08d691888b0cb3010e351a27963421e17 Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Mon, 13 Nov 2023 20:01:18 -0700 Subject: [PATCH 08/15] Fix order --- src/metpy/testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metpy/testing.py b/src/metpy/testing.py index 97c5d8d2d8d..94ad694f989 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -62,7 +62,7 @@ def module_version_check(version_spec): raise ValueError('Specified package version older than MetPy minimum requirement.') try: - return comparison_operators[comparison](specified_version, installed_version) + return comparison_operators[comparison](installed_version, specified_version) except KeyError: raise ValueError( "Comparison operator not one of ['==', '=', '!=', '<', '<=', '>', '>=']." From 4c3ccb319941406ed8d710182276e7d99ed994de Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Mon, 13 Nov 2023 20:03:37 -0700 Subject: [PATCH 09/15] Limit xfail to appropriate scipy --- tests/calc/test_thermo.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/calc/test_thermo.py b/tests/calc/test_thermo.py index 42f6dbb1c20..42acc3b1779 100644 --- a/tests/calc/test_thermo.py +++ b/tests/calc/test_thermo.py @@ -39,7 +39,7 @@ virtual_temperature, virtual_temperature_from_dewpoint, wet_bulb_temperature) from metpy.calc.thermo import _find_append_zero_crossings -from metpy.testing import assert_almost_equal, assert_array_almost_equal, assert_nan +from metpy.testing import assert_almost_equal, assert_array_almost_equal, assert_nan, module_version_check from metpy.units import is_quantity, masked_array, units @@ -201,8 +201,9 @@ def test_moist_lapse_starting_points(start, direction): @pytest.mark.xfail(platform.machine() == 'aarch64', reason='ValueError is not raised on aarch64') @pytest.mark.xfail(platform.machine() == 'arm64', reason='ValueError is not raised on Mac M2') -@pytest.mark.xfail(sys.platform == 'win32', reason='solve_ivp() does not error on Windows') -@pytest.mark.xfail(packaging.version.parse(scipy.__version__) < packaging.version.parse('1.7'), +@pytest.mark.xfail((sys.platform == 'win32') & module_version_check('scipy<1.11.3'), + reason='solve_ivp() does not error on Windows + SciPy < 1.11.3') +@pytest.mark.xfail(module_version_check('scipy<1.7'), reason='solve_ivp() does not error on Scipy < 1.7') def test_moist_lapse_failure(): """Test moist_lapse under conditions that cause the ODE solver to fail.""" From 9a3d77b803e2ae2cd0408ea1d35ffef95ea92209 Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Mon, 13 Nov 2023 20:34:41 -0700 Subject: [PATCH 10/15] Use operator in comparison dict --- src/metpy/testing.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/metpy/testing.py b/src/metpy/testing.py index 94ad694f989..a0fd118bf6c 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -10,6 +10,7 @@ import contextlib import functools from importlib.metadata import requires, version +import operator as op import re import numpy as np @@ -42,9 +43,9 @@ def module_version_check(version_spec): bool : Whether the installed package validates against the provided specification """ comparison_operators = { - '==': lambda x, y: x == y, '=': lambda x, y: x == y, '!=': lambda x, y: x != y, - '<': lambda x, y: x < y, '<=': lambda x, y: x <= y, - '>': lambda x, y: x > y, '>=': lambda x, y: x >= y, + '==': op.eq, '=': op.eq, '!=': op.ne, + '<': op.lt, '<=': op.le, + '>': op.gt, '>=': op.ge, } # Match version_spec for groups of module name, From fe629283658e21e88d681849a65eb31c6b4fb120 Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Mon, 13 Nov 2023 20:36:08 -0700 Subject: [PATCH 11/15] Shorten name --- src/metpy/testing.py | 2 +- tests/calc/test_indices.py | 4 ++-- tests/calc/test_thermo.py | 9 ++++----- tests/plots/test_declarative.py | 6 +++--- tests/plots/test_skewt.py | 8 ++++---- tests/plots/test_util.py | 6 +++--- tests/test_testing.py | 10 +++++----- 7 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/metpy/testing.py b/src/metpy/testing.py index a0fd118bf6c..94a5f0c3526 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -26,7 +26,7 @@ from .units import units -def module_version_check(version_spec): +def version_check(version_spec): """Return comparison between the active module and a requested version number. Will also validate specification against package metadata to alert if spec is irrelevant. diff --git a/tests/calc/test_indices.py b/tests/calc/test_indices.py index 3b03979e4a5..77c5f637d1d 100644 --- a/tests/calc/test_indices.py +++ b/tests/calc/test_indices.py @@ -13,7 +13,7 @@ mean_pressure_weighted, precipitable_water, significant_tornado, supercell_composite, weighted_continuous_average) from metpy.testing import (assert_almost_equal, assert_array_almost_equal, get_upper_air_data, - module_version_check) + version_check) from metpy.units import concatenate, units @@ -131,7 +131,7 @@ def test_weighted_continuous_average(): assert_almost_equal(v, 6.900543760612305 * units('m/s'), 7) -@pytest.mark.xfail(condition=module_version_check('pint<0.21'), reason='hgrecco/pint#1593') +@pytest.mark.xfail(condition=version_check('pint<0.21'), reason='hgrecco/pint#1593') def test_weighted_continuous_average_temperature(): """Test pressure-weighted mean temperature function with vertical interpolation.""" data = get_upper_air_data(datetime(2016, 5, 22, 0), 'DDC') diff --git a/tests/calc/test_thermo.py b/tests/calc/test_thermo.py index 42acc3b1779..450fd6229ce 100644 --- a/tests/calc/test_thermo.py +++ b/tests/calc/test_thermo.py @@ -8,9 +8,7 @@ import warnings import numpy as np -import packaging.version import pytest -import scipy import xarray as xr from metpy.calc import (brunt_vaisala_frequency, brunt_vaisala_frequency_squared, @@ -39,7 +37,8 @@ virtual_temperature, virtual_temperature_from_dewpoint, wet_bulb_temperature) from metpy.calc.thermo import _find_append_zero_crossings -from metpy.testing import assert_almost_equal, assert_array_almost_equal, assert_nan, module_version_check +from metpy.testing import (assert_almost_equal, assert_array_almost_equal, assert_nan, + version_check) from metpy.units import is_quantity, masked_array, units @@ -201,9 +200,9 @@ def test_moist_lapse_starting_points(start, direction): @pytest.mark.xfail(platform.machine() == 'aarch64', reason='ValueError is not raised on aarch64') @pytest.mark.xfail(platform.machine() == 'arm64', reason='ValueError is not raised on Mac M2') -@pytest.mark.xfail((sys.platform == 'win32') & module_version_check('scipy<1.11.3'), +@pytest.mark.xfail((sys.platform == 'win32') & version_check('scipy<1.11.3'), reason='solve_ivp() does not error on Windows + SciPy < 1.11.3') -@pytest.mark.xfail(module_version_check('scipy<1.7'), +@pytest.mark.xfail(version_check('scipy<1.7'), reason='solve_ivp() does not error on Scipy < 1.7') def test_moist_lapse_failure(): """Test moist_lapse under conditions that cause the ODE solver to fail.""" diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index d9e3e5f1411..bacee611cdf 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -20,7 +20,7 @@ from metpy.io.metar import parse_metar_file from metpy.plots import (ArrowPlot, BarbPlot, ContourPlot, FilledContourPlot, ImagePlot, MapPanel, PanelContainer, PlotGeometry, PlotObs, RasterPlot) -from metpy.testing import module_version_check, needs_cartopy +from metpy.testing import needs_cartopy, version_check from metpy.units import units @@ -336,7 +336,7 @@ def test_declarative_contour_cam(): @pytest.mark.mpl_image_compare( remove_text=True, - tolerance=3.71 if module_version_check('matplotlib<3.8') else 0.74) + tolerance=3.71 if version_check('matplotlib<3.8') else 0.74) @needs_cartopy def test_declarative_contour_options(): """Test making a contour plot.""" @@ -431,7 +431,7 @@ def test_declarative_additional_layers_plot_options(): @pytest.mark.mpl_image_compare( remove_text=True, - tolerance=2.74 if module_version_check('matplotlib<3.8') else 1.91) + tolerance=2.74 if version_check('matplotlib<3.8') else 1.91) @needs_cartopy def test_declarative_contour_convert_units(): """Test making a contour plot.""" diff --git a/tests/plots/test_skewt.py b/tests/plots/test_skewt.py index e87be2f6ce9..433fa095491 100644 --- a/tests/plots/test_skewt.py +++ b/tests/plots/test_skewt.py @@ -11,7 +11,7 @@ import pytest from metpy.plots import Hodograph, SkewT -from metpy.testing import module_version_check +from metpy.testing import version_check from metpy.units import units @@ -156,9 +156,9 @@ def test_skewt_units(): # On Matplotlib <= 3.6, ax[hv]line() doesn't trigger unit labels assert skew.ax.get_xlabel() == ( - 'degree_Celsius' if module_version_check('matplotlib==3.7.0') else '') + 'degree_Celsius' if version_check('matplotlib==3.7.0') else '') assert skew.ax.get_ylabel() == ( - 'hectopascal' if module_version_check('matplotlib==3.7.0') else '') + 'hectopascal' if version_check('matplotlib==3.7.0') else '') # Clear them for the image test skew.ax.set_xlabel('') @@ -321,7 +321,7 @@ def test_hodograph_api(): @pytest.mark.mpl_image_compare( - remove_text=True, tolerance=0.6 if module_version_check('matplotlib==3.5') else 0.) + remove_text=True, tolerance=0.6 if version_check('matplotlib==3.5') else 0.) def test_hodograph_units(): """Test passing quantities to Hodograph.""" fig = plt.figure(figsize=(9, 9)) diff --git a/tests/plots/test_util.py b/tests/plots/test_util.py index b33acd18287..6d77ae33ef9 100644 --- a/tests/plots/test_util.py +++ b/tests/plots/test_util.py @@ -11,7 +11,7 @@ import xarray as xr from metpy.plots import add_metpy_logo, add_timestamp, add_unidata_logo, convert_gempak_color -from metpy.testing import get_test_data, module_version_check +from metpy.testing import get_test_data, version_check @pytest.mark.mpl_image_compare(tolerance=2.638, remove_text=True) @@ -92,7 +92,7 @@ def test_add_logo_invalid_size(): @pytest.mark.mpl_image_compare( - tolerance=1.072 if module_version_check('matplotlib<3.5') else 0, + tolerance=1.072 if version_check('matplotlib<3.5') else 0, remove_text=True) def test_gempak_color_image_compare(): """Test creating a plot with all the GEMPAK colors.""" @@ -113,7 +113,7 @@ def test_gempak_color_image_compare(): @pytest.mark.mpl_image_compare( - tolerance=1.215 if module_version_check('matplotlib<3.5') else 0, + tolerance=1.215 if version_check('matplotlib<3.5') else 0, remove_text=True) def test_gempak_color_xw_image_compare(): """Test creating a plot with all the GEMPAK colors using xw style.""" diff --git a/tests/test_testing.py b/tests/test_testing.py index 28987c286a6..bf7a5cf17cd 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -10,7 +10,7 @@ from metpy.deprecation import MetpyDeprecationWarning from metpy.testing import (assert_array_almost_equal, check_and_drop_units, - check_and_silence_deprecation, module_version_check) + check_and_silence_deprecation, version_check) # Test #1183: numpy.testing.assert_array* ignores any masked value, so work-around @@ -47,22 +47,22 @@ def test_check_and_drop_units_with_dataarray(): def test_module_version_check(): """Test parsing and version comparison of installed package.""" numpy_version = np.__version__ - assert module_version_check(f'numpy >={numpy_version}') + assert version_check(f'numpy >={numpy_version}') def test_module_version_check_outdated_spec(): """Test checking test version specs against package metadata.""" with pytest.raises(ValueError, match='Specified package version '): - module_version_check('numpy>0.0.0') + version_check('numpy>0.0.0') def test_module_version_check_nonsense(): """Test failed pattern match of package specification.""" with pytest.raises(ValueError, match='No valid version '): - module_version_check('thousands of birds picking packages') + version_check('thousands of birds picking packages') def test_module_version_check_invalid_comparison(): """Test invalid operator in version comparison.""" with pytest.raises(ValueError, match='Comparison operator not '): - module_version_check('numpy << 36') + version_check('numpy << 36') From ab3fb4c5d38f8d7ec1c0d6c0ed39c587f95cd6df Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Mon, 13 Nov 2023 20:39:17 -0700 Subject: [PATCH 12/15] Add values to error messages --- src/metpy/testing.py | 13 ++++++++----- tests/test_testing.py | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/metpy/testing.py b/src/metpy/testing.py index 94a5f0c3526..8493ac69672 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -36,7 +36,7 @@ def version_check(version_spec): version_spec : str Module version specification to validate against installed package. Must take the form of `f'{module_name}{comparison_operator}{version_number}'` where `comparison_operator` - must be one of `['==', '=', '!=', '<', '<=', '>', '>=']`. + must be one of `['==', '=', '!=', '<', '<=', '>', '>=']`, eg `'metpy>1.0'`. Returns ------- @@ -53,20 +53,22 @@ def version_check(version_spec): module_name, comparison, version_number = _parse_version_spec(version_spec) # Check MetPy metadata for minimum required version of same package - _, _, minimum_version_number = _parse_version_spec(_get_metadata_spec(module_name)) + metadata_spec = _get_metadata_spec(module_name) + _, _, minimum_version_number = _parse_version_spec(metadata_spec) installed_version = Version(version(module_name)) specified_version = Version(version_number) minimum_version = Version(minimum_version_number) if specified_version < minimum_version: - raise ValueError('Specified package version older than MetPy minimum requirement.') + raise ValueError( + f'Specified {version_spec} outdated according to MetPy minimum {metadata_spec}.') try: return comparison_operators[comparison](installed_version, specified_version) except KeyError: raise ValueError( - "Comparison operator not one of ['==', '=', '!=', '<', '<=', '>', '>=']." + f'Comparison operator {comparison} not one of {list(comparison_operators)}.' ) from None @@ -87,7 +89,8 @@ def _parse_version_spec(version_spec): match = pattern.match(version_spec) if not match: - raise ValueError('No valid version specification string matched.') + raise ValueError(f'Invalid version specification {version_spec}.' + f'See version_check documentation for more information.') else: return match.groups() diff --git a/tests/test_testing.py b/tests/test_testing.py index bf7a5cf17cd..0625fc9e398 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -52,17 +52,17 @@ def test_module_version_check(): def test_module_version_check_outdated_spec(): """Test checking test version specs against package metadata.""" - with pytest.raises(ValueError, match='Specified package version '): + with pytest.raises(ValueError, match='Specified numpy'): version_check('numpy>0.0.0') def test_module_version_check_nonsense(): """Test failed pattern match of package specification.""" - with pytest.raises(ValueError, match='No valid version '): + with pytest.raises(ValueError, match='Invalid version '): version_check('thousands of birds picking packages') def test_module_version_check_invalid_comparison(): """Test invalid operator in version comparison.""" - with pytest.raises(ValueError, match='Comparison operator not '): + with pytest.raises(ValueError, match='Comparison operator << '): version_check('numpy << 36') From 3ec60ca912befd3d3d0a2ef01ca8ac33d350c50c Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Mon, 13 Nov 2023 20:39:27 -0700 Subject: [PATCH 13/15] Fix indent --- src/metpy/testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metpy/testing.py b/src/metpy/testing.py index 8493ac69672..81408d57c0d 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -105,7 +105,7 @@ def _get_metadata_spec(module_name): Returns ------- - str : Package spec string for request module + str : Package spec string for request module """ return [entry for entry in requires('metpy') if module_name.lower() in entry.lower()][0] From df0db3413bd20fdd3479445bf6c9b45a3c167ec9 Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Tue, 14 Nov 2023 14:06:03 -0700 Subject: [PATCH 14/15] Use correct and Co-authored-by: Ryan May --- tests/calc/test_thermo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/calc/test_thermo.py b/tests/calc/test_thermo.py index 450fd6229ce..05144fd408d 100644 --- a/tests/calc/test_thermo.py +++ b/tests/calc/test_thermo.py @@ -200,7 +200,7 @@ def test_moist_lapse_starting_points(start, direction): @pytest.mark.xfail(platform.machine() == 'aarch64', reason='ValueError is not raised on aarch64') @pytest.mark.xfail(platform.machine() == 'arm64', reason='ValueError is not raised on Mac M2') -@pytest.mark.xfail((sys.platform == 'win32') & version_check('scipy<1.11.3'), +@pytest.mark.xfail((sys.platform == 'win32') and version_check('scipy<1.11.3'), reason='solve_ivp() does not error on Windows + SciPy < 1.11.3') @pytest.mark.xfail(version_check('scipy<1.7'), reason='solve_ivp() does not error on Scipy < 1.7') From 0fe46ed820ca3dc1cbbc1ac7ef28a5dcb7760e35 Mon Sep 17 00:00:00 2001 From: Drew Camron Date: Tue, 14 Nov 2023 14:07:19 -0700 Subject: [PATCH 15/15] Shrink dict lines --- src/metpy/testing.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/metpy/testing.py b/src/metpy/testing.py index 81408d57c0d..529b6609293 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -43,9 +43,7 @@ def version_check(version_spec): bool : Whether the installed package validates against the provided specification """ comparison_operators = { - '==': op.eq, '=': op.eq, '!=': op.ne, - '<': op.lt, '<=': op.le, - '>': op.gt, '>=': op.ge, + '==': op.eq, '=': op.eq, '!=': op.ne, '<': op.lt, '<=': op.le, '>': op.gt, '>=': op.ge, } # Match version_spec for groups of module name,