diff --git a/lib/iris/tests/integration/test_netcdf__loadsaveattrs.py b/lib/iris/tests/integration/test_netcdf__loadsaveattrs.py index a4870ce61f..545f27344a 100644 --- a/lib/iris/tests/integration/test_netcdf__loadsaveattrs.py +++ b/lib/iris/tests/integration/test_netcdf__loadsaveattrs.py @@ -28,15 +28,10 @@ import iris.coord_systems import iris.fileformats.netcdf -# -# Testing handling of netCDF file ("global") and variable ("local") attributes. -# - # First define the known controlled attribute names defined by netCDf and CF conventions # -# Note: certain attributes these are "normally" global (e.g. "Conventions"), whilst -# others will only usually appear on a data-variable (e.g. "scale_factor"", -# "coordinates"). +# Note: certain attributes are "normally" global (e.g. "Conventions"), whilst others +# will only usually appear on a data-variable (e.g. "scale_factor"", "coordinates"). # I'm calling these 'global-style' and 'local-style'. # Any attributes either belongs to one of these 2 groups, or neither. Those 3 distinct # types may then have different behaviour in Iris load + save. @@ -74,7 +69,7 @@ def local_attr(request): return request.param # Return the name of the attribute to test. -class TestLoadSaveAttributes: +class MixinAttrsTesting: @staticmethod def _calling_testname(): """ @@ -100,6 +95,19 @@ def _calling_testname(): assert test_name is not None return test_name + @pytest.fixture(autouse=True) + def make_tempdir(self, tmp_path_factory): + """ + Automatically-run fixture to activate the 'tmp_path_factory' fixture on *every* + test: Make a directory for temporary files, and record it on the test instance. + + N.B. "tmp_path_factory" is a standard PyTest fixture, which provides a dirpath + *shared* by all tests. This is a bit quicker and more debuggable than having a + directory per-testcase. + """ + # Store the temporary directory path on the test instance + self.tmpdir = str(tmp_path_factory.getbasetemp()) + def _testfile_path(self, basename: str) -> str: # Make a filepath in the temporary directory, based on the name of the calling # test method, and the "self.attrname" it sets up. @@ -121,7 +129,7 @@ def _default_vars_and_attrvalues(vars_and_attrvalues): vars_and_attrvalues = {"var": vars_and_attrvalues} return vars_and_attrvalues - def _create_testcase_files( + def create_testcase_files( self, attr_name: str, global_value_file1: Optional[str] = None, @@ -181,6 +189,16 @@ def make_file( ) return filepaths + +class TestRoundtrip(MixinAttrsTesting): + """ + Test handling of attributes in roundtrip netcdf-iris-netcdf. + + This behaviour should be (almost) unchanged by the adoption of + split-attribute handling. + + """ + def _roundtrip_load_and_save( self, input_filepaths: Union[str, Iterable[str]], output_filepath: str ) -> None: @@ -191,20 +209,7 @@ def _roundtrip_load_and_save( cubes = iris.load(input_filepaths) iris.save(cubes, output_filepath) - @pytest.fixture(autouse=True) - def make_tempdir(self, tmp_path_factory): - """ - Automatically-run fixture to make every test use 'tmp_path_factory' to provide - a directory for temporary files, and record it on the test instance. - - N.B. "tmp_path_factory" is a standard PyTest fixture, which provides a dirpath - *shared* by all tests. This is a bit quicker and more debuggable than having a - directory per-testcase. - """ - # Store the temporary directory path on the test instance - self.tmpdir = str(tmp_path_factory.getbasetemp()) - - def create_testcase( + def create_roundtrip_testcase( self, attr_name, global_value_file1=None, @@ -216,12 +221,12 @@ def create_testcase( Initialise the testcase from the passed-in controls, configure the input files and run a save-load roundtrip to produce the output file. - The name of the tested attribute and all the temporary filepaths are stored - on the instance, from where "self.check_expected_results()" can get them. + The name of the attribute, and the input and output temporary filepaths are + stored on the instance, where "self.check_expected_results()" can get them. """ self.attrname = attr_name - self.input_filepaths = self._create_testcase_files( + self.input_filepaths = self.create_testcase_files( attr_name=attr_name, global_value_file1=global_value_file1, var_values_file1=vars_values_file1, @@ -232,9 +237,8 @@ def create_testcase( self._roundtrip_load_and_save( self.input_filepaths, self.result_filepath ) - return self.result_filepath - def check_expected_results( + def check_roundtrip_results( self, global_attr_value=None, var_attr_vals=None ): """ @@ -271,7 +275,7 @@ def check_expected_results( # def test_01_usertype_single_global(self): - self.create_testcase( + self.create_roundtrip_testcase( attr_name="myname", # A generic "user" attribute with no special handling global_value_file1="single-value", vars_values_file1={ @@ -280,7 +284,7 @@ def test_01_usertype_single_global(self): ) # Default behaviour for a general global user-attribute. # It simply remains global. - self.check_expected_results( + self.check_roundtrip_results( global_attr_value="single-value", # local values eclipse the global ones var_attr_vals={ "myvar": None @@ -290,11 +294,11 @@ def test_01_usertype_single_global(self): def test_02_usertype_single_local(self): # Default behaviour for a general local user-attribute. # It results in a "promoted" global attribute. - self.create_testcase( + self.create_roundtrip_testcase( attr_name="myname", # A generic "user" attribute with no special handling vars_values_file1={"myvar": "single-value"}, ) - self.check_expected_results( + self.check_roundtrip_results( global_attr_value="single-value", # local values eclipse the global ones # N.B. the output var has NO such attribute ) @@ -304,7 +308,7 @@ def test_03_usertype_multiple_different(self): # The global attribute is lost because there are local ones. vars1 = {"f1_v1": "f1v1", "f1_v2": "f2v2"} vars2 = {"f2_v1": "x1", "f2_v2": "x2"} - self.create_testcase( + self.create_roundtrip_testcase( attr_name="random", # A generic "user" attribute with no special handling global_value_file1="global_file1", vars_values_file1=vars1, @@ -318,44 +322,44 @@ def test_03_usertype_multiple_different(self): # see: https://peps.python.org/pep-0584/ # just check they are all there and distinct assert len(all_vars_and_attrs) == len(vars1) + len(vars2) - self.check_expected_results( + self.check_roundtrip_results( global_attr_value=None, # local values eclipse the global ones var_attr_vals=all_vars_and_attrs, ) def test_04_usertype_matching_promoted(self): # matching local user-attributes are "promoted" to a global one. - self.create_testcase( + self.create_roundtrip_testcase( attr_name="random", global_value_file1="global_file1", vars_values_file1={"v1": "same-value", "v2": "same-value"}, ) - self.check_expected_results( + self.check_roundtrip_results( global_attr_value="same-value", var_attr_vals={"v1": None, "v2": None}, ) def test_05_usertype_matching_crossfile_promoted(self): # matching user-attributes are promoted, even across input files. - self.create_testcase( + self.create_roundtrip_testcase( attr_name="random", global_value_file1="global_file1", vars_values_file1={"v1": "same-value", "v2": "same-value"}, vars_values_file2={"f2_v1": "same-value", "f2_v2": "same-value"}, ) - self.check_expected_results( + self.check_roundtrip_results( global_attr_value="same-value", var_attr_vals={x: None for x in ("v1", "v2", "f2_v1", "f2_v2")}, ) def test_06_usertype_nonmatching_remainlocal(self): # Non-matching user attributes remain 'local' to the individual variables. - self.create_testcase( + self.create_roundtrip_testcase( attr_name="random", global_value_file1="global_file1", vars_values_file1={"v1": "value-1", "v2": "value-2"}, ) - self.check_expected_results( + self.check_roundtrip_results( global_attr_value=None, # NB it still destroys the global one !! var_attr_vals={"v1": "value-1", "v2": "value-2"}, ) @@ -373,24 +377,24 @@ def test_06_usertype_nonmatching_remainlocal(self): def test_07_conventions_var_local(self): # What happens if 'Conventions' appears as a variable-local attribute. # N.B. this is not good CF, but we'll see what happens anyway. - self.create_testcase( + self.create_roundtrip_testcase( attr_name="Conventions", global_value_file1=None, vars_values_file1="user_set", ) - self.check_expected_results( + self.check_roundtrip_results( global_attr_value="CF-1.7", # this is standard output from var_attr_vals=None, ) def test_08_conventions_var_both(self): # What happens if 'Conventions' appears as both global + local attribute. - self.create_testcase( + self.create_roundtrip_testcase( attr_name="Conventions", global_value_file1="global-setting", vars_values_file1="local-setting", ) - self.check_expected_results( + self.check_roundtrip_results( global_attr_value="CF-1.7", # this is standard output from var_attr_vals=None, ) @@ -402,32 +406,32 @@ def test_08_conventions_var_both(self): def test_09_globalstyle__global(self, global_attr): attr_content = f"Global tracked {global_attr}" - self.create_testcase( + self.create_roundtrip_testcase( attr_name=global_attr, global_value_file1=attr_content, ) - self.check_expected_results(global_attr_value=attr_content) + self.check_roundtrip_results(global_attr_value=attr_content) def test_10_globalstyle__local(self, global_attr): # Strictly, not correct CF, but let's see what it does with it. attr_content = f"Local tracked {global_attr}" - self.create_testcase( + self.create_roundtrip_testcase( attr_name=global_attr, vars_values_file1=attr_content, ) - self.check_expected_results( + self.check_roundtrip_results( global_attr_value=attr_content ) # "promoted" def test_11_globalstyle__both(self, global_attr): attr_global = f"Global-{global_attr}" attr_local = f"Local-{global_attr}" - self.create_testcase( + self.create_roundtrip_testcase( attr_name=global_attr, global_value_file1=attr_global, vars_values_file1=attr_local, ) - self.check_expected_results( + self.check_roundtrip_results( global_attr_value=attr_local # promoted local setting "wins" ) @@ -439,11 +443,11 @@ def test_12_globalstyle__multivar_different(self, global_attr): UserWarning, match="should only be a CF global attribute" ): # A warning should be raised when writing the result. - self.create_testcase( + self.create_roundtrip_testcase( attr_name=global_attr, vars_values_file1={"v1": attr_1, "v2": attr_2}, ) - self.check_expected_results( + self.check_roundtrip_results( global_attr_value=None, var_attr_vals={"v1": attr_1, "v2": attr_2}, ) @@ -451,11 +455,11 @@ def test_12_globalstyle__multivar_different(self, global_attr): def test_13_globalstyle__multivar_same(self, global_attr): # Multiple *same* local settings are promoted to a common global one attrval = f"Locally-defined-{global_attr}" - self.create_testcase( + self.create_roundtrip_testcase( attr_name=global_attr, vars_values_file1={"v1": attrval, "v2": attrval}, ) - self.check_expected_results( + self.check_roundtrip_results( global_attr_value=attrval, var_attr_vals={"v1": None, "v2": None}, ) @@ -468,14 +472,14 @@ def test_14_globalstyle__multifile_different(self, global_attr): UserWarning, match="should only be a CF global attribute" ): # A warning should be raised when writing the result. - self.create_testcase( + self.create_roundtrip_testcase( attr_name=global_attr, global_value_file1=attr_1, vars_values_file1={"v1": None}, global_value_file2=attr_2, vars_values_file2={"v2": None}, ) - self.check_expected_results( + self.check_roundtrip_results( # Combining them "demotes" the common global attributes to local ones var_attr_vals={"v1": attr_1, "v2": attr_2} ) @@ -483,14 +487,14 @@ def test_14_globalstyle__multifile_different(self, global_attr): def test_15_globalstyle__multifile_same(self, global_attr): # Matching global-type attributes in multiple files are retained as global attrval = f"Global-{global_attr}" - self.create_testcase( + self.create_roundtrip_testcase( attr_name=global_attr, global_value_file1=attrval, vars_values_file1={"v1": None}, global_value_file2=attrval, vars_values_file2={"v2": None}, ) - self.check_expected_results( + self.check_roundtrip_results( # The attribute remains as a common global setting global_attr_value=attrval, # The individual variables do *not* have an attribute of this name @@ -525,13 +529,13 @@ def test_16_localstyle(self, local_attr, origin_style): # as global or a variable attribute if origin_style == "input_global": # Record in source as a global attribute - self.create_testcase( + self.create_roundtrip_testcase( attr_name=local_attr, global_value_file1=attrval ) else: assert origin_style == "input_local" # Record in source as a variable-local attribute - self.create_testcase( + self.create_roundtrip_testcase( attr_name=local_attr, vars_values_file1=attrval ) @@ -558,7 +562,272 @@ def test_16_localstyle(self, local_attr, origin_style): # A special case, output translates this to a different attribute name. self.attrname = "um_stash_source" - self.check_expected_results( + self.check_roundtrip_results( global_attr_value=expect_global, var_attr_vals=expect_var, ) + + +class TestLoad(MixinAttrsTesting): + """ + Test loading of file attributes into Iris cube attribute dictionaries. + + Tests loading of various combinations to cube dictionaries, treated as a + single combined result (i.e. not split). This behaviour should be (almost) + conserved with the adoption of split attributes **except possibly for key + orderings** -- i.e. we test only up to dictionary equality. + + NOTE: the tested combinations are identical to the roundtrip test. Test numbering + is kept the same, so some (which are inapplicable for this) are missing. + + """ + + @pytest.fixture(autouse=True) + def make_tempdir(self, tmp_path_factory): + """ + Automatically-run fixture to make every test use 'tmp_path_factory' to provide + a directory for temporary files, and record it on the test instance. + + N.B. "tmp_path_factory" is a standard PyTest fixture, which provides a dirpath + *shared* by all tests. This is a bit quicker and more debuggable than having a + directory per-testcase. + """ + # Store the temporary directory path on the test instance + self.tmpdir = str(tmp_path_factory.getbasetemp()) + + def create_load_testcase( + self, + attr_name, + global_value_file1=None, + vars_values_file1=None, + global_value_file2=None, + vars_values_file2=None, + ) -> iris.cube.CubeList: + """ + Initialise the testcase from the passed-in controls, configure the input + files and run a save-load roundtrip to produce the output file. + + The name of the tested attribute and all the temporary filepaths are stored + on the instance, from where "self.check_expected_results()" can get them. + + """ + self.attrname = attr_name + self.input_filepaths = self.create_testcase_files( + attr_name=attr_name, + global_value_file1=global_value_file1, + var_values_file1=vars_values_file1, + global_value_file2=global_value_file2, + var_values_file2=vars_values_file2, + ) + result_cubes = iris.load(self.input_filepaths) + result_cubes = sorted(result_cubes, key=lambda cube: cube.name()) + return result_cubes + + def check_load_results(self, cubes, *cube_attr_dicts): + """ + Run checks on loaded cubes. + + """ + # N.B. there is only ever one result-file, but it can contain various variables + # which came from different input files. + assert len(cubes) == len(cube_attr_dicts) + for cube, attrs in zip(cubes, cube_attr_dicts): + assert cube.attributes == attrs + + ####################################################### + # Tests on "user-style" attributes. + # This means any arbitrary attribute which a user might have added -- i.e. one with + # a name which is *not* recognised in the netCDF or CF conventions. + # + + def test_01_usertype_single_global(self): + cube1, cube2 = self.create_load_testcase( + attr_name="myname", # A generic "user" attribute with no special handling + global_value_file1="single-value", + vars_values_file1={ + "myvar": None, + "myvar2": None, + }, # the variable has no such attribute + ) + # Default behaviour for a general global user-attribute. + # It is attached to all loaded cubes. + assert cube1.attributes == {"myname": "single-value"} + assert cube2.attributes == {"myname": "single-value"} + + def test_02_usertype_single_local(self): + # Default behaviour for a general local user-attribute. + # It is attached to only the specific cube. + cube1, cube2 = self.create_load_testcase( + attr_name="myname", # A generic "user" attribute with no special handling + vars_values_file1={"myvar1": "single-value", "myvar2": None}, + ) + assert cube1.attributes == {"myname": "single-value"} + assert cube2.attributes == {} + + def test_03_usertype_multiple_different(self): + # Default behaviour for differing local user-attributes. + # The global attribute is simply lost, because there are local ones. + vars1 = {"f1_v1": "f1v1", "f1_v2": "f1v2"} + vars2 = {"f2_v1": "x1", "f2_v2": "x2"} + cube1, cube2, cube3, cube4 = self.create_load_testcase( + attr_name="random", # A generic "user" attribute with no special handling + global_value_file1="global_file1", + vars_values_file1=vars1, + global_value_file2="global_file2", + vars_values_file2=vars2, + ) + assert cube1.attributes == {"random": "f1v1"} + assert cube2.attributes == {"random": "f1v2"} + assert cube3.attributes == {"random": "x1"} + assert cube4.attributes == {"random": "x2"} + + def test_04_usertype_multiple_same(self): + # Nothing special to note in tis case + # TODO: ??remove?? + cube1, cube2 = self.create_load_testcase( + attr_name="random", + global_value_file1="global_file1", + vars_values_file1={"v1": "same-value", "v2": "same-value"}, + ) + assert cube1.attributes == {"random": "same-value"} + assert cube2.attributes == {"random": "same-value"} + + ####################################################### + # Tests on "Conventions" attribute. + # Note: the usual 'Conventions' behaviour is already tested elsewhere + # - see :class:`TestConventionsAttributes` above + # + # TODO: the name 'conventions' (lower-case) is also listed in _CF_GLOBAL_ATTRS, but + # we have excluded it from the global-attrs testing here. We probably still need to + # test what that does, though it's inclusion might simply be a mistake. + # + + def test_07_conventions_var_local(self): + # What happens if 'Conventions' appears as a variable-local attribute. + # N.B. this is not good CF, but we'll see what happens anyway. + (cube,) = self.create_load_testcase( + attr_name="Conventions", + global_value_file1=None, + vars_values_file1="user_set", + ) + assert cube.attributes == {"Conventions": "user_set"} + + def test_08_conventions_var_both(self): + # What happens if 'Conventions' appears as both global + local attribute. + # = the global version gets lost. + (cube,) = self.create_load_testcase( + attr_name="Conventions", + global_value_file1="global-setting", + vars_values_file1="local-setting", + ) + assert cube.attributes == {"Conventions": "local-setting"} + + ####################################################### + # Tests on "global" style attributes + # = those specific ones which 'ought' only to be global (except on collisions) + # + + def test_09_globalstyle__global(self, global_attr): + attr_content = f"Global tracked {global_attr}" + (cube,) = self.create_load_testcase( + attr_name=global_attr, + global_value_file1=attr_content, + ) + assert cube.attributes == {global_attr: attr_content} + + def test_10_globalstyle__local(self, global_attr): + # Strictly, not correct CF, but let's see what it does with it. + # = treated the same as a global setting + attr_content = f"Local tracked {global_attr}" + (cube,) = self.create_load_testcase( + attr_name=global_attr, + vars_values_file1=attr_content, + ) + assert cube.attributes == {global_attr: attr_content} + + def test_11_globalstyle__both(self, global_attr): + attr_global = f"Global-{global_attr}" + attr_local = f"Local-{global_attr}" + (cube,) = self.create_load_testcase( + attr_name=global_attr, + global_value_file1=attr_global, + vars_values_file1=attr_local, + ) + # promoted local setting "wins" + assert cube.attributes == {global_attr: attr_local} + + def test_12_globalstyle__multivar_different(self, global_attr): + # Multiple *different* local settings are retained + attr_1 = f"Local-{global_attr}-1" + attr_2 = f"Local-{global_attr}-2" + cube1, cube2 = self.create_load_testcase( + attr_name=global_attr, + vars_values_file1={"v1": attr_1, "v2": attr_2}, + ) + assert cube1.attributes == {global_attr: attr_1} + assert cube2.attributes == {global_attr: attr_2} + + def test_14_globalstyle__multifile_different(self, global_attr): + # Different global attributes from multiple files are retained as local ones + attr_1 = f"Global-{global_attr}-1" + attr_2 = f"Global-{global_attr}-2" + cube1, cube2, cube3, cube4 = self.create_load_testcase( + attr_name=global_attr, + global_value_file1=attr_1, + vars_values_file1={"f1v1": None, "f1v2": None}, + global_value_file2=attr_2, + vars_values_file2={"f2v1": None, "f2v2": None}, + ) + assert cube1.attributes == {global_attr: attr_1} + assert cube2.attributes == {global_attr: attr_1} + assert cube3.attributes == {global_attr: attr_2} + assert cube4.attributes == {global_attr: attr_2} + + ####################################################### + # Tests on "local" style attributes + # = those specific ones which 'ought' to appear attached to a variable, rather than + # being global + # + + @pytest.mark.parametrize("origin_style", ["input_global", "input_local"]) + def test_16_localstyle(self, local_attr, origin_style): + # local-style attributes should *not* get 'promoted' to global ones + # Set the name extension to avoid tests with different 'style' params having + # collisions over identical testfile names + self.testname_extension = origin_style + + attrval = f"Attr-setting-{local_attr}" + if local_attr == "missing_value": + # Special-case : 'missing_value' type must be compatible with the variable + attrval = 303 + elif local_attr == "ukmo__process_flags": + # Another special case : the handling of this one is "unusual". + attrval = "process" + + # Create testfiles and load them, which should always produce a single cube. + if origin_style == "input_global": + # Record in source as a global attribute + (cube,) = self.create_load_testcase( + attr_name=local_attr, global_value_file1=attrval + ) + else: + assert origin_style == "input_local" + # Record in source as a variable-local attribute + (cube,) = self.create_load_testcase( + attr_name=local_attr, vars_values_file1=attrval + ) + + # Work out the expected result. + # NOTE: generally, result will be the same whether the original attribute is + # provided as a global or variable attribute ... + expected_result = {local_attr: attrval} + # ... but there are some special cases + if origin_style == "input_local": + if local_attr == "ukmo__process_flags": + # Some odd special behaviour here. + expected_result = {local_attr: ("process",)} + elif local_attr in ("standard_error_multiplier", "missing_value"): + # For some reason, these ones never appear on the cube + expected_result = {} + + assert cube.attributes == expected_result