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()