diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 3f3100faba9..a755c579c45 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -15,7 +15,7 @@ inputs: pytest-args: description: 'Additional arguments to pass to pytest' required: false - default: '--mpl -W error::metpy.deprecation.MetpyDeprecationWarning' + default: '--mpl --record-mode=none -W error::metpy.deprecation.MetpyDeprecationWarning' runs: using: composite steps: diff --git a/ci-dev/test_requirements.txt b/ci-dev/test_requirements.txt index f3732008fe4..9d33caaf865 100644 --- a/ci-dev/test_requirements.txt +++ b/ci-dev/test_requirements.txt @@ -1,5 +1,6 @@ packaging==25.0 pytest==8.4.0 pytest-mpl==0.17.0 +pytest-recording==0.13.2 coverage==7.9.1 vcrpy==7.0.0 diff --git a/conftest.py b/conftest.py index 21a68efb448..492759c87cc 100644 --- a/conftest.py +++ b/conftest.py @@ -183,3 +183,20 @@ def geog_data(request): geod=crs.get_geod())[0][0], metpy.calc.lat_lon_grid_deltas(numpy.zeros_like(lats.m), lats.m, geod=crs.get_geod())[1][:, 0]) + + +@pytest.fixture(scope='module') +def vcr_cassette_dir(request): + """Modify default cassette path for vcr mark.""" + return str(request.path.parent / 'fixtures') + + +@pytest.fixture(scope='package') +def vcr_config(): + """Pass default config to vcr mark.""" + return { + # Record new cassettes if empty and replay existing cassettes by default; + # we can use 'none' in CI to refuse new recordings and replay old only. + # Use pytest --record-mode=rewrite to delete existing cassettes and re-record. + 'record_mode': 'once' + } diff --git a/pyproject.toml b/pyproject.toml index 33bf3be94c4..241a96c2067 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ test = [ "packaging>=21.0", "pytest>=7.0", "pytest-mpl", + "pytest-recording", "vcrpy>=4.3.1" ] extras = [ diff --git a/src/metpy/testing.py b/src/metpy/testing.py index f02a7cf23ab..46f62805fd5 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -10,9 +10,7 @@ import contextlib import functools from importlib.metadata import PackageNotFoundError, requires, version -import inspect import operator as op -from pathlib import Path import re import matplotlib.pyplot as plt @@ -134,33 +132,6 @@ def wrapped(*args, **kwargs): needs_cartopy = needs_module('cartopy') -def needs_aws(test_func): - """Decorate a test function that needs AWS functionality. - - This both sets up recording using VCRPy as well as ensures that the the appropriate - AWS libraries are installed, otherwise the test is skipped. - """ - # Get the vcr module this way so we can skip tests if it's not present - vcr = pytest.importorskip('vcr') - - # Set up the fixtures relative to the test file - func_path = inspect.getfile(test_func) - fixture_path = Path(func_path).with_name('fixtures') / f'{test_func.__name__}.yaml' - - # Set the cassette to use - # also wrap to skip the test if no boto3 - # and filter s3 resource unclosed SSL warnings - return ( - vcr.use_cassette(fixture_path)( - needs_module('boto3')( - pytest.mark.filterwarnings('default:unclosed:ResourceWarning')( - test_func - ) - ) - ) - ) - - @contextlib.contextmanager def autoclose_figure(*args, **kwargs): """Create a figure that is automatically closed when exiting a block. diff --git a/tests/remote/test_aws.py b/tests/remote/test_aws.py index 5540598a3c5..f0d49e97618 100644 --- a/tests/remote/test_aws.py +++ b/tests/remote/test_aws.py @@ -6,11 +6,21 @@ from pathlib import Path import tempfile +import pytest + from metpy.remote import GOESArchive, MLWPArchive, NEXRADLevel2Archive, NEXRADLevel3Archive -from metpy.testing import needs_aws +from metpy.testing import needs_module + +# Add pytest marks for all tests in module +pytestmark = [ + # Reset warning filter to default ignore for all + # ResourceWarning: unclosed SSL from boto3 resource + pytest.mark.filterwarnings('default:unclosed:ResourceWarning') +] -@needs_aws +@pytest.mark.vcr +@needs_module('boto3') def test_nexrad3_single(): """Test getting a single product from the NEXRAD level 3 archive.""" l3 = NEXRADLevel3Archive().get_product('FTG', 'N0Q', datetime(2020, 4, 1, 12, 30)) @@ -18,7 +28,8 @@ def test_nexrad3_single(): assert l3.access() -@needs_aws +@pytest.mark.vcr +@needs_module('boto3') def test_nexrad3_range(): """Test getting a range of products from the NEXRAD level 3 archive.""" prods = list(NEXRADLevel3Archive().get_range('FTG', 'N0B', datetime(2024, 12, 31, 23, 45), @@ -40,14 +51,16 @@ def test_nexrad3_range(): assert (Path(tmpdir) / 'tempprod').exists() -@needs_aws +@pytest.mark.vcr +@needs_module('boto3') def test_nexrad2_single(): """Test getting a single volume from the NEXRAD level 2 archive.""" l2 = NEXRADLevel2Archive().get_product('KTLX', datetime(2013, 5, 20, 20, 15)) assert l2.name == 'KTLX20130520_201643_V06.gz' -@needs_aws +@pytest.mark.vcr +@needs_module('boto3') def test_nexrad2_range(): """Test getting a range of products from the NEXRAD level 2 archive.""" vols = list(NEXRADLevel2Archive().get_range('KFTG', datetime(2024, 12, 14, 15, 15), @@ -59,7 +72,8 @@ def test_nexrad2_range(): 'KFTG20241214_161349_V06', 'KFTG20241214_162248_V06'] -@needs_aws +@pytest.mark.vcr +@needs_module('boto3') def test_goes_single(): """Test getting a single product from the GOES archive.""" prod = GOESArchive(18).get_product('ABI-L1b-RadM1', datetime(2025, 1, 9, 23, 56), band=2) @@ -70,7 +84,8 @@ def test_goes_single(): '_e20250092356311_c20250092356338.nc') -@needs_aws +@pytest.mark.vcr +@needs_module('boto3') def test_goes_range(): """Test getting a range of products from the GOES archive.""" prods = list(GOESArchive(16).get_range('ABI-L1b-RadC', datetime(2024, 12, 10, 1, 0), @@ -94,7 +109,8 @@ def test_goes_range(): assert names == truth -@needs_aws +@pytest.mark.vcr +@needs_module('boto3') def test_mlwp_single(): """Test getting a single product from the MLWP archive.""" prod = MLWPArchive().get_product('graphcast', datetime(2025, 1, 30, 10)) @@ -102,7 +118,8 @@ def test_mlwp_single(): '2025/0130/GRAP_v100_GFS_2025013012_f000_f240_06.nc') -@needs_aws +@pytest.mark.vcr +@needs_module('boto3') def test_mlwp_range(): """Test getting a single product from the MLWP archive.""" prods = MLWPArchive().get_range('fourcastnet', datetime(2025, 2, 3), datetime(2025, 2, 6))