diff --git a/lib/iris/_representation.py b/lib/iris/_representation.py index 301f4a9a22..ee1e1a0d55 100644 --- a/lib/iris/_representation.py +++ b/lib/iris/_representation.py @@ -6,8 +6,10 @@ """ Provides objects describing cube summaries. """ +import re import iris.util +from iris.common.metadata import _hexdigest as quickhash class DimensionHeader: @@ -46,6 +48,35 @@ def __init__(self, cube, name_padding=35): self.dimension_header = DimensionHeader(cube) +def string_repr(text, quote_strings=False): + """Produce a one-line printable form of a text string.""" + if re.findall("[\n\t]", text) or quote_strings: + # Replace the string with its repr (including quotes). + text = repr(text) + return text + + +def array_repr(arr): + """Produce a single-line printable repr of an array.""" + # First take whatever numpy produces.. + text = repr(arr) + # ..then reduce any multiple spaces and newlines. + text = re.sub("[ \t\n]+", " ", text) + return text + + +def value_repr(value, quote_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 = str(value) + return value + + class CoordSummary: def _summary_coord_extra(self, cube, coord): # Returns the text needed to ensure this coordinate can be @@ -66,12 +97,21 @@ def _summary_coord_extra(self, cube, coord): vary.add(key) break value = similar_coord.attributes[key] - if attributes.setdefault(key, value) != value: + # Like "if attributes.setdefault(key, value) != value:" + # ..except setdefault fails if values are numpy arrays. + if key not in attributes: + attributes[key] = value + elif quickhash(attributes[key]) != quickhash(value): + # NOTE: fast and array-safe comparison, as used in + # :mod:`iris.common.metadata`. vary.add(key) break keys = sorted(vary & set(coord.attributes.keys())) bits = [ - "{}={!r}".format(key, coord.attributes[key]) for key in keys + "{}={}".format( + key, value_repr(coord.attributes[key], quote_strings=True) + ) + for key in keys ] if bits: extra = ", ".join(bits) @@ -105,13 +145,17 @@ def __init__(self, cube, coord): coord_cell = coord.cell(0) if isinstance(coord_cell.point, str): self.string_type = True + # 'lines' is value split on '\n', and _each one_ length-clipped. self.lines = [ iris.util.clip_string(str(item)) for item in coord_cell.point.split("\n") ] self.point = None self.bound = None - self.content = "\n".join(self.lines) + # 'content' contains a one-line printable version of the string, + content = string_repr(coord_cell.point) + content = iris.util.clip_string(content) + self.content = content else: self.string_type = False self.lines = None @@ -132,9 +176,6 @@ def __init__(self, cube, coord): class Section: - def _init_(self): - self.contents = [] - def is_empty(self): return self.contents == [] @@ -166,7 +207,8 @@ def __init__(self, title, attributes): self.values = [] self.contents = [] for name, value in sorted(attributes.items()): - value = iris.util.clip_string(str(value)) + value = value_repr(value) + value = iris.util.clip_string(value) self.names.append(name) self.values.append(value) content = "{}: {}".format(name, value) @@ -180,11 +222,13 @@ def __init__(self, title, cell_methods): class CubeSummary: + """ + This class provides a structure for output representations of an Iris cube. + TODO: use to produce the printout of :meth:`iris.cube.Cube.__str__`. + + """ + def __init__(self, cube, shorten=False, name_padding=35): - self.section_indent = 5 - self.item_indent = 10 - self.extra_indent = 13 - self.shorten = shorten self.header = FullHeader(cube, name_padding) # Cache the derived coords so we can rely on consistent @@ -249,9 +293,9 @@ def add_vector_section(title, contents, iscoord=True): add_vector_section("Dimension coordinates:", vector_dim_coords) add_vector_section("Auxiliary coordinates:", vector_aux_coords) add_vector_section("Derived coordinates:", vector_derived_coords) - add_vector_section("Cell Measures:", vector_cell_measures, False) + add_vector_section("Cell measures:", vector_cell_measures, False) add_vector_section( - "Ancillary Variables:", vector_ancillary_variables, False + "Ancillary variables:", vector_ancillary_variables, False ) self.scalar_sections = {} @@ -260,7 +304,7 @@ def add_scalar_section(section_class, title, *args): self.scalar_sections[title] = section_class(title, *args) add_scalar_section( - ScalarSection, "Scalar Coordinates:", cube, scalar_coords + ScalarSection, "Scalar coordinates:", cube, scalar_coords ) add_scalar_section( ScalarCellMeasureSection, diff --git a/lib/iris/tests/unit/representation/test_representation.py b/lib/iris/tests/unit/representation/test_representation.py index 212f454e70..69d2a71a97 100644 --- a/lib/iris/tests/unit/representation/test_representation.py +++ b/lib/iris/tests/unit/representation/test_representation.py @@ -54,8 +54,8 @@ def test_blank_cube(self): "Dimension coordinates:", "Auxiliary coordinates:", "Derived coordinates:", - "Cell Measures:", - "Ancillary Variables:", + "Cell measures:", + "Ancillary variables:", ] self.assertEqual( list(rep.vector_sections.keys()), expected_vector_sections @@ -66,7 +66,7 @@ def test_blank_cube(self): self.assertTrue(vector_section.is_empty()) expected_scalar_sections = [ - "Scalar Coordinates:", + "Scalar coordinates:", "Scalar cell measures:", "Attributes:", "Cell methods:", @@ -103,21 +103,28 @@ def test_scalar_coord(self): scalar_coord_with_bounds = AuxCoord( [10], long_name="foo", units="K", bounds=[(5, 15)] ) - scalar_coord_text = AuxCoord( - ["a\nb\nc"], long_name="foo", attributes={"key": "value"} + scalar_coord_simple_text = AuxCoord( + ["this and that"], + long_name="foo", + attributes={"key": 42, "key2": "value-str"}, + ) + scalar_coord_awkward_text = AuxCoord( + ["a is\nb\n and c"], long_name="foo_2" ) cube.add_aux_coord(scalar_coord_no_bounds) cube.add_aux_coord(scalar_coord_with_bounds) - cube.add_aux_coord(scalar_coord_text) + cube.add_aux_coord(scalar_coord_simple_text) + cube.add_aux_coord(scalar_coord_awkward_text) rep = iris._representation.CubeSummary(cube) - scalar_section = rep.scalar_sections["Scalar Coordinates:"] + scalar_section = rep.scalar_sections["Scalar coordinates:"] - self.assertEqual(len(scalar_section.contents), 3) + self.assertEqual(len(scalar_section.contents), 4) no_bounds_summary = scalar_section.contents[0] bounds_summary = scalar_section.contents[1] - text_summary = scalar_section.contents[2] + text_summary_simple = scalar_section.contents[2] + text_summary_awkward = scalar_section.contents[3] self.assertEqual(no_bounds_summary.name, "bar") self.assertEqual(no_bounds_summary.content, "10 K") @@ -127,9 +134,15 @@ def test_scalar_coord(self): self.assertEqual(bounds_summary.content, "10 K, bound=(5, 15) K") self.assertEqual(bounds_summary.extra, "") - self.assertEqual(text_summary.name, "foo") - self.assertEqual(text_summary.content, "a\nb\nc") - self.assertEqual(text_summary.extra, "key='value'") + self.assertEqual(text_summary_simple.name, "foo") + self.assertEqual(text_summary_simple.content, "this and that") + self.assertEqual(text_summary_simple.lines, ["this and that"]) + self.assertEqual(text_summary_simple.extra, "key=42, key2='value-str'") + + self.assertEqual(text_summary_awkward.name, "foo_2") + self.assertEqual(text_summary_awkward.content, r"'a is\nb\n and c'") + self.assertEqual(text_summary_awkward.lines, ["a is", "b", " and c"]) + self.assertEqual(text_summary_awkward.extra, "") def test_cell_measure(self): cube = self.cube @@ -137,7 +150,7 @@ def test_cell_measure(self): cube.add_cell_measure(cell_measure, 0) rep = iris._representation.CubeSummary(cube) - cm_section = rep.vector_sections["Cell Measures:"] + cm_section = rep.vector_sections["Cell measures:"] self.assertEqual(len(cm_section.contents), 1) cm_summary = cm_section.contents[0] @@ -150,7 +163,7 @@ def test_ancillary_variable(self): cube.add_ancillary_variable(cell_measure, 0) rep = iris._representation.CubeSummary(cube) - av_section = rep.vector_sections["Ancillary Variables:"] + av_section = rep.vector_sections["Ancillary variables:"] self.assertEqual(len(av_section.contents), 1) av_summary = av_section.contents[0] @@ -159,12 +172,14 @@ def test_ancillary_variable(self): def test_attributes(self): cube = self.cube - cube.attributes = {"a": 1, "b": "two"} + cube.attributes = {"a": 1, "b": "two", "c": " this \n that\tand."} rep = iris._representation.CubeSummary(cube) attribute_section = rep.scalar_sections["Attributes:"] attribute_contents = attribute_section.contents - expected_contents = ["a: 1", "b: two"] + expected_contents = ["a: 1", "b: two", "c: ' this \\n that\\tand.'"] + # Note: a string with \n or \t in it gets "repr-d". + # Other strings don't (though in coord 'extra' lines, they do.) self.assertEqual(attribute_contents, expected_contents) @@ -182,6 +197,108 @@ def test_cell_methods(self): expected_contents = ["mean: x, y", "mean: x"] self.assertEqual(cell_method_section.contents, expected_contents) + def test_scalar_cube(self): + cube = self.cube + while cube.ndim > 0: + cube = cube[0] + rep = iris._representation.CubeSummary(cube) + self.assertEqual(rep.header.nameunit, "air_temperature / (K)") + self.assertTrue(rep.header.dimension_header.scalar) + self.assertEqual(rep.header.dimension_header.dim_names, []) + self.assertEqual(rep.header.dimension_header.shape, []) + self.assertEqual(rep.header.dimension_header.contents, ["scalar cube"]) + self.assertEqual(len(rep.vector_sections), 5) + 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["Scalar coordinates:"].contents), 1 + ) + self.assertTrue( + rep.scalar_sections["Scalar cell measures:"].is_empty() + ) + self.assertTrue(rep.scalar_sections["Attributes:"].is_empty()) + self.assertTrue(rep.scalar_sections["Cell methods:"].is_empty()) + + def test_coord_attributes(self): + cube = self.cube + co1 = cube.coord("latitude") + co1.attributes.update(dict(a=1, b=2)) + co2 = co1.copy() + co2.attributes.update(dict(a=7, z=77, text="ok", text2="multi\nline")) + cube.add_aux_coord(co2, cube.coord_dims(co1)) + rep = iris._representation.CubeSummary(cube) + co1_summ = rep.vector_sections["Dimension coordinates:"].contents[0] + co2_summ = rep.vector_sections["Auxiliary coordinates:"].contents[0] + # Notes: 'b' is same so does not appear; sorted order; quoted strings. + self.assertEqual(co1_summ.extra, "a=1") + self.assertEqual( + co2_summ.extra, "a=7, text='ok', text2='multi\\nline', z=77" + ) + + def test_array_attributes(self): + cube = self.cube + co1 = cube.coord("latitude") + co1.attributes.update(dict(a=1, array=np.array([1.2, 3]))) + co2 = co1.copy() + co2.attributes.update(dict(b=2, array=np.array([3.2, 1]))) + cube.add_aux_coord(co2, cube.coord_dims(co1)) + rep = iris._representation.CubeSummary(cube) + co1_summ = rep.vector_sections["Dimension coordinates:"].contents[0] + co2_summ = rep.vector_sections["Auxiliary coordinates:"].contents[0] + self.assertEqual(co1_summ.extra, "array=array([1.2, 3. ])") + self.assertEqual(co2_summ.extra, "array=array([3.2, 1. ]), b=2") + + def test_attributes_subtle_differences(self): + cube = Cube([0]) + + # Add a pair that differ only in having a list instead of an array. + co1a = DimCoord( + [0], + long_name="co1_list_or_array", + attributes=dict(x=1, arr1=np.array(2), arr2=np.array([1, 2])), + ) + co1b = co1a.copy() + co1b.attributes.update(dict(arr2=[1, 2])) + for co in (co1a, co1b): + cube.add_aux_coord(co) + + # Add a pair that differ only in an attribute array dtype. + co2a = AuxCoord( + [0], + long_name="co2_dtype", + attributes=dict(x=1, arr1=np.array(2), arr2=np.array([3, 4])), + ) + co2b = co2a.copy() + co2b.attributes.update(dict(arr2=np.array([3.0, 4.0]))) + assert co2b != co2a + for co in (co2a, co2b): + cube.add_aux_coord(co) + + # Add a pair that differ only in an attribute array shape. + co3a = DimCoord( + [0], + long_name="co3_shape", + attributes=dict(x=1, arr1=np.array([5, 6]), arr2=np.array([3, 4])), + ) + co3b = co3a.copy() + co3b.attributes.update(dict(arr1=np.array([[5], [6]]))) + for co in (co3a, co3b): + cube.add_aux_coord(co) + + rep = iris._representation.CubeSummary(cube) + co_summs = rep.scalar_sections["Scalar coordinates:"].contents + co1a_summ, co1b_summ = co_summs[0:2] + self.assertEqual(co1a_summ.extra, "arr2=array([1, 2])") + self.assertEqual(co1b_summ.extra, "arr2=[1, 2]") + co2a_summ, co2b_summ = co_summs[2:4] + self.assertEqual(co2a_summ.extra, "arr2=array([3, 4])") + self.assertEqual(co2b_summ.extra, "arr2=array([3., 4.])") + co3a_summ, co3b_summ = co_summs[4:6] + self.assertEqual(co3a_summ.extra, "arr1=array([5, 6])") + self.assertEqual(co3b_summ.extra, "arr1=array([[5], [6]])") + if __name__ == "__main__": tests.main()