|
1 | 1 | """Test recipes are well formed.""" |
2 | | - |
3 | | -import os |
4 | 2 | from pathlib import Path |
| 3 | +from unittest.mock import create_autospec |
5 | 4 |
|
6 | | -import iris |
7 | | -import numpy as np |
8 | 5 | import pytest |
| 6 | +import yaml |
9 | 7 |
|
10 | 8 | import esmvalcore |
11 | 9 | import esmvaltool |
12 | | -from esmvalcore import _data_finder, _recipe_checks |
13 | | -from esmvalcore._config import read_config_user_file |
14 | | -from esmvalcore._recipe import read_recipe_file |
15 | 10 |
|
16 | 11 | from .test_diagnostic_run import write_config_user_file |
17 | 12 |
|
18 | 13 |
|
19 | | -def _get_recipes(): |
20 | | - recipes_path = Path(esmvaltool.__file__).absolute().parent / 'recipes' |
21 | | - recipes = recipes_path.glob("**/recipe*.yml") |
22 | | - return recipes |
23 | | - |
24 | | - |
25 | | -def _tracking_ids(i=0): |
26 | | - while True: |
27 | | - yield i |
28 | | - i += 1 |
29 | | - |
30 | | - |
31 | | -def _create_test_file(filename, tracking_id=None): |
32 | | - """Generate dummy data file for recipe checker.""" |
33 | | - dirname = os.path.dirname(filename) |
34 | | - if not os.path.exists(dirname): |
35 | | - os.makedirs(dirname) |
36 | | - |
37 | | - attributes = {} |
38 | | - if tracking_id is not None: |
39 | | - attributes['tracking_id'] = tracking_id |
40 | | - |
41 | | - xcoord = iris.coords.DimCoord(np.linspace(0, 5, 5), |
42 | | - standard_name="longitude") |
43 | | - ycoord = iris.coords.DimCoord(np.linspace(0, 5, 12), |
44 | | - standard_name="latitude") |
45 | | - zcoord = iris.coords.DimCoord(np.linspace(0, 5, 17), |
46 | | - standard_name="height", |
47 | | - attributes={'positive': 'up'}) |
48 | | - cube = iris.cube.Cube(np.zeros((5, 12, 17), np.float32), |
49 | | - dim_coords_and_dims=[(xcoord, 0), (ycoord, 1), |
50 | | - (zcoord, 2)], |
51 | | - attributes=attributes) |
52 | | - iris.save(cube, filename) |
53 | | - |
54 | | - |
55 | | -def _get_dummy_filenames(drs): |
56 | | - """Generate list of realistic dummy filename(s) according to drs. |
57 | | -
|
58 | | - drs is the directory structure used to find input files in ESMValTool |
59 | | - """ |
60 | | - dummy_filenames = [] |
61 | | - |
62 | | - # Time-invariant (fx) variables don't have years in their filename |
63 | | - if 'fx' in drs: |
64 | | - if drs.endswith('[_.]*nc'): |
65 | | - dummy_filename = drs.replace('[_.]*', '.') |
66 | | - elif drs.endswith('*.nc'): |
67 | | - dummy_filename = drs.replace('*', '') |
68 | | - dummy_filenames.append(dummy_filename) |
69 | | - # For other variables, add custom (large) intervals in dummy filename |
70 | | - elif '*' in drs: |
71 | | - if drs.endswith('[_.]*nc'): |
72 | | - dummy_filename = drs[:-len('[_.]*nc')] |
73 | | - elif drs.endswith('*.nc'): |
74 | | - dummy_filename = drs[:-len('*.nc')] |
75 | | - # Spread dummy data over multiple files for realistic test |
76 | | - # Note: adding too many intervals here makes the tests really slow! |
77 | | - for interval in ['0000_1849', '1850_9999']: |
78 | | - dummy_filenames.append(dummy_filename + '_' + interval + '.nc') |
79 | | - # Provide for the possibility of filename drss without *. |
80 | | - else: |
81 | | - dummy_filename = drs |
82 | | - dummy_filenames.append(dummy_filename) |
83 | | - return dummy_filenames |
84 | | - |
85 | | - |
86 | 14 | @pytest.fixture |
87 | 15 | def config_user(tmp_path): |
88 | 16 | """Generate dummy config-user file for testing purposes.""" |
89 | 17 | filename = write_config_user_file(tmp_path) |
90 | | - cfg = read_config_user_file(filename, 'recipe_test') |
| 18 | + cfg = esmvalcore._config.read_config_user_file(filename, 'recipe_test') |
91 | 19 | cfg['synda_download'] = False |
| 20 | + cfg['auxiliary_data_dir'] = str(tmp_path / 'auxiliary_data_dir') |
92 | 21 | return cfg |
93 | 22 |
|
94 | 23 |
|
95 | | -@pytest.fixture |
96 | | -def patched_datafinder(tmp_path, monkeypatch): |
97 | | - """Replace `_datafinder.find_files()`. |
98 | | -
|
99 | | - Creates and points to dummy data input files instead of searching for |
100 | | - existing data. |
101 | | - """ |
102 | | - def find_files(_, filenames): |
103 | | - drs = filenames[0] |
104 | | - dummyfiles = str(tmp_path / 'input' / drs) |
105 | | - filenames = _get_dummy_filenames(dummyfiles) |
106 | | - |
107 | | - for file in filenames: |
108 | | - _create_test_file(file, next(tracking_id)) |
109 | | - |
110 | | - return filenames |
111 | | - |
112 | | - tracking_id = _tracking_ids() |
113 | | - monkeypatch.setattr(_data_finder, 'find_files', find_files) |
114 | | - |
115 | | - |
116 | | -@pytest.fixture |
117 | | -def patched_extract_shape(monkeypatch): |
118 | | - """Replace `_recipe_checks.extract_shape`. |
119 | | -
|
120 | | - Skips check that shapefile exists. |
121 | | - """ |
122 | | - def extract_shape(settings): |
123 | | - valid = { |
124 | | - 'method': {'contains', 'representative'}, |
125 | | - 'crop': {True, False}, |
126 | | - } |
127 | | - |
128 | | - for key in valid: |
129 | | - value = settings.get(key) |
130 | | - if not (value is None or value in valid[key]): |
131 | | - raise _recipe_checks.RecipeError( |
132 | | - "In preprocessor function `extract_shape`: Invalid value" |
133 | | - f"'{value}' for argument '{key}', choose from " |
134 | | - "{}".format(', '.join(f"'{k}'".lower() |
135 | | - for k in valid[key]))) |
136 | | - |
137 | | - monkeypatch.setattr(_recipe_checks, 'extract_shape', extract_shape) |
138 | | - |
139 | | - |
140 | | -@pytest.fixture |
141 | | -def patched_get_reference_levels(monkeypatch): |
142 | | - """Replace `_regrid.get_reference_levels`. |
| 24 | +def _get_recipes(): |
| 25 | + recipes_path = Path(esmvaltool.__file__).absolute().parent / 'recipes' |
| 26 | + recipes = tuple(recipes_path.glob("**/recipe*.yml")) |
| 27 | + ids = tuple(str(p.relative_to(recipes_path)) for p in recipes) |
| 28 | + return recipes, ids |
143 | 29 |
|
144 | | - Return a random set of reference levels |
145 | | - """ |
146 | | - def get_reference_levels(*_, **_a): |
147 | | - return [1, 2] |
148 | 30 |
|
149 | | - monkeypatch.setattr(esmvalcore._recipe, 'get_reference_levels', |
150 | | - get_reference_levels) |
| 31 | +RECIPES, IDS = _get_recipes() |
151 | 32 |
|
152 | 33 |
|
153 | | -@pytest.mark.parametrize('recipe_file', _get_recipes()) |
154 | | -def test_diagnostic_run(recipe_file, config_user, patched_datafinder, |
155 | | - patched_extract_shape, patched_get_reference_levels): |
| 34 | +@pytest.mark.parametrize('recipe_file', RECIPES, ids=IDS) |
| 35 | +def test_recipe_valid(recipe_file, config_user, monkeypatch): |
156 | 36 | """Check that recipe files are valid ESMValTool recipes.""" |
157 | | - read_recipe_file(recipe_file, config_user) |
| 37 | + # Mock input files |
| 38 | + find_files = create_autospec(esmvalcore._data_finder.find_files, |
| 39 | + spec_set=True) |
| 40 | + find_files.side_effect = lambda *_, **__: [ |
| 41 | + 'test_0000-1849.nc', |
| 42 | + 'test_1850-9999.nc', |
| 43 | + ] |
| 44 | + monkeypatch.setattr(esmvalcore._data_finder, 'find_files', find_files) |
| 45 | + |
| 46 | + # Mock vertical levels |
| 47 | + levels = create_autospec(esmvalcore._recipe.get_reference_levels, |
| 48 | + spec_set=True) |
| 49 | + levels.side_effect = lambda *_, **__: [1, 2] |
| 50 | + monkeypatch.setattr(esmvalcore._recipe, 'get_reference_levels', levels) |
| 51 | + |
| 52 | + # Mock valid NCL version |
| 53 | + ncl_version = create_autospec(esmvalcore._recipe_checks.ncl_version, |
| 54 | + spec_set=True) |
| 55 | + monkeypatch.setattr(esmvalcore._recipe_checks, 'ncl_version', ncl_version) |
| 56 | + |
| 57 | + # Mock interpreters installed |
| 58 | + def which(executable): |
| 59 | + if executable in ('julia', 'ncl', 'python', 'Rscript'): |
| 60 | + path = '/path/to/' + executable |
| 61 | + else: |
| 62 | + path = None |
| 63 | + return path |
| 64 | + |
| 65 | + monkeypatch.setattr(esmvalcore._task, 'which', which) |
| 66 | + |
| 67 | + # Create a shapefile for extract_shape preprocessor if needed |
| 68 | + recipe = yaml.safe_load(recipe_file.read_text()) |
| 69 | + for preproc in recipe.get('preprocessors', {}).values(): |
| 70 | + extract_shape = preproc.get('extract_shape') |
| 71 | + if extract_shape and 'shapefile' in extract_shape: |
| 72 | + filename = Path( |
| 73 | + config_user['auxiliary_data_dir']) / extract_shape['shapefile'] |
| 74 | + filename.parent.mkdir(parents=True, exist_ok=True) |
| 75 | + filename.touch() |
| 76 | + |
| 77 | + esmvalcore._recipe.read_recipe_file(recipe_file, config_user) |
0 commit comments