diff --git a/docs/src/further_topics/ugrid/data_model.rst b/docs/src/further_topics/ugrid/data_model.rst index 55e4f79a96..cc3cc7b793 100644 --- a/docs/src/further_topics/ugrid/data_model.rst +++ b/docs/src/further_topics/ugrid/data_model.rst @@ -405,6 +405,9 @@ the :class:`~iris.cube.Cube`\'s unstructured dimension. Mesh coordinates: latitude x - longitude x - + Mesh: + name my_mesh + location edge >>> print(edge_cube.location) edge diff --git a/docs/src/further_topics/ugrid/operations.rst b/docs/src/further_topics/ugrid/operations.rst index c636043640..a4e0e593d7 100644 --- a/docs/src/further_topics/ugrid/operations.rst +++ b/docs/src/further_topics/ugrid/operations.rst @@ -189,6 +189,9 @@ Creating a :class:`~iris.cube.Cube` is unchanged; the Mesh coordinates: latitude x - longitude x - + Mesh: + name my_mesh + location edge Save @@ -392,6 +395,9 @@ etcetera: Mesh coordinates: latitude x - longitude x - + Mesh: + name my_mesh + location face Attributes: Conventions 'CF-1.7' @@ -620,6 +626,9 @@ the link between :class:`~iris.cube.Cube` and Mesh coordinates: latitude x - longitude x - + Mesh: + name my_mesh + location edge # Sub-setted MeshCoords have become AuxCoords. >>> print(edge_cube[:-1]) diff --git a/lib/iris/_representation/cube_printout.py b/lib/iris/_representation/cube_printout.py index 81d46bb29f..b0b569512d 100644 --- a/lib/iris/_representation/cube_printout.py +++ b/lib/iris/_representation/cube_printout.py @@ -252,15 +252,15 @@ def add_scalar_row(name, value=""): # Add a row for each item # NOTE: different section types need different handling title = sect_name.lower() - if "scalar coordinate" in title: + if title == "scalar coordinates:": for item in sect.contents: add_scalar_row(item.name, item.content) if item.extra: add_scalar_row(item_to_extra_indent + item.extra) - elif "attribute" in title or "cell method" in title: + elif title in ("attributes:", "cell methods:", "mesh:"): for title, value in zip(sect.names, sect.values): add_scalar_row(title, value) - elif "scalar cell measure" in title: + elif title == "scalar cell measures:": # These are just strings: nothing in the 'value' column. for name in sect.contents: add_scalar_row(name) diff --git a/lib/iris/_representation/cube_summary.py b/lib/iris/_representation/cube_summary.py index 1e78a92fd1..885de9acf3 100644 --- a/lib/iris/_representation/cube_summary.py +++ b/lib/iris/_representation/cube_summary.py @@ -48,11 +48,25 @@ def __init__(self, cube, name_padding=35): self.dimension_header = DimensionHeader(cube) -def string_repr(text, quote_strings=False): +def string_repr(text, quote_strings=False, clip_strings=False): """Produce a one-line printable form of a text string.""" - if re.findall("[\n\t]", text) or quote_strings: + force_quoted = re.findall("[\n\t]", text) or quote_strings + if force_quoted: # Replace the string with its repr (including quotes). text = repr(text) + if clip_strings: + # First check for quotes. + # N.B. not just 'quote_strings', but also array values-as-strings + has_quotes = text[0] in "\"'" + if has_quotes: + # Strip off (and store) any outer quotes before clipping. + pre_quote, post_quote = text[0], text[-1] + text = text[1:-1] + # clipping : use 'rider' with extra space in case it ends in a '.' + text = iris.util.clip_string(text, rider=" ...") + if has_quotes: + # Replace in original quotes + text = pre_quote + text + post_quote return text @@ -62,17 +76,20 @@ def array_repr(arr): text = repr(arr) # ..then reduce any multiple spaces and newlines. text = re.sub("[ \t\n]+", " ", text) + text = string_repr(text, quote_strings=False, clip_strings=True) return text -def value_repr(value, quote_strings=False): +def value_repr(value, quote_strings=False, clip_strings=False): """ Produce a single-line printable version of an attribute or scalar value. """ if hasattr(value, "dtype"): value = array_repr(value) elif isinstance(value, str): - value = string_repr(value, quote_strings=quote_strings) + value = string_repr( + value, quote_strings=quote_strings, clip_strings=clip_strings + ) value = str(value) return value @@ -132,7 +149,7 @@ def __init__(self, cube, vector, iscoord): self.extra = "" -class ScalarSummary(CoordSummary): +class ScalarCoordSummary(CoordSummary): def __init__(self, cube, coord): self.name = coord.name() if ( @@ -188,10 +205,12 @@ def __init__(self, title, cube, vectors, iscoord): ] -class ScalarSection(Section): +class ScalarCoordSection(Section): def __init__(self, title, cube, scalars): self.title = title - self.contents = [ScalarSummary(cube, scalar) for scalar in scalars] + self.contents = [ + ScalarCoordSummary(cube, scalar) for scalar in scalars + ] class ScalarCellMeasureSection(Section): @@ -207,14 +226,32 @@ def __init__(self, title, attributes): self.values = [] self.contents = [] for name, value in sorted(attributes.items()): - value = value_repr(value, quote_strings=True) - value = iris.util.clip_string(value) + value = value_repr(value, quote_strings=True, clip_strings=True) self.names.append(name) self.values.append(value) content = "{}: {}".format(name, value) self.contents.append(content) +class ScalarMeshSection(AttributeSection): + # This happens to behave just like an attribute sections, but it + # initialises direct from the cube. + def __init__(self, title, cube): + self.title = title + self.names = [] + self.values = [] + self.contents = [] + if cube.mesh is not None: + self.names.extend(["name", "location"]) + self.values.extend([cube.mesh.name(), cube.location]) + self.contents.extend( + [ + "{}: {}".format(name, value) + for name, value in zip(self.names, self.values) + ] + ) + + class CellMethodSection(Section): def __init__(self, title, cell_methods): self.title = title @@ -322,8 +359,10 @@ def add_vector_section(title, contents, iscoord=True): def add_scalar_section(section_class, title, *args): self.scalar_sections[title] = section_class(title, *args) + add_scalar_section(ScalarMeshSection, "Mesh:", cube) + add_scalar_section( - ScalarSection, "Scalar coordinates:", cube, scalar_coords + ScalarCoordSection, "Scalar coordinates:", cube, scalar_coords ) add_scalar_section( ScalarCellMeasureSection, diff --git a/lib/iris/experimental/representation.py b/lib/iris/experimental/representation.py index 48e11e1fb0..116b340592 100644 --- a/lib/iris/experimental/representation.py +++ b/lib/iris/experimental/representation.py @@ -85,28 +85,32 @@ def __init__(self, cube): self.cube_id = id(self.cube) self.cube_str = escape(str(self.cube)) - self.str_headings = { - "Dimension coordinates:": None, - "Auxiliary coordinates:": None, - "Mesh coordinates:": None, - "Derived coordinates:": None, - "Cell measures:": None, - "Ancillary variables:": None, - "Scalar coordinates:": None, - "Scalar cell measures:": None, - "Cell methods:": None, - "Attributes:": None, - } - self.dim_desc_coords = [ + # 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:", - "Auxiliary coordinates:", "Mesh coordinates:", + "Auxiliary coordinates:", "Derived coordinates:", "Cell measures:", "Ancillary variables:", ] - - self.two_cell_headers = ["Scalar coordinates:", "Attributes:"] + 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 @@ -160,7 +164,7 @@ def _get_bits(self, bits): # Get heading indices within the printout. start_inds = [] - for hdg in self.str_headings.keys(): + for hdg in self.sections_data.keys(): heading = "{}{}".format(left_indent, hdg) try: start_ind = bits.index(heading) @@ -178,7 +182,7 @@ def _get_bits(self, bits): content = bits[i0 + 1 : i1] else: content = bits[i0 + 1 :] - self.str_headings[str_heading_name] = content + self.sections_data[str_heading_name] = content def _make_header(self): """ @@ -272,47 +276,29 @@ def _make_row(self, title, body=None, col_span=0): row.append("") return row - def _expand_last_cell(self, element, body): - """Expand an element containing a cell by adding a new line.""" - split_point = element.index("") - element = element[:split_point] + "
" + body + element[split_point:] - return element - def _make_content(self): elements = [] - for k, v in self.str_headings.items(): + 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.dim_desc_coords: + if k in self.vector_section_names: body = re.findall(r"[\w-]+", line) title = body.pop(0) colspan = 0 - elif k in self.two_cell_headers: - try: - split_point = line.index(":") - except ValueError: - # When a line exists in v without a ':', we expect - # that this is due to the value of some attribute - # containing multiple lines. We collect all these - # lines in the same cell. - body = line.strip() - # We choose the element containing the last cell - # in the last row. - element = elements[-2] - element = self._expand_last_cell(element, body) - elements[-2] = element - continue + 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() - colspan = self.ndims - else: - title = line.strip() - body = "" - colspan = self.ndims + elements.extend( self._make_row(title, body=body, col_span=colspan) ) diff --git a/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content/mesh_result.txt b/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content/mesh_result.txt new file mode 100644 index 0000000000..e20527cb49 --- /dev/null +++ b/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content/mesh_result.txt @@ -0,0 +1,24 @@ + + Mesh coordinates + + + + latitude + x + + + longitude + x + + + Mesh + + + + name + unknown + + + location + face + \ No newline at end of file diff --git a/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/embedded_newlines_string_attribute.txt b/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/embedded_newlines_string_attribute.txt new file mode 100644 index 0000000000..e886d25e60 --- /dev/null +++ b/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/embedded_newlines_string_attribute.txt @@ -0,0 +1,8 @@ + + Attributes + + + + newlines-string + 'string\nwith\nnewlines' + \ No newline at end of file diff --git a/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/long_string_attribute.txt b/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/long_string_attribute.txt new file mode 100644 index 0000000000..e972e1d6df --- /dev/null +++ b/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/long_string_attribute.txt @@ -0,0 +1,8 @@ + + Attributes + + + + long-string + 'long string.. long string.. long string.. long string.. long string.. long ...' + \ No newline at end of file diff --git a/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/multi_string_attribute.txt b/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/multi_string_attribute.txt new file mode 100644 index 0000000000..1736a083d6 --- /dev/null +++ b/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/multi_string_attribute.txt @@ -0,0 +1,8 @@ + + Attributes + + + + multi-string + ['vector', 'of', 'strings'] + \ No newline at end of file diff --git a/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/simple_string_attribute.txt b/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/simple_string_attribute.txt new file mode 100644 index 0000000000..8726d1f6ea --- /dev/null +++ b/lib/iris/tests/results/unit/experimental/representation/CubeRepresentation/_make_content__string_attrs/simple_string_attribute.txt @@ -0,0 +1,8 @@ + + Attributes + + + + single-string + 'single string' + \ No newline at end of file diff --git a/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py b/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py index eab3e7942d..99f7e7f2dd 100644 --- a/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py +++ b/lib/iris/tests/unit/experimental/representation/test_CubeRepresentation.py @@ -31,7 +31,7 @@ def test_cube_attributes(self): self.assertStringEqual(str(self.cube), self.representer.cube_str) def test__heading_contents(self): - content = set(self.representer.str_headings.values()) + content = set(self.representer.sections_data.values()) self.assertEqual(len(content), 1) self.assertIsNone(list(content)[0]) @@ -131,21 +131,21 @@ def setUp(self): def test_population(self): nonmesh_values = [ value - for key, value in self.representer.str_headings.items() + 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.str_headings["Dimension coordinates:"] + 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.str_headings["Auxiliary coordinates:"] + 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,) @@ -154,14 +154,14 @@ def test_headings__auxcoords(self): self.assertIn(coord, content_str) def test_headings__derivedcoords(self): - contents = self.representer.str_headings["Derived coordinates:"] + 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.str_headings["Cell measures:"] + 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,) @@ -170,7 +170,7 @@ def test_headings__cellmeasures(self): self.assertIn(coord, content_str) def test_headings__ancillaryvars(self): - contents = self.representer.str_headings["Ancillary variables:"] + 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() @@ -179,7 +179,7 @@ def test_headings__ancillaryvars(self): self.assertIn(coord, content_str) def test_headings__scalarcellmeasures(self): - contents = self.representer.str_headings["Scalar cell measures:"] + 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,) @@ -188,7 +188,7 @@ def test_headings__scalarcellmeasures(self): self.assertIn(coord, content_str) def test_headings__scalarcoords(self): - contents = self.representer.str_headings["Scalar coordinates:"] + 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,) @@ -197,14 +197,14 @@ def test_headings__scalarcoords(self): self.assertIn(coord, content_str) def test_headings__attributes(self): - contents = self.representer.str_headings["Attributes:"] + 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.str_headings["Cell methods:"] + 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 @@ -328,22 +328,6 @@ def test__attribute_row(self): self.assertIn(colspan_str, row_str) -@tests.skip_data -class Test__expand_last_cell(tests.IrisTest): - def setUp(self): - self.cube = stock.simple_3d() - self.representer = CubeRepresentation(self.cube) - self.representer._get_bits(self.representer._get_lines()) - col_span = self.representer.ndims - self.row = self.representer._make_row( - "title", body="first", col_span=col_span - ) - - def test_add_line(self): - cell = self.representer._expand_last_cell(self.row[-2], "second") - self.assertIn("first
second", cell) - - @tests.skip_data class Test__make_content(tests.IrisTest): def setUp(self): @@ -372,15 +356,21 @@ def test_included(self): def test_not_included(self): # `stock.simple_3d()` only contains the `Dimension coordinates` attr. - not_included = list(self.representer.str_headings.keys()) + not_included = list(self.representer.sections_data.keys()) not_included.pop(not_included.index("Dimension coordinates:")) for heading in not_included: self.assertNotIn(heading, self.result) def test_mesh_included(self): # self.mesh_cube contains a `Mesh coordinates` section. - included = "Mesh coordinates" - self.assertIn(included, self.mesh_result) + self.assertIn( + 'Mesh coordinates', + self.mesh_result, + ) + # and a `Mesh:` section. + self.assertIn( + 'Mesh', self.mesh_result + ) mesh_coord_names = [ c.name() for c in self.mesh_cube.coords(mesh_coords=True) ] @@ -389,11 +379,55 @@ 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.str_headings.keys()) + not_included = list(self.representer.sections_data.keys()) not_included.pop(not_included.index("Mesh coordinates:")) for heading in not_included: self.assertNotIn(heading, self.result) + def test_mesh_result(self): + # A plain snapshot of a simple meshcube case. + self.assertString(self.mesh_result) + + +class Test__make_content__string_attrs(tests.IrisTest): + # Check how we handle "multi-line" string attributes. + # NOTE: before the adoption of iris._representation.CubeSummary, these + # used to appear as extra items in sections_data, identifiable by + # their not containing a ":", and which required to be combined into a + # single cell. + # This case no longer occurs. For now, just snapshot some current + # 'correct' behaviours, for change security and any future refactoring. + + @staticmethod + 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 + + def test_simple_string_attribute(self): + html = self._cube_stringattribute_html( + "single-string", "single string" + ) + self.assertString(html) + + def test_long_string_attribute(self): + attr = "long string.. " * 20 + html = self._cube_stringattribute_html("long-string", attr) + self.assertString(html) + + def test_embedded_newlines_string_attribute(self): + attr = "string\nwith\nnewlines" + html = self._cube_stringattribute_html("newlines-string", attr) + self.assertString(html) + + def test_multi_string_attribute(self): + attr = ["vector", "of", "strings"] + html = self._cube_stringattribute_html("multi-string", attr) + self.assertString(html) + @tests.skip_data class Test_repr_html(tests.IrisTest): diff --git a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py index 40a932b9e0..ff42acf566 100644 --- a/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py +++ b/lib/iris/tests/unit/representation/cube_printout/test_CubePrintout.py @@ -465,11 +465,11 @@ def test_section_cube_attributes__string_extras(self): " escaped 'escaped\\tstring'", ( " long 'this is very very very " - "very very very very very very very very very very..." + "very very very very very very very very very very ...'" ), ( " long_multi 'multi\\nline, " - "this is very very very very very very very very very very..." + "this is very very very very very very very very very very ...'" ), ] self.assertEqual(rep, expected) @@ -488,7 +488,7 @@ def test_section_cube_attributes__array(self): " array array([1.2, 3.4])", ( " bigarray array([[ 0, 1], [ 2, 3], " - "[ 4, 5], [ 6, 7], [ 8, 9], [10, 11], [12, 13],..." + "[ 4, 5], [ 6, 7], [ 8, 9], [10, 11], [12, 13], ..." ), ] self.assertEqual(rep, expected) @@ -528,6 +528,9 @@ def test_unstructured_cube(self): " longitude - x", " Auxiliary coordinates:", " mesh_face_aux - x", + " Mesh:", + " name unknown", + " location face", ] self.assertEqual(rep, expected) diff --git a/lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py b/lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py index 3e411c020d..8314c5c9ae 100644 --- a/lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py +++ b/lib/iris/tests/unit/representation/cube_summary/test_CubeSummary.py @@ -72,6 +72,7 @@ def test_blank_cube(self): self.assertTrue(vector_section.is_empty()) expected_scalar_sections = [ + "Mesh:", "Scalar coordinates:", "Scalar cell measures:", "Cell methods:", @@ -221,7 +222,7 @@ def test_scalar_cube(self): self.assertTrue( all(sect.is_empty() for sect in rep.vector_sections.values()) ) - self.assertEqual(len(rep.scalar_sections), 4) + self.assertEqual(len(rep.scalar_sections), 5) self.assertEqual( len(rep.scalar_sections["Scalar coordinates:"].contents), 1 )