diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 43bacd3ec5..270046164e 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -46,7 +46,7 @@ jobs: session: "tests" env: - IRIS_TEST_DATA_VERSION: "2.16" + IRIS_TEST_DATA_VERSION: "2.17" ENV_NAME: "ci-tests" steps: diff --git a/docs/src/whatsnew/3.3.rst b/docs/src/whatsnew/3.3.rst index 5812b79860..c2e47f298a 100644 --- a/docs/src/whatsnew/3.3.rst +++ b/docs/src/whatsnew/3.3.rst @@ -31,6 +31,36 @@ This document explains the changes made to Iris for this release any issues or feature requests for improving Iris. Enjoy! +v3.3.1 (29 Sep 2022) +==================== + +.. dropdown:: :opticon:`alert` v3.3.1 Patches + :container: + shadow + :title: text-primary text-center font-weight-bold + :body: bg-light + :animate: fade-in + + The patches in this release of Iris include: + + #. `@pp-mo`_ fixed the Jupyter notebook display of :class:`~iris.cube.CubeList`. + (:issue:`4973`, :pull:`4976`) + + #. `@pp-mo`_ fixed a bug in NAME loaders where data with no associated statistic would + load as a cube with invalid cell-methods, which cannot be printed or saved to netcdf. + (:issue:`3288`, :pull:`4933`) + + #. `@pp-mo`_ ensured that :data:`iris.cube.Cube.cell_methods` must always be an iterable + of :class:`iris.coords.CellMethod` objects (:pull:`4933`). + + #. `@trexfeathers`_ advanced the Cartopy pin to ``>=0.21``, as Cartopy's + change to default Transverse Mercator projection affects an Iris test. + See `SciTools/cartopy@fcb784d`_ and `SciTools/cartopy@8860a81`_ for more + details. (:pull:`4992`) + + #. `@trexfeathers`_ introduced the ``netcdf4!=1.6.1`` pin to avoid a + problem with segfaults. (:pull:`4992`) + + 📢 Announcements ================ @@ -339,3 +369,5 @@ This document explains the changes made to Iris for this release .. _PyData Sphinx Theme: https://pydata-sphinx-theme.readthedocs.io/en/stable/index.html .. _pytest: https://docs.pytest.org .. _setuptools-scm: https://github.com/pypa/setuptools_scm +.. _SciTools/cartopy@fcb784d: https://github.com/SciTools/cartopy/commit/fcb784daa65d95ed9a74b02ca292801c02bc4108 +.. _SciTools/cartopy@8860a81: https://github.com/SciTools/cartopy/commit/8860a8186d4dc62478e74c83f3b2b3e8f791372e diff --git a/lib/iris/cube.py b/lib/iris/cube.py index 8879ade621..9779558506 100644 --- a/lib/iris/cube.py +++ b/lib/iris/cube.py @@ -185,6 +185,12 @@ def _assert_is_cube(obj): ) raise ValueError(msg) + def _repr_html_(self): + from iris.experimental.representation import CubeListRepresentation + + representer = CubeListRepresentation(self) + return representer.repr_html() + # TODO #370 Which operators need overloads? def __add__(self, other): @@ -2293,10 +2299,23 @@ def cell_methods(self): return self._metadata_manager.cell_methods @cell_methods.setter - def cell_methods(self, cell_methods): - self._metadata_manager.cell_methods = ( - tuple(cell_methods) if cell_methods else tuple() - ) + def cell_methods(self, cell_methods: Iterable): + if not cell_methods: + # For backwards compatibility: Empty or null value is equivalent to (). + cell_methods = () + else: + # Can supply any iterable, which is converted (copied) to a tuple. + cell_methods = tuple(cell_methods) + for cell_method in cell_methods: + # All contents should be CellMethods. Requiring class membership is + # somewhat non-Pythonic, but simple, and not a problem for now. + if not isinstance(cell_method, iris.coords.CellMethod): + msg = ( + f"Cube.cell_methods assigned value includes {cell_method}, " + "which is not an iris.coords.CellMethod." + ) + raise ValueError(msg) + self._metadata_manager.cell_methods = cell_methods def core_data(self): """ diff --git a/lib/iris/fileformats/name_loaders.py b/lib/iris/fileformats/name_loaders.py index 3aaba3679e..d15a3717d0 100644 --- a/lib/iris/fileformats/name_loaders.py +++ b/lib/iris/fileformats/name_loaders.py @@ -571,7 +571,9 @@ def _generate_cubes( cube.attributes[key] = value if cell_methods is not None: - cube.add_cell_method(cell_methods[i]) + cell_method = cell_methods[i] + if cell_method is not None: + cube.add_cell_method(cell_method) yield cube @@ -610,7 +612,7 @@ def _build_cell_methods(av_or_ints, coord): cell_method = None msg = "Unknown {} statistic: {!r}. Unable to create cell method." warnings.warn(msg.format(coord, av_or_int)) - cell_methods.append(cell_method) + cell_methods.append(cell_method) # NOTE: this can be a None return cell_methods diff --git a/lib/iris/tests/results/name/NAMEII_field__no_time_averaging.cml b/lib/iris/tests/results/name/NAMEII_field__no_time_averaging.cml new file mode 100644 index 0000000000..9bc2c0d1ac --- /dev/null +++ b/lib/iris/tests/results/name/NAMEII_field__no_time_averaging.cml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/iris/tests/results/name/NAMEII_field__no_time_averaging_0.cml b/lib/iris/tests/results/name/NAMEII_field__no_time_averaging_0.cml new file mode 100644 index 0000000000..8d1ad620d0 --- /dev/null +++ b/lib/iris/tests/results/name/NAMEII_field__no_time_averaging_0.cml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/iris/tests/test_name.py b/lib/iris/tests/test_name.py index 2843673da8..b4e91bafd7 100644 --- a/lib/iris/tests/test_name.py +++ b/lib/iris/tests/test_name.py @@ -8,6 +8,9 @@ # import iris tests first so that some things can be initialised before # importing anything else import iris.tests as tests # isort:skip + +import tempfile + import iris @@ -39,7 +42,7 @@ def test_NAMEIII_version2(self): ) self.assertCMLApproxData(cubes, ("name", "NAMEIII_version2.cml")) - def test_NAMEII_trajectory(self): + def test_NAMEIII_trajectory(self): cubes = iris.load( tests.get_data_path(("NAME", "NAMEIII_trajectory.txt")) ) @@ -48,6 +51,32 @@ def test_NAMEII_trajectory(self): cubes, ("name", "NAMEIII_trajectory.cml"), checksum=False ) + def test_NAMEII__no_time_averaging(self): + cubes = iris.load( + tests.get_data_path(("NAME", "NAMEII_no_time_averaging.txt")) + ) + + # Also check that it saves without error. + # This was previously failing, see https://github.com/SciTools/iris/issues/3288 + with tempfile.TemporaryDirectory() as temp_dirpath: + iris.save(cubes, temp_dirpath + "/tmp.nc") + + self.assertCML( + cubes[0], + ( + "name", + "NAMEII_field__no_time_averaging_0.cml", + ), + ) + self.assertCML( + cubes, + ( + "name", + "NAMEII_field__no_time_averaging.cml", + ), + checksum=False, + ) + if __name__ == "__main__": tests.main() diff --git a/lib/iris/tests/unit/cube/test_Cube.py b/lib/iris/tests/unit/cube/test_Cube.py index f38d6ef35d..62c719aab9 100644 --- a/lib/iris/tests/unit/cube/test_Cube.py +++ b/lib/iris/tests/unit/cube/test_Cube.py @@ -9,6 +9,7 @@ # importing anything else. import iris.tests as tests # isort:skip +from collections import namedtuple from itertools import permutations from unittest import mock @@ -2937,64 +2938,254 @@ def test_cell_method_correct_order(self): self.assertTrue(cube1 == cube2) +@pytest.fixture +def simplecube(): + return stock.simple_2d_w_cell_measure_ancil_var() + + class Test__dimensional_metadata: - @pytest.fixture - def cube(self): - return stock.simple_2d_w_cell_measure_ancil_var() + """ + Tests for the "Cube._dimensional_data" method. - def test_not_found(self, cube): + NOTE: test could all be static methods, but that adds a line to each definition. + """ + + def test_not_found(self, simplecube): with pytest.raises(KeyError, match="was not found in"): - cube._dimensional_metadata("grid_latitude") + simplecube._dimensional_metadata("grid_latitude") - def test_dim_coord_name_found(self, cube): - res = cube._dimensional_metadata("bar") - assert res == cube.coord("bar") + def test_dim_coord_name_found(self, simplecube): + res = simplecube._dimensional_metadata("bar") + assert res == simplecube.coord("bar") - def test_dim_coord_instance_found(self, cube): - res = cube._dimensional_metadata(cube.coord("bar")) - assert res == cube.coord("bar") + def test_dim_coord_instance_found(self, simplecube): + res = simplecube._dimensional_metadata(simplecube.coord("bar")) + assert res == simplecube.coord("bar") - def test_aux_coord_name_found(self, cube): - res = cube._dimensional_metadata("wibble") - assert res == cube.coord("wibble") + def test_aux_coord_name_found(self, simplecube): + res = simplecube._dimensional_metadata("wibble") + assert res == simplecube.coord("wibble") - def test_aux_coord_instance_found(self, cube): - res = cube._dimensional_metadata(cube.coord("wibble")) - assert res == cube.coord("wibble") + def test_aux_coord_instance_found(self, simplecube): + res = simplecube._dimensional_metadata(simplecube.coord("wibble")) + assert res == simplecube.coord("wibble") - def test_cell_measure_name_found(self, cube): - res = cube._dimensional_metadata("cell_area") - assert res == cube.cell_measure("cell_area") + def test_cell_measure_name_found(self, simplecube): + res = simplecube._dimensional_metadata("cell_area") + assert res == simplecube.cell_measure("cell_area") - def test_cell_measure_instance_found(self, cube): - res = cube._dimensional_metadata(cube.cell_measure("cell_area")) - assert res == cube.cell_measure("cell_area") + def test_cell_measure_instance_found(self, simplecube): + res = simplecube._dimensional_metadata( + simplecube.cell_measure("cell_area") + ) + assert res == simplecube.cell_measure("cell_area") - def test_ancillary_var_name_found(self, cube): - res = cube._dimensional_metadata("quality_flag") - assert res == cube.ancillary_variable("quality_flag") + def test_ancillary_var_name_found(self, simplecube): + res = simplecube._dimensional_metadata("quality_flag") + assert res == simplecube.ancillary_variable("quality_flag") - def test_ancillary_var_instance_found(self, cube): - res = cube._dimensional_metadata( - cube.ancillary_variable("quality_flag") + def test_ancillary_var_instance_found(self, simplecube): + res = simplecube._dimensional_metadata( + simplecube.ancillary_variable("quality_flag") ) - assert res == cube.ancillary_variable("quality_flag") + assert res == simplecube.ancillary_variable("quality_flag") - def test_two_with_same_name(self, cube): + def test_two_with_same_name(self, simplecube): # If a cube has two _DimensionalMetadata objects with the same name, the # current behaviour results in _dimensional_metadata returning the first # one it finds. - cube.cell_measure("cell_area").rename("wibble") - res = cube._dimensional_metadata("wibble") - assert res == cube.coord("wibble") + simplecube.cell_measure("cell_area").rename("wibble") + res = simplecube._dimensional_metadata("wibble") + assert res == simplecube.coord("wibble") - def test_two_with_same_name_specify_instance(self, cube): + def test_two_with_same_name_specify_instance(self, simplecube): # The cube has two _DimensionalMetadata objects with the same name so # we specify the _DimensionalMetadata instance to ensure it returns the # correct one. - cube.cell_measure("cell_area").rename("wibble") - res = cube._dimensional_metadata(cube.cell_measure("wibble")) - assert res == cube.cell_measure("wibble") + simplecube.cell_measure("cell_area").rename("wibble") + res = simplecube._dimensional_metadata( + simplecube.cell_measure("wibble") + ) + assert res == simplecube.cell_measure("wibble") + + +class TestReprs: + """ + Confirm that str(cube), repr(cube) and cube.summary() work by creating a fresh + :class:`iris._representation.cube_printout.CubePrinter` object, and using it + in the expected ways. + + Notes + ----- + This only tests code connectivity. The functionality is tested elsewhere, in + `iris.tests.unit._representation.cube_printout.test_CubePrintout`. + """ + + # Note: logically this could be a staticmethod, but that seems to upset Pytest + @pytest.fixture + def patched_cubeprinter(self): + target = "iris._representation.cube_printout.CubePrinter" + instance_mock = mock.MagicMock( + to_string=mock.MagicMock( + return_value="" + ) # NB this must return a string + ) + with mock.patch(target, return_value=instance_mock) as class_mock: + yield class_mock, instance_mock + + @staticmethod + def _check_expected_effects( + simplecube, patched_cubeprinter, oneline, padding + ): + class_mock, instance_mock = patched_cubeprinter + assert class_mock.call_args_list == [ + # "CubePrinter()" was called exactly once, with the cube as arg + mock.call(simplecube) + ] + assert instance_mock.to_string.call_args_list == [ + # "CubePrinter(cube).to_string()" was called exactly once, with these args + mock.call(oneline=oneline, name_padding=padding) + ] + + def test_str_effects(self, simplecube, patched_cubeprinter): + str(simplecube) + self._check_expected_effects( + simplecube, patched_cubeprinter, oneline=False, padding=35 + ) + + def test_repr_effects(self, simplecube, patched_cubeprinter): + repr(simplecube) + self._check_expected_effects( + simplecube, patched_cubeprinter, oneline=True, padding=1 + ) + + def test_summary_effects(self, simplecube, patched_cubeprinter): + simplecube.summary( + shorten=mock.sentinel.oneliner, name_padding=mock.sentinel.padding + ) + self._check_expected_effects( + simplecube, + patched_cubeprinter, + oneline=mock.sentinel.oneliner, + padding=mock.sentinel.padding, + ) + + +class TestHtmlRepr: + """ + Confirm that Cube._repr_html_() creates a fresh + :class:`iris.experimental.representation.CubeRepresentation` object, and uses it + in the expected way. + + Notes + ----- + This only tests code connectivity. The functionality is tested elsewhere, in + `iris.tests.unit.experimental.representation.test_CubeRepresentation`. + """ + + # Note: logically this could be a staticmethod, but that seems to upset Pytest + @pytest.fixture + def patched_cubehtml(self): + target = "iris.experimental.representation.CubeRepresentation" + instance_mock = mock.MagicMock( + repr_html=mock.MagicMock( + return_value="" + ) # NB this must return a string + ) + with mock.patch(target, return_value=instance_mock) as class_mock: + yield class_mock, instance_mock + + @staticmethod + def test__repr_html__effects(simplecube, patched_cubehtml): + simplecube._repr_html_() + + class_mock, instance_mock = patched_cubehtml + assert class_mock.call_args_list == [ + # "CubeRepresentation()" was called exactly once, with the cube as arg + mock.call(simplecube) + ] + assert instance_mock.repr_html.call_args_list == [ + # "CubeRepresentation(cube).repr_html()" was called exactly once, with no args + mock.call() + ] + + +class Test__cell_methods: + @pytest.fixture(autouse=True) + def cell_measures_testdata(self): + self.cube = Cube([0]) + self.cm = CellMethod("mean", "time", "6hr") + self.cm2 = CellMethod("max", "latitude", "4hr") + + def test_none(self): + assert self.cube.cell_methods == () + + def test_one(self): + cube = Cube([0], cell_methods=[self.cm]) + expected = (self.cm,) + assert expected == cube.cell_methods + + def test_empty_assigns(self): + testargs = [(), [], {}, 0, 0.0, False, None] + results = [] + for arg in testargs: + cube = self.cube.copy() + cube.cell_methods = arg # assign test object + results.append(cube.cell_methods) # capture what is read back + expected_results = [()] * len(testargs) + assert expected_results == results + + def test_single_assigns(self): + cms = (self.cm, self.cm2) + # Any type of iterable ought to work + # But N.B. *not* testing sets, as order is not stable + testargs = [cms, list(cms), {cm: 1 for cm in cms}] + results = [] + for arg in testargs: + cube = self.cube.copy() + cube.cell_methods = arg # assign test object + results.append(cube.cell_methods) # capture what is read back + expected_results = [cms] * len(testargs) + assert expected_results == results + + def test_fail_assign_noniterable(self): + test_object = object() + with pytest.raises(TypeError, match="not iterable"): + self.cube.cell_methods = test_object + + def test_fail_create_noniterable(self): + test_object = object() + with pytest.raises(TypeError, match="not iterable"): + Cube([0], cell_methods=test_object) + + def test_fail_assign_noncellmethod(self): + test_object = object() + with pytest.raises(ValueError, match="not an iris.coords.CellMethod"): + self.cube.cell_methods = (test_object,) + + def test_fail_create_noncellmethod(self): + test_object = object() + with pytest.raises(ValueError, match="not an iris.coords.CellMethod"): + Cube([0], cell_methods=[test_object]) + + def test_assign_derivedcellmethod(self): + class DerivedCellMethod(CellMethod): + pass + + test_object = DerivedCellMethod("mean", "time", "6hr") + cms = (test_object,) + self.cube.cell_methods = (test_object,) + assert cms == self.cube.cell_methods + + def test_fail_assign_duckcellmethod(self): + # Can't currently assign a "duck-typed" CellMethod replacement, since + # implementation requires class membership (boo!) + DuckCellMethod = namedtuple("DuckCellMethod", CellMethod._names) + test_object = DuckCellMethod( + *CellMethod._names + ) # fill props with value==name + with pytest.raises(ValueError, match="not an iris.coords.CellMethod"): + self.cube.cell_methods = (test_object,) if __name__ == "__main__": diff --git a/lib/iris/tests/unit/cube/test_CubeList.py b/lib/iris/tests/unit/cube/test_CubeList.py index 1ebfe57773..86457d3888 100644 --- a/lib/iris/tests/unit/cube/test_CubeList.py +++ b/lib/iris/tests/unit/cube/test_CubeList.py @@ -735,5 +735,36 @@ def test_copy(self): self.assertIsInstance(self.copied_cube_list, iris.cube.CubeList) +class TestHtmlRepr: + """ + Confirm that Cubelist._repr_html_() creates a fresh + :class:`iris.experimental.representation.CubeListRepresentation` object, and uses + it in the expected way. + + Notes + ----- + This only tests code connectivity. The functionality is tested elsewhere, at + `iris.tests.unit.experimental.representation.test_CubeListRepresentation` + """ + + @staticmethod + def test__repr_html_(): + test_cubelist = CubeList([]) + + target = "iris.experimental.representation.CubeListRepresentation" + with mock.patch(target) as class_mock: + # Exercise the function-under-test. + test_cubelist._repr_html_() + + assert class_mock.call_args_list == [ + # "CubeListRepresentation()" was called exactly once, with the cubelist as arg + mock.call(test_cubelist) + ] + assert class_mock.return_value.repr_html.call_args_list == [ + # "CubeListRepresentation(cubelist).repr_html()" was called exactly once, with no args + mock.call() + ] + + if __name__ == "__main__": tests.main()