diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst index cc51ff2d0a..a50331160d 100644 --- a/docs/src/whatsnew/latest.rst +++ b/docs/src/whatsnew/latest.rst @@ -47,6 +47,9 @@ This document explains the changes made to Iris for this release #. `@ukmo-ccbunney`_ fixed loading and merging of masked data in scalar ``AuxCoords``. (:issue:`3584`, :pull:`6468`) +#. `@stephenworsley`_ fixed the html representation of cubes in Jupyter when coordinates + share the same name. (:pull:`6476`) + 💣 Incompatible Changes ======================= diff --git a/lib/iris/experimental/representation.py b/lib/iris/experimental/representation.py index 0648cc8e0d..4beac376ee 100644 --- a/lib/iris/experimental/representation.py +++ b/lib/iris/experimental/representation.py @@ -6,7 +6,8 @@ """Definitions of how Iris objects should be represented.""" from html import escape -import re + +from iris._representation.cube_summary import CubeSummary class CubeRepresentation: @@ -77,34 +78,8 @@ class CubeRepresentation: def __init__(self, cube): self.cube = cube + self.summary = CubeSummary(cube) self.cube_id = id(self.cube) - self.cube_str = escape(str(self.cube)) - - # Define the expected vector and scalar sections in output, in expected - # order of appearance. - # NOTE: if we recoded this to use a CubeSummary, these section titles - # would be available from that. - self.vector_section_names = [ - "Dimension coordinates:", - "Mesh coordinates:", - "Auxiliary coordinates:", - "Derived coordinates:", - "Cell measures:", - "Ancillary variables:", - ] - self.scalar_section_names = [ - "Mesh:", - "Scalar coordinates:", - "Scalar cell measures:", - "Cell methods:", - "Attributes:", - ] - self.sections_data = { - name: None for name in self.vector_section_names + self.scalar_section_names - } - # 'Scalar-cell-measures' is currently alone amongst the scalar sections, - # in displaying only a 'name' and no 'value' field. - self.single_cell_section_names = ["Scalar cell measures:"] # Important content that summarises a cube is defined here. self.shapes = self.cube.shape @@ -144,40 +119,6 @@ def _dim_names(self): dim_names = self._get_dim_names() return dim_names - def _get_lines(self): - return self.cube_str.split("\n") - - def _get_bits(self, bits): - """Parse the body content (`bits`) of the cube string. - - Parse the body content (`bits`) of the cube string in preparation for - being converted into table rows. - - """ - left_indent = re.split(r"\w+", bits[1])[0] - - # Get heading indices within the printout. - start_inds = [] - for hdg in self.sections_data.keys(): - heading = "{}{}".format(left_indent, hdg) - try: - start_ind = bits.index(heading) - except ValueError: - continue - else: - start_inds.append(start_ind) - # Mark the end of the file. - start_inds.append(0) - - # Retrieve info for each heading from the printout. - for i0, i1 in zip(start_inds[:-1], start_inds[1:]): - str_heading_name = bits[i0].strip() - if i1 != 0: - content = bits[i0 + 1 : i1] - else: - content = bits[i0 + 1 :] - self.sections_data[str_heading_name] = content - def _make_header(self): """Make the table header. @@ -251,8 +192,7 @@ def _make_row(self, title, body=None, col_span=0): content=sub_title, ) ) - # One further item or more than that? - if col_span != 0: + if not isinstance(body, list): html_cls = ' class="{}" colspan="{}"'.format("iris-word-cell", col_span) row.append(template.format(html_cls=html_cls, content=body)) else: @@ -269,28 +209,55 @@ def _make_row(self, title, body=None, col_span=0): def _make_content(self): elements = [] - for k, v in self.sections_data.items(): - if v is not None: - # Add the sub-heading title. - elements.extend(self._make_row(k)) - for line in v: - # Add every other row in the sub-heading. - if k in self.vector_section_names: - body = re.findall(r"[\w-]+", line) - title = body.pop(0) - colspan = 0 - else: - colspan = self.ndims - if k in self.single_cell_section_names: - title = line.strip() - body = "" - else: - line = line.strip() - split_point = line.index(" ") - title = line[:split_point].strip() - body = line[split_point + 2 :].strip() - - elements.extend(self._make_row(title, body=body, col_span=colspan)) + INDENT = 4 * " " + for sect in self.summary.vector_sections.values(): + if sect.contents: + sect_title = sect.title + elements.extend(self._make_row(sect_title)) + + for content in sect.contents: + body = content.dim_chars + + title = escape(content.name) + if content.extra: + title = title + "
" + INDENT + escape(content.extra) + elements.extend(self._make_row(title, body=body, col_span=0)) + for sect in self.summary.scalar_sections.values(): + if sect.contents: + sect_title = sect.title + elements.extend(self._make_row(sect_title)) + st = sect_title.lower() + if st == "scalar coordinates:": + for item in sect.contents: + body = escape(item.content) + title = escape(item.name) + if item.extra: + title = title + "
" + INDENT + escape(item.extra) + elements.extend( + self._make_row(title, body=body, col_span=self.ndims) + ) + elif st in ("attributes:", "cell methods:", "mesh:"): + for title, body in zip(sect.names, sect.values): + title = escape(title) + body = escape(body) + elements.extend( + self._make_row(title, body=body, col_span=self.ndims) + ) + pass + elif st in ( + "scalar ancillary variables:", + "scalar cell measures:", + ): + body = "" + # These are just strings: nothing in the 'value' column. + for title in sect.contents: + title = escape(title) + elements.extend( + self._make_row(title, body=body, col_span=self.ndims) + ) + else: + msg = f"Unknown section type : {type(sect)}" + raise ValueError(msg) return "\n".join(element for element in elements) def repr_html(self): @@ -305,16 +272,7 @@ def repr_html(self): self.ndims = 1 else: shape = self._make_shapes_row() - - # Now deal with the rest of the content. - lines = self._get_lines() - # If we only have a single line `cube_str` we have no coords / attrs! - # We need to handle this case specially. - if len(lines) == 1: - content = "" - else: - self._get_bits(lines) - content = self._make_content() + content = self._make_content() return self._template.format( header=header, id=self.cube_id, shape=shape, content=content diff --git a/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py b/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py index 7515fad08a..f47a1c9ef5 100644 --- a/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py +++ b/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py @@ -4,6 +4,8 @@ # See LICENSE in the root of the repository for full licensing details. """Unit tests for the `iris.cube.CubeRepresentation` class.""" +import pytest + # Import iris.tests first so that some things can be initialised before # importing anything else. import iris.tests as tests # isort:skip @@ -12,7 +14,7 @@ import numpy as np -from iris.coords import AncillaryVariable, CellMeasure, CellMethod +from iris.coords import AncillaryVariable, AuxCoord, CellMeasure, CellMethod from iris.cube import Cube from iris.experimental.representation import CubeRepresentation import iris.tests.stock as stock @@ -27,12 +29,6 @@ def setUp(self): def test_cube_attributes(self): self.assertEqual(id(self.cube), self.representer.cube_id) - self.assertMultiLineEqual(str(self.cube), self.representer.cube_str) - - def test__heading_contents(self): - content = set(self.representer.sections_data.values()) - self.assertEqual(len(content), 1) - self.assertIsNone(list(content)[0]) @tests.skip_data @@ -111,96 +107,177 @@ def test_ndims(self): self.assertEqual(expected, result) -@tests.skip_data -class Test__get_bits(tests.IrisTest): - def setUp(self): - self.cube = stock.realistic_4d() - cmth = CellMethod("mean", "time", "6hr") - self.cube.add_cell_method(cmth) - cms = CellMeasure([0, 1, 2, 3, 4, 5], long_name="foo") - self.cube.add_cell_measure(cms, 0) - avr = AncillaryVariable([0, 1, 2, 3, 4, 5], long_name="bar") - self.cube.add_ancillary_variable(avr, 0) - scms = CellMeasure([0], long_name="baz") - self.cube.add_cell_measure(scms) - self.representer = CubeRepresentation(self.cube) - self.representer._get_bits(self.representer._get_lines()) +@pytest.fixture +def realistic_4d(): + cube = stock.realistic_4d() + cmth = CellMethod("mean", "time", "6hr") + cube.add_cell_method(cmth) + cms = CellMeasure([0, 1, 2, 3, 4, 5], long_name="foo") + cube.add_cell_measure(cms, 0) + avr = AncillaryVariable([0, 1, 2, 3, 4, 5], long_name="bar") + cube.add_ancillary_variable(avr, 0) + scms = CellMeasure([0], long_name="baz") + cube.add_cell_measure(scms) + return cube - def test_population(self): - nonmesh_values = [ - value - for key, value in self.representer.sections_data.items() - if "Mesh" not in key - ] - for v in nonmesh_values: - self.assertIsNotNone(v) - - def test_headings__dimcoords(self): - contents = self.representer.sections_data["Dimension coordinates:"] - content_str = ",".join(content for content in contents) - dim_coords = [c.name() for c in self.cube.dim_coords] - for coord in dim_coords: - self.assertIn(coord, content_str) - - def test_headings__auxcoords(self): - contents = self.representer.sections_data["Auxiliary coordinates:"] - content_str = ",".join(content for content in contents) - aux_coords = [c.name() for c in self.cube.aux_coords if c.shape != (1,)] - for coord in aux_coords: - self.assertIn(coord, content_str) - - def test_headings__derivedcoords(self): - contents = self.representer.sections_data["Derived coordinates:"] - content_str = ",".join(content for content in contents) - derived_coords = [c.name() for c in self.cube.derived_coords] - for coord in derived_coords: - self.assertIn(coord, content_str) - - def test_headings__cellmeasures(self): - contents = self.representer.sections_data["Cell measures:"] - content_str = ",".join(content for content in contents) - cell_measures = [c.name() for c in self.cube.cell_measures() if c.shape != (1,)] - for coord in cell_measures: - self.assertIn(coord, content_str) - - def test_headings__ancillaryvars(self): - contents = self.representer.sections_data["Ancillary variables:"] - content_str = ",".join(content for content in contents) - ancillary_variables = [c.name() for c in self.cube.ancillary_variables()] - for coord in ancillary_variables: - self.assertIn(coord, content_str) - - def test_headings__scalarcellmeasures(self): - contents = self.representer.sections_data["Scalar cell measures:"] - content_str = ",".join(content for content in contents) - scalar_cell_measures = [ - c.name() for c in self.cube.cell_measures() if c.shape == (1,) - ] - for coord in scalar_cell_measures: - self.assertIn(coord, content_str) - - def test_headings__scalarcoords(self): - contents = self.representer.sections_data["Scalar coordinates:"] - content_str = ",".join(content for content in contents) - scalar_coords = [c.name() for c in self.cube.coords() if c.shape == (1,)] - for coord in scalar_coords: - self.assertIn(coord, content_str) - - def test_headings__attributes(self): - contents = self.representer.sections_data["Attributes:"] - content_str = ",".join(content for content in contents) - for attr_name, attr_value in self.cube.attributes.items(): - self.assertIn(attr_name, content_str) - self.assertIn(attr_value, content_str) - - def test_headings__cellmethods(self): - contents = self.representer.sections_data["Cell methods:"] - content_str = ",".join(content for content in contents) - for method in self.cube.cell_methods: - name = method.method - value = str(method)[len(name + ": ") :] - self.assertIn(name, content_str) - self.assertIn(value, content_str) + +@tests.skip_data +def test_realistic(realistic_4d): + representer = CubeRepresentation(realistic_4d) + result = representer._make_content() + expected = ( + '\n' + ' Dimension coordinates\n' + ' \n' + ' \n' + ' \n' + ' \n' + "\n" + '\n' + ' \ttime\n' + ' x\n' + ' -\n' + ' -\n' + ' -\n' + "\n" + '\n' + ' \t' + "model_level_number\n" + ' -\n' + ' x\n' + ' -\n' + ' -\n' + "\n" + '\n' + ' \tgrid_latitude\n' + ' -\n' + ' -\n' + ' x\n' + ' -\n' + "\n" + '\n' + ' \tgrid_longitude\n' + ' -\n' + ' -\n' + ' -\n' + ' x\n' + "\n" + '\n' + ' Auxiliary coordinates\n' + ' \n' + ' \n' + ' \n' + ' \n' + "\n" + '\n' + ' \tlevel_height\n' + ' -\n' + ' x\n' + ' -\n' + ' -\n' + "\n" + '\n' + ' \tsigma\n' + ' -\n' + ' x\n' + ' -\n' + ' -\n' + "\n" + '\n' + ' \tsurface_altitude\n' + ' -\n' + ' -\n' + ' x\n' + ' x\n' + "\n" + '\n' + ' Derived coordinates\n' + ' \n' + ' \n' + ' \n' + ' \n' + "\n" + '\n' + ' \taltitude\n' + ' -\n' + ' x\n' + ' x\n' + ' x\n' + "\n" + '\n' + ' Cell measures\n' + ' \n' + ' \n' + ' \n' + ' \n' + "\n" + '\n' + ' \tfoo\n' + ' x\n' + ' -\n' + ' -\n' + ' -\n' + "\n" + '\n' + ' Ancillary variables\n' + ' \n' + ' \n' + ' \n' + ' \n' + "\n" + '\n' + ' \tbar\n' + ' x\n' + ' -\n' + ' -\n' + ' -\n' + "\n" + '\n' + ' Scalar coordinates\n' + ' \n' + ' \n' + ' \n' + ' \n' + "\n" + '\n' + ' \tforecast_period\n' + ' 0.0 hours\n' + "\n" + '\n' + ' Scalar cell measures\n' + ' \n' + ' \n' + ' \n' + ' \n' + "\n" + '\n' + ' \tbaz\n' + ' \n' + "\n" + '\n' + ' Cell methods\n' + ' \n' + ' \n' + ' \n' + ' \n' + "\n" + '\n' + ' \t0\n' + ' time: mean (interval: 6hr)\n' + "\n" + '\n' + ' Attributes\n' + ' \n' + ' \n' + ' \n' + ' \n' + "\n" + '\n' + ' \tsource\n' + ' 'Iris test case'\n' + "" + ) + assert expected == result @tests.skip_data @@ -208,7 +285,6 @@ class Test__make_header(tests.IrisTest): def setUp(self): self.cube = stock.simple_3d() self.representer = CubeRepresentation(self.cube) - self.representer._get_bits(self.representer._get_lines()) self.header_emts = self.representer._make_header().split("\n") def test_name_and_units(self): @@ -241,7 +317,6 @@ class Test__make_shapes_row(tests.IrisTest): def setUp(self): self.cube = stock.simple_3d() self.representer = CubeRepresentation(self.cube) - self.representer._get_bits(self.representer._get_lines()) self.result = self.representer._make_shapes_row().split("\n") def test_row_title(self): @@ -262,7 +337,6 @@ def setUp(self): cm = CellMethod("mean", "time", "6hr") self.cube.add_cell_method(cm) self.representer = CubeRepresentation(self.cube) - self.representer._get_bits(self.representer._get_lines()) def test__title_row(self): title = "Wibble:" @@ -323,8 +397,20 @@ class Test__make_content(tests.IrisTest): def setUp(self): self.cube = stock.simple_3d() self.representer = CubeRepresentation(self.cube) - self.representer._get_bits(self.representer._get_lines()) self.result = self.representer._make_content() + self.sections_keys = [ + "Dimension coordinates:", + "Mesh coordinates:", + "Auxiliary coordinates:", + "Derived coordinates:", + "Cell measures:", + "Ancillary variables:", + "Mesh:", + "Scalar coordinates:", + "Scalar cell measures:", + "Cell methods:", + "Attributes:", + ] # Also provide an ultra-simple mesh cube, with only meshcoords. mesh = sample_mesh() @@ -334,7 +420,6 @@ def setUp(self): mesh_cube.add_aux_coord(meshco_y, (0,)) self.mesh_cube = mesh_cube self.mesh_representer = CubeRepresentation(self.mesh_cube) - self.mesh_representer._get_bits(self.mesh_representer._get_lines()) self.mesh_result = self.mesh_representer._make_content() def test_included(self): @@ -346,7 +431,7 @@ def test_included(self): def test_not_included(self): # `stock.simple_3d()` only contains the `Dimension coordinates` attr. - not_included = list(self.representer.sections_data.keys()) + not_included = list(self.sections_keys) not_included.pop(not_included.index("Dimension coordinates:")) for heading in not_included: self.assertNotIn(heading, self.result) @@ -367,7 +452,7 @@ def test_mesh_included(self): def test_mesh_not_included(self): # self.mesh_cube _only_ contains a `Mesh coordinates` section. - not_included = list(self.representer.sections_data.keys()) + not_included = list(self.sections_keys) not_included.pop(not_included.index("Mesh coordinates:")) for heading in not_included: self.assertNotIn(heading, self.result) @@ -391,7 +476,6 @@ def _cube_stringattribute_html(name, attr): cube = Cube([0]) cube.attributes[name] = attr representer = CubeRepresentation(cube) - representer._get_bits(representer._get_lines()) result = representer._make_content() return result @@ -414,6 +498,139 @@ def test_multi_string_attribute(self): html = self._cube_stringattribute_html("multi-string", attr) self.assertString(html) + def test_coord_distinguishing_attributes(self): + # Printout of differing attributes to differentiate same-named coords. + # include : vector + scalar + cube = Cube([0, 1], long_name="name", units=1) + # Add a pair of vector coords with same name but different attributes. + cube.add_aux_coord(AuxCoord([0, 1], long_name="co1", attributes=dict(a=1)), 0) + cube.add_aux_coord(AuxCoord([0, 1], long_name="co1", attributes=dict(a=2)), 0) + # Likewise for scalar coords with same name but different attributes. + cube.add_aux_coord(AuxCoord([0], long_name="co2", attributes=dict(a=10, b=12))) + cube.add_aux_coord(AuxCoord([1], long_name="co2", attributes=dict(a=10, b=11))) + + rep = CubeRepresentation(cube) + result = rep._make_content() + expected = ( + '\n' + ' Auxiliary coordinates\n' + ' \n' + "\n" + '\n' + ' \t' + "co1
    a=1\n" + ' x\n' + "\n" + '\n' + ' \t' + "co1
    a=2\n" + ' x\n' + "\n" + '\n' + ' Scalar coordinates\n' + ' \n' + "\n" + '\n' + ' \t' + "co2
    b=12\n" + ' 0\n' + "\n" + '\n' + ' \t' + "co2
    b=11\n" + ' 1\n' + "" + ) + self.assertEqual(result, expected) + + def test_coord_extra_attributes__array(self): + cube = Cube(0, long_name="name", units=1) + # Add a pair of vector coords with same name but different attributes. + array1 = np.arange(0, 3) + array2 = np.arange(10, 13) + cube.add_aux_coord( + AuxCoord([1.2], long_name="co1", attributes=dict(a=1, arr=array1)) + ) + cube.add_aux_coord( + AuxCoord([3.4], long_name="co1", attributes=dict(a=1, arr=array2)) + ) + + rep = CubeRepresentation(cube) + result = rep._make_content() + expected = ( + '\n' + ' Scalar coordinates\n' + "\n" + '\n' + ' \t' + "co1
    arr=array([0, 1, 2])\n" + ' 1.2\n' + "\n" + '\n' + ' \t' + "co1
    arr=array([10, 11, 12])\n" + ' 3.4\n' + "" + ) + self.assertEqual(result, expected) + + def test_coord_extra_attributes__array__long(self): + # Also test with a long array representation. + array = 10 + np.arange(24.0).reshape((2, 3, 4)) + cube = Cube(0, long_name="name", units=1) + cube.add_aux_coord(AuxCoord([1], long_name="co")) + cube.add_aux_coord( + AuxCoord([2], long_name="co", attributes=dict(a=array + 1.0)) + ) + + rep = CubeRepresentation(cube) + result = rep._make_content() + expected = ( + '\n' + ' Scalar coordinates\n' + "\n" + '\n' + ' \tco\n' + ' 1\n' + "\n" + '\n' + ' \t' + "co
    a=array([[[11., 12., 13., 14.], [15., 16., " + "17., 18.], [19., 20., 21., 22.]],...\n" + ' 2\n' + "" + ) + self.assertEqual(result, expected) + + def test_coord_extra_attributes__string_escaped(self): + cube = Cube(0, long_name="name", units=1) + cube.add_aux_coord(AuxCoord([1], long_name="co")) + cube.add_aux_coord( + AuxCoord( + [2], + long_name="co", + attributes=dict(note="line 1\nline 2\t& ends."), + ) + ) + rep = CubeRepresentation(cube) + result = rep._make_content() + expected = ( + '\n' + ' Scalar coordinates\n' + "\n" + '\n' + ' \tco\n' + ' 1\n' + "\n" + '\n' + ' \t' + "co
    note='line 1\\nline 2\\t& " + "ends.'\n" + ' 2\n' + "" + ) + self.assertEqual(result, expected) + @tests.skip_data class Test_repr_html(tests.IrisTest):